diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f27dfe2..c35a322d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Flutter CICD # action name +name: Flutter CICD on: push: @@ -6,39 +6,83 @@ on: - "v*" jobs: - build: # job's name - runs-on: ubuntu-latest # container os - env: # ADD environment variables + build: + runs-on: ubuntu-latest + env: KEY_JKS: ${{ secrets.KEY_JKS }} KEY_PATH: ${{ github.workspace }}/key.jks KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }} - defaults: - run: - working-directory: app permissions: contents: write steps: - - uses: actions/checkout@v2 # cd to current dir - - uses: actions/setup-java@v2 + - name: Free Disk Space + uses: jlumbroso/free-disk-space@main with: - distribution: 'adopt' # See 'Supported distributions' for available options + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: false + docker-images: false + swap-storage: false + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' java-version: '17' - - name: Create key file - run: echo $KEY_JKS | base64 -di > $KEY_PATH - - uses: subosito/flutter-action@v1 + - uses: ruby/setup-ruby@v1 with: - flutter-version: 3.24.0 - - run: flutter pub get - # - run: flutter test - - run: flutter build apk --release --split-per-abi - - run: flutter build appbundle - - name: Create github artifact release # disable this to save storage + ruby-version: '3.3' + bundler-cache: true + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Download bundle dependencies + run: | + gem install bundler + bundle config path vendor/bundle + bundle install + - name: Build container proxy + working-directory: packages/flutter_mozilla_components/javascript/container_proxy + run: | + npm i + npx webpack + - name: Create key file + working-directory: app + run: echo $KEY_JKS | base64 -di > $KEY_PATH + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: 3.32.4 + cache: true + - name: Activate melos + working-directory: app + run: dart pub global activate melos + - name: Bootstrap melos + working-directory: app + run: melos bs + - name: Build APK + working-directory: app + run: flutter build apk --verbose --release --split-per-abi --no-tree-shake-icons + - name: Build App Bundle + working-directory: app + run: flutter build appbundle --verbose --no-tree-shake-icons + - name: Create github artifact release uses: ncipollo/release-action@v1 with: artifacts: "app/build/app/outputs/apk/release/*.apk,app/build/app/outputs/bundle/release/app-release.aab" - name: Upload app bundle artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: appbundle - path: app/build/app/outputs/bundle/release/app-release.aab \ No newline at end of file + path: app/build/app/outputs/bundle/release/app-release.aab + - name: Release to Google Play (internal) + working-directory: app + env: + SUPPLY_PACKAGE_NAME: ${{ secrets.ANDROID_PACKAGE_NAME }} + SUPPLY_JSON_KEY_DATA: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }} + run: | + bundle exec fastlane supply \ + --aab build/app/outputs/bundle/release/app-release.aab \ + --track internal diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..bf2c985e --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# IntelliJ +*.iml +*.ipr +*.iws +.idea/ + +# Mac +.DS_Store + +# Flutter/Dart/Pub related +.dart_tool/ +build/ +pubspec.lock diff --git a/.vscode/launch.json b/.vscode/launch.json index 4a2cd249..a11517b5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,10 @@ "cwd": "app", "request": "launch", "type": "dart", - "args": ["--device-user", "0"] + "args": [ + "--device-user", + "0" + ] }, { "name": "app (profile mode)", @@ -17,7 +20,10 @@ "request": "launch", "type": "dart", "flutterMode": "profile", - "args": ["--device-user", "0"] + "args": [ + "--device-user", + "0" + ] }, { "name": "app (release mode)", @@ -25,7 +31,30 @@ "request": "launch", "type": "dart", "flutterMode": "release", - "args": ["--device-user", "0"] - } + "args": [ + "--device-user", + "0" + ] + }, + { + "name": "flutter_mozilla_components", + "cwd": "packages/flutter_mozilla_components/example", + "request": "launch", + "type": "dart", + "args": [ + "--device-user", + "0" + ] + }, + { + "name": "simple_intent_receiver", + "cwd": "packages/simple_intent_receiver/example", + "request": "launch", + "type": "dart", + "args": [ + "--device-user", + "0" + ] + }, ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..7d1a672c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dart.runPubGetOnPubspecChanges": "never" +} \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..adc90d98 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..5e383655 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,227 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1116.0) + aws-sdk-core (3.225.2) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.105.0) + aws-sdk-core (~> 3, >= 3.225.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.189.1) + aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.228.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.12.2) + jwt (2.10.1) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.2.0) + optparse (0.6.0) + os (1.1.4) + plist (3.7.2) + public_suffix (6.0.2) + rake (13.3.0) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.4.1) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.20.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.6.9 diff --git a/LICENSE b/LICENSE index 9588c568..d38fc722 100644 --- a/LICENSE +++ b/LICENSE @@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: - Lensai Copyright (C) 2024 Fabian Freund + WebLibre Copyright (C) 2024 Fabian Freund This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 9048ecef..32415eb5 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,71 +1 @@ -**Privacy Policy** - -This privacy policy applies to the Lensai app (hereby referred to as "Application") for mobile devices that was created by Fabian Freund (hereby referred to as "Service Provider") as an Open Source service. This service is intended for use "AS IS". - -**Information Collection and Use** - -The Application collects information when you download and use it. This information may include information such as - -* Your device's Internet Protocol address (e.g. IP address) -* The pages of the Application that you visit, the time and date of your visit, the time spent on those pages -* The time spent on the Application -* The operating system you use on your mobile device - -The Application does not gather precise information about the location of your mobile device. - -The Application collects your device's location, which helps the Service Provider determine your approximate geographical location and make use of in below ways: - -* Geolocation Services: The Service Provider utilizes location data to provide features such as personalized content, relevant recommendations, and location-based services. -* Analytics and Improvements: Aggregated and anonymized location data helps the Service Provider to analyze user behavior, identify trends, and improve the overall performance and functionality of the Application. -* Third-Party Services: Periodically, the Service Provider may transmit anonymized location data to external services. These services assist them in enhancing the Application and optimizing their offerings. - -The Service Provider may use the information you provided to contact you from time to time to provide you with important information, required notices and marketing promotions. - -For a better experience, while using the Application, the Service Provider may require you to provide us with certain personally identifiable information, including but not limited to Kagi Session-ID. The information that the Service Provider request will be retained by them and used as described in this privacy policy. - -**Third Party Access** - -Only aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in improving the Application and their service. The Service Provider may share your information with third parties in the ways that are described in this privacy statement. - -Please note that the Application utilizes third-party services that have their own Privacy Policy about handling data. Below are the links to the Privacy Policy of the third-party service providers used by the Application: - -* [Google Play Services](https://www.google.com/policies/privacy/) -* [Kagi](https://kagi.com/privacy) - -The Service Provider may disclose User Provided and Automatically Collected Information: - -* as required by law, such as to comply with a subpoena, or similar legal process; -* when they believe in good faith that disclosure is necessary to protect their rights, protect your safety or the safety of others, investigate fraud, or respond to a government request; -* with their trusted services providers who work on their behalf, do not have an independent use of the information we disclose to them, and have agreed to adhere to the rules set forth in this privacy statement. - -**Opt-Out Rights** - -You can stop all collection of information by the Application easily by uninstalling it. You may use the standard uninstall processes as may be available as part of your mobile device or via the mobile application marketplace or network. - -**Data Retention Policy** - -The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please contact them at oss@movenext.me and they will respond in a reasonable time. - -**Children** - -The Service Provider does not use the Application to knowingly solicit data from or market to children under the age of 13. - -The Application does not address anyone under the age of 13\. The Service Provider does not knowingly collect personally identifiable information from children under 13 years of age. In the case the Service Provider discover that a child under 13 has provided personal information, the Service Provider will immediately delete this from their servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact the Service Provider (oss@movenext.me) so that they will be able to take the necessary actions. - -**Security** - -The Service Provider is concerned about safeguarding the confidentiality of your information. The Service Provider provides physical, electronic, and procedural safeguards to protect information the Service Provider processes and maintains. - -**Changes** - -This Privacy Policy may be updated from time to time for any reason. The Service Provider will notify you of any changes to the Privacy Policy by updating this page with the new Privacy Policy. You are advised to consult this Privacy Policy regularly for any changes, as continued use is deemed approval of all changes. - -This privacy policy is effective as of 2024-05-17 - -**Your Consent** - -By using the Application, you are consenting to the processing of your information as set forth in this Privacy Policy now and as amended by us. - -**Contact Us** - -If you have any questions regarding privacy while using the Application, or have questions about the practices, please contact the Service Provider via email at oss@movenext.me. +[Privacy Policy](https://docs.weblibre.eu/Privacy-Policy) diff --git a/README.md b/README.md index f076b507..6d45c9cc 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,60 @@

- Lensai Logo + WebLibre Logo

-# Lensai: The Privacy-Focused & AI-Powered Research Browser with Kagi integration +# WebLibre: The Privacy-Focused Browser Donate using Liberapay -Lensai enhances your mobile research experience while keeping your personal data and identity secure. -Powered by advanced AI tools and browser extensions, Lensai offers modern features designed to elevate your browsing experience. - -

- - Get it on Google Play - -

+WebLibre is an independent browser project built on the foundation of [Mozilla's Gecko Engine](https://en.wikipedia.org/wiki/Gecko_(software)) and [Mozilla Android Components](https://mozac.org/). ## Features -Lensai is packed with a variety of features aimed at improving usability and maintaining privacy: +### Privacy & Security +- **No Tracking:** WebLibre does not track or monitor your browsing. +- **Local Data Storage:** All your data stays on your device. +- **Tor integration:** Route container or private tab traffic through the Tor network for greater anonymity. +- **uBlock Origin integration:** Optional content blocker during onboarding +- **Web Engine Hardening**: Comprehensive and easy to use presets. `about:config` access is available for expert users. +- **Container Isolation:** Organize tabs into containers, each with its own cookie context and network settings. +- **Biometric Authentication:** Restrict access to sensitive containers using fingerprint or face recognition. -- **Privacy by Default**: Incognito mode is enabled by default to keep your searches and browsing history private. -- **[Kagi Integration](https://github.com/FaFre/lensai/wiki/Kagi-Tools)**: Seamlessly integrates Kagi's search, assistant, and summarizer tools within a user-friendly interface. -- **[Easy Sharing](https://github.com/FaFre/lensai/wiki/Kagi-Tools)**: Share URLs or text from any app directly into Lensai without the need to copy and paste, for direct use with summarizer/assistant. -- **[Quick Summarization](https://github.com/FaFre/lensai/wiki/Browser)**: Long press any link on a website to get a summary via Kagi's summarizer. -- **[Content Blocking](https://github.com/FaFre/lensai/wiki/Content-Blocking)**: A powerful content blocker is enabled by default to filter out ads, malware, and tracking sites. -- **[Home Widgets](https://github.com/FaFre/lensai/wiki/Home-Widget)**: Includes home screen widgets in three different sizes for quick actions, enhancing productivity. -- **[PDF processing](https://github.com/FaFre/lensai/wiki/Kagi-Tools)**: Open and directly summarize/prompt PDF documents. -- **Reader Mode**: Extract and simplify web pages, enhancing readability by removing ads, sidebars, and other non-essential elements. -- **[Chat Archive](https://github.com/FaFre/lensai/wiki/Chat-Archive)**: Easily archive conversations with the Kagi assistant on your device and search through them locally -- **[Bang Repository](https://github.com/FaFre/lensai/wiki/Bangs)**: Automatically synchronizes all official Kagi Bangs from the official Repository and makes them available through a convenient interface. -- **[Enhanced Text Interaction](https://github.com/FaFre/lensai/wiki/Browser)**: Custom context menu allows for easy passage of marked text to Kagi's search or assistant tools. -- **[Speech Input](https://github.com/FaFre/lensai/wiki/Kagi-Tools)**: Supports voice commands for hands-free operation and accessibility. +### Personal Local Search Engine -## Getting started +- **Unified Search:** Instantly search across: + - **Open Tabs:** By title, address, and full page content (no size limits). + - **Web Feeds:** Aggregate and search your favorite news sites and blogs, with full content indexing. + - **Browsing History:** Quickly find previously visited sites by title or address. + - **Bangs:** Use custom shortcuts for direct on-site search. +- **Local-First Search:** All indexing and search operations happen on your device—your data never leaves your control. -Head over to the [wiki](./wiki/Quickstart-Guide) to learn how to set up the app and explore its features. +### Improved Tab System + +- **Tab Containers:** Organize tabs into isolated containers for privacy and workflow management. + - **Container Cookie Contexts:** Separate “cookie jars” for each container. + - **Tor Proxy:** Route container traffic through the Tor Network. + - **Biometric Authentication:** Restrict access to containers with fingerprint or face recognition. +- **Tree View Navigation:** Visualize and manage complex tab hierarchies, making it easy to track your browsing context. + +### Bangs + +- **Direct On-Site Search:** Use thousands of powerful shortcuts (“bangs”) to search directly on popular websites—no third-party search engine required. + +--- + +## Getting Started + +1. **Download & Install** WebLibre on your device. +2. **Organize Tabs:** Use containers for work, personal, or research contexts. +3. **Enable Tor Proxy:** For sensitive browsing, assign Tor to containers or private tabs. +4. **Add Web Feeds:** Aggregate your favorite sites for unified, private search. +5. **Try Bangs:** Search directly on your favorite websites with custom shortcuts. + +

+ + Get it on Google Play + + + Get it on GitHub + +

diff --git a/TOC.md b/TOC.md deleted file mode 100644 index 980a59de..00000000 --- a/TOC.md +++ /dev/null @@ -1,34 +0,0 @@ -**Terms & Conditions** - -These terms and conditions applies to the Lensai app (hereby referred to as "Application") for mobile devices that was created by Fabian Freund (hereby referred to as "Service Provider") as an Open Source service. - -Upon downloading or utilizing the Application, you are automatically agreeing to the following terms. It is strongly advised that you thoroughly read and understand these terms prior to using the Application. Unauthorized copying, modification of the Application, any part of the Application, or our trademarks is strictly prohibited. Any attempts to extract the source code of the Application, translate the Application into other languages, or create derivative versions are not permitted. All trademarks, copyrights, database rights, and other intellectual property rights related to the Application remain the property of the Service Provider. - -The Service Provider is dedicated to ensuring that the Application is as beneficial and efficient as possible. As such, they reserve the right to modify the Application or charge for their services at any time and for any reason. The Service Provider assures you that any charges for the Application or its services will be clearly communicated to you. - -The Application stores and processes personal data that you have provided to the Service Provider in order to provide the Service. It is your responsibility to maintain the security of your phone and access to the Application. The Service Provider strongly advise against jailbreaking or rooting your phone, which involves removing software restrictions and limitations imposed by the official operating system of your device. Such actions could expose your phone to malware, viruses, malicious programs, compromise your phone's security features, and may result in the Application not functioning correctly or at all. - -Please note that the Application utilizes third-party services that have their own Terms and Conditions. Below are the links to the Terms and Conditions of the third-party service providers used by the Application: - -* [Google Play Services](https://policies.google.com/terms) -* [Kagi](https://kagi.com/privacy) - -Please be aware that the Service Provider does not assume responsibility for certain aspects. Some functions of the Application require an active internet connection, which can be Wi-Fi or provided by your mobile network provider. The Service Provider cannot be held responsible if the Application does not function at full capacity due to lack of access to Wi-Fi or if you have exhausted your data allowance. - -If you are using the application outside of a Wi-Fi area, please be aware that your mobile network provider's agreement terms still apply. Consequently, you may incur charges from your mobile provider for data usage during the connection to the application, or other third-party charges. By using the application, you accept responsibility for any such charges, including roaming data charges if you use the application outside of your home territory (i.e., region or country) without disabling data roaming. If you are not the bill payer for the device on which you are using the application, they assume that you have obtained permission from the bill payer. - -Similarly, the Service Provider cannot always assume responsibility for your usage of the application. For instance, it is your responsibility to ensure that your device remains charged. If your device runs out of battery and you are unable to access the Service, the Service Provider cannot be held responsible. - -In terms of the Service Provider's responsibility for your use of the application, it is important to note that while they strive to ensure that it is updated and accurate at all times, they do rely on third parties to provide information to them so that they can make it available to you. The Service Provider accepts no liability for any loss, direct or indirect, that you experience as a result of relying entirely on this functionality of the application. - -The Service Provider may wish to update the application at some point. The application is currently available as per the requirements for the operating system (and for any additional systems they decide to extend the availability of the application to) may change, and you will need to download the updates if you want to continue using the application. The Service Provider does not guarantee that it will always update the application so that it is relevant to you and/or compatible with the particular operating system version installed on your device. However, you agree to always accept updates to the application when offered to you. The Service Provider may also wish to cease providing the application and may terminate its use at any time without providing termination notice to you. Unless they inform you otherwise, upon any termination, (a) the rights and licenses granted to you in these terms will end; (b) you must cease using the application, and (if necessary) delete it from your device. - -**Changes to These Terms and Conditions** - -The Service Provider may periodically update their Terms and Conditions. Therefore, you are advised to review this page regularly for any changes. The Service Provider will notify you of any changes by posting the new Terms and Conditions on this page. - -These terms and conditions are effective as of 2024-05-17 - -**Contact Us** - -If you have any questions or suggestions about the Terms and Conditions, please do not hesitate to contact the Service Provider at oss@movenext.me. diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..c06b2c8f --- /dev/null +++ b/TODO.md @@ -0,0 +1 @@ +* check al sqlite IS with and conditions to match values with and where both params are optional \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore index 29a3a501..d480bf29 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -31,6 +31,66 @@ migrate_working_dir/ .pub/ /build/ +# Android related +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Windows +**/windows/flutter/ephemeral/ +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake + +# Linux +**/linux/flutter/ephemeral/ +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake +c # Symbolication related app.*.symbols diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml index d23dad1e..1c3e6755 100644 --- a/app/analysis_options.yaml +++ b/app/analysis_options.yaml @@ -16,14 +16,17 @@ include: package:lint/strict.yaml linter: rules: - - unawaited_futures - - discarded_futures - - collection_methods_unrelated_type + unawaited_futures: true + discarded_futures: true + collection_methods_unrelated_type: true analyzer: + errors: + overridden_fields: ignore plugins: - custom_lint exclude: + - "**.drift" - "**.g.dart" - "**.swagger.dart" - "**.freezed.dart" diff --git a/app/android/.gitignore b/app/android/.gitignore index 6f568019..89ee5bf2 100644 --- a/app/android/.gitignore +++ b/app/android/.gitignore @@ -5,6 +5,7 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java +.cxx # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 5448e536..65fdef4a 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -24,9 +24,10 @@ if (flutterVersionName == null) { } android { - namespace = "me.movenext.bang_navigator" - compileSdk = 34 - ndkVersion = flutter.ndkVersion + namespace = "eu.weblibre.gecko" + compileSdk = 35 + //Temporary fix + ndkVersion = "27.0.12077973" //flutter.ndkVersion compileOptions { sourceCompatibility JavaVersion.VERSION_17 @@ -39,11 +40,11 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "me.movenext.bang_navigator" + applicationId = "eu.weblibre.gecko" // https://api.flutter.dev/flutter/widgets/AndroidView-class.html //https://inappwebview.dev/docs/intro/#setup-android minSdk = 23 - targetSdk = 34 + targetSdk = 35 versionCode = flutterVersionCode.toInteger() versionName = flutterVersionName } @@ -51,7 +52,7 @@ android { dependencies { // implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" // implementation 'androidx.glance:glance-appwidget:1.0.0' - implementation 'com.google.android.material:material:1.11.0' + implementation 'com.google.android.material:material:1.12.0' } signingConfigs { @@ -68,6 +69,28 @@ android { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.release + + minifyEnabled false + shrinkResources false + + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + bundle { + // Profiler issues require us to temporarily package native code compressed to + // match the previous APK packaging. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1865634 + packagingOptions { + jniLibs { + it.useLegacyPackaging = true + } + } + } + + packagingOptions { + jniLibs { + useLegacyPackaging true } } @@ -94,4 +117,4 @@ android.applicationVariants.all { variant -> output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode } } -} \ No newline at end of file +} diff --git a/app/android/app/proguard-rules.pro b/app/android/app/proguard-rules.pro new file mode 100644 index 00000000..622b5737 --- /dev/null +++ b/app/android/app/proguard-rules.pro @@ -0,0 +1,125 @@ +# We do not want to obfuscate - It's just painful to debug without the right mapping file. +# If we update this, we'll have to update our Sentry config to upload ProGuard mappings. +-dontobfuscate + + +##### Default proguard settings: + +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/sebastian/Library/Android/sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +#################################################################################################### +# Adjust +#################################################################################################### + +-keep public class com.adjust.sdk.** { *; } +-keep class com.google.android.gms.common.ConnectionResult { + int SUCCESS; +} +-keep class com.google.android.gms.ads.identifier.AdvertisingIdClient { + com.google.android.gms.ads.identifier.AdvertisingIdClient$Info getAdvertisingIdInfo(android.content.Context); +} +-keep class com.google.android.gms.ads.identifier.AdvertisingIdClient$Info { + java.lang.String getId(); + boolean isLimitAdTrackingEnabled(); +} +-keep class dalvik.system.VMRuntime { + java.lang.String getRuntime(); +} +-keep class android.os.Build { + java.lang.String[] SUPPORTED_ABIS; + java.lang.String CPU_ABI; +} +-keep class android.content.res.Configuration { + android.os.LocaledList getLocales(); + java.util.Locale locale; +} +-keep class android.os.LocaledList { + java.util.Locale get(int); +} + +#################################################################################################### +# Okhttp +#################################################################################################### + +# JSR 305 annotations are for embedding nullability information. +-dontwarn javax.annotation.** + +# A resource is loaded with a relative path so the package of this class must be preserved. +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase + +# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java. +-dontwarn org.codehaus.mojo.animal_sniffer.* + +# OkHttp platform used only on JVM and when Conscrypt dependency is available. +-dontwarn okhttp3.internal.platform.ConscryptPlatform + +#################################################################################################### +# Android architecture components +#################################################################################################### + +-dontwarn android.** +-dontwarn androidx.** +-dontwarn com.google.** +-dontwarn org.mozilla.geckoview.** +-dontwarn mozilla.components.** + +# https://developer.android.com/topic/libraries/architecture/release-notes.html +# According to the docs this won't be needed when 1.0 of the library is released. +-keep class * implements android.arch.lifecycle.GeneratedAdapter {(...);} + +# Temporary fix until we can use androidx +-dontwarn mozilla.components.service.fretboard.scheduler.workmanager.** + +# Fix for ViewModels +-keep class * extends androidx.lifecycle.ViewModel { + (); +} +-keep class * extends androidx.lifecycle.AndroidViewModel { + (android.app.Application); +} + +#################################################################################################### +# Mozilla Application Services +#################################################################################################### + +-keep class mozilla.appservices.** { *; } + +#################################################################################################### +# Kotlinx +#################################################################################################### + +-dontwarn kotlinx.atomicfu.** + +#################################################################################################### +# snakeyaml +#################################################################################################### + +-dontwarn java.beans.PropertyDescriptor +-dontwarn java.beans.Introspector +-dontwarn java.beans.BeanInfo +-dontwarn java.beans.IntrospectionException +-dontwarn java.beans.FeatureDescriptor + +#################################################################################################### +# 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; +} \ No newline at end of file diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index fd933494..4e2a7985 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,19 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:usesCleartextTraffic="true" + android:enableOnBackInvokedCallback="true"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -54,6 +185,7 @@ + @@ -62,6 +194,7 @@ + @@ -70,12 +203,40 @@ + + + + + + + + + + + + + + + + + + + - - - diff --git a/app/android/build.gradle b/app/android/build.gradle index 3eef6e9c..54f73e4f 100644 --- a/app/android/build.gradle +++ b/app/android/build.gradle @@ -1,11 +1,19 @@ +//Required by background_fetch build +ext { + compileSdkVersion = 35 + targetSdkVersion = 35 +} + buildscript { - ext.kotlin_version = '1.9.22' + ext.kotlin_version = '2.1.20' } allprojects { repositories { google() mavenCentral() + //Required by background_fetch build + maven { url "${project(':background_fetch').projectDir}/libs" } } } diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties index 2d695e8a..c6a2952d 100644 --- a/app/android/gradle/wrapper/gradle-wrapper.properties +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -2,5 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip -distributionSha256Sum=194717442575a6f96e1c1befa2c30e9a4fc90f701d7aee33eb879b79e7ff05c0 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip diff --git a/app/android/settings.gradle b/app/android/settings.gradle index 9151bc04..4b2a2ac1 100644 --- a/app/android/settings.gradle +++ b/app/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.9.22" apply false + id "com.android.application" version '8.10.1' apply false + id "org.jetbrains.kotlin.android" version "2.1.20" apply false } include ":app" diff --git a/app/assets/fonts/tor-icons.ttf b/app/assets/fonts/tor-icons.ttf new file mode 100644 index 00000000..46bdc962 Binary files /dev/null and b/app/assets/fonts/tor-icons.ttf differ diff --git a/app/assets/icon/icon.png b/app/assets/icon/icon.png index 776a20d0..b24b5da9 100644 Binary files a/app/assets/icon/icon.png and b/app/assets/icon/icon.png differ diff --git a/app/assets/icon/icon.svg b/app/assets/icon/icon.svg new file mode 100644 index 00000000..c833a9c3 --- /dev/null +++ b/app/assets/icon/icon.svg @@ -0,0 +1,5 @@ + + +
+ +
diff --git a/app/assets/images/tor_white.svg b/app/assets/images/tor_white.svg new file mode 100644 index 00000000..9e39356e --- /dev/null +++ b/app/assets/images/tor_white.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/ublock.svg b/app/assets/images/ublock.svg new file mode 100644 index 00000000..ea2ffd3c --- /dev/null +++ b/app/assets/images/ublock.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + diff --git a/app/assets/landing/description.md b/app/assets/landing/description.md deleted file mode 100644 index 5b2fcc9c..00000000 --- a/app/assets/landing/description.md +++ /dev/null @@ -1,17 +0,0 @@ -Lensai enhances your mobile research experience while keeping your personal data and identity secure. -Powered by advanced AI tools and browser extensions, Lensai offers modern features designed to elevate your browsing experience. - -Lensai is packed with a variety of features aimed at improving usability and maintaining privacy: - -- **Privacy by Default**: Incognito mode is enabled by default to keep your searches and browsing history private. -- **[Kagi Integration](https://github.com/FaFre/lensai/wiki/Kagi-Tools)**: Seamlessly integrates Kagi's search, assistant, and summarizer tools within a user-friendly interface. -- **[Easy Sharing](https://github.com/FaFre/lensai/wiki/Kagi-Tools)**: Share URLs or text from any app directly into Lensai without the need to copy and paste, for direct use with summarizer/assistant. -- **[Quick Summarization](https://github.com/FaFre/lensai/wiki/Browser)**: Long press any link on a website to get a summary via Kagi's summarizer. -- **[Content Blocking](https://github.com/FaFre/lensai/wiki/Content-Blocking)**: A powerful content blocker is enabled by default to filter out ads, malware, and tracking sites. -- **[Home Widgets](https://github.com/FaFre/lensai/wiki/Home-Widget)**: Includes home screen widgets in three different sizes for quick actions, enhancing productivity. -- **[PDF processing](https://github.com/FaFre/lensai/wiki/Kagi-Tools)**: Open and directly summarize/prompt PDF documents. -- **Reader Mode**: Extract and simplify web pages, enhancing readability by removing ads, sidebars, and other non-essential elements. -- **[Chat Archive](https://github.com/FaFre/lensai/wiki/Chat-Archive)**: Easily archive conversations with the Kagi assistant on your device and search through them locally -- **[Bang Repository](https://github.com/FaFre/lensai/wiki/Bangs)**: Automatically synchronizes all official Kagi Bangs from the official Repository and makes them available through a convenient interface. -- **[Enhanced Text Interaction](https://github.com/FaFre/lensai/wiki/Browser)**: Custom context menu allows for easy passage of marked text to Kagi's search or assistant tools. -- **[Speech Input](https://github.com/FaFre/lensai/wiki/Kagi-Tools)**: Supports voice commands for hands-free operation and accessibility. diff --git a/app/assets/preferences/settings.json b/app/assets/preferences/settings.json new file mode 100644 index 00000000..c7295fb2 --- /dev/null +++ b/app/assets/preferences/settings.json @@ -0,0 +1,436 @@ +{ + "user": { + "Safe Browsing": { + "description": "Google Safe Browsing is a service from Google that warns users when they attempt to navigate to a dangerous website or download dangerous files.", + "preferences": { + "browser.safebrowsing.downloads.remote.enabled": { + "value": false, + "title": "Disable Google Safe Browsing for downloads", + "description": "To verify the safety of certain executable files, GeckoView may submit some information about the file, including the name, origin, size and a cryptographic hash of the contents, to the Google Safe Browsing service which helps GeckoView determine whether or not the file should be blocked." + } + } + }, + "Block Implicit Outbound": { + "description": "Control not explicitly user invoked interactions", + "preferences": { + "network.prefetch-next": { + "value": false, + "title": "Disable link prefetching", + "shouldBeDefault": true + }, + "network.dns.disablePrefetch": { + "value": true, + "title": "Disable DNS prefetching", + "shouldBeDefault": true + }, + "network.dns.disablePrefetchFromHTTPS": { + "value": true, + "title": "Disable HTTPS DNS prefetching" + }, + "network.predictor.enabled": { + "value": false, + "title": "Disable prefetching predictor", + "shouldBeDefault": true + }, + "network.predictor.enable-prefetch": { + "value": false, + "title": "Disable prefetching predictions", + "shouldBeDefault": true + }, + "network.http.speculative-parallel-limit": { + "value": 0, + "title": "Disable link-mouseover opening connection to linked server", + "shouldBeDefault": true + }, + "network.preconnect": { + "value": false, + "title": "Disable automatic connections to third-party domains," + } + } + }, + "DNS": { + "description": "Improve DNS and Proxy/SOCKS security", + "preferences": { + "network.trr.mode": { + "value": 3, + "title": "Enable DNS over HTTPS", + "requireUserOptIn": true + }, + "network.trr.uri": { + "value": "https://dns.quad9.net/dns-query", + "title": "Use quad9 for Trusted Recursive Resolver (TRR)", + "description": "Quad9 is a privacy-focused DNS resolver that provides malware blocking and security features.", + "requireUserOptIn": true + }, + "network.trr.custom.uri": { + "value": "https://dns.quad9.net/dns-query", + "title": "Use quad9 for custom Trusted Recursive Resolver (TRR)", + "description": "Quad9 is a privacy-focused DNS resolver that provides malware blocking and security features.", + "requireUserOptIn": true + }, + "network.dns.native_https_query": { + "value": true, + "title": "Enable native DNS HTTPS Query", + "description": "DNS over HTTPS", + "shouldBeDefault": true + }, + "network.proxy.socks_remote_dns": { + "value": true, + "title": "Set the proxy server to do any DNS lookups when using SOCKS", + "description": "e.g. in Tor, this stops your local DNS server from knowing your Tor destination as a remote Tor node will handle the DNS request" + }, + "network.file.disable_unc_paths": { + "value": true, + "title": "Disable using UNC (Uniform Naming Convention) paths", + "description": "Warning: Can break extensions for profiles on network shares", + "requireUserOptIn": true + }, + "network.gio.supported-protocols": { + "value": "", + "title": "Disable GIO as a potential proxy bypass vector" + }, + "media.peerconnection.ice.proxy_only_if_behind_proxy": { + "value": true, + "title": "Force WebRTC inside the proxy" + }, + "media.peerconnection.enabled": { + "value": false, + "title": "Disable WebRTC PeerConnection", + "description": "Prevent exposing local IP Address" + }, + "media.peerconnection.ice.default_address_only": { + "value": true, + "title": "Force a single network interface for ICE candidates generation", + "description": "When using a system-wide proxy, it uses the proxy interface" + }, + "network.dns.localDomains": { + "value": "250analytics.com,a.omappapi.com,activity-stream-icons.services.mozilla.com,ads.allizom.org,ads.mozilla.org,ads.nonprod.webservices.mozgcp.net,ads.prod.webservices.mozgcp.net,ads-img.mozilla.org,analytics.getpocket.com,analytics.google.com,analytics.withgoogle.com,anf1.fuzzing.mozilla.org,anonymco.com,api.divviup.org,asan-nightly-frontend-elb-1348905149.us-east-2.elb.amazonaws.com,braze.com,contile.services.mozilla.com,contile-images.services.mozilla.com,classify-client.nonprod.webservices.mozgcp.net,classify-client.prod.webservices.mozgcp.net,classify-client.services.mozilla.com,crash-reports.allizom.org,crash-reports.mozilla.com,crash-reports-xpsp2.mozilla.com,crash-stacks.mozilla.com,crash-stats.allizom.org,crash-stats.mozilla.com,crash-stats.mozilla.org,dap.services.mozilla.com,dap.nonprod.webservices.mozgcp.net,dap.prod.webservices.mozgcp.net,dap-09-3.api.divviup.org,data.mozilla.com,data-ingestion.prod.dataops.mozgcp.net,dataops.mozgcp.net,dataservices.mozgcp.net,discovery.addons.allizom.org,discovery.addons.mozilla.org,discovery.addons-dev.allizom.org,divviup.org,download-stats.mozilla.org,download-stats.r53-2.services.mozilla.com,experimenter.services.mozilla.com,experimenter.nonprod.webservices.mozgcp.net,experimenter.prod.webservices.mozgcp.net,fhr.data.mozilla.com,fhr.r53-2.services.mozilla.com,firefox-android-home-recommendations.getpocket.com,firefox-dns-perf-test.net,fuzzing.mozilla.org,google-analytics.com,google-analytics-cn.com,googleanalytics.com,googlesyndication.com,googlesyndication-cn.com,googletagmanager.com,googletagmanager-cn.com,googletagservices.com,googletagservices-cn.com,improving.duckduckgo.com,incoming.telemetry.mozilla.org,incoming.thunderbird.net,incoming-telemetry.thunderbird.net,ingestion-edge.prod.dataops.mozgcp.net,location.services.mozilla.com,locprod1-elb-eu-west-1.prod.mozaws.net,locprod2-elb-us-west-2.prod.mozaws.net,metrics-content.duckduckgo.com,new-sentry.gitlab.net,nonprod.classify-client.nonprod.webservices.mozgcp.net,normandy.cdn.mozilla.net,normandy.nonprod.cloudops.mozgcp.net,normandy.prod.cloudops.mozgcp.net,normandy-cdn.services.mozilla.com,omappapi.com,pagead2.googlesyndication.com,pipeline-incoming-prod-elb-149169523.us-west-2.elb.amazonaws.com,prod.ads.prod.webservices.mozgcp.net,prod.classify-client.prod.webservices.mozgcp.net,prod.dap.prod.webservices.mozgcp.net,prod.data-ingestion.prod.dataops.mozgcp.net,prod.dataops.mozgcp.net,prod.experimenter.prod.webservices.mozgcp.net,prod.ingestion-edge.prod.dataops.mozgcp.net,prod.sentry.prod.cloudops.mozgcp.net,prod-classifyclient.normandy.prod.cloudops.mozgcp.net,sdk.iad-05.braze.com,search.r53-2.services.mozilla.com,search.services.mozilla.com,self-repair.mozilla.org,self-repair.r53-2.services.mozilla.com,sentry.gitlab.net,sentry.io,sentry.nonprod.cloudops.mozgcp.net,sentry.prod.cloudops.mozgcp.net,sentry.prod.mozaws.net,sitereview.zscaler.com,snippets.allizom.org,snippets.cdn.mozilla.net,snippets.mozilla.com,snippets-prod.frankfurt.moz.works,snippets-prod.moz.works,snippets-prod.oregon-b.moz.works,snippets-stage.moz.works,snippets-stage.oregon-b.moz.works,snowplow.trx.gitlab.net,snowplowalb-1011729428.us-east-1.elb.amazonaws.com,snowplowprd.trx.gitlab.net,snowplowprdnlb-1490493263.us-east-2.elb.amazonaws.com,socorro.nonprod.webservices.mozgcp.net,socorro.prod.webservices.mozgcp.net,socorro-collector.services.mozilla.com,socorro-webapp-allizom.stage.mozaws.net,socorro-webapp.services.mozilla.com,spocs.getpocket.com,spocs.getpocket.dev,spocs.mozilla.net,ssl.google-analytics.com,ssl-google-analytics.l.google.com,stage.sentry.nonprod.cloudops.mozgcp.net,start.fedoraproject.org,start.thunderbird.net,start.ubuntu.com,start-stage.thunderbird.net,survey.mozilla.com,tagmanager.google.com,talkback.mozilla.org,talkback-public.mozilla.org,talkback-reports.mozilla.org,telemetry-coverage.mozilla.org,telemetry-coverage.r53-2.services.mozilla.com,telemetry-experiment.cdn.mozilla.net,telemetry-incoming.r53-2.services.mozilla.com,telemetry-incoming-a.r53-2.services.mozilla.com,telemetry-incoming-b.r53-2.services.mozilla.com,telemetry-prod-1054754349.us-east-1.elb.amazonaws.com,tiles-cdn.prod.ads.prod.webservices.mozgcp.net,updates.thunderbird.net,updates-stage.thunderbird.net,use-application-dns.net,vf.startpage.com,widgets.getpocket.com,www.250analytics.com,www.anonymco.com,www.google-analytics.com,www.google-analytics-cn.com,www.googleanalytics.com,www.googlesyndication.com,www.googlesyndication-cn.com,www.googletagmanager.com,www.googletagmanager-cn.com,www.googletagservices.com,www.googletagservices-cn.com,www.sentry.io,www-google-analytics.l.google.com,www-googletagmanager.l.google.com", + "title": "Block Tracking Domains" + }, + "network.proxy.type": { + "value": 0, + "title": "Prevents using automatically the system's proxy configuration by default" + } + } + }, + "Secure Connections": { + "description": "SSL/TLS / OCSP / CERTS / HPKP", + "preferences": { + "security.ssl.require_safe_negotiation": { + "value": true, + "title": "Require safe negotiation", + "description": "Blocks connections to servers that don't support RFC 5746 as they're potentially vulnerable to a MiTM attack." + }, + "security.tls.enable_0rtt_data": { + "value": false, + "title": "Disable TLS1.3 0-RTT (round-trip time)", + "description": "This data is not forward secret, as it is encrypted solely under keys derived using the offered PSK. There are no guarantees of non-replay between connections." + }, + "security.OCSP.enabled": { + "value": 1, + "title": "Enforce OCSP fetching to confirm current validity of certificates" + }, + "security.OCSP.require": { + "value": true, + "title": "Set OCSP fetch failures to hard-fail", + "description": "When a CA cannot be reached to validate a cert, GeckoView just continues the connection (=soft-fail). Setting this pref to true tells GeckoView to instead terminate the connection (=hard-fail)." + }, + "security.cert_pinning.enforcement_level": { + "value": 2, + "title": "Enable strict PKP (Public Key Pinning)" + }, + "security.remote_settings.crlite_filters.enabled": { + "value": true, + "title": "Enable CRLite" + }, + "security.pki.crlite_mode": { + "value": 2, + "title": "Enable CRLite for PKI", + "shouldBeDefault": true + }, + "security.tls.enable_kyber": { + "value": true, + "title": "Enable Post-Quantum Kyber TLS" + }, + "network.http.http3.enable_kyber": { + "value": true, + "title": "Enable HTTP3 Post-Quantum Kyber TLS" + }, + "security.ssl.treat_unsafe_negotiation_as_broken": { + "value": true, + "title": "Display warning on the padlock for 'broken security'" + }, + "browser.xul.error_pages.expert_bad_cert": { + "value": true, + "title": "Display advanced information on Insecure Connection warning pages" + }, + "dom.security.https_first": { + "value": true, + "title": "Attempt to establish HTTPS connections first before falling back to HTTP", + "shouldBeDefault": true + } + } + }, + "Privacy": { + "preferences": { + "network.http.referer.XOriginTrimmingPolicy": { + "value": 2, + "title": "Trim cross-origin referrers", + "description": "Trims down referrers to just the scheme, hostname and port" + }, + "network.http.referer.XOriginPolicy": { + "value": 2, + "title": "Only send cross-origin referer if host matches" + }, + "network.http.referer.disallowCrossSiteRelaxingDefault": { + "value": true, + "title": "Disallow relaxing referrer policy for cross-site requests", + "shouldBeDefault": true + }, + "network.http.referer.disallowCrossSiteRelaxingDefault.top_navigation": { + "value": true, + "title": "Disallow relaxing referrer policy for cross-site requests", + "description": "On top navigation", + "shouldBeDefault": true + }, + "general.useragent.updates.enabled": { + "value": false, + "title": "Disable automatic User Agent updates", + "description": "This preference controls whether Firefox automatically updates its User-Agent string. When set to false, it prevents Firefox from automatically updating the browser's User-Agent string, which is a text identifier that tells websites which browser and version you're using." + }, + "privacy.query_stripping.enabled": { + "value": true, + "title": "Strip known tracking query parameters from URLs" + }, + "privacy.query_stripping.enabled.pbmode": { + "value": true, + "title": "Strip known tracking query parameters from URLs (Private browsing)" + }, + "privacy.query_stripping.strip_list": { + "value": "__hsfp __hssc __hstc __s _bhlid _branch_match_id _branch_referrer _gl _hsenc _kx _openstat at_recipient_id at_recipient_list bbeml bsft_clkid bsft_uid dclid et_rid fb_action_ids fb_comment_id fbclid gbraid gclid guce_referrer guce_referrer_sig hsCtaTracking igshid irclickid mc_eid mkt_tok ml_subscriber ml_subscriber_hash msclkid mtm_cid oft_c oft_ck oft_d oft_id oft_ids oft_k oft_lk oft_sk oly_anon_id oly_enc_id pk_cid rb_clickid s_cid sc_customer sc_eh sc_uid srsltid ss_email_id twclid unicorn_click_id vero_conv vero_id vgo_ee wbraid wickedid yclid ymclid ysclid", + "title": "Improve built-in query stripping" + }, + "dom.private-attribution.submission.enabled": { + "value": false, + "title": "Disable Private Attribution Submission Control", + "shouldBeDefault": true + }, + "privacy.trackingprotection.enabled": { + "value": true, + "title": "Enable Tracking Protection" + }, + "privacy.trackingprotection.pbmode.enabled": { + "value": true, + "title": "Enable Tracking Protection (Private browsing)" + }, + "privacy.trackingprotection.socialtracking.enabled": { + "value": true, + "title": "Enable Social Tracking Protection", + "shouldBeDefault": true + }, + "privacy.trackingprotection.cryptomining.enabled": { + "value": true, + "title": "Enable Cryptomining Protection", + "shouldBeDefault": true + }, + "privacy.trackingprotection.fingerprinting.enabled": { + "value": true, + "title": "Enable Fingerprinting Tracking Protection", + "shouldBeDefault": true + }, + "cookiebanners.service.mode": { + "value": 1, + "title": "Enable Cookie Banner Reduction", + "shouldBeDefault": true + }, + "cookiebanners.service.mode.privateBrowsing": { + "value": 1, + "title": "Enable Cookie Banner Reduction (Private browsing)", + "shouldBeDefault": true + }, + "cookiebanners.listService.testSkipRemoteSettings": { + "value": true, + "title": "Use local cookie banner rules" + }, + "cookiebanners.listService.testRules": { + "value": "[\r\n {\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie_dismiss\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"duh.de\"\r\n ],\r\n \"id\": \"3b73c6b4-b746-462b-8f5e-054ffcd5f2b3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button.rejectAll\",\r\n \"presence\": \"div.cookiebanner\"\r\n },\r\n \"domains\": [\r\n \"destatis.de\"\r\n ],\r\n \"id\": \"b337aaa7-683f-4c8c-8a22-75ab6ae301ff\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button.cn-decline\",\r\n \"presence\": \"div.cookie-notice\"\r\n },\r\n \"domains\": [\r\n \"gls.de\"\r\n ],\r\n \"id\": \"727d6577-ae04-4dc0-8ae3-c0af3f3787d5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fides-accept-all-button\",\r\n \"optOut\": \"button.fides-reject-all-button\",\r\n \"presence\": \"div#fides-banner\"\r\n },\r\n \"domains\": [\r\n \"nytimes.com\"\r\n ],\r\n \"id\": \"488f07f9-0a01-4118-aab3-2b817e9fe6dc\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"custom-button#consentAccept\",\r\n \"presence\": \"main[data-consent-main]\"\r\n },\r\n \"domains\": [\r\n \"rp-online.de\"\r\n ],\r\n \"id\": \"06ae9b8e-909a-4f50-be66-0046a1a75ddf\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cookie-consent-banner__btn-primary\",\r\n \"presence\": \"#cookie-consent-banner\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"__tnw_cookieConsent\",\r\n \"value\": \"{%22ad_storage%22:%22denied%22%2C%22analytics_storage%22:%22denied%22}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"thenextweb.com\"\r\n ],\r\n \"id\": \"02c3c5e1-03a6-426a-b00b-fa34f62322fd\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div[data-tracking-opt-in-accept=\\\"true\\\"]\",\r\n \"optOut\": \"div[data-tracking-opt-in-reject=\\\"true\\\"]\",\r\n \"presence\": \"div[data-tracking-opt-in-overlay=\\\"true\\\"]\"\r\n },\r\n \"domains\": [\r\n \"fandom.com\"\r\n ],\r\n \"id\": \"D168AF87-F481-4AD7-BE78-28A59F798406\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"d_prefs\",\r\n \"value\": \"MToxLGNvbnNlbnRfdmVyc2lvbjoyLHRleHRfdmVyc2lvbjoxMDAw\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"d_prefs\",\r\n \"value\": \"MjoxLGNvbnNlbnRfdmVyc2lvbjoyLHRleHRfdmVyc2lvbjoxMDAw\"\r\n },\r\n {\r\n \"name\": \"twtr_pixel_opt_in\",\r\n \"value\": \"N\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"twitter.com\",\r\n \"x.com\"\r\n ],\r\n \"id\": \"05b3b417-c4c7-4ed0-a3cf-43053e8b33ab\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[data-cookiebanner=\\\"accept_button\\\"]\",\r\n \"optOut\": \"[data-cookiebanner=\\\"accept_only_essential_button\\\"]\",\r\n \"presence\": \"[data-testid=\\\"cookie-policy-manage-dialog\\\"]\"\r\n },\r\n \"domains\": [\r\n \"facebook.com\",\r\n \"instagram.com\",\r\n \"messenger.com\",\r\n \"meta.com\",\r\n \"oculus.com\",\r\n \"workplace.com\"\r\n ],\r\n \"id\": \"d1d8ba36-ced7-4453-8b17-2e051e0ab1eb\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#shopify-pc__banner__btn-accept\",\r\n \"optOut\": \"#shopify-pc__banner__btn-decline\",\r\n \"presence\": \"#shopify-pc__banner\"\r\n },\r\n \"domains\": [\r\n \"decathlon.com.sa\",\r\n \"decathlon.gp\",\r\n \"decathlon.mq\"\r\n ],\r\n \"id\": \"1da8a6ad-f894-400e-8b3d-2a281015b86d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#ppms_cm_agree-to-all\",\r\n \"optOut\": \"#ppms_cm_reject-all\",\r\n \"presence\": \"#ppms_cm_popup_overlay\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"credit-agricole.pl\"\r\n ],\r\n \"id\": \"5050fb9c-fd8a-44c2-817c-23d95f494190\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie_manager_cookie_marketing_enabled\",\r\n \"value\": \"false\"\r\n },\r\n {\r\n \"name\": \"cookie_manager_cookie_necessary_enabled\",\r\n \"value\": \"true\"\r\n },\r\n {\r\n \"name\": \"cookie_manager_cookie_statistic_enabled\",\r\n \"value\": \"false\"\r\n },\r\n {\r\n \"name\": \"cookie_manager_policy_accepted\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"credit-agricole.it\"\r\n ],\r\n \"id\": \"1944a25e-6f16-434d-8c59-0493ba587fe7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#popin_tc_privacy_button_2\",\r\n \"optOut\": \"#popin_tc_privacy_button_3\",\r\n \"presence\": \"#tc-privacy-wrapper\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"TC_PRIVACY\",\r\n \"value\": \"1%40006%7C86%7C3315%40%40%402524608000000%2C2524608000000%2C2524608000000%40\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"credit-agricole.fr\"\r\n ],\r\n \"id\": \"f643be2a-b805-4ab0-9e67-929419e5c7c7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".allow\",\r\n \"optOut\": \".deny\",\r\n \"presence\": \"#tarteaucitronWelcome\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie_manager\",\r\n \"value\": \"!atinternet=false!recaptcha=false!addthis=false!twitter=false!ezplatform=true!youtube=false!hidebanner=true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"credit-agricole.de\"\r\n ],\r\n \"id\": \"1e12c729-34ff-4aa5-9317-d19309affd2c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".accept\",\r\n \"optOut\": \".deny\",\r\n \"presence\": \"#tarteaucitronAlertBig\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie_manager\",\r\n \"value\": \"!atinternet=false!recaptcha=false!addthis=false!linkedin=false!twitter=false!ezplatform=true!youtube=false!hidebanner=true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"credit-agricole.com\"\r\n ],\r\n \"id\": \"1f0a3536-5b09-426a-b6e6-b902c556cb8a\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"_cookies_v2\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"blablacar.com.br\",\r\n \"blablacar.com.tr\",\r\n \"blablacar.com.ua\",\r\n \"blablacar.in\",\r\n \"blablacar.mx\",\r\n \"blablacar.rs\",\r\n \"blablacar.ru\"\r\n ],\r\n \"id\": \"2f4e1235-a360-46ca-bf26-8b09645ee3d5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".allowAll\",\r\n \"optOut\": \".denyAll\",\r\n \"presence\": \".cookiesjsr-banner\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookiesjsr\",\r\n \"value\": \"%7B%22functional%22%3Afalse%2C%22recaptcha%22%3Afalse%2C%22video%22%3Afalse%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"mediamarktsaturn.com\"\r\n ],\r\n \"id\": \"6f85da65-e44e-4d58-b87c-3e31861de3e0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#pwa-consent-layer-accept-all-button\",\r\n \"optOut\": \"[data-test=\\\"pwa-consent-layer-deny-all\\\"]\",\r\n \"presence\": \"[data-test=\\\"mms-privacy-layer\\\"]\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"pwaconsent\",\r\n \"value\": \"v:1.0~required:1&clf:1,cli:1,gfb:1,gtm:1,jot:1,ocx:1|comfort:0&baz:0,cne:0,con:0,fix:0,gfa:0,goa:0,gom:0,grc:0,grv:0,inm:0,lib:0,lob:0,opt:0,orc:0,ore:0,pcl:0,sen:0,sis:0,spe:0,sst:0,swo:0,twi:0,usw:0,usz:0,yte:0|marketing:0&cri:0,eam:0,fab:0,fbc:0,fcm:0,gac:0,gad:0,gcl:0,gcm:0,gdv:0,gos:0,gse:0,msb:0,omp:0,pin:0,ttd:0,twt:0|\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"mediamarkt.at\",\r\n \"mediamarkt.be\",\r\n \"mediamarkt.de\",\r\n \"mediamarkt.es\",\r\n \"mediamarkt.nl\",\r\n \"mediamarkt.pl\",\r\n \"saturn.de\"\r\n ],\r\n \"id\": \"2a2b8102-d276-4ece-afbd-005e8e917d18\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#acceptAllQuick\",\r\n \"optOut\": \"#rejectAllQuick\",\r\n \"presence\": \"#gdpr-component\",\r\n \"runContext\": \"child\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cocos\",\r\n \"value\": \"%7B%22funkcni%22%3Afalse%2C%22statisticke%22%3Afalse%2C%22reklamni%22%3Afalse%7D\"\r\n },\r\n {\r\n \"name\": \"cookieConsent\",\r\n \"value\": \"%7B%22preferences%22%3Afalse%2C%22statistics%22%3Afalse%2C%22marketing%22%3Afalse%7D\"\r\n },\r\n {\r\n \"name\": \"corec\",\r\n \"value\": \"%7B%22youtube%22%3Afalse%2C%22rtbhouse%22%3Afalse%2C%22google_recaptcha%22%3Atrue%2C%22foxentry_funkcni%22%3Afalse%2C%22tmobile_nezbytne%22%3Atrue%2C%22linkedin%22%3Afalse%2C%22medallia%22%3Afalse%2C%22tmobile_funkcni%22%3Afalse%2C%22tealium%22%3Afalse%2C%22adform%22%3Afalse%2C%22xaxis%22%3Afalse%2C%22twitter%22%3Afalse%2C%22appnexus%22%3Afalse%2C%22gemius%22%3Afalse%2C%22exponea%22%3Afalse%2C%22hotjar%22%3Afalse%2C%22tmobile_reklamni%22%3Afalse%2C%22facebook%22%3Afalse%2C%22inspectlet%22%3Afalse%2C%22cloudflare%22%3Afalse%2C%22google_ads%22%3Afalse%2C%22bing%22%3Afalse%2C%22foxentry_reklamni%22%3Afalse%2C%22clarity%22%3Afalse%2C%22seznam%22%3Afalse%2C%22tmobile_statisticke%22%3Afalse%2C%22google_analytics%22%3Afalse%2C%22rejectAllDate%22%3A%222024-03-28%22%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"t-mobile.cz\"\r\n ],\r\n \"id\": \"c7f03541-c93e-4939-a640-7c686d595986\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"orange_cookieconsent_dismissed\",\r\n \"value\": \"no\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"orange.sn\"\r\n ],\r\n \"id\": \"24350444-6b01-46a5-b8a4-99f4d417f08f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".js-consent-all-submit\",\r\n \"optOut\": \".js-consent-bypass-button\",\r\n \"presence\": \".fancybox-type-html\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"consent\",\r\n \"value\": \"bypass\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"orange.jobs\"\r\n ],\r\n \"id\": \"2e026e27-1356-40c8-a25c-24fbe4bf8af4\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#consent_optin\",\r\n \"optOut\": \"#consent_optout\",\r\n \"presence\": \"#__tealiumGDPRecModal\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CONSENTMGR\",\r\n \"value\": \"c1:0%7Cc2:0%7Cc3:0%7Cc4:0%7Cc5:0%7Cc6:0%7Cc7:0%7Cc8:0%7Cc9:0%7Cc10:0%7Cc11:0%7Cc12:0%7Cc13:0%7Cc14:0%7Cc15:0%7Cts:2524608000000%7Cconsent:false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"orange.es\"\r\n ],\r\n \"id\": \"1626f70d-7761-467c-b3ed-56e324786902\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#consent_prompt_submit\",\r\n \"presence\": \"#__tealiumGDPRecModal\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CONSENTMGR\",\r\n \"value\": \"c1:0%7Cc6:0%7Cc9:0%7Cts:2524608000000%7Cconsent:false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"orange.be\"\r\n ],\r\n \"id\": \"2172b091-8b03-4f66-b20c-08c14e21c0aa\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cookie-agree\",\r\n \"optOut\": \".no-agree > a\",\r\n \"presence\": \"#modal-cookie-info\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"site_cookie_info_i\",\r\n \"value\": \"2\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"intersport.mk\"\r\n ],\r\n \"id\": \"6fdb72ae-429c-49ad-87d3-4ff2675e5d29\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#acceptAllCookiesBtn\",\r\n \"optOut\": \"#acceptNecessaryCookiesBtn\",\r\n \"presence\": \"#cookieConsentModal\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"ConsentV2\",\r\n \"value\": \"[%22necessary%22]\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"intersport.fo\"\r\n ],\r\n \"id\": \"1450d6da-220a-42dd-b523-1771343cbd90\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".js--cookie-banner-accept\",\r\n \"optOut\": \".js--cookie-banner-save-required-settings\",\r\n \"presence\": \".cookie-banner\"\r\n },\r\n \"domains\": [\r\n \"intersport.de\"\r\n ],\r\n \"id\": \"de176046-8be7-4876-8332-0559dfd0b70b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".js-accept\",\r\n \"optOut\": \".js-decline\",\r\n \"presence\": \"#fancybox-container-1\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"ConsentChecked\",\r\n \"value\": \"{\\\"userHasSetCookies\\\":true,\\\"functionalityCookies\\\":false,\\\"statisticCookies\\\":false,\\\"marketingCookies\\\":false}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"intersport.bg\",\r\n \"intersport.com.cy\",\r\n \"intersport.gr\",\r\n \"intersport.ro\"\r\n ],\r\n \"id\": \"a8a9ddf7-1cf3-4252-b4f6-6d396e4c7ba7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#CybotCookiebotDialogBodyButtonAccept\",\r\n \"optOut\": \"#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CookieConsent\",\r\n \"value\": \"{necessary:true%2Cpreferences:false%2Cstatistics:false%2Cmarketing:false%2Cmethod:%27explicit%27%2Cver:1%2Cutc:2524608000000%2Cregion:%27zz%27}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"intersport.be\"\r\n ],\r\n \"id\": \"9c762187-68bd-4d9b-b16c-014236082550\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#acceptAllQuick\",\r\n \"optOut\": \"#rejectAllQuick\",\r\n \"presence\": \"#gdpr-component\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookieConsent\",\r\n \"value\": \"%7B%22strictlyNecessary%22:true,%22preferences%22:false,%22marketing%22:false,%22statistics%22:false%7D\"\r\n },\r\n {\r\n \"name\": \"cookieConsentVersion\",\r\n \"value\": \"V3\"\r\n },\r\n {\r\n \"name\": \"purpose_cookie\",\r\n \"value\": \"1\"\r\n },\r\n {\r\n \"name\": \"receiver_cookie\",\r\n \"value\": \"14\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"telekom.sk\"\r\n ],\r\n \"id\": \"7b4506b2-1c6f-4afc-ab5f-892331cabad3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cookiesModal #all-cookies-btn\",\r\n \"optOut\": \"#cookiesModal #required-cookies-btn\",\r\n \"presence\": \"#cookiesModal\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookieConsent\",\r\n \"value\": \"%7B%22strictlyNecessary%22%3Atrue%2C%22marketing%22%3Afalse%2C%22statistics%22%3Afalse%2C%22preferences%22%3Afalse%7D\"\r\n },\r\n {\r\n \"name\": \"gdpr\",\r\n \"value\": \"0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"telekom.mk\"\r\n ],\r\n \"id\": \"2858c963-0a13-4c5b-b7e8-c3f9b79c5b8d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#consentAcceptAll\",\r\n \"optOut\": \"#rejectAll\",\r\n \"presence\": \"#__tealiumGDPRecModal\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CONSENTMGR\",\r\n \"value\": \"c1:0%7Cc3:0%7Cc7:0%7Cts:2524608000000%7Cconsent:false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"magentacloud.de\",\r\n \"magentasport.de\",\r\n \"t-systems.com\",\r\n \"t-systems.jobs\",\r\n \"telekom-hauptstadtrepraesentanz.com\",\r\n \"telekom.com\",\r\n \"telekom.de\",\r\n \"telekom.jobs\"\r\n ],\r\n \"id\": \"8907164e-17d8-4d27-b0a3-edda59f53dbe\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#modals #accept-all\",\r\n \"optOut\": \"#modals #decline-all\",\r\n \"presence\": \"#modals\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookies\",\r\n \"value\": \"%7B%22submitted%22%3A%222050-01-01T00%3A00%3A00Z%22%2C%22consent%22%3A%7B%22statistics%22%3Afalse%2C%22marketing%22%3Afalse%7D%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"deutschetelekomitsolutions.sk\"\r\n ],\r\n \"id\": \"2f9c701b-8b1f-4f44-82cc-e79d717e390f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#didomi-notice-agree-button\",\r\n \"presence\": \"#didomi-notice\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"decathlon.si\",\r\n \"t-mobile.pl\"\r\n ],\r\n \"id\": \"83ad4c2f-e59a-4295-a342-5db40fb81763\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".ch2-allow-all-btn\",\r\n \"optOut\": \".ch2-deny-all-btn\",\r\n \"presence\": \".ch2-dialog\"\r\n },\r\n \"domains\": [\r\n \"cookiehub.com\",\r\n \"semrush.com\",\r\n \"vodafone.is\"\r\n ],\r\n \"id\": \"305b6c0d-5b66-4b3f-bf0f-fb85db21fe60\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#didomi-notice-agree-button\",\r\n \"optOut\": \"#didomi-notice-x-button\",\r\n \"presence\": \"#didomi-popup\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"decathlon.ee\"\r\n ],\r\n \"id\": \"e4b0998b-a54c-458d-935b-6ec957175711\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#didomi-notice-agree-button\",\r\n \"optOut\": \"#didomi-notice-disagree-button\",\r\n \"presence\": \"#didomi-popup\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"orange-business.com\"\r\n ],\r\n \"id\": \"49719e34-a29a-4604-ae3c-b9835e286473\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#didomi-notice-agree-button\",\r\n \"optOut\": \"#didomi-notice-disagree-button\",\r\n \"presence\": \"#didomi-popup\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"blablacar.be\",\r\n \"blablacar.co.uk\",\r\n \"blablacar.cz\",\r\n \"blablacar.de\",\r\n \"blablacar.es\",\r\n \"blablacar.fr\",\r\n \"blablacar.hr\",\r\n \"blablacar.hu\",\r\n \"blablacar.it\",\r\n \"blablacar.nl\",\r\n \"blablacar.pl\",\r\n \"blablacar.pt\",\r\n \"blablacar.ro\",\r\n \"blablacar.sk\",\r\n \"decathlon-united.media\",\r\n \"decathlon.at\",\r\n \"decathlon.be\",\r\n \"decathlon.com.dz\",\r\n \"decathlon.de\",\r\n \"decathlon.pl\",\r\n \"decathlon.pt\",\r\n \"decathlon.ro\",\r\n \"decathlon.sk\"\r\n ],\r\n \"id\": \"3b20cf84-991e-4155-8620-4e897d703530\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#didomi-notice-agree-button\",\r\n \"optOut\": \".didomi-continue-without-agreeing\",\r\n \"presence\": \"#didomi-notice\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"decathlon.cz\",\r\n \"decathlon.it\",\r\n \"hnonline.sk\",\r\n \"orange.md\",\r\n \"orange.sk\"\r\n ],\r\n \"id\": \"5da98b86-9a16-47ec-8607-046744d93396\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#didomi-notice-agree-button\",\r\n \"optOut\": \".didomi-continue-without-agreeing\",\r\n \"presence\": \"#didomi-popup\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"decathlon.ch\",\r\n \"decathlon.co.uk\",\r\n \"decathlon.es\",\r\n \"decathlon.fr\",\r\n \"decathlon.hu\",\r\n \"decathlon.media\",\r\n \"decathlon.mt\",\r\n \"decathlon.nl\",\r\n \"decathlon.yoga\",\r\n \"orange.pl\",\r\n \"quechua.fr\"\r\n ],\r\n \"id\": \"05b53737-a488-4312-b845-72d804872158\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cpnb-accept-btn\",\r\n \"optOut\": \"#cpnb-decline-btn\",\r\n \"presence\": \"#cpnb\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cpnb_cookiesSettings\",\r\n \"value\": \"%7B%22required-cookies%22%3A1%2C%22analytical-cookies%22%3A0%2C%22social-media-cookies%22%3A0%2C%22targeted-advertising-cookies%22%3A0%7D\"\r\n },\r\n {\r\n \"name\": \"cpnbCookiesDeclined\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"vodafone.pf\"\r\n ],\r\n \"id\": \"723c6f57-2399-4a6c-bd5e-83650d2db861\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#dip-consent-summary-accept-all\",\r\n \"optOut\": \"#dip-consent-summary-reject-all\",\r\n \"presence\": \"#dip-consent\"\r\n },\r\n \"domains\": [\r\n \"vodafone.de\"\r\n ],\r\n \"id\": \"b85f7d67-d6d4-40ee-8472-a32b6cd01e0e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[value=\\\"allowAll\\\"]\",\r\n \"optOut\": \"[value=\\\"saveNecessarily\\\"]\",\r\n \"presence\": \".vfcc__overlay\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"vfconsents\",\r\n \"value\": \"\\\"cvx:5|vt:2524608000000|vn:1|ci:1|funa:o|mktg:o|cond:1|dldt:2524608000000|cvd:5|cvu:5|cdd:2524608000000|vind:1\\\"\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"vodafone.cz\"\r\n ],\r\n \"id\": \"ba7bde95-93ff-43fd-845f-b8a396b46480\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \".cookie-banner__close\",\r\n \"presence\": \".cookie-banner\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"meta_connect_cookies_session\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"metaconnect.com\"\r\n ],\r\n \"id\": \"529b0511-417d-46e3-a601-4e9c8e662d01\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#onetrust-accept-btn-handler\",\r\n \"presence\": \"div#onetrust-consent-sdk\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"gitlab.com\",\r\n \"speedtest.net\",\r\n \"name.com\",\r\n \"mlb.com\",\r\n \"qualtrics.com\",\r\n \"tim.it\",\r\n \"hotnews.ro\",\r\n \"mashable.com\",\r\n \"pcmag.com\",\r\n \"barnesandnoble.com\",\r\n \"politico.com\",\r\n \"quillbot.com\",\r\n \"newyorker.com\",\r\n \"upwork.com\",\r\n \"mediafax.ro\",\r\n \"elisa.fi\",\r\n \"blick.ch\",\r\n \"tvn24.pl\",\r\n \"olx.pl\",\r\n \"olx.bg\",\r\n \"gsp.ro\",\r\n \"fastly.com\",\r\n \"spotify.com\",\r\n \"20min.ch\",\r\n \"olx.ro\",\r\n \"olx.pt\",\r\n \"sportal.bg\",\r\n \"gazeta.pl\",\r\n \"romaniatv.net\",\r\n \"teamviewer.com\",\r\n \"ted.com\",\r\n \"tripadvisor.com\",\r\n \"webmd.com\",\r\n \"cambridge.org\",\r\n \"investing.com\",\r\n \"businesswire.com\",\r\n \"istockphoto.com\",\r\n \"iso.org\",\r\n \"quizlet.com\",\r\n \"genius.com\",\r\n \"jstor.org\",\r\n \"trendmicro.com\",\r\n \"duolingo.com\",\r\n \"sophos.com\",\r\n \"rte.ie\",\r\n \"euro.com.pl\",\r\n \"wired.com\",\r\n \"arstechnica.com\",\r\n \"gartner.com\",\r\n \"thelancet.com\",\r\n \"weebly.com\",\r\n \"irishtimes.com\",\r\n \"libertatea.ro\",\r\n \"otomoto.pl\",\r\n \"sport.pl\",\r\n \"novini.bg\",\r\n \"stiripesurse.ro\",\r\n \"suomi24.fi\",\r\n \"ziare.com\",\r\n \"irishexaminer.com\",\r\n \"tripadvisor.it\",\r\n \"thejournal.ie\",\r\n \"superbet.ro\",\r\n \"g4media.ro\",\r\n \"wyborcza.pl\",\r\n \"nachrichten.at\",\r\n \"tt.com\",\r\n \"three.ie\",\r\n \"tripadvisor.co.uk\",\r\n \"dcnews.ro\",\r\n \"vol.at\",\r\n \"plotek.pl\",\r\n \"howstuffworks.com\",\r\n \"tripadvisor.de\",\r\n \"acer.com\",\r\n \"allaboutcookies.org\",\r\n \"bankier.pl\",\r\n \"brother.co.uk\",\r\n \"brother.es\",\r\n \"brother.it\",\r\n \"digicert.com\",\r\n \"epson.co.uk\",\r\n \"fiverr.com\",\r\n \"frontiersin.org\",\r\n \"glassdoor.com\",\r\n \"global.brother\",\r\n \"gq-magazine.co.uk\",\r\n \"ingka.com\",\r\n \"inpost.pl\",\r\n \"instructure.com\",\r\n \"komputronik.pl\",\r\n \"mediaexpert.pl\",\r\n \"oleole.pl\",\r\n \"ookla.com\",\r\n \"otodom.pl\",\r\n \"play.pl\",\r\n \"prnewswire.com\",\r\n \"salesforce.com\",\r\n \"slack.com\",\r\n \"thawte.com\",\r\n \"ui.com\",\r\n \"uisp.com\",\r\n \"vodafone.com.tr\",\r\n \"vodafone.it\"\r\n ],\r\n \"id\": \"256259D5-28AA-44C4-A837-8A30424005BB\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#popin_tc_privacy_button_2\",\r\n \"presence\": \"div#popin_tc_privacy_container_button\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [],\r\n \"optOut\": [\r\n {\r\n \"name\": \"TC_PRIVACY\",\r\n \"value\": \"1@006%7C86%7C3315@@@1665406595598%2C1665406595598%2C1680958595598@\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"sparkasse.at\"\r\n ],\r\n \"id\": \"850ca0e7-372f-4c9f-bfbd-76d38a076cf7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cc-btn.cc-allow\",\r\n \"optOut\": \".cc-btn.cc-deny\",\r\n \"presence\": \".cc-window.cc-banner\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookieconsent_status\",\r\n \"value\": \"deny\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"decathlon.nc\",\r\n \"decathlon.re\",\r\n \"magnite.com\",\r\n \"mecklenburg-vorpommern.de\",\r\n \"omv.com\",\r\n \"omv.at\",\r\n \"omv.bg\",\r\n \"omv.de\",\r\n \"omv.nz\",\r\n \"omv.no\",\r\n \"omv.ro\",\r\n \"omv.co.rs\",\r\n \"omv.sk\",\r\n \"omv.cz\",\r\n \"omv.tn\",\r\n \"omv.ae\",\r\n \"omv.hu\",\r\n \"omv.si\",\r\n \"omv-gas.com\",\r\n \"omv-gas.at\",\r\n \"omv-gas.be\",\r\n \"omv-gas.de\",\r\n \"omv-gas.hu\",\r\n \"omv-gas.nl\",\r\n \"omv-gas.com.tr\",\r\n \"omv-gas-storage.com\",\r\n \"omvpetrom.com\",\r\n \"petrom.ro\",\r\n \"petrom.md\",\r\n \"avanti.at\",\r\n \"avanti-tankstellen.de\",\r\n \"diskonttanken.at\",\r\n \"tocimceneje.si\"\r\n ],\r\n \"id\": \"8f401b10-02b6-4e05-88fa-c37012d4c8c0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#onetrust-accept-btn-handler\",\r\n \"optOut\": \".ot-pc-refuse-all-handler, #onetrust-reject-all-handler\",\r\n \"presence\": \"div#onetrust-consent-sdk\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"espncricinfo.com\",\r\n \"blackboard.com\",\r\n \"roche.com\",\r\n \"apnews.com\",\r\n \"nationalgeographic.com\",\r\n \"espn.com\",\r\n \"hotjar.com\",\r\n \"marriott.com\",\r\n \"hootsuite.com\",\r\n \"wattpad.com\",\r\n \"gamespot.com\",\r\n \"apa.org\",\r\n \"opendns.com\",\r\n \"epicgames.com\",\r\n \"zendesk.com\",\r\n \"drei.at\",\r\n \"ikea.com\",\r\n \"search.ch\",\r\n \"centrum.sk\",\r\n \"zoom.us\",\r\n \"pluska.sk\",\r\n \"hp.com\",\r\n \"ceskatelevize.cz\",\r\n \"telenet.be\",\r\n \"adobe.com\",\r\n \"rottentomatoes.com\",\r\n \"dhl.com\",\r\n \"dhl.de\",\r\n \"nvidia.com\",\r\n \"cloudflare.com\",\r\n \"webex.com\",\r\n \"indeed.com\",\r\n \"discord.com\",\r\n \"sport.ro\",\r\n \"ricardo.ch\",\r\n \"stirileprotv.ro\",\r\n \"1177.se\",\r\n \"meinbezirk.at\",\r\n \"orange.ro\",\r\n \"ica.se\",\r\n \"flashscore.pl\",\r\n \"kuleuven.be\",\r\n \"tutti.ch\",\r\n \"post.at\",\r\n \"rezultati.com\",\r\n \"nbg.gr\",\r\n \"behance.net\",\r\n \"zemanta.com\",\r\n \"grammarly.com\",\r\n \"usatoday.com\",\r\n \"cnet.com\",\r\n \"npr.org\",\r\n \"binance.com\",\r\n \"linktr.ee\",\r\n \"time.com\",\r\n \"cisco.com\",\r\n \"udemy.com\",\r\n \"shutterstock.com\",\r\n \"investopedia.com\",\r\n \"cbsnews.com\",\r\n \"okta.com\",\r\n \"appsflyer.com\",\r\n \"typepad.com\",\r\n \"calendly.com\",\r\n \"verisign.com\",\r\n \"outbrain.com\",\r\n \"zdnet.com\",\r\n \"deloitte.com\",\r\n \"hdfcbank.com\",\r\n \"media.net\",\r\n \"docker.com\",\r\n \"avast.com\",\r\n \"bluehost.com\",\r\n \"nba.com\",\r\n \"hostgator.com\",\r\n \"scientificamerican.com\",\r\n \"aljazeera.com\",\r\n \"sahibinden.com\",\r\n \"rackspace.com\",\r\n \"namecheap.com\",\r\n \"people.com\",\r\n \"branch.io\",\r\n \"tv2.dk\",\r\n \"criteo.com\",\r\n \"trustpilot.com\",\r\n \"hm.com\",\r\n \"mailchimp.com\",\r\n \"surveymonkey.com\",\r\n \"mckinsey.com\",\r\n \"rollingstone.com\",\r\n \"slate.com\",\r\n \"dictionary.com\",\r\n \"coursera.org\",\r\n \"msn.com\",\r\n \"chegg.com\",\r\n \"variety.com\",\r\n \"cnn.com\",\r\n \"proximus.be\",\r\n \"adevarul.ro\",\r\n \"cnbc.com\",\r\n \"oe24.at\",\r\n \"reuters.com\",\r\n \"booking.com\",\r\n \"bluewin.ch\",\r\n \"viaplay.dk\",\r\n \"aib.ie\",\r\n \"hbomax.com\",\r\n \"rtlnieuws.nl\",\r\n \"buienradar.be\",\r\n \"viaplay.se\",\r\n \"antena3.ro\",\r\n \"statista.com\",\r\n \"pixabay.com\",\r\n \"constantcontact.com\",\r\n \"atlassian.com\",\r\n \"bmj.com\",\r\n \"trendyol.com\",\r\n \"meetup.com\",\r\n \"vmware.com\",\r\n \"bitbucket.org\",\r\n \"viaplay.no\",\r\n \"asana.com\",\r\n \"freepik.com\",\r\n \"heute.at\",\r\n \"mtvuutiset.fi\",\r\n \"buienradar.nl\",\r\n \"nypost.com\",\r\n \"panasonic.com\",\r\n \"safeway.com\",\r\n \"amd.com\",\r\n \"atg.se\",\r\n \"brother.de\",\r\n \"brother.eu\",\r\n \"brother.fr\",\r\n \"corsair.com\",\r\n \"crucial.com\",\r\n \"dc.com\",\r\n \"dn.no\",\r\n \"epson.de\",\r\n \"epson.es\",\r\n \"epson.eu\",\r\n \"epson.fr\",\r\n \"epson.it\",\r\n \"evga.com\",\r\n \"fortnite.com\",\r\n \"fujitsu.com\",\r\n \"global.canon\",\r\n \"gpuopen.com\",\r\n \"info.lidl\",\r\n \"inpost.es\",\r\n \"inpost.eu\",\r\n \"inpost.it\",\r\n \"intel.com\",\r\n \"intersport.at\",\r\n \"intersport.com\",\r\n \"intersport.com.tr\",\r\n \"intersport.cz\",\r\n \"intersport.dk\",\r\n \"intersport.es\",\r\n \"intersport.hu\",\r\n \"intersport.nl\",\r\n \"intersport.sk\",\r\n \"kaufland.de\",\r\n \"lg.com\",\r\n \"lidl.co.uk\",\r\n \"lidl.com\",\r\n \"lidl.cz\",\r\n \"lidl.de\",\r\n \"lidl.fr\",\r\n \"lidl.it\",\r\n \"lidl.pl\",\r\n \"lifewire.com\",\r\n \"logitech.com\",\r\n \"micron.com\",\r\n \"mythomson.com\",\r\n \"oki.com\",\r\n \"otto.de\",\r\n \"razer.com\",\r\n \"rightmove.co.uk\",\r\n \"sbb.ch\",\r\n \"seagate.com\",\r\n \"soundcloud.com\",\r\n \"trello.com\",\r\n \"unrealengine.com\",\r\n \"vodafone.al\",\r\n \"vodafone.co.uk\",\r\n \"vodafone.es\",\r\n \"vodafone.gr\",\r\n \"vodafone.hu\",\r\n \"vodafone.ie\",\r\n \"vodafone.nl\",\r\n \"vodafone.ro\",\r\n \"askubuntu.com\",\r\n \"mathoverflow.net\",\r\n \"serverfault.com\",\r\n \"stackapps.com\",\r\n \"stackexchange.com\",\r\n \"stackoverflow.com\",\r\n \"superuser.com\",\r\n \"carrefour.fr\"\r\n ],\r\n \"id\": \"6c7366a0-4762-47b9-8eeb-04e86cc7a0cc\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#didomi-notice-agree-button\",\r\n \"optOut\": \"button#didomi-notice-disagree-button\",\r\n \"presence\": \"div#didomi-host\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"doctolib.fr\",\r\n \"pravda.sk\",\r\n \"topky.sk\",\r\n \"zoznam.sk\",\r\n \"tvnoviny.sk\",\r\n \"aukro.cz\",\r\n \"krone.at\",\r\n \"cas.sk\",\r\n \"heureka.sk\",\r\n \"free.fr\",\r\n \"markiza.sk\",\r\n \"willhaben.at\",\r\n \"francetvinfo.fr\",\r\n \"france.tv\",\r\n \"france24.com\",\r\n \"opodo.at\",\r\n \"opodo.ch\",\r\n \"opodo.co.uk\",\r\n \"opodo.de\",\r\n \"opodo.dk\",\r\n \"opodo.fi\",\r\n \"opodo.fr\",\r\n \"opodo.it\",\r\n \"opodo.nl\",\r\n \"opodo.no\",\r\n \"opodo.pl\",\r\n \"opodo.pt\",\r\n \"radiofrance.fr\",\r\n \"rfi.fr\",\r\n \"rtl.fr\",\r\n \"6play.fr\",\r\n \"marianne.net\"\r\n ],\r\n \"id\": \"690aa076-4a8b-48ec-b52c-1443d44ff008\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#onetrust-accept-btn-handler\",\r\n \"optOut\": \"button.onetrust-close-btn-handler\",\r\n \"presence\": \"div#onetrust-consent-sdk\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"allopneus.com\",\r\n \"darty.com\",\r\n \"e.leclerc\",\r\n \"fnac.be\",\r\n \"fnac.ch\",\r\n \"fnac.com\",\r\n \"fnac.pt\",\r\n \"leclercdrive.fr\",\r\n \"mondialrelay.fr\",\r\n \"pasteur.fr\"\r\n ],\r\n \"id\": \"2d821158-5945-4134-a078-56c6da4f678d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#popin_tc_privacy_button_3\",\r\n \"optOut\": \"#popin_tc_privacy_button_2\",\r\n \"presence\": \"#tc-privacy-wrapper\"\r\n },\r\n \"domains\": [\r\n \"auto5.be\",\r\n \"bouyguestelecom.fr\",\r\n \"enedis.fr\",\r\n \"fortuneo.fr\",\r\n \"lcl.fr\",\r\n \"norauto-franchise.com\",\r\n \"norauto-pro.com\",\r\n \"norauto.es\",\r\n \"norauto.fr\",\r\n \"norauto.it\",\r\n \"norauto.pt\",\r\n \"tf1.fr\",\r\n \"tf1info.fr\"\r\n ],\r\n \"id\": \"98D89E26-F4B6-4C2D-BABF-4295B922E433\"\r\n },\r\n {\r\n \"domains\": [\r\n \"tumblr.com\",\r\n \"paypal.com\",\r\n \"amazon.co.uk\",\r\n \"amazon.com.be\",\r\n \"amazon.com.tr\",\r\n \"amazon.de\",\r\n \"amazon.es\",\r\n \"amazon.fr\",\r\n \"amazon.it\",\r\n \"amazon.nl\",\r\n \"amazon.pl\",\r\n \"amazon.se\"\r\n ],\r\n \"id\": \"disabled\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"__Secure-HO_Cookie_Consent_Declined\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"hetzner.com\"\r\n ],\r\n \"id\": \"a8222cf2-8f6c-48df-8215-05a15d4ac592\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#didomi-notice-agree-button\",\r\n \"presence\": \"#didomi-popup\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"decathlon.bg\",\r\n \"decathlon.ca\",\r\n \"decathlon.hr\",\r\n \"decathlon.ie\",\r\n \"decathlon.lt\",\r\n \"decathlon.lv\",\r\n \"decathlon.se\",\r\n \"orange.lu\",\r\n \"reverso.net\"\r\n ],\r\n \"id\": \"05157ed1-12c2-4f84-9dff-718fae5bc096\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".t_cm_ec_continue_button\",\r\n \"optOut\": \".t_cm_ec_reject_button\",\r\n \"presence\": \"#__tealiumGDPRecModal\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CONSENTMGR\",\r\n \"value\": \"c1:0%7Cc2:0%7Cc3:0%7Cc4:0%7Cc5:0%7Cc6:0%7Cc7:0%7Cc8:0%7Cc9:0%7Cc10:0%7Cc11:0%7Cc12:0%7Cc13:0%7Cc14:0%7Cc15:0%7Cts:2524608000000%7Cconsent:false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"vodafone.com\"\r\n ],\r\n \"id\": \"a4cb7b9f-0a47-4fc8-ac4c-5e9d0d598531\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#didomi-notice-agree-button\",\r\n \"optOut\": \"span.didomi-continue-without-agreeing\",\r\n \"presence\": \"div#didomi-popup\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"theconversation.com\",\r\n \"leparisien.fr\",\r\n \"lesechos.fr\",\r\n \"numerama.com\",\r\n \"jofogas.hu\",\r\n \"orange.com\",\r\n \"orange.fr\",\r\n \"meteofrance.com\",\r\n \"subito.it\",\r\n \"hasznaltauto.hu\",\r\n \"zdnet.de\",\r\n \"zdnet.fr\",\r\n \"intersport.fr\",\r\n \"leboncoin.fr\",\r\n \"boursorama.com\",\r\n \"boursobank.com\",\r\n \"intermarche.com\",\r\n \"bricomarche.com\",\r\n \"entrepot-du-bricolage.fr\",\r\n \"lesnumeriques.com\",\r\n \"seloger.com\",\r\n \"societe.com\",\r\n \"manomano.fr\",\r\n \"pagesjaunes.fr\",\r\n \"sncf-connect.com\",\r\n \"largus.fr\"\r\n ],\r\n \"id\": \"c1d7be10-151e-4a66-b83b-31a762869a97\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"gdpr\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"kinopoisk.ru\",\r\n \"ya.ru\",\r\n \"yandex.az\",\r\n \"yandex.by\",\r\n \"yandex.co.il\",\r\n \"yandex.com\",\r\n \"yandex.com.am\",\r\n \"yandex.com.ge\",\r\n \"yandex.com.tr\",\r\n \"yandex.ee\",\r\n \"yandex.eu\",\r\n \"yandex.kz\",\r\n \"yandex.lt\",\r\n \"yandex.lv\",\r\n \"yandex.md\",\r\n \"yandex.pt\",\r\n \"yandex.ru\",\r\n \"yandex.tj\",\r\n \"yandex.tm\",\r\n \"yandex.uz\",\r\n \"yandex.vc\"\r\n ],\r\n \"id\": \"37319f5d-9484-4da8-aee1-570a78688da3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"button#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"div#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"intermarche.be\",\r\n \"intermarche.pl\",\r\n \"intermarche.pt\",\r\n \"intersport.hr\",\r\n \"intersport.it\",\r\n \"intersport.pl\",\r\n \"intersport.si\",\r\n \"issuu.com\",\r\n \"telekom.ro\",\r\n \"voetbal24.be\",\r\n \"weforum.org\"\r\n ],\r\n \"id\": \"019c0709-e9ef-4b0d-94bf-958d251a51b5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#gdpr-banner-accept\",\r\n \"optOut\": \"#gdpr-banner-decline\",\r\n \"presence\": \"#gdpr-banner\"\r\n },\r\n \"domains\": [\r\n \"ebay.at\",\r\n \"ebay.be\",\r\n \"ebay.ca\",\r\n \"ebay.ch\",\r\n \"ebay.co.uk\",\r\n \"ebay.com\",\r\n \"ebay.com.au\",\r\n \"ebay.com.hk\",\r\n \"ebay.com.my\",\r\n \"ebay.com.sg\",\r\n \"ebay.de\",\r\n \"ebay.es\",\r\n \"ebay.fr\",\r\n \"ebay.ie\",\r\n \"ebay.it\",\r\n \"ebay.nl\",\r\n \"ebay.ph\",\r\n \"ebay.pl\",\r\n \"ebay.vn\"\r\n ],\r\n \"id\": \"1e6d35e7-b907-4f5c-a09a-9f3336ef6e61\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[data-t=\\\"acceptAllBtn\\\"]\",\r\n \"optOut\": \"[data-t=\\\"continueWithoutAcceptingBtn\\\"]\",\r\n \"presence\": \"[data-t=\\\"cookiesMessage\\\"]\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie_policy_agreement\",\r\n \"value\": \"3\"\r\n },\r\n {\r\n \"name\": \"dont-track\",\r\n \"value\": \"1\"\r\n },\r\n {\r\n \"name\": \"f_c\",\r\n \"value\": \"0\"\r\n },\r\n {\r\n \"name\": \"g_p\",\r\n \"value\": \"0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"chollometro.com\",\r\n \"dealabs.com\",\r\n \"hotukdeals.com\",\r\n \"mydealz.de\",\r\n \"pepper.com\",\r\n \"pepper.it\",\r\n \"pepper.pl\",\r\n \"preisjaeger.at\"\r\n ],\r\n \"id\": \"069f4d94-8031-4b83-b8f9-89752c5c1353\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".js-accept\",\r\n \"optOut\": \".js-refuse\",\r\n \"presence\": \".cookie-banner-buttons\"\r\n },\r\n \"domains\": [\r\n \"emag.bg\",\r\n \"emag.hu\",\r\n \"emag.ro\"\r\n ],\r\n \"id\": \"dbbccc0a-13ba-4cd8-9cf0-32420401be55\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#kc-acceptAndHide\",\r\n \"optOut\": \"#kc-denyAndHide\",\r\n \"presence\": \".kc-onsent\"\r\n },\r\n \"domains\": [\r\n \"intersport.fi\",\r\n \"k-ruoka.fi\"\r\n ],\r\n \"id\": \"282ff551-ce28-4b7f-9633-eaaa7ce89890\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#uc-btn-accept-banner\",\r\n \"optOut\": \"#uc-btn-deny-banner\",\r\n \"presence\": \"#uc-main-banner\"\r\n },\r\n \"domains\": [\r\n \"zalando.at\",\r\n \"zalando.be\",\r\n \"zalando.ch\",\r\n \"zalando.co.uk\",\r\n \"zalando.com\",\r\n \"zalando.cz\",\r\n \"zalando.de\",\r\n \"zalando.dk\",\r\n \"zalando.ee\",\r\n \"zalando.es\",\r\n \"zalando.fi\",\r\n \"zalando.fr\",\r\n \"zalando.hr\",\r\n \"zalando.hu\",\r\n \"zalando.ie\",\r\n \"zalando.it\",\r\n \"zalando.lt\",\r\n \"zalando.lv\",\r\n \"zalando.nl\",\r\n \"zalando.no\",\r\n \"zalando.pl\",\r\n \"zalando.ro\",\r\n \"zalando.se\",\r\n \"zalando.si\",\r\n \"zalando.sk\"\r\n ],\r\n \"id\": \"3203ac4e-2454-4022-90fb-d4f51467ce20\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".ebu-cookies-layer__modal-buttons > .confirm\",\r\n \"presence\": \"#cookiesModal\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"OPTOUTCONSENT\",\r\n \"value\": \"1:1&2:0&3:0&4:0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"vodafone.pt\"\r\n ],\r\n \"id\": \"522a2d72-8131-406e-b058-b27ec07808fc\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[data-testid=\\\"uc-accept-all-button\\\"]\",\r\n \"optOut\": \"[data-testid=\\\"uc-deny-all-button\\\"]\",\r\n \"presence\": \"#focus-lock-id\"\r\n },\r\n \"domains\": [\r\n \"magenta.at\",\r\n \"post.ch\",\r\n \"rts.ch\"\r\n ],\r\n \"id\": \"c40f3982-0372-4cdd-8aea-c150afd8328e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cc-banner__button-accept\",\r\n \"optOut\": \"button.cc-banner__button-reject\",\r\n \"presence\": \".cc-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"springer.com\",\r\n \"nature.com\"\r\n ],\r\n \"id\": \"9ab30eae-9592-47b1-b46e-7640c4316f14\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \".consent\",\r\n \"value\": \"fu1-ma1-pe1\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \".consent\",\r\n \"value\": \"fu0-ma0-pe0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"galaxus.de\"\r\n ],\r\n \"id\": \"cc78c082-2dc6-4287-9a7c-168c591810fd\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".sp_choice_type_11\",\r\n \"presence\": \".message-container > #notice\",\r\n \"runContext\": \"child\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"thetimes.co.uk\",\r\n \"qz.com\",\r\n \"privacy-mgmt.com\",\r\n \"independent.co.uk\",\r\n \"gizmodo.com\",\r\n \"e24.no\",\r\n \"thesun.co.uk\",\r\n \"thesun.ie\",\r\n \"focus.de\",\r\n \"bild.de\",\r\n \"computerbild.de\",\r\n \"t-online.de\",\r\n \"wetteronline.de\",\r\n \"chip-kiosk.de\",\r\n \"chip.de\",\r\n \"chip.info\",\r\n \"n-tv.de\",\r\n \"newsnow.co.uk\",\r\n \"telegraph.co.uk\",\r\n \"theguardian.com\",\r\n \"faz.net\",\r\n \"sueddeutsche.de\",\r\n \"rtl.de\",\r\n \"gutefrage.net\",\r\n \"express.de\",\r\n \"tvspielfilm.de\",\r\n \"finanzen.net\",\r\n \"tag24.de\",\r\n \"kino.de\",\r\n \"heise.de\",\r\n \"bunte.de\",\r\n \"golem.de\",\r\n \"meinestadt.de\",\r\n \"berliner-zeitung.de\",\r\n \"karlsruhe-insider.de\",\r\n \"wetter.de\"\r\n ],\r\n \"id\": \"d42bbaee-f96e-47e7-8e81-efc642518e97\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#didomi-notice-agree-button\",\r\n \"presence\": \"div#didomi-host\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"echo24.cz\",\r\n \"jeuxvideo.com\",\r\n \"24sata.hr\",\r\n \"nova.cz\",\r\n \"lidovky.cz\",\r\n \"jutarnji.hr\",\r\n \"vecernji.hr\",\r\n \"sport.es\",\r\n \"elespanol.com\",\r\n \"in-pocasi.cz\",\r\n \"20minutes.fr\",\r\n \"actu.fr\",\r\n \"hbvl.be\",\r\n \"naszemiasto.pl\",\r\n \"rtbf.be\",\r\n \"20minutos.es\",\r\n \"sudinfo.be\",\r\n \"elpais.com\",\r\n \"sinoptik.bg\",\r\n \"lequipe.fr\",\r\n \"abc.es\",\r\n \"gva.be\",\r\n \"eltiempo.es\",\r\n \"eldiario.es\",\r\n \"larazon.es\",\r\n \"extra.cz\",\r\n \"ladepeche.fr\",\r\n \"marmiton.org\",\r\n \"poslovni.hr\",\r\n \"softonic.com\",\r\n \"sydsvenskan.se\",\r\n \"telecinco.es\",\r\n \"giphy.com\",\r\n \"filmstarts.de\",\r\n \"funda.nl\",\r\n \"idnes.cz\",\r\n \"aktualne.cz\",\r\n \"blesk.cz\",\r\n \"centrum.cz\",\r\n \"denik.cz\",\r\n \"csfd.cz\",\r\n \"hn.cz\",\r\n \"moviepilot.de\",\r\n \"chip.cz\"\r\n ],\r\n \"id\": \"af4c5b38-d210-472b-9a07-21cbe53c85ab\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#accept-btn\",\r\n \"optOut\": \"#decline-btn\",\r\n \"presence\": \"#privacy-manager-popin\"\r\n },\r\n \"domains\": [\r\n \"canalplus.com\"\r\n ],\r\n \"id\": \"7b2e3401-697f-440a-b418-8477fcf2cfeb\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".btn-ok\",\r\n \"optOut\": \".btn-reject\",\r\n \"presence\": \"#cookie-policy-info\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"isReadCookiePolicyDNT\",\r\n \"value\": \"No\"\r\n },\r\n {\r\n \"name\": \"isReadCookiePolicyDNTAa\",\r\n \"value\": \"false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"asus.com\"\r\n ],\r\n \"id\": \"8c949b75-4c7b-4559-8ade-780064af370a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \".sp_choice_type_13\",\r\n \"presence\": \".message-container > #notice\",\r\n \"runContext\": \"child\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"aktuality.sk\",\r\n \"sky.it\",\r\n \"azet.sk\",\r\n \"bloomberg.com\",\r\n \"formula1.com\"\r\n ],\r\n \"id\": \"ae8f7761-35ff-45b2-92df-7868ca288ad2\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#c-bns #c-p-bn\",\r\n \"optOut\": \"#c-bns button.grey\",\r\n \"presence\": \"#cm\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cc_cookie\",\r\n \"value\": \"{\\\"level\\\":[\\\"necessary\\\"],\\\"revision\\\":0,\\\"data\\\":null,\\\"rfc_cookie\\\":false}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"yazio.com\"\r\n ],\r\n \"id\": \"B8CB497B-9E12-49A7-BA2A-B7842CAEDFC3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#rgpd-btn-index-accept\",\r\n \"optOut\": \"#rgpd-btn-index-continue\",\r\n \"presence\": \"#modalTpl\"\r\n },\r\n \"domains\": [\r\n \"rueducommerce.fr\"\r\n ],\r\n \"id\": \"BC3582FC-C5FA-4743-85E8-7E46F67629AB\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cookieConsentAcceptButton\",\r\n \"optOut\": \"#cookieConsentRefuseButton\",\r\n \"presence\": \"#cookieConsentBanner\"\r\n },\r\n \"domains\": [\r\n \"ldlc.com\",\r\n \"ldlc.pro\",\r\n \"materiel.net\"\r\n ],\r\n \"id\": \"4A767353-98B9-4284-857A-D98DC3ECDFE1\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#header_tc_privacy_button_3\",\r\n \"optOut\": \"#header_tc_privacy_button\",\r\n \"presence\": \"#tc-privacy-wrapper\"\r\n },\r\n \"domains\": [\r\n \"ovh.com\",\r\n \"ovhcloud.com\",\r\n \"ovhtelecom.fr\"\r\n ],\r\n \"id\": \"17B1F270-F499-451C-AED5-5C737106F003\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#popin_tc_privacy_button_2\",\r\n \"optOut\": \"#optout_link\",\r\n \"presence\": \"#tc-privacy-wrapper\"\r\n },\r\n \"domains\": [\r\n \"laredoute.fr\"\r\n ],\r\n \"id\": \"9022E9FE-2DC2-48DD-BE4A-EFA8A2C81E2B\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#popin_tc_privacy_button_2\",\r\n \"optOut\": \"#popin_tc_privacy_button\",\r\n \"presence\": \"#tc-privacy-wrapper\"\r\n },\r\n \"domains\": [\r\n \"quechoisir.org\",\r\n \"quechoisirensemble.fr\"\r\n ],\r\n \"id\": \"5D50AA8D-D00E-4A51-8D32-64965A0575CA\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#footer_tc_privacy_button_3\",\r\n \"optOut\": \"button#footer_tc_privacy_button_2\",\r\n \"presence\": \"div#tc-privacy-wrapper\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"labanquepostale.fr\"\r\n ],\r\n \"id\": \"6c1ebd2b-867a-40a5-8184-0ead733eae69\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.orejime-Notice-saveButton\",\r\n \"optOut\": \"button.orejime-Notice-declineButton\",\r\n \"presence\": \"div.orejime-Notice\"\r\n },\r\n \"domains\": [\r\n \"service-public.fr\"\r\n ],\r\n \"id\": \"7293dc4c-1d3d-4236-84a6-3c5cb3def55a\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cb\",\r\n \"value\": \"1_2055_07_11_\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cb\",\r\n \"value\": \"1_2055_07_11_2-3\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"threads.net\"\r\n ],\r\n \"id\": \"c232eab8-f55a-436a-8033-478746d05d98\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#footer_tc_privacy_button\",\r\n \"optOut\": \"#footer_tc_privacy_button_2\",\r\n \"presence\": \".tc-privacy-wrapper.tc-privacy-override.tc-privacy-wrapper\"\r\n },\r\n \"domains\": [\r\n \"arte.tv\",\r\n \"urssaf.fr\"\r\n ],\r\n \"id\": \"cc818b41-7b46-46d3-9b17-cf924cbe87d1\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#footer_tc_privacy_button_2\",\r\n \"optOut\": \"button#footer_tc_privacy_button_3\",\r\n \"presence\": \"div#tc-privacy-wrapper\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"cdiscount.com\",\r\n \"laposte.net\",\r\n \"laposte.fr\"\r\n ],\r\n \"id\": \"1871561d-65f8-4972-8e8a-84fa9eb704b4\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"button#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"div#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"politiken.dk\"\r\n ],\r\n \"id\": \"1006f951-cd51-47cc-9527-5036cef4b85a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#popin_tc_privacy_button\",\r\n \"optOut\": \"#popin_tc_privacy_button_3\",\r\n \"presence\": \"#tc-privacy-wrapper\"\r\n },\r\n \"domains\": [\r\n \"dpd.com\",\r\n \"dpdgroup.com\",\r\n \"edf.fr\",\r\n \"totalenergies.fr\"\r\n ],\r\n \"id\": \"43ad2b6b-a57b-4f7a-9d76-e32e696ddc10\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".moove-gdpr-infobar-allow-all\",\r\n \"optOut\": \".moove-gdpr-infobar-reject-btn\",\r\n \"presence\": \"#moove_gdpr_cookie_info_bar\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"moove_gdpr_popup\",\r\n \"value\": \"%7B%22strict%22%3A%221%22%2C%22thirdparty%22%3A%221%22%2C%22advanced%22%3A%221%22%7D\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"moove_gdpr_popup\",\r\n \"value\": \"%7B%22strict%22%3A%221%22%2C%22thirdparty%22%3A%220%22%2C%22advanced%22%3A%220%22%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"endorfy.com\"\r\n ],\r\n \"id\": \"1be1e4d7-bcad-4fc8-a348-ae64af8bcac7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#popin_tc_privacy_button\",\r\n \"optOut\": \"#popin_tc_privacy_button_2\",\r\n \"presence\": \"#tc-privacy-wrapper\"\r\n },\r\n \"domains\": [\r\n \"but.fr\"\r\n ],\r\n \"id\": \"3030D307-A610-4F3D-B589-D2BE133850D7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".ei_btn_typ_validate\",\r\n \"optOut\": \".ei_lb_btnskip\",\r\n \"presence\": \"#cookieLB\"\r\n },\r\n \"domains\": [\r\n \"creditmutuel.fr\"\r\n ],\r\n \"id\": \"1B309A53-16B4-4BBE-91F4-86260A15B8E7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#wt-cli-accept-all-btn\",\r\n \"presence\": \"#cookie-law-info-bar\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"viewed_cookie_policy\",\r\n \"value\": \"yes\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"krux.tech\",\r\n \"silentiumpc.com\",\r\n \"spcgear.com\"\r\n ],\r\n \"id\": \"04e919eb-13c2-4b37-bf7f-888767888640\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"#CybotCookiebotDialogBodyLevelButtonLevelOptinDeclineAll\",\r\n \"presence\": \"#cookiebanner\"\r\n },\r\n \"domains\": [\r\n \"action.com\",\r\n \"gog.com\"\r\n ],\r\n \"id\": \"04afc564-14b2-4c56-b72d-47a26e121f3b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cc-allow\",\r\n \"optOut\": \".cc-deny\",\r\n \"presence\": \".cc-window\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"eclipse_cookieconsent_status\",\r\n \"value\": \"allow\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"eclipse_cookieconsent_status\",\r\n \"value\": \"deny\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"adoptium.net\",\r\n \"eclipse.dev\",\r\n \"eclipse.org\",\r\n \"glassfish.org\",\r\n \"jakarta.ee\",\r\n \"mbse-capella.org\",\r\n \"oniroproject.org\",\r\n \"open-vsx.org\",\r\n \"openmdm.org\",\r\n \"osgi.org\",\r\n \"planeteclipse.org\"\r\n ],\r\n \"id\": \"92361e84-664e-46b3-ae55-95bc185dc88e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cc-allow\",\r\n \"optOut\": \".cc-dismiss\",\r\n \"presence\": \".cc-window\"\r\n },\r\n \"domains\": [\r\n \"dell.com\",\r\n \"dell.eu\",\r\n \"delltechnologies.com\"\r\n ],\r\n \"id\": \"f1849b07-95e8-4ae0-a99d-24df5abbb3cb\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#btn-allowAllCookie\",\r\n \"optOut\": \"#btn-rejectAllCookie\",\r\n \"presence\": \"#cc-window\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"_youtube_vimeo_vid\",\r\n \"value\": \"allow\"\r\n },\r\n {\r\n \"name\": \"cookieControllerStatus\",\r\n \"value\": \"allow\"\r\n },\r\n {\r\n \"name\": \"functionalCookieStatus\",\r\n \"value\": \"allow\"\r\n },\r\n {\r\n \"name\": \"googleAnalyticsCookieStatus\",\r\n \"value\": \"allow\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"_youtube_vimeo_vid\",\r\n \"value\": \"deny\"\r\n },\r\n {\r\n \"name\": \"cookieControllerStatus\",\r\n \"value\": \"deny\"\r\n },\r\n {\r\n \"name\": \"functionalCookieStatus\",\r\n \"value\": \"deny\"\r\n },\r\n {\r\n \"name\": \"googleAnalyticsCookieStatus\",\r\n \"value\": \"deny\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"verbatim.co.il\"\r\n ],\r\n \"id\": \"3c0e4924-29ee-4d9a-99ec-e4805a7ffed9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cookiescript_accept\",\r\n \"optOut\": \"#cookiescript_reject\",\r\n \"presence\": \"#cookiescript_injected_wrapper\"\r\n },\r\n \"domains\": [\r\n \"deskmodder.de\",\r\n \"zyxel.com\"\r\n ],\r\n \"id\": \"7AF1C34C-750E-44FC-A3C4-31FB61EBF71B\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button[data-js-item=\\\"privacy-protection-default\\\"]\",\r\n \"presence\": \".c-privacy-protection-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"bundeswehr.de\"\r\n ],\r\n \"id\": \"52ad1edd-5696-482c-855d-a8d669f9e7f5\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookiehint\",\r\n \"value\": \"{\\\"matomo\\\":0}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"dataport.de\"\r\n ],\r\n \"id\": \"1ae88c1b-2b26-49b2-98d0-f6b14a85e376\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"dp-cookie-consent\",\r\n \"value\": \"false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bob-sh.de\",\r\n \"bolapla-sh.de\"\r\n ],\r\n \"id\": \"a70791ff-0001-4238-b68f-9ba8f1c00c96\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"BayernMatomo\",\r\n \"value\": \"deaktiviert\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bayern.de\"\r\n ],\r\n \"id\": \"07228673-9d62-4c00-b18d-37e0fcfd37e7\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"gsbbanner\",\r\n \"value\": \"closed\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bmbf.de\",\r\n \"bmj.de\",\r\n \"bva.bund.de\",\r\n \"bundesrechnungshof.de\",\r\n \"schleswig-holstein.de\",\r\n \"zoll.de\"\r\n ],\r\n \"id\": \"b2a17900-dc1f-4273-bb0e-10e73c6f63bd\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"isTrackingConsentGiven\",\r\n \"value\": \"false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bund.de\"\r\n ],\r\n \"id\": \"431be341-e09a-4fb5-beac-3c4366caaaa0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#privacy-init-wall-button-accept\",\r\n \"optOut\": \"#privacy-init-wall-button-deny\",\r\n \"presence\": \"#privacy-init-wall\"\r\n },\r\n \"domains\": [\r\n \"comdirect.de\"\r\n ],\r\n \"id\": \"5DC4F89B-2A66-4B91-AE73-87954F3A0B4A\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#ccmgt_explicit_accept\",\r\n \"presence\": \"#GDPRConsentManagerContainer\"\r\n },\r\n \"domains\": [\r\n \"stepstone.de\"\r\n ],\r\n \"id\": \"3FD9FDA8-5F69-46C4-BA33-35FE3C804B4C\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[data-testid=\\\"as24-cmp-accept-all-button\\\"]\",\r\n \"presence\": \"#as24-cmp-popup\"\r\n },\r\n \"domains\": [\r\n \"autoscout24.at\",\r\n \"autoscout24.de\"\r\n ],\r\n \"id\": \"46FECFD3-2384-450A-8CCC-53BCFE65DF4C\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".lnc-acceptCookiesButton\",\r\n \"optOut\": \".lnc-declineCookiesButton\",\r\n \"presence\": \".lnc-firstRunPopup\"\r\n },\r\n \"domains\": [\r\n \"jw.org\"\r\n ],\r\n \"id\": \"45AEAD27-6328-4B26-A235-4EA4D74A829E\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cmp-accept\",\r\n \"presence\": \"#cmp-modal\"\r\n },\r\n \"domains\": [\r\n \"gamestar.de\"\r\n ],\r\n \"id\": \"7D7FA535-719F-4637-8DE0-9F90B17AD7A7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#consent_wall_optin\",\r\n \"optOut\": \"#consent_wall_optout\",\r\n \"presence\": \"#consent-wall\"\r\n },\r\n \"domains\": [\r\n \"1und1.de\"\r\n ],\r\n \"id\": \"C553BBDC-632F-4F18-AA7D-D6C090E7A7A2\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".ccm--save-settings\",\r\n \"optOut\": \".ccm--decline-cookies\",\r\n \"presence\": \"#ccm-widget\"\r\n },\r\n \"domains\": [\r\n \"kaufmich.com\"\r\n ],\r\n \"id\": \"72A1389D-943D-4BF0-93D5-F274F7FD3CF2\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".kick__data-grid__main .kick__btn\",\r\n \"presence\": \"#kick__logi-container\"\r\n },\r\n \"domains\": [\r\n \"kicker.de\"\r\n ],\r\n \"id\": \"E220B441-CAE6-4E4B-9F5D-BA167AE06812\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cookieBtnAll\",\r\n \"optOut\": \"#cookieBtnContinue\",\r\n \"presence\": \".cookie_consent_withings\"\r\n },\r\n \"domains\": [\r\n \"withings.com\"\r\n ],\r\n \"id\": \"0132691F-247B-4CB9-BF9B-0EB61B7435F3\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookiebanner\",\r\n \"value\": \"closed\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"thw.de\",\r\n \"service.bund.de\"\r\n ],\r\n \"id\": \"58226c30-e975-42f3-99e4-ca140b91e96c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".js-accept-cookies\",\r\n \"optOut\": \".js-reject-cookies\",\r\n \"presence\": \".js-consent-banner\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"OptanonAlertBoxClosed\",\r\n \"value\": \"2030-01-01T00:00:00.000Z\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"stackoverflow.blog\",\r\n \"stackoverflow.co\",\r\n \"stackoverflowteams.com\"\r\n ],\r\n \"id\": \"71443ce9-15b8-4e07-b51b-1f2158521ea9\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"js-cookie-opt-in__consent\",\r\n \"value\": \"needed\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"anexia.com\",\r\n \"netcup.com\",\r\n \"netcup.de\",\r\n \"netcup.eu\",\r\n \"netcup-news.de\",\r\n \"netcup-sonderangebote.de\"\r\n ],\r\n \"id\": \"dea34d82-9c05-4c08-9262-18a7f62be91e\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie-allow-necessary\",\r\n \"value\": \"1\"\r\n },\r\n {\r\n \"name\": \"cookie-allow-tracking\",\r\n \"value\": \"0\"\r\n },\r\n {\r\n \"name\": \"cookie-banner\",\r\n \"value\": \"hide\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bundesfinanzministerium.de\",\r\n \"bundesregierung.de\"\r\n ],\r\n \"id\": \"84f68b1a-18a2-478c-bc8e-9ec32d4e3e80\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cc-dismiss\",\r\n \"presence\": \"div.cc-compliance\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieconsent_status\",\r\n \"value\": \"dismiss\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"021.rs\",\r\n \"photobucket.com\",\r\n \"brandenburg.de\"\r\n ],\r\n \"id\": \"5371fb3e-3242-4864-9443-62116afe5f3c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cmpboxbtnyes \",\r\n \"presence\": \"div#cmpbox\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"hoerzu.de\",\r\n \"nzz.ch\"\r\n ],\r\n \"id\": \"a2404864-0163-4f71-ab4c-915713f8f349\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[data-cookieman-accept-all]\",\r\n \"optOut\": \"[data-cookieman-accept-none]\",\r\n \"presence\": \"#cookieman-modal\"\r\n },\r\n \"domains\": [\r\n \"hfm-frankfurt.de\"\r\n ],\r\n \"id\": \"5FF5EEB9-1045-4ACF-8A12-0630B71063F6\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"paydirektCookieAllowed\",\r\n \"value\": \"false\"\r\n },\r\n {\r\n \"name\": \"paydirektCookieAllowedPWS\",\r\n \"value\": \"{%22necessary%22:true,%22analytics%22:false}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"paydirekt.de\"\r\n ],\r\n \"id\": \"769dcf5b-afd1-438d-940d-3069ff4b2f51\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.iubenda-cs-accept-btn\",\r\n \"optOut\": \"button.iubenda-cs-reject-btn\",\r\n \"presence\": \"div#iubenda-cs-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"giallozafferano.it\",\r\n \"virgilio.it\",\r\n \"upfit.de\",\r\n \"treedom.net\"\r\n ],\r\n \"id\": \"65638975-8222-425c-9be0-3f41a51db13c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"#zdf-cmp-deny-btn\",\r\n \"presence\": \".zdf-cmp-modal-content\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"zdf.de\",\r\n \"3sat.de\"\r\n ],\r\n \"id\": \"91484461-01AD-4D78-9ED8-D17C688F47E7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".root__OblK1:not(.secondaryButton__N1rJw)\",\r\n \"optOut\": \".root__OblK1.secondaryButton__N1rJw\",\r\n \"presence\": \".root__XMKIj\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"forbes.com\"\r\n ],\r\n \"id\": \"30293090-f064-473a-ae0f-cd390507c1c7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[href=\\\"#accept\\\"]\",\r\n \"optOut\": \"[href=\\\"#refuse\\\"]\",\r\n \"presence\": \"div#cookie-consent-banner\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cck1\",\r\n \"value\": \"%7B%22cm%22%3Atrue%2C%22all1st%22%3Afalse%2C%22closed%22%3Atrue%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"europa.eu\"\r\n ],\r\n \"id\": \"0c74749a-8c53-4bb0-b31a-ab2f89b7f493\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".CookieBanner_buttonContainer__NOZxH:nth-child(1) button\",\r\n \"optOut\": \".CookieBanner_buttonContainer__NOZxH:nth-child(2) button\",\r\n \"presence\": \".CookieBanner_cookieBanner__R_BOh\"\r\n },\r\n \"domains\": [\r\n \"cbinsights.com\"\r\n ],\r\n \"id\": \"4CE0ADCF-E232-4F3B-981B-CA83A0C40874\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".modal-box__buttons--save-all\",\r\n \"presence\": \".new-policy-box\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"polityka15\",\r\n \"value\": \"security_storage%3Dtrue%26functionality_storage%3Dtrue%26analytics_storage%3Dtrue%26personalization_storage%3Dtrue%26ad_storage%3Dtrue\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"polityka15\",\r\n \"value\": \"security_storage%3Dtrue%26functionality_storage%3Dfalse%26analytics_storage%3Dfalse%26personalization_storage%3Dfalse%26ad_storage%3Dfalse\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"nazwa.pl\"\r\n ],\r\n \"id\": \"ddff9528-161c-471e-bd2d-ba4d874a3931\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cc-dismiss\",\r\n \"optOut\": \".cc-deny\",\r\n \"presence\": \".cc-window\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieconsent_status\",\r\n \"value\": \"dismiss\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookieconsent_status\",\r\n \"value\": \"deny\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"zotac.com\"\r\n ],\r\n \"id\": \"4159a3d2-f331-4d56-b051-a753c7e1308a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[onclick*=\\\"cookie_Agree()\\\"]\",\r\n \"optOut\": \"[onclick*=\\\"cookie_Disagree()\\\"]\",\r\n \"presence\": \"#legal_notice\"\r\n },\r\n \"domains\": [\r\n \"transcend-info.com\"\r\n ],\r\n \"id\": \"5e0387bb-1f19-4f61-b587-49d995a691c9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".tp-cookie-accept-all\",\r\n \"presence\": \"#tp-cookie\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"tp_privacy_base\",\r\n \"value\": \"1\"\r\n },\r\n {\r\n \"name\": \"tp_privacy_marketing\",\r\n \"value\": \"1\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"tp_privacy_base\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"tp-link.com\"\r\n ],\r\n \"id\": \"48e9b863-c642-4a7a-9ee5-c085d337233e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".acceptAll\",\r\n \"presence\": \".privacyArea\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"privacy\",\r\n \"value\": \"{\\\"necessary\\\":\\\"ok\\\",\\\"functional\\\":\\\"ok\\\",\\\"marketing\\\":\\\"ok\\\"}\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"privacy\",\r\n \"value\": \"{\\\"necessary\\\":\\\"ok\\\"}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"teamgroupinc.com\"\r\n ],\r\n \"id\": \"9034f6e4-09ca-42a2-a8da-4f65968b8b36\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".btn_accept\",\r\n \"presence\": \".syno_cookie_element\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"syno_confirm_v4_answer\",\r\n \"value\": \"{\\\"necessary\\\":true,\\\"performance\\\":true,\\\"functionality\\\":true,\\\"targeting\\\":true}\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"syno_confirm_v4_answer\",\r\n \"value\": \"{\\\"necessary\\\":true,\\\"performance\\\":false,\\\"functionality\\\":false,\\\"targeting\\\":false}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"synology.cn\",\r\n \"synology.com\"\r\n ],\r\n \"id\": \"d01204bd-8a94-4e6e-8ce4-d155b0681053\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cp-yes\",\r\n \"optOut\": \"#cp-no\",\r\n \"presence\": \"#cp-overlay\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookiepermission\",\r\n \"value\": \"yes\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookiepermission\",\r\n \"value\": \"no\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"seasonic.com\"\r\n ],\r\n \"id\": \"6c9b123a-ec42-4128-91a2-a4cdd65059bc\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[value=\\\"Accept\\\"]\",\r\n \"optOut\": \"[value=\\\"Reject\\\"]\",\r\n \"presence\": \"#pp_info\"\r\n },\r\n \"domains\": [\r\n \"gainward.com\",\r\n \"palit.com\"\r\n ],\r\n \"id\": \"c79fc6da-0143-46a0-abfc-debbd4d05f4b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#Footer_butAccept\",\r\n \"optOut\": \"#Footer_butCancel\",\r\n \"presence\": \"#Footer_Cookie\"\r\n },\r\n \"domains\": [\r\n \"cablexpert.be\",\r\n \"cablexpert.com\",\r\n \"cablexpert.de\",\r\n \"cablexpert.gr\",\r\n \"cablexpert.nl\",\r\n \"energenie.com\",\r\n \"gembird.be\",\r\n \"gembird.com\",\r\n \"gembird.com.pl\",\r\n \"gembird.es\",\r\n \"gembird.nl\",\r\n \"gembird3.com\",\r\n \"gembird3.nl\",\r\n \"gmb-online.nl\",\r\n \"gmb.nl\"\r\n ],\r\n \"id\": \"f7aa3175-3c2a-4458-b822-da0bd57c2524\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#Cookies_consent_btn\",\r\n \"optOut\": \"#Cookies_consent_internal_btn\",\r\n \"presence\": \"#qkies_info\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookiesconsent\",\r\n \"value\": \"true\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookiesconsent\",\r\n \"value\": \"internal\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"dreammachines.by\",\r\n \"dreammachines.eu\",\r\n \"dreammachines.io\",\r\n \"dreammachines.nl\",\r\n \"dreammachines.pl\",\r\n \"dreammachines.ru\"\r\n ],\r\n \"id\": \"c8cb50a3-7604-4de0-a0ac-c2e7b0ad45c0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[name=\\\"accept\\\"]\",\r\n \"presence\": \".ck-notiz\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieSettings\",\r\n \"value\": \"%7B%22necessary%22%3Atrue%2C%22analytics%22%3Atrue%2C%22thirdParty%22%3Atrue%2C%22dismissed%22%3Atrue%7D\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookieSettings\",\r\n \"value\": \"%7B%22necessary%22%3Atrue%2C%22analytics%22%3Afalse%2C%22thirdParty%22%3Afalse%2C%22dismissed%22%3Atrue%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"creative.com\"\r\n ],\r\n \"id\": \"32fc1292-e26f-49c8-8de8-c41966c0bd34\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cookie-accept\",\r\n \"optOut\": \"#cookie-accept-technical\",\r\n \"presence\": \".cookie-banner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"_CookiePolicyHint\",\r\n \"value\": \"true\"\r\n },\r\n {\r\n \"name\": \"cookie_functional\",\r\n \"value\": \"on\"\r\n },\r\n {\r\n \"name\": \"cookie_marketing\",\r\n \"value\": \"on\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"_CookiePolicyHint\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bequiet.com\"\r\n ],\r\n \"id\": \"de38e8a0-25d2-42d2-974d-04684e54b7ce\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cookiesPrivacyPolicyAllowBtn\",\r\n \"optOut\": \"#cookiesPrivacyPolicyDenyBtn\",\r\n \"presence\": \"#cookiesPrivacyPolicyContainerWrapper\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookiesPrivacyPolicy\",\r\n \"value\": \"1\"\r\n },\r\n {\r\n \"name\": \"cookiesPrivacyPolicyExtended\",\r\n \"value\": \"1\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookiesPrivacyPolicy\",\r\n \"value\": \"1\"\r\n },\r\n {\r\n \"name\": \"cookiesPrivacyPolicyExtended\",\r\n \"value\": \"0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"akyga.com\"\r\n ],\r\n \"id\": \"3bf04e3c-efe8-49af-bf80-506f12ba2da4\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fcQwZX\",\r\n \"optOut\": \"button.fLZgds\",\r\n \"presence\": \".kDNyTh.hbTFXs\"\r\n },\r\n \"domains\": [\r\n \"androidpolice.com\"\r\n ],\r\n \"id\": \"5B57603A-0CE0-4511-B324-18D34F60EC51\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button[data-testid=\\\"allow-all-cookies-button\\\"]\",\r\n \"optOut\": \"div[data-testid=\\\"cookie-popover\\\"] button:nth-child(2)\",\r\n \"presence\": \"div[data-testid=\\\"cookie-popover\\\"]\"\r\n },\r\n \"domains\": [\r\n \"ondo.finance\"\r\n ],\r\n \"id\": \"50105C82-FEDE-424E-884D-430FA7BCBED\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#gdpr-cookie-accept\",\r\n \"presence\": \"#gdpr-cookie-message\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieControl\",\r\n \"value\": \"true\"\r\n },\r\n {\r\n \"name\": \"cookieControlPrefs\",\r\n \"value\": \"[\\\"analytics\\\",\\\"marketing\\\"]\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookieControl\",\r\n \"value\": \"true\"\r\n },\r\n {\r\n \"name\": \"cookieControlPrefs\",\r\n \"value\": \"[]\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"teamspeak.com\"\r\n ],\r\n \"id\": \"8005fd7b-26ec-43c0-ad94-1c6834cc7905\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"happycow-cookie-policy\",\r\n \"value\": \"1\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"happycow-cookie-policy\",\r\n \"value\": \"0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"happycow.net\"\r\n ],\r\n \"id\": \"4710a874-0ff4-4072-8476-36a22d7f698e\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookiebanner_accepted\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"raspberrypi.com\"\r\n ],\r\n \"id\": \"61dfb6a1-21b4-4f95-aa6d-946eb09f8511\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button.js-decline-all-cookies\",\r\n \"presence\": \"div.cookie-consent-spice\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"thomann.de\"\r\n ],\r\n \"id\": \"5aa2d4df-2a5d-4abf-bb5a-bd714951f790\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button[data-testid=\\\"gdpr-btn-accept-all\\\"]\",\r\n \"optOut\": \"button[data-testid=\\\"gdpr-btn-refuse-all\\\"]\",\r\n \"presence\": \"[data-testid=\\\"cookie-banner\\\"]\"\r\n },\r\n \"domains\": [\r\n \"deezer.com\"\r\n ],\r\n \"id\": \"5bdcb3ce-6270-4270-af7b-d1fdef5cecb4\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button[data-cookie_consent=\\\"1\\\"]\",\r\n \"optOut\": \"button[data-cookie_consent=\\\"0\\\"]\",\r\n \"presence\": \"#js_reveal_cookie_content\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"voelkner.de\"\r\n ],\r\n \"id\": \"bcf09922-64d7-4879-974a-119e8bd05fee\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button[data-qa=\\\"privacy-settings-action-info\\\"]\",\r\n \"optOut\": \"button[data-qa=\\\"privacy-settings-action-close\\\"]\",\r\n \"presence\": \"[data-qa=\\\"privacy-settings\\\"]\"\r\n },\r\n \"domains\": [\r\n \"lieferando.de\",\r\n \"lieferando.at\",\r\n \"just-eat.ch\"\r\n ],\r\n \"id\": \"31d9971f-e23d-4dd9-a891-99a85d97ad19\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.osano-cm-accept-all\",\r\n \"optOut\": \".osano-cm-denyAll\",\r\n \"presence\": \"div.osano-cm-dialog__buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"slideshare.net\",\r\n \"ieee.org\",\r\n \"linuxfoundation.eu\"\r\n ],\r\n \"id\": \"23710ccf-85e6-450e-953d-7ffc3f80bbf0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#ccc-recommended-settings\",\r\n \"optOut\": \"button#ccc-reject-settings\",\r\n \"presence\": \"div#ccc\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"metoffice.gov.uk\",\r\n \"footballmanager.com\",\r\n \"sigames.com\"\r\n ],\r\n \"id\": \"9e4d932a-eaf9-44e4-a3ac-55861d66a7c3\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookieDeclined\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"grundstoff.net\"\r\n ],\r\n \"id\": \"ca62d977-4e2e-48ab-a186-055f9f3277e4\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"onleiheTracking\",\r\n \"value\": \"false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"onleihe.de\"\r\n ],\r\n \"id\": \"c19b1009-d609-438c-8d7c-82fc7144eeaa\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cm-btn-success\",\r\n \"optOut\": \"button.cm-btn-danger\",\r\n \"presence\": \"div#klaro\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"karlsruhe.de\"\r\n ],\r\n \"id\": \"63b5c74c-67ae-47f2-b0ba-10c03132ad6f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.js-cookie-accept\",\r\n \"optOut\": \"button.js-cookie-decline\",\r\n \"presence\": \"div#toast-container\"\r\n },\r\n \"domains\": [\r\n \"startnext.com\"\r\n ],\r\n \"id\": \"312f32e1-a6bf-4e87-b7a6-7480345823cf\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".consentAgree\",\r\n \"optOut\": \"#consentDisagree\",\r\n \"presence\": \"div.consent\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"strato.de\"\r\n ],\r\n \"id\": \"18b1dd86-aee5-4697-b601-4fa320a75dbe\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"hidecookie\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"vrn.de\"\r\n ],\r\n \"id\": \"0ae7f461-8705-4222-b8ee-afa03e5150ff\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"consent_functional\",\r\n \"value\": \"DENY\"\r\n },\r\n {\r\n \"name\": \"consent_marketing\",\r\n \"value\": \"DENY\"\r\n },\r\n {\r\n \"name\": \"consent_technical\",\r\n \"value\": \"ALLOW\"\r\n },\r\n {\r\n \"name\": \"consent_version\",\r\n \"value\": \"2.6\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"huk24.de\"\r\n ],\r\n \"id\": \"0fbacecc-fbda-4e4b-883f-9424790ccc74\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie-preference\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"korodrogerie.de\"\r\n ],\r\n \"id\": \"cdd5646d-06b3-4fdf-8530-b7d8a93f03df\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cl-consent__btn\",\r\n \"optOut\": \"button.cl-consent__close-link\",\r\n \"presence\": \"div#cl-consent\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"hdblog.it\",\r\n \"hdmotori.it\"\r\n ],\r\n \"id\": \"342bd7ca-6502-4df7-bd3a-0b884b51aaa7\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookies_accepted\",\r\n \"value\": \"false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"mindfactory.de\"\r\n ],\r\n \"id\": \"aa077f01-0574-4f1b-ad1b-3225c4dc59f7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#cookieok\",\r\n \"optOut\": \"button#cookiecancel\",\r\n \"presence\": \"div#cookieconsent\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"reichelt.de\"\r\n ],\r\n \"id\": \"b81fc066-5cdd-4af6-b288-49de860e369a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#cookie-consent-button\",\r\n \"presence\": \"dialog.consent\"\r\n },\r\n \"domains\": [\r\n \"computerbase.de\"\r\n ],\r\n \"id\": \"2c73714d-0e9b-41a1-ad12-6bd15ddade67\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cm-btn-accept-all\",\r\n \"optOut\": \"button.cm-btn-accept\",\r\n \"presence\": \"div#klaro\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"lancom-systems.de\"\r\n ],\r\n \"id\": \"efc6f62d-8d53-4f52-9dd3-172d9b04f5de\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.iubenda-cs-accept-btn\",\r\n \"presence\": \"div#iubenda-cs-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ansa.it\"\r\n ],\r\n \"id\": \"8e55f0f4-262f-4d35-a461-47afea6e9069\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button[data-component-name=\\\"consent\\\"]\",\r\n \"optOut\": \"button[data-component-name=\\\"reject\\\"]\",\r\n \"presence\": \".cookie-notice-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"shopify.com\"\r\n ],\r\n \"id\": \"531324c9-83ba-4ba3-a488-3ebde87b10af\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"#tmart-cookie-layer-only-necessary\",\r\n \"presence\": \".cookieLayer\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"sparda-hessen.de\"\r\n ],\r\n \"id\": \"0EAF9E99-36C6-4165-87BE-A62EF1751E1D\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[data-testid=\\\"accept-all-cookies-button\\\"]\",\r\n \"presence\": \"[data-testid=\\\"cookie-banner\\\"]\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"elsevier.com\"\r\n ],\r\n \"id\": \"0AB3A01E-10A9-4509-9350-6EF61AB223F3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cky-btn.cky-btn-accept\",\r\n \"optOut\": \".cky-btn.cky-btn-reject\",\r\n \"presence\": \".cky-consent-bar\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"met.ie\"\r\n ],\r\n \"id\": \"536f8027-111f-4798-a9ef-745b30fe65c8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".sp_choice_type_11\",\r\n \"presence\": \".message-container > #notice\",\r\n \"runContext\": \"child\",\r\n \"skipPresenceVisibilityCheck\": true\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"hln.be\",\r\n \"nu.nl\",\r\n \"vg.no\",\r\n \"aftonbladet.se\",\r\n \"voetbalzone.nl\",\r\n \"ad.nl\",\r\n \"finn.no\",\r\n \"sporza.be\",\r\n \"derstandard.at\",\r\n \"tori.fi\",\r\n \"vrt.be\",\r\n \"spiegel.de\",\r\n \"aftenposten.no\",\r\n \"vglive.no\",\r\n \"oikotie.fi\",\r\n \"klart.se\",\r\n \"zeit.de\",\r\n \"gala.de\",\r\n \"gala.fr\",\r\n \"volkskrant.nl\",\r\n \"tek.no\",\r\n \"omni.se\",\r\n \"dpgmediagroup.com\",\r\n \"economist.com\",\r\n \"chefkoch.de\",\r\n \"sport1.de\",\r\n \"tagesspiegel.de\",\r\n \"stern.de\",\r\n \"sport.de\",\r\n \"giga.de\",\r\n \"2dehands.be\",\r\n \"handelsblatt.com\"\r\n ],\r\n \"id\": \"c58a0bac-3a5f-43af-93e9-8b5462a6bcb5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cb_accept\",\r\n \"presence\": \"[data-module=\\\"cookieBanner\\\"]\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"host\": \"ok.ru\",\r\n \"name\": \"cookieChoice\",\r\n \"value\": \"\\\"PRIVACY,1,2,3\\\"\",\r\n \"unsetValue\": \"\\\"\\\"\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"host\": \"ok.ru\",\r\n \"name\": \"cookieChoice\",\r\n \"value\": \"PRIVACY\",\r\n \"unsetValue\": \"\\\"\\\"\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"ok.ru\"\r\n ],\r\n \"id\": \"384b70c3-2458-4dc9-ac6f-d9c5e90cc62a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#didomi-notice-agree-button\",\r\n \"presence\": \"div#didomi-host\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"euconsent-v2\",\r\n \"value\": \"CPgejgAPgejgAAHABBENCjCgAAAAAAAAAATIAAAAAACBoAMAAQSCEQAYAAgkEKgAwABBIIZABgACCQQA.YAAAAAAAAAAA\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"b92.net\",\r\n \"index.hr\",\r\n \"abv.bg\",\r\n \"orf.at\",\r\n \"expressen.se\",\r\n \"nieuwsblad.be\",\r\n \"independent.ie\",\r\n \"telegraaf.nl\",\r\n \"as.com\",\r\n \"njuskalo.hr\",\r\n \"slobodnadalmacija.hr\",\r\n \"dnevnik.hr\",\r\n \"nova.bg\",\r\n \"mundodeportivo.com\",\r\n \"dn.se\",\r\n \"ouest-france.fr\",\r\n \"standaard.be\",\r\n \"heureka.cz\",\r\n \"bfmtv.com\",\r\n \"filmweb.pl\",\r\n \"expresso.pt\",\r\n \"lavanguardia.com\",\r\n \"idealista.com\",\r\n \"idealista.pt\",\r\n \"di.se\",\r\n \"hola.com\",\r\n \"lesoir.be\",\r\n \"vesti.bg\",\r\n \"gloria.hr\",\r\n \"huffingtonpost.es\",\r\n \"rtl.be\",\r\n \"gong.bg\",\r\n \"okdiario.com\",\r\n \"dhnet.be\",\r\n \"lecturas.com\",\r\n \"elperiodico.com\",\r\n \"dumpert.nl\"\r\n ],\r\n \"id\": \"77885432-826b-4d03-bb3d-dfdd36015a82\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"24chasa.bg\"\r\n ],\r\n \"id\": \"97ba1ec3-c8e7-45a0-845f-c732b71bd213\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".qxOn2zvg.e1sXLPUy\",\r\n \"presence\": \".ulheJb0a\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"wykop.pl\"\r\n ],\r\n \"id\": \"0caa99d5-2b67-44e1-bd45-509d8e785071\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"plovdiv24.bg\"\r\n ],\r\n \"id\": \"1dbf0b53-df44-472c-a126-13740e9d2e39\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#gdpr-consent-banner-accept-button\",\r\n \"presence\": \"div.gdpr-consent-modal-footer\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"marktplaats.nl\"\r\n ],\r\n \"id\": \"2cb11bb1-1d52-45c7-a764-9d2e2be6c310\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".css-15sam98\",\r\n \"optOut\": \".css-1tpwlwl\",\r\n \"presence\": \".css-1243dq3\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"figma.com\"\r\n ],\r\n \"id\": \"e0dd9ba6-6514-4618-a405-3b6458c13272\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"[data-testid=\\\"cookie-wall-accept\\\"]\",\r\n \"optOut\": \"[data-testid=\\\"cookie-wall-reject\\\"]\",\r\n \"presence\": \"[data-testid=\\\"cookie-wall-modal\\\"]\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"change.org\"\r\n ],\r\n \"id\": \"1c3ab910-8635-4007-8ea3-8aeea0c56143\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".dialog-actions-accept-btn\",\r\n \"optOut\": \".dialog-actions-decline-btn\",\r\n \"presence\": \"[data-testid=\\\"cookie-dialog-root\\\"]\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"sq\",\r\n \"value\": \"3\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"sq\",\r\n \"value\": \"0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"nike.com\"\r\n ],\r\n \"id\": \"719a0a85-a706-4393-9668-0b7123891058\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".agree-button.eu-cookie-compliance-default-button\",\r\n \"optOut\": \".eu-cookie-compliance-default-button.eu-cookie-compliance-reject-button\",\r\n \"presence\": \".eu-cookie-compliance-banner.eu-cookie-compliance-banner-info.eu-cookie-compliance-banner--categories\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"_hjFirstSeen\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"nokia.com\"\r\n ],\r\n \"id\": \"c18ccca7-1b7d-4022-b9ca-c725e6030c80\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#accept_all_cookies_button\",\r\n \"optOut\": \"#decline_cookies_button\",\r\n \"presence\": \"#ccpa_consent_banner\",\r\n \"runContext\": \"child\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"dropbox.com\"\r\n ],\r\n \"id\": \"7f82a27a-eaf8-4c16-b15e-66b5efe62fa2\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div._2j0fmugLb1FgYz6KPuB91w > button\",\r\n \"optOut\": \"div._2j0fmugLb1FgYz6KPuB91w > button + button\",\r\n \"presence\": \"div#cookie-banner, #wcpConsentBannerCtrl\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"microsoft.com\",\r\n \"office.com\"\r\n ],\r\n \"id\": \"f899d35e-20af-4cfb-9856-143a80b86ba9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cookie-disclosure-accept\",\r\n \"optOut\": \"#cookie-disclosure-reject\",\r\n \"presence\": \"#cookie-disclosure\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"netflix.com\"\r\n ],\r\n \"id\": \"6037802d-9a37-4df2-bf35-9ad60c478725\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#bbccookies-continue-button\",\r\n \"presence\": \"#bbccookies\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"bbc.com\",\r\n \"bbc.co.uk\"\r\n ],\r\n \"id\": \"e9a07bcd-28b5-4034-a663-c017e6a8208e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".css-1e382ig\",\r\n \"optOut\": \".css-7gbax3\",\r\n \"presence\": \"#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"nemzetisport.hu\"\r\n ],\r\n \"id\": \"da1104e6-0ce0-4956-bd53-fd13c5c2c90b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1kpvtti\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"voetbalprimeur.nl\"\r\n ],\r\n \"id\": \"17c60799-e59b-475f-8007-fec046609121\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"rtl.hr\"\r\n ],\r\n \"id\": \"1d9f6559-cbc8-4cdb-b5f5-ca24844621d3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.wscrOk\",\r\n \"presence\": \"div.wscrBannerContent\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"nordea.com\"\r\n ],\r\n \"id\": \"fe09c786-b60e-455e-abd6-212c8878f06a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".css-ofc9r3\",\r\n \"optOut\": \".css-1jlb8eq\",\r\n \"presence\": \"#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"car.gr\"\r\n ],\r\n \"id\": \"690ce1d3-e595-4181-a109-8f55c1039c81\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#minf-privacy-accept-btn-screen1-id\",\r\n \"presence\": \"div.minf-privacy-rationale\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"mediaset.it\"\r\n ],\r\n \"id\": \"a54d7325-5847-4f20-815f-a938dacd81b4\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#accept-ufti\",\r\n \"presence\": \"div.Modal__Content-sc-1sm9281-2\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"blocket.se\"\r\n ],\r\n \"id\": \"12b031c4-abfd-4156-a83c-5e8418f4a866\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"abola.pt\"\r\n ],\r\n \"id\": \"8e8f6719-0350-4310-8cc1-c00bb51b72b0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-dialog-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"sme.sk\",\r\n \"ripost.hu\",\r\n \"fanatik.ro\",\r\n \"net.hr\",\r\n \"freemail.hu\",\r\n \"marica.bg\",\r\n \"dn.pt\",\r\n \"imhd.sk\"\r\n ],\r\n \"id\": \"5fd67d61-aaa7-4431-af6f-0f1c31c849fc\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".fc-button-label\",\r\n \"presence\": \".fc-consent-root\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"framar.bg\"\r\n ],\r\n \"id\": \"ff5b2d52-6cc0-47e5-a515-4a31c9207b81\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#pt-accept-all\",\r\n \"optOut\": \".pt-k2L\",\r\n \"presence\": \".pt-VnJ\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"dagospia.com\"\r\n ],\r\n \"id\": \"794449a7-94aa-402a-a32c-3859bec7eff8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#button_i_accept\",\r\n \"presence\": \"div#consent-info\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookiesconsent\",\r\n \"value\": \"eyJwZXJzb25hbGFkIjp0cnVlLCJsYXN0dmlzaXRlZCI6dHJ1ZX0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"alo.bg\"\r\n ],\r\n \"id\": \"9dcffb37-fccc-4c6f-9127-e9d2b0db7521\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".css-2rlcm4\",\r\n \"presence\": \"#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"borsonline.hu\"\r\n ],\r\n \"id\": \"a1d99e05-e32c-4cf1-931c-25c8baf538ae\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#iubenda-cs-accept-btn iubenda-cs-btn-primary\",\r\n \"presence\": \"div#iubenda-cs-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"3bmeteo.com\"\r\n ],\r\n \"id\": \"2625ca4e-dd77-4e72-a6d0-c959f3575ad8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \".CookiesAlert_cookiesAlert__3qSl1 .Button_button__3Me73\",\r\n \"presence\": \".CookiesAlert_cookiesAlert__3qSl1\"\r\n },\r\n \"domains\": [\r\n \"traderjoes.com\"\r\n ],\r\n \"id\": \"87cbcbb6-b531-4c9c-889a-aaa5678c06c5\"\r\n },\r\n {\r\n \"click\": {\r\n \"hide\": \"section[aria-label=\\\"GDPR\\\"]\",\r\n \"optOut\": \"#cancel\",\r\n \"presence\": \"#gdpr\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookies_ok\",\r\n \"value\": \"false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"project529.com\"\r\n ],\r\n \"id\": \"a029bf7e-3274-4877-967e-e7517457ac18\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cookielaw-banner .btn-default\",\r\n \"presence\": \".cookielaw-banner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookielaw\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"openwrt.org\"\r\n ],\r\n \"id\": \"14ea41fa-68ac-4c12-b906-7d2a259d5fb7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div[data-name=\\\"comp-banner\\\"] button[aria-label=\\\"Dismiss\\\"]\",\r\n \"presence\": \"div[data-name=\\\"comp-banner\\\"]\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieNotification\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"vegetology.com\"\r\n ],\r\n \"id\": \"f7633fd9-05a0-4c21-b9e7-acfcd5df5234\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#didomi-notice-agree-button\",\r\n \"presence\": \"div#didomi-host\"\r\n },\r\n \"domains\": [],\r\n \"id\": \"didomi\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".banner-actions-container > #onetrust-accept-btn-handler, #onetrust-button-group > #onetrust-accept-btn-handler, .onetrust-banner-options > #onetrust-accept-btn-handler\",\r\n \"optOut\": \".banner-actions-container > #onetrust-reject-all-handler, #onetrust-button-group > #onetrust-reject-all-handler, .onetrust-banner-options > #onetrust-reject-all-handler\",\r\n \"presence\": \"#onetrust-consent-sdk\"\r\n },\r\n \"domains\": [],\r\n \"id\": \"onetrust\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"consentLevel\",\r\n \"value\": \"1\"\r\n },\r\n {\r\n \"name\": \"euconsent-bypass\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"web.de\",\r\n \"gmx.net\"\r\n ],\r\n \"id\": \"290db348-ced2-4f16-9954-da476d0aef01\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.duet--cta--cookie-banner button.py-12\",\r\n \"optOut\": \"div.duet--cta--cookie-banner button.text-blurple\",\r\n \"presence\": \"div.duet--cta--cookie-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"theverge.com\"\r\n ],\r\n \"id\": \"323bae6b-e146-4318-b8ba-1f819c0cfc53\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".fc-cta-consent\",\r\n \"optOut\": \".fc-cta-do-not-consent\",\r\n \"presence\": \".fc-dialog-container\"\r\n },\r\n \"domains\": [\r\n \"accuweather.com\"\r\n ],\r\n \"id\": \"10B17126-C30B-4EE5-9CF5-A4CAFD361AEB\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".js-disc-cp-accept-all\",\r\n \"optOut\": \".js-disc-cp-deny-all\",\r\n \"presence\": \".disc-cp-modal__modal\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"obiConsent\",\r\n \"value\": \"c1-1|c2-0|c3-0|c4-0|c5-0|ts-4127464398701|consent-true\"\r\n },\r\n {\r\n \"name\": \"miCookieOptOut\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"obi.de\"\r\n ],\r\n \"id\": \"02BB875E-D588-4C12-9BE8-FD467C050B86\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cmpwelcomebtnyes a\",\r\n \"presence\": \"#cmpbox\"\r\n },\r\n \"domains\": [\r\n \"mail.ru\",\r\n \"blic.rs\",\r\n \"hrt.hr\",\r\n \"eventim.de\"\r\n ],\r\n \"id\": \"EA3C8C1C-8B1A-46A4-8631-750ECC29425A\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cmpwelcomebtnyes a\",\r\n \"optOut\": \"#cmpwelcomebtnno a\",\r\n \"presence\": \"#cmpbox\"\r\n },\r\n \"domains\": [\r\n \"adac.de\"\r\n ],\r\n \"id\": \"E46708B7-B378-4AA5-A700-12D7221537A0\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie_consent\",\r\n \"value\": \"denied\"\r\n },\r\n {\r\n \"name\": \"marketing_consent\",\r\n \"value\": \"denied\"\r\n },\r\n {\r\n \"name\": \"personalization_consent\",\r\n \"value\": \"denied\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"arbeitsagentur.de\"\r\n ],\r\n \"id\": \"A04DD123-A10F-4665-9C2B-2FB5CC293F42\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CONSENTMGR\",\r\n \"value\": \"c1:1%7Cc2:0%7Cc3:0%7Cc4:0%7Cc5:0%7Cc6:0%7Cc7:0%7Cc8:0%7Cc9:0%7Cc10:0%7Cc11:0%7Cc12:0%7Cc13:0%7Cc14:0%7Cc15:0%7Cts:111697211775123%7Cconsent:true\"\r\n },\r\n {\r\n \"name\": \"request_consent_v\",\r\n \"value\": \"3\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bahn.de\"\r\n ],\r\n \"id\": \"D4AD5843-DDBD-4D93-BFBA-F53DD8B53111\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"consent_status\",\r\n \"value\": \"true\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"consent_status\",\r\n \"value\": \"false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"immobilienscout24.de\"\r\n ],\r\n \"id\": \"3BD1F0DA-BABB-4F75-9B56-C92A1CFD114B\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cmpboxbtnyes\",\r\n \"optOut\": \".cmpboxbtnsave\",\r\n \"presence\": \"#cmpwrapper\"\r\n },\r\n \"domains\": [\r\n \"gls-pakete.de\"\r\n ],\r\n \"id\": \"1D4DDA28-C6FC-489F-B2F3-07EAAB4F5AFE\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button[data-test-id=\\\"cookie-notice-accept\\\"]\",\r\n \"optOut\": \"button[data-test-id=\\\"cookie-notice-reject\\\"]\",\r\n \"presence\": \".cookie-notice\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"ECCC\",\r\n \"value\": \"e\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"ecosia.org\"\r\n ],\r\n \"id\": \"7BA8E4AF-438A-40FE-8941-F921AA1FEA2A\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button[data-testid=\\\"cookie-banner-strict-accept-all\\\"]\",\r\n \"optOut\": \"button[data-testid=\\\"cookie-banner-strict-accept-selected\\\"]\",\r\n \"presence\": \"div[data-testid=\\\"dl-cookieBanner\\\"]\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"privacySettings\",\r\n \"value\": \"%7B%22v%22%3A%221%22%2C%22t%22%3A4127362064%2C%22m%22%3A%22STRICT%22%2C%22consent%22%3A%5B%22NECESSARY%22%5D%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"deepl.com\"\r\n ],\r\n \"id\": \"e3968548-ebca-4d6b-b8cf-5707e90fb612\"\r\n },\r\n {\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"c24consent\",\r\n \"value\": \"f\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"check24.de\"\r\n ],\r\n \"id\": \"d4f2492e-c801-4687-84c8-e2cd17b4f115\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".qc-cmp2-summary-buttons > :nth-child(3)\",\r\n \"optOut\": \".qc-cmp2-summary-buttons > :nth-child(2)\",\r\n \"presence\": \"#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ign.com\"\r\n ],\r\n \"id\": \"FB43FF71-9546-43D1-8B52-D208D1347095\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#gdpr-banner-accept\",\r\n \"presence\": \"#gdpr-banner-container\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [],\r\n \"optOut\": [\r\n {\r\n \"name\": \"ekConsentTcf2\",\r\n \"value\": \"{%22customVersion%22:6%2C%22encodedConsentString%22:%22CPzMR3KPzMR3KE1ABADEC4CgAAAAAAAAAAYgJNwLgAXAA4ACWAFMAPwAzYCLAIuAZ8A14B0gD7AI8ASKAlcBMgCmwFhALqAXeAvoBggDBgGfANGAaaA1UBtADggHHgOUAc6A58B2wDuQHggPJAfaA_YCCIEFAI0gR2Aj6BIiCSIJJgSbAAAASMgAwABBKEdABgACCUJCADAAEEoSUAGAAIJQlIAMAAQShCQAYAAglCGgAwABBKERABgACCUIqADAAEEoQA%22%2C%22googleConsentGiven%22:false%2C%22consentInterpretation%22:{%22googleAdvertisingFeaturesAllowed%22:false%2C%22googleAnalyticsAllowed%22:false%2C%22infonlineAllowed%22:false%2C%22theAdexAllowed%22:false%2C%22criteoAllowed%22:false%2C%22facebookAllowed%22:false%2C%22amazonAdvertisingAllowed%22:false%2C%22rtbHouseAllowed%22:false}}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"kleinanzeigen.de\"\r\n ],\r\n \"id\": \"dbc0dde3-600b-40ab-ab8b-b07e3e3c7af8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".implicit_privacy_prompt > .close_btn_thick\",\r\n \"presence\": \".implicit_privacy_prompt\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"HASSEENNOTICE\",\r\n \"value\": \"TRUE\"\r\n },\r\n {\r\n \"name\": \"CONSENTMGR\",\r\n \"value\": \"c1:0%7Cc3:0%7Cc5:0%7Cc6:0%7Cc7:0%7Cc8:0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"ups.com\"\r\n ],\r\n \"id\": \"f462b0ee-4d17-4dcb-a951-1a23249227f6\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CONSENT\",\r\n \"path\": \"\/\",\r\n \"value\": \"PENDING+742\",\r\n \"sameSite\": 0\r\n },\r\n {\r\n \"name\": \"SOCS\",\r\n \"path\": \"\/\",\r\n \"value\": \"CAESNQgDEitib3FfaWRlbnRpdHlmcm9udGVuZHVpc2VydmVyXzIwMjMwOTEyLjA4X3AwGgJlbiACGgYIgOCTqAY\",\r\n \"sameSite\": 0\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"youtube.com\"\r\n ],\r\n \"id\": \"788ed05e-cf93-41a8-b2e5-c5571d855232\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#acceptAllButton\",\r\n \"optOut\": \"#rejectAllButton\",\r\n \"presence\": \"#cookiePrefPopup\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieSettings\",\r\n \"value\": \"%7B%22version%22%3A1%2C%22preference_state%22%3A1%2C%22content_customization%22%3Anull%2C%22valve_analytics%22%3Anull%2C%22third_party_analytics%22%3Anull%2C%22third_party_content%22%3Anull%2C%22utm_enabled%22%3Atrue%7D\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookieSettings\",\r\n \"value\": \"%7B%22version%22%3A1%2C%22preference_state%22%3A2%2C%22content_customization%22%3Anull%2C%22valve_analytics%22%3Anull%2C%22third_party_analytics%22%3Anull%2C%22third_party_content%22%3Anull%2C%22utm_enabled%22%3Atrue%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"steamcommunity.com\",\r\n \"steampowered.com\"\r\n ],\r\n \"id\": \"63104024-41b5-4be5-8019-2c59eaaf612c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.accept-all\",\r\n \"optOut\": \"button.reject-all\",\r\n \"presence\": \"div#consent-page\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"yahoo.com\"\r\n ],\r\n \"id\": \"2f9123b1-7d6b-460f-8da8-988f82238ec7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#truste-consent-button\",\r\n \"optOut\": \"#truste-consent-required\",\r\n \"presence\": \"#truste-consent-content, .truste-consent-content, #truste-consent-track\"\r\n },\r\n \"domains\": [],\r\n \"id\": \"trustarcbar\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".qc-cmp2-buttons-desktop button[mode=\\\"primary\\\"], .qc-cmp2-summary-buttons button[mode=\\\"primary\\\"]\",\r\n \"presence\": \".qc-cmp2-container\"\r\n },\r\n \"domains\": [],\r\n \"id\": \"quantcast\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#CookieBoxSaveButton\",\r\n \"optOut\": \"._brlbs-refuse-btn a\",\r\n \"presence\": \"#BorlabsCookieBox\"\r\n },\r\n \"domains\": [],\r\n \"id\": \"borlabs\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cmpwelcomebtnyes a\",\r\n \"optOut\": \"#cmpwelcomebtnno a\",\r\n \"presence\": \"#cmpbox\"\r\n },\r\n \"domains\": [],\r\n \"id\": \"consentmanagernet\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"#CybotCookiebotDialog\"\r\n },\r\n \"domains\": [],\r\n \"id\": \"cookiebot\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".cmplz-btn.cmplz-accept\",\r\n \"optOut\": \".cmplz-btn.cmplz-deny\",\r\n \"presence\": \"#cmplz-cookiebanner-container\"\r\n },\r\n \"domains\": [],\r\n \"id\": \"complianz\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".sp_choice_type_11\",\r\n \"presence\": \".message-container > #notice\",\r\n \"runContext\": \"child\",\r\n \"skipPresenceVisibilityCheck\": true\r\n },\r\n \"domains\": [],\r\n \"id\": \"sourcepoint\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#hs-eu-confirmation-button\",\r\n \"optOut\": \"button#hs-eu-decline-button\",\r\n \"presence\": \"div#hs-eu-cookie-confirmation\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"hubspot.com\"\r\n ],\r\n \"id\": \"8308357f-6a66-433d-bfc3-de401410c350\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookie-consent\",\r\n \"value\": \"{%22ga%22:true%2C%22af%22:true%2C%22fbp%22:true%2C%22lip%22:true%2C%22bing%22:true%2C%22ttads%22:true%2C%22reddit%22:true%2C%22criteo%22:true%2C%22version%22:%22v9%22}\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie-consent\",\r\n \"value\": \"{%22ga%22:false%2C%22af%22:false%2C%22fbp%22:false%2C%22lip%22:false%2C%22bing%22:false%2C%22ttads%22:false%2C%22reddit%22:false%2C%22criteo%22:false%2C%22version%22:%22v9%22}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"tiktok.com\"\r\n ],\r\n \"id\": \"d9166ae8-dcc7-4ca2-8b02-0884fb1d6f70\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"_s.cookie_consent\",\r\n \"value\": \"marketing=0:analytics=0:version=2021-07-01:timestamp=1678179320141\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"smallpdf.com\"\r\n ],\r\n \"id\": \"bb1114eb-12ef-4465-8990-b33bbb270d3f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cc-banner__button-accept\",\r\n \"optOut\": \"button.cc-banner__button-reject\",\r\n \"presence\": \"div.cc-banner__content\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"biomedcentral.com\"\r\n ],\r\n \"id\": \"d5220250-697b-4b60-8284-f87e8ec2565c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.o-cookie-message__button\",\r\n \"presence\": \"div.o-cookie-message\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ft.com\"\r\n ],\r\n \"id\": \"b485c89f-130e-4d5a-94f8-99eacad96e5f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button.TCF2Popup__continueWithoutAccepting___1fMWW\",\r\n \"presence\": \"div.TCF2Popup__container___1TN_W\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"dailymotion.com\"\r\n ],\r\n \"id\": \"e5d31bce-4a62-4c6d-a40f-94fda7c41c9d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#L2AGLb\",\r\n \"optOut\": \"button#W0wltc\",\r\n \"presence\": \"div.spoKVd\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"CONSENT\",\r\n \"value\": \"PENDING\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"SOCS\",\r\n \"value\": \"CAESHAgBEhJnd3NfMjAyMjA5MjktMF9SQzEaAnJvIAEaBgiAkvOZBg\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"google.ad\",\r\n \"google.ae\",\r\n \"google.al\",\r\n \"google.am\",\r\n \"google.as\",\r\n \"google.at\",\r\n \"google.az\",\r\n \"google.ba\",\r\n \"google.be\",\r\n \"google.bf\",\r\n \"google.bg\",\r\n \"google.bi\",\r\n \"google.bj\",\r\n \"google.bs\",\r\n \"google.bt\",\r\n \"google.by\",\r\n \"google.ca\",\r\n \"google.cat\",\r\n \"google.cd\",\r\n \"google.cf\",\r\n \"google.cg\",\r\n \"google.ch\",\r\n \"google.ci\",\r\n \"google.cl\",\r\n \"google.cm\",\r\n \"google.cn\",\r\n \"google.co.ao\",\r\n \"google.co.bw\",\r\n \"google.co.ck\",\r\n \"google.co.cr\",\r\n \"google.co.id\",\r\n \"google.co.il\",\r\n \"google.co.in\",\r\n \"google.co.jp\",\r\n \"google.co.ke\",\r\n \"google.co.kr\",\r\n \"google.co.ls\",\r\n \"google.co.ma\",\r\n \"google.co.mz\",\r\n \"google.co.nz\",\r\n \"google.co.th\",\r\n \"google.co.tz\",\r\n \"google.co.ug\",\r\n \"google.co.uk\",\r\n \"google.co.uz\",\r\n \"google.co.ve\",\r\n \"google.co.vi\",\r\n \"google.co.za\",\r\n \"google.co.zm\",\r\n \"google.co.zw\",\r\n \"google.com\",\r\n \"google.com.af\",\r\n \"google.com.ag\",\r\n \"google.com.ai\",\r\n \"google.com.ar\",\r\n \"google.com.au\",\r\n \"google.com.bd\",\r\n \"google.com.bh\",\r\n \"google.com.bn\",\r\n \"google.com.bo\",\r\n \"google.com.br\",\r\n \"google.com.bz\",\r\n \"google.com.co\",\r\n \"google.com.cu\",\r\n \"google.com.cy\",\r\n \"google.com.do\",\r\n \"google.com.ec\",\r\n \"google.com.eg\",\r\n \"google.com.et\",\r\n \"google.com.fj\",\r\n \"google.com.gh\",\r\n \"google.com.gi\",\r\n \"google.com.gt\",\r\n \"google.com.hk\",\r\n \"google.com.jm\",\r\n \"google.com.kh\",\r\n \"google.com.kw\",\r\n \"google.com.lb\",\r\n \"google.com.ly\",\r\n \"google.com.mm\",\r\n \"google.com.mt\",\r\n \"google.com.mx\",\r\n \"google.com.my\",\r\n \"google.com.na\",\r\n \"google.com.ng\",\r\n \"google.com.ni\",\r\n \"google.com.np\",\r\n \"google.com.om\",\r\n \"google.com.pa\",\r\n \"google.com.pe\",\r\n \"google.com.pg\",\r\n \"google.com.ph\",\r\n \"google.com.pk\",\r\n \"google.com.pr\",\r\n \"google.com.py\",\r\n \"google.com.qa\",\r\n \"google.com.sa\",\r\n \"google.com.sb\",\r\n \"google.com.sg\",\r\n \"google.com.sl\",\r\n \"google.com.sv\",\r\n \"google.com.tj\",\r\n \"google.com.tr\",\r\n \"google.com.tw\",\r\n \"google.com.ua\",\r\n \"google.com.uy\",\r\n \"google.com.vc\",\r\n \"google.com.vn\",\r\n \"google.cv\",\r\n \"google.cz\",\r\n \"google.de\",\r\n \"google.dj\",\r\n \"google.dk\",\r\n \"google.dm\",\r\n \"google.dz\",\r\n \"google.ee\",\r\n \"google.es\",\r\n \"google.fi\",\r\n \"google.fm\",\r\n \"google.fr\",\r\n \"google.ga\",\r\n \"google.ge\",\r\n \"google.gg\",\r\n \"google.gl\",\r\n \"google.gm\",\r\n \"google.gr\",\r\n \"google.gy\",\r\n \"google.hn\",\r\n \"google.hr\",\r\n \"google.ht\",\r\n \"google.hu\",\r\n \"google.ie\",\r\n \"google.im\",\r\n \"google.iq\",\r\n \"google.is\",\r\n \"google.it\",\r\n \"google.je\",\r\n \"google.jo\",\r\n \"google.kg\",\r\n \"google.ki\",\r\n \"google.kz\",\r\n \"google.la\",\r\n \"google.li\",\r\n \"google.lk\",\r\n \"google.lt\",\r\n \"google.lu\",\r\n \"google.lv\",\r\n \"google.md\",\r\n \"google.me\",\r\n \"google.mg\",\r\n \"google.mk\",\r\n \"google.ml\",\r\n \"google.mn\",\r\n \"google.ms\",\r\n \"google.mu\",\r\n \"google.mv\",\r\n \"google.mw\",\r\n \"google.ne\",\r\n \"google.nl\",\r\n \"google.no\",\r\n \"google.nr\",\r\n \"google.nu\",\r\n \"google.pl\",\r\n \"google.pn\",\r\n \"google.ps\",\r\n \"google.pt\",\r\n \"google.ro\",\r\n \"google.rs\",\r\n \"google.ru\",\r\n \"google.rw\",\r\n \"google.sc\",\r\n \"google.se\",\r\n \"google.sh\",\r\n \"google.si\",\r\n \"google.sk\",\r\n \"google.sm\",\r\n \"google.sn\",\r\n \"google.so\",\r\n \"google.sr\",\r\n \"google.st\",\r\n \"google.td\",\r\n \"google.tg\",\r\n \"google.tl\",\r\n \"google.tm\",\r\n \"google.tn\",\r\n \"google.to\",\r\n \"google.tt\",\r\n \"google.vg\",\r\n \"google.vu\",\r\n \"google.ws\"\r\n ],\r\n \"id\": \"4a37ab17-dbd7-45cf-83d8-42b84af9a0df\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#bnp_btn_accept\",\r\n \"optOut\": \"button#bnp_btn_reject\",\r\n \"presence\": \"div#bnp_cookie_banner\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"BCP\",\r\n \"value\": \"AD=0&AL=0&SM=0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bing.com\"\r\n ],\r\n \"id\": \"31dc6160-3495-4f4e-8c67-594527bd4051\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1d0m171\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"in.gr\"\r\n ],\r\n \"id\": \"77896946-2dbf-4b65-9641-9958f9e2a228\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-consent-root\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"dnes.bg\",\r\n \"dennikn.sk\",\r\n \"jn.pt\",\r\n \"ojogo.pt\",\r\n \"klik.hr\"\r\n ],\r\n \"id\": \"ed097835-3d75-4864-8cdf-ea8fb19b033c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-7c2c0h\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"deviantart.com\"\r\n ],\r\n \"id\": \"489a59fb-1054-4d7a-ae1f-c6c561d2cd81\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div#gdpr_accept\",\r\n \"presence\": \"div#gdpr\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"wikihow.com\"\r\n ],\r\n \"id\": \"90b68b2d-46eb-4500-8cfd-9ee794653aaa\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1lgi3ta\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ethnos.gr\",\r\n \"sportdog.gr\"\r\n ],\r\n \"id\": \"570920f9-6a8a-4a26-a68a-8fc773ba30a9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"optOut\": \"button.fc-cta-do-not-consent\",\r\n \"presence\": \"div.fc-consent-root\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"coolinarika.com\",\r\n \"ndtv.com\",\r\n \"hbr.org\"\r\n ],\r\n \"id\": \"12552893-278a-43e6-83ba-1db5267b3d27\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.snow-ali-kit_Button-Floating__button__ph4zrl\",\r\n \"presence\": \"div.SnowPrivacyPolicyBanner_SnowPrivacyPolicyBanner__privacyPolicyBanner__1jg07\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"aliexpress.ru\"\r\n ],\r\n \"id\": \"407fe21e-7730-47f9-b83f-51d19dddc700\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.dismiss\",\r\n \"presence\": \"div#cookie-disclaimer\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"arukereso.hu\"\r\n ],\r\n \"id\": \"7cbce78d-ec34-4e9b-a4f5-5b401f155e36\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.cookieinfo-close\",\r\n \"presence\": \"div.cookieinfo\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"glasistre.hr\"\r\n ],\r\n \"id\": \"266c0df8-d8bc-4824-88d8-06d3970f0c2a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-wum6af\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"flash.pt\"\r\n ],\r\n \"id\": \"f081b651-7065-46a9-8d71-d7531850382e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"label.c-notifier-btn\",\r\n \"presence\": \"div.c-notifier-container\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"gdpr\",\r\n \"value\": \"[]\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"ria.com\"\r\n ],\r\n \"id\": \"3d2936df-3ca5-42fc-924b-1144c3d5b424\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#acceptAllCookiesBtn\",\r\n \"presence\": \"div#cookie-popover\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"sverigesradio.se\"\r\n ],\r\n \"id\": \"aa18156e-646e-4c5c-b550-6979d181f192\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.btn\",\r\n \"presence\": \"div#gdpr-box\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"privacy_accepted\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"naslovi.net\"\r\n ],\r\n \"id\": \"87d2bcfa-8685-4289-8c0a-d0fc31c74cc5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.orange-gradient\",\r\n \"presence\": \"div.cookie-bar-wrap\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"politica_cookie\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"dedeman.ro\"\r\n ],\r\n \"id\": \"84a2ec9b-bf4a-4638-aa94-d1b13652aae8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.jad_cmp_paywall_button-cookies\",\r\n \"presence\": \"div#cmp-main\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"allocine.fr\"\r\n ],\r\n \"id\": \"7d70347b-fe27-4c60-a9f2-6e0638f0ce37\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.primary\",\r\n \"presence\": \"div.cookie-consent-overlay\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"komplett.no\"\r\n ],\r\n \"id\": \"be728b6d-8eeb-4179-beac-c1234fc0ae9f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.cookieButton\",\r\n \"presence\": \"div#cookieConsentContainer\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"chmi.cz\"\r\n ],\r\n \"id\": \"d8923166-cf05-4f6c-8829-270fc9b2efe1\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.PONCHO-button--decide\",\r\n \"presence\": \"div#TRCO-application\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie-policy-agreement\",\r\n \"value\": \"%7B%22revision%22%3A20%2C%22consentLevel%22%3A1%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"anwb.nl\"\r\n ],\r\n \"id\": \"e63a688a-7eca-49b1-9b0f-bebae773a6ec\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.shared-module_w-full_3cBSk\",\r\n \"presence\": \"div#consent-modal-yr73k\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"euconsent-v2\",\r\n \"value\": \"CPmmxEAPmmxEADhADBPLC1CgAAAAAAAAAB5YAAAAAAAA\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"se.pl\"\r\n ],\r\n \"id\": \"d0b8bc2c-c633-4628-aa39-e840060efc2e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div#cookiescript_accept\",\r\n \"optOut\": \"div#cookiescript_reject \",\r\n \"presence\": \"div#cookiescript_injected\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"idealmedia.io\"\r\n ],\r\n \"id\": \"7f8a8bfd-343c-4fa5-969b-35a0690f6f4f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cookieBarConsentButton\",\r\n \"presence\": \"div#cookieBar\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"pki.goog\"\r\n ],\r\n \"id\": \"259f5d9f-b127-47c2-a42e-161cb70a0a81\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-11uejzu\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"huffpost.com\"\r\n ],\r\n \"id\": \"e9d772f8-92fd-4e0e-b742-0bfd6555877e\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookiesAccepted\",\r\n \"value\": \"essential\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"onlyfans.com\"\r\n ],\r\n \"id\": \"647013d0-7a3f-4e80-8c26-00f3389ba551\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#cookie-accept\",\r\n \"presence\": \"div.banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"tandfonline.com\"\r\n ],\r\n \"id\": \"73c5220c-3cdb-446b-b2f6-ce9e0f1d9a8b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.banner-lgpd-consent__accept\",\r\n \"presence\": \"div.banner-lgpd-consent-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"uol.com.br\"\r\n ],\r\n \"id\": \"a491dd76-8759-4c10-a4fa-2e87b1034f23\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"uw_marketo_opt_in\",\r\n \"value\": \"true\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"mkto_opt_out\",\r\n \"value\": \"id:true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"washington.edu\"\r\n ],\r\n \"id\": \"1de8aa61-94f1-49b4-a05d-d19a57829c71\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-2wu0d3\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"buzzfeed.com\"\r\n ],\r\n \"id\": \"a4186ce7-bb7a-4c55-aec6-34f265943367\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cky-btn-accept\",\r\n \"optOut\": \"button.cky-btn-reject\",\r\n \"presence\": \"div.cky-consent-bar\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"hugedomains.com\"\r\n ],\r\n \"id\": \"b3b80b81-90f0-497f-b78c-53b611d4dfaf\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"trackingconsent\",\r\n \"value\": \"{\\\"marketingenabled\\\":true}\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"trackingconsent\",\r\n \"value\": \"{\\\"marketingenabled\\\":false}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"abc.net.au\"\r\n ],\r\n \"id\": \"c213b36a-4893-46e2-bb0e-f98a03d58287\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"presence\": \"div#CookieBanner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"jotform.com\"\r\n ],\r\n \"id\": \"6efa8f4a-97a3-4677-8cde-8727f270d267\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.bcpConsentButton\",\r\n \"presence\": \"div.bcpNotificationBar\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"inc.com\"\r\n ],\r\n \"id\": \"990f03a5-c180-407b-9a0e-f6db1278330d\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookiePolicy\",\r\n \"value\": \"accept\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"pnas.org\"\r\n ],\r\n \"id\": \"a327ad8a-daa9-4d7c-abb5-0f3e3f3c59db\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"consent\",\r\n \"value\": \"{\\\"Marketing\\\":false,\\\"created_time\\\":\\\"2023-02-14T12:27:52.910Z\\\"}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"science.org\"\r\n ],\r\n \"id\": \"67702151-14e2-41c7-af4b-1f22de8185a5\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"ks-cookie-consent\",\r\n \"value\": \"allow\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"ks-cookie-consent\",\r\n \"value\": \"deny\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"roku.com\"\r\n ],\r\n \"id\": \"08513283-d50d-4a85-93fc-cd0568b09976\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.gdpr-banner__accept\",\r\n \"presence\": \"div.gdpr-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"scmp.com\"\r\n ],\r\n \"id\": \"68aff357-d47c-4dc2-b7fa-27ac4b982257\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cc-dismiss\",\r\n \"presence\": \"div.cc-window\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"braze.com\"\r\n ],\r\n \"id\": \"28bee54e-47d3-4192-8044-0bef6870a7c1\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#cookie-allow-all\",\r\n \"optOut\": \"a#cookie-necessary-only\",\r\n \"presence\": \"div#cookiebanner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"adjust.com\"\r\n ],\r\n \"id\": \"ca37bf31-bf4e-4172-bf7e-91baca32c9e2\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-hxv78t\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"rsvplive.ie\"\r\n ],\r\n \"id\": \"5a256bf7-29a7-485e-8305-fcafd7a52af9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1euwp1t\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"enikos.gr\"\r\n ],\r\n \"id\": \"20e1f1c3-0914-42b4-9022-3ce80b79a480\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-fz9f1h\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"dikaiologitika.gr\"\r\n ],\r\n \"id\": \"1c742d88-b3a7-43da-b205-3ba4afd92e63\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-v43ltw\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"filmaffinity.com\"\r\n ],\r\n \"id\": \"ef8548ec-5dd9-4201-a075-a32893f09953\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#consent_prompt_submit\",\r\n \"presence\": \"div.consent_prompt_footer\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"argos.co.uk\"\r\n ],\r\n \"id\": \"88e79126-8072-4079-aa60-1d71d9ddb65b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.iubenda-cs-accept-btn\",\r\n \"presence\": \"div#iubenda-cs-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ilmessaggero.it\"\r\n ],\r\n \"id\": \"c839d2c3-ee29-4015-a990-8cfe9884a4c4\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#btn-agree-all\",\r\n \"presence\": \"div#rgpd\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"rtp.pt\"\r\n ],\r\n \"id\": \"46e07f9b-9ce4-4fea-8be7-48a089bdb8d0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"span.inline-block\",\r\n \"presence\": \"div#notice-cookie-block\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"user_allowed_save_cookie\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"altex.ro\"\r\n ],\r\n \"id\": \"32139cbb-7e13-4cec-ba54-7bfb4f46277c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-47sehv\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"timeanddate.com\",\r\n \"livescience.com\",\r\n \"cancan.ro\",\r\n \"evz.ro\",\r\n \"gandul.ro\",\r\n \"kurir.rs\",\r\n \"capital.ro\"\r\n ],\r\n \"id\": \"5a3bf87a-62c2-40b2-bfbb-b2dcf8c23f84\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-tzlaik\",\r\n \"presence\": \"div#qc-cmp2-ui\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"nit.pt\"\r\n ],\r\n \"id\": \"30542b9b-2225-4c22-bbd9-0e9d8f6273df\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#cookies-agree\",\r\n \"presence\": \"div.cookies-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookiesPolicy\",\r\n \"value\": \"10000\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"elcorteingles.es\"\r\n ],\r\n \"id\": \"90b22cfd-00a8-4467-9334-96773066c2c1\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"presence\": \"div#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ingatlan.com\"\r\n ],\r\n \"id\": \"009f2741-56cb-485e-af5c-3102f8e9cf55\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.jad_cmp_paywall_button-cookies\",\r\n \"presence\": \"div#didomi-host\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"purepeople.com\"\r\n ],\r\n \"id\": \"b933bc7f-27b7-44a7-9127-66f98c93ea8f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1xqnplm\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"lifo.gr\"\r\n ],\r\n \"id\": \"4f86f2e9-ea7f-4ee7-a4d2-b2253a5f1c1d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.close-accept\",\r\n \"presence\": \"div.woahbar\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"meteomedia.com\"\r\n ],\r\n \"id\": \"8e772dc2-be57-4cf6-ad51-574b2accf86c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button.accept-button\",\r\n \"presence\": \"div.gdpr-content\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"mozzartbet.com\"\r\n ],\r\n \"id\": \"b8b2cf50-d9cf-432b-947c-894350121024\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"optOut\": \"button.fc-cta-do-not-consent\",\r\n \"presence\": \"div.fc-footer-buttons-container\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPgbQkAPgbQkAEsABBITCjCgAAAAAH_AABCYAAAO9QD2F2K2kKEkfjSUeYAQBCujIEIBUAAAAEKBIAAAAUgQAgFIIAgABlACEAAAABAQAQCAgAQABAAAoICgACAAAAAAAAAQAAQQAABAAIAAAAAAAAEAQAAAAAQAAAAAAAhEhCAAQQAEIAAAAAAAAAAAAAAAAAABAAAAEAA%22%2C%221~%22%2C%22A83BB90B-53A8-400D-8DAA-F4EF44E9B970%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"ilmeteo.it\"\r\n ],\r\n \"id\": \"71d0715b-3e8e-4077-969e-7b7722f78910\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-78i25x\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"mirror.co.uk\"\r\n ],\r\n \"id\": \"21593dac-f469-4206-87d9-044e6d69404b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.js-cookies-info-accept\",\r\n \"optOut\": \"a.js-cookies-info-reject\",\r\n \"presence\": \"div.cookies-info__buttons\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CBARIH\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"alza.cz\"\r\n ],\r\n \"id\": \"c7a067c4-a365-4497-a990-c042545dfd6c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-k8o10q\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"femina.hu\"\r\n ],\r\n \"id\": \"d1b9f7af-26d3-4f1b-812c-9f8cf41da900\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#truste-consent-button\",\r\n \"optOut\": \"a#truste-consent-required2\",\r\n \"presence\": \"div#truste-consent-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"poste.it\"\r\n ],\r\n \"id\": \"450e91f1-22ee-4718-9e98-05f831adfeff\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#almacmp-modalConfirmBtn\",\r\n \"presence\": \"div.almacmp-controls\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"kauppalehti.fi\"\r\n ],\r\n \"id\": \"abb44f4b-bc8a-49bf-8f8c-204fe33806e9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1j8kkja\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"news247.gr\"\r\n ],\r\n \"id\": \"1b1bdbb4-089c-45dc-9d42-e87a1fa85882\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"a#CybotCookiebotDialogBodyLevelButtonLevelOptinDeclineAll\",\r\n \"presence\": \"div#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [],\r\n \"optOut\": [\r\n {\r\n \"name\": \"CookieConsent\",\r\n \"value\": \"{stamp:%27aQlJVTwi41tewItmxlt\/w7TRvv2UDwd39lOxJC\/s1TG9dFvEWjL4jQ==%27%2Cnecessary:true%2Cpreferences:false%2Cstatistics:false%2Cmarketing:false%2Cver:1%2Cutc:1665481193123%2Cregion:%27ro%27}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"rip.ie\"\r\n ],\r\n \"id\": \"051ccd77-b9b3-4558-9c27-5293d8d735f4\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.sfc-button--primary-white\",\r\n \"presence\": \"div.sfc-col-lg-3\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"RABO_PSL\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"rabobank.nl\"\r\n ],\r\n \"id\": \"fe8a3adb-ce3c-4dd6-83db-772e71674da3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cmptxt_btn_yes\",\r\n \"presence\": \"div.cmpboxbtns\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"informer.rs\"\r\n ],\r\n \"id\": \"27572d79-fc44-49f0-b271-bd44e76f3a3f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.eu-cookie-compliance-default-button\",\r\n \"presence\": \"div#popup-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookie-agreed\",\r\n \"value\": \"2\"\r\n }\r\n ],\r\n \"optOut\": []\r\n },\r\n \"domains\": [\r\n \"eluniversal.com.mx\"\r\n ],\r\n \"id\": \"34e9d8ee-5e8d-462c-ab39-d25260124cf6\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-k8o10q\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"athensmagazine.gr\"\r\n ],\r\n \"id\": \"c0e69dcc-9602-4d7a-89b8-a4a06f0432ca\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cookie-banner-lgpd_accept-button\",\r\n \"presence\": \"div#cookie-banner-lgpd\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"globo.com\"\r\n ],\r\n \"id\": \"90b8eda3-d964-4301-94fc-646e92d33d56\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1x99van\",\r\n \"presence\": \"div.qc-cmp2-summary-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"express.co.uk\"\r\n ],\r\n \"id\": \"edaa2c50-1fbc-4287-8d15-68af2b5dc1bf\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1lgi3ta\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"pronews.gr\"\r\n ],\r\n \"id\": \"9e16f526-4d46-4c2e-a3ed-d3b43d67b4fb\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#almacmp-modalConfirmBtn\",\r\n \"presence\": \"div.almacmp-controls\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"nettiauto.com\"\r\n ],\r\n \"id\": \"7e5d072f-ea5a-4a82-9ea4-3bbf06d5cc8d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-b2defl\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ilgiornale.it\"\r\n ],\r\n \"id\": \"362b64e3-c12d-4d5e-9c8e-169df9782b28\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CookieConsent\",\r\n \"value\": \"{stamp:%27hLskZi6GtaCNeWwvKfJCnNdwijLGPiuqz0obMpsw2C4o6inSM80MLQ==%27%2Cnecessary:true%2Cpreferences:false%2Cstatistics:false%2Cmarketing:false%2Cmethod:%27explicit%27%2Cver:1%2Cutc:1675088694646%2Ciab2:%27CPmZlUAPmZlUACGABBENC1CgAAAAAAAAAAAAAAAAAAAA.YAAAAAAAAAAA%27%2Cgacm:%271~%27%2Cregion:%27ro%27}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"worten.pt\"\r\n ],\r\n \"id\": \"905b5c20-000d-4b7b-a8d6-d84d2311dfdf\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-14ubilm\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"tradera.com\"\r\n ],\r\n \"id\": \"deca4013-d55c-424b-9a46-2df8139fd415\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cmptxt_btn_yes\",\r\n \"presence\": \"div.cmpboxbtns\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"alo.rs\"\r\n ],\r\n \"id\": \"4dce4250-5d45-4cf8-bb0c-f485de812859\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons-container\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPg1oEAPg1oEAEsABCBGClCgAAAAAAAAAAIwAAAOhQD2F2K2kKEkfjSUWYAQBCujKEIhUAAAAECBIAAAAUgQAgFIIAgAAlACAAAAABAQAQCAgAQABAAAoACgAAAAAAAAAAAAAAQQAABAAIAAAAAAAAEAQAAIAAQAAAAAAABEhCAAQQAEAAAAAAAAAAAAAAAAAAABAAA%22%2C%221~%22%2C%22A7193324-AE65-4BF2-B769-43CD7980AA9B%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"mobile.bg\"\r\n ],\r\n \"id\": \"3708e6f7-9237-4ef1-8aca-bec748cc676e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-mn27n0\",\r\n \"presence\": \"div.qc-cmp2-summary-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"observador.pt\"\r\n ],\r\n \"id\": \"c49fa28b-a67c-42b8-a4e5-4d48d34fba04\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.boton_cookies\",\r\n \"presence\": \"div#popup_cookies\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"accept_cookies\",\r\n \"value\": \"ok\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"aemet.es\"\r\n ],\r\n \"id\": \"60ea2eca-10af-47d1-a240-cbdf44519e9d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-k8o10q\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"napi.hu\"\r\n ],\r\n \"id\": \"2b12c85e-4ec0-4a3d-9cbe-9892db3f5850\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPhCz0APhCz0AEsABCFIClCgAAAAAAAAAApAAAAOhQD2F2K2kKEkfjSUWYAQBCujIEIhUAAAAECBIAAAAUgQAgFIIAgAAlACAAAAABAQAQCAgAQABAAAoACgAAAAAAAAAAAAAAQQAABAAIAAAAAAAAEAQAAIAAQAAAAAAABEhCAAQQAEAAAAAAAAAAAAAAAAAAABAAA.YAAAAAAAAAA%22%2C%221~%22%2C%22EE7DBC34-5A3A-4088-90DB-A679D6C3A244%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"telsu.fi\"\r\n ],\r\n \"id\": \"0d950828-684c-48f3-8b0c-3c9523c05fb6\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-k8o10q\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"gossip-tv.gr\"\r\n ],\r\n \"id\": \"ba9c17cd-19aa-4fe3-ac0c-9c3055133c5b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#pt-accept-all\",\r\n \"optOut\": \"div#pt-close\",\r\n \"presence\": \"div#pubtech-cmp\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"liberoquotidiano.it\"\r\n ],\r\n \"id\": \"531d8b09-8fce-4019-9f96-31d5add26a43\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-19ukivv\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"vi.nl\"\r\n ],\r\n \"id\": \"5026561f-ffec-437e-bad8-820ac9d55659\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPhCz0APhCz0AEsABCBGClCgAAAAAAAAAAIwAAAQvQD-F2K2lKGkfjaUeYIQBKujOEIhUBgEAEKBIQAEA0gQAgFIIAgADlgCUAAAABARCQCAgAQABAAAoICgAAAAACAAAABAASQQAABAAIAAICAABAUBQAAIAARAAgAAMBBEhDAASSAECAAAAAACAAIAAAAAAAABAAAAAAAAAIAAAAAAAAAAAAAEAAA.YAAAAAAAAAA%22%2C%221~%22%2C%22924A9049-E66F-4E81-92F6-E6AE4366B6B6%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"news.bg\"\r\n ],\r\n \"id\": \"1a473c52-96a0-4d12-a8cd-d12d1cc8edca\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-k8o10q\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"zougla.gr\"\r\n ],\r\n \"id\": \"0587a3cf-e084-40bf-b01a-2586c2313f3b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1al1vdb\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"calciomercato.com\"\r\n ],\r\n \"id\": \"68c6fe41-fb1d-41ed-a8c6-55fde975f0ab\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#consent_prompt_submit\",\r\n \"presence\": \"div#__tealiumGDPRecModal\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CONSENTMGR\",\r\n \"value\": \"c1:0%7Cc6:0%7Cts:1666183304450%7Cconsent:false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"telenor.no\"\r\n ],\r\n \"id\": \"4ed03d1e-155c-45fa-a8c9-fbccc9ac0f33\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#accept-privacy-policies\",\r\n \"presence\": \"div.emb38ba0\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"walmart.ca\"\r\n ],\r\n \"id\": \"0485cf43-5df0-41aa-83da-92cd3b82d6f7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1hzdrx2\",\r\n \"presence\": \"div.qc-cmp2-summary-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"newsbeast.gr\"\r\n ],\r\n \"id\": \"b9fd5871-876d-4931-9f76-dd695e613919\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.rodo-popup-agree\",\r\n \"presence\": \"div.rodo-popup-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"pomponik.pl\"\r\n ],\r\n \"id\": \"397fdd1a-d6b7-4bab-a105-3f1f36f755ff\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.accept-cookie-button\",\r\n \"presence\": \"div.cookie-banner-content\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"avanza.se\"\r\n ],\r\n \"id\": \"803fbd5e-3734-4cf6-a878-5ffe24ec6809\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#didomi-notice-agree-button\",\r\n \"presence\": \"div#buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"zerozero.pt\"\r\n ],\r\n \"id\": \"1a37cced-b12a-44d9-a576-d1b259312471\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"input.btn-secondary\",\r\n \"presence\": \"div.stampenCookieContainer\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"gp.se\"\r\n ],\r\n \"id\": \"e4e59ff1-b18c-453b-a4e0-6c0ec4008d81\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#wt-cli-accept-all-btn\",\r\n \"presence\": \"div.cli-bar-container\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"viewed_cookie_policy\",\r\n \"value\": \"yes\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"katalozi.net\"\r\n ],\r\n \"id\": \"caddc16b-c8ad-404a-bd29-6383977695cb\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.rounded-button--tertiary\",\r\n \"presence\": \"div.legal-consent\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"mal_consent_gdpr_remarketing\",\r\n \"value\": \"f\"\r\n },\r\n {\r\n \"name\": \"mal_consent_gdpr_personalization\",\r\n \"value\": \"f\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"mall.cz\"\r\n ],\r\n \"id\": \"6bc9ceec-b225-4284-b924-589c3ff4f249\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.wscrOk\",\r\n \"optOut\": \"a.wscrOk2\",\r\n \"presence\": \"div#CookieReportsPanel\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"nordea.fi\"\r\n ],\r\n \"id\": \"cf282f72-5333-48a8-9097-cbc89ea26634\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#grantPermissionButton\",\r\n \"presence\": \"div#cookie-bar\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CookiePermissionInfo\",\r\n \"value\": \"%7B%22LastModifiedDate%22%3A%22%5C%2FDate(1666257491849)%5C%2F%22%2C%22ExpirationDate%22%3A%22%5C%2FDate(1697793491849)%5C%2F%22%2C%22Allow%22%3Afalse%2C%22CategoryPermission%22%3A%5B%7B%22Category%22%3A%22Cat.8%22%2C%22Permission%22%3Atrue%7D%2C%7B%22Category%22%3A%22Cat.9%22%2C%22Permission%22%3Atrue%7D%2C%7B%22Category%22%3A%22Cat.10%22%2C%22Permission%22%3Afalse%7D%2C%7B%22Category%22%3A%22Cat.11%22%2C%22Permission%22%3Afalse%7D%2C%7B%22Category%22%3A%22Cat.12%22%2C%22Permission%22%3Afalse%7D%5D%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"postnl.nl\"\r\n ],\r\n \"id\": \"e4a9e084-a37e-4f10-969c-c872f578dd15\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.pr-riqlv6\",\r\n \"optOut\": \"button.pr-1l8klmj\",\r\n \"presence\": \"div#consent\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"data_consent\",\r\n \"value\": \"1.1.0.0.0.1675177081479.Default\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"pricerunner.dk\"\r\n ],\r\n \"id\": \"be039e17-f095-46f4-a809-c00699799db8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.tvp-covl__ab\",\r\n \"presence\": \"div#ip\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"tvp.pl\"\r\n ],\r\n \"id\": \"fb333123-4f00-4b45-acf9-21cb038c76ba\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1g5s5vy\",\r\n \"presence\": \"div#qc-cmp2-ui\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"meo.pt\"\r\n ],\r\n \"id\": \"992a91e5-9d29-411a-a8e6-e689fb75c2b7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"span#banner-cookie--button\",\r\n \"presence\": \"div#banner-cookie\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookie_banner\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bitly.com\"\r\n ],\r\n \"id\": \"84a097a3-5cd1-448a-afcf-4be950bc5756\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.btn-success\",\r\n \"presence\": \"div.vue-component\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"remove.bg\"\r\n ],\r\n \"id\": \"86bc6c6f-c082-466f-ac77-994a2296fbb0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#CybotCookiebotDialogBodyButtonAccept\",\r\n \"presence\": \"div#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"sectigo.com\"\r\n ],\r\n \"id\": \"7078621c-2701-40e0-83f1-109c8ea0f82f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button.eu-cookie-compliance-save-preferences-button\",\r\n \"presence\": \"div.eu-cookie-compliance-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"cam.ac.uk\"\r\n ],\r\n \"id\": \"f5f827d0-60c9-4fe6-af97-a901d0d96ddc\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#_evidon-accept-button\",\r\n \"optOut\": \"button#_evidon-decline-button\",\r\n \"presence\": \"div#_evidon-message\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"playstation.com\"\r\n ],\r\n \"id\": \"23df4915-5954-41ea-8d5c-e76a1d98e70b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#truste-consent-button\",\r\n \"presence\": \"div#consent_blackbar\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"fortune.com\"\r\n ],\r\n \"id\": \"c0908337-da46-4155-8178-43e4a67693ec\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#truste-consent-button\",\r\n \"presence\": \"div#truste-consent-track\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"mi.com\"\r\n ],\r\n \"id\": \"30a2b81a-b5a6-4c49-818d-de34ecddafbe\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1k47zha\",\r\n \"presence\": \"div#qc-cmp2-ui\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"9gag.com\"\r\n ],\r\n \"id\": \"6c246e7d-2a12-4b13-94b4-d2908cd64aad\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.a8c-cookie-banner__accept-all-button\",\r\n \"presence\": \"form.a8c-cookie-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"akismet.com\"\r\n ],\r\n \"id\": \"9a5038c6-31da-40e5-94d8-6eea3ecfa9ec\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#truste-consent-button\",\r\n \"optOut\": \"button#truste-consent-required\",\r\n \"presence\": \"div#truste-consent-track\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"box.com\",\r\n \"ea.com\"\r\n ],\r\n \"id\": \"b8e927a8-e0fc-4e7b-ad60-edca983accd3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.eu-cookie-compliance-default-button\",\r\n \"presence\": \"div.cookies\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"unesco.org\"\r\n ],\r\n \"id\": \"689fe5d6-7792-4475-98fc-71aa1715d0d9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.zbc-cta-accept\",\r\n \"presence\": \"div.zbottom-cookie-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"zoho.com\"\r\n ],\r\n \"id\": \"3656dd64-5840-4918-adc8-caa723503f20\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1k3tyyb\",\r\n \"presence\": \"div#qc-cmp2-ui\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"theatlantic.com\"\r\n ],\r\n \"id\": \"43f34378-73c3-4851-a948-e073908edddd\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#truste-consent-button\",\r\n \"optOut\": \"button#truste-consent-required\",\r\n \"presence\": \"div#truste-consent-content\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"notice_gdpr_prefs\",\r\n \"value\": \"0:\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"squarespace.com\"\r\n ],\r\n \"id\": \"b17a2376-ae8b-40de-9b6f-61efdf25f862\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.osano-cm-accept-all\",\r\n \"presence\": \"div.osano-cm-window\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"scribd.com\",\r\n \"chicagotribune.com\"\r\n ],\r\n \"id\": \"f6f48ce2-0487-4003-b1c7-dfcd37def8d7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#cookie-accept-all-secondary\",\r\n \"optOut\": \"button.CookieConsentSecondary__RejectAllButton-sc-12do1ry-2\",\r\n \"presence\": \"div.ModalInner__Backdrop-sc-1ghkydj-1\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"nordnet.no\"\r\n ],\r\n \"id\": \"9ea83ecf-3e82-4976-80e7-86872d7e4aeb\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.primary\",\r\n \"presence\": \"div#camus-cookie-disclaimer\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"accepts-cookies\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"milenio.com\"\r\n ],\r\n \"id\": \"befe1d72-f33a-4e63-be43-236bedc3b49a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-k8o10q\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"newsit.gr\",\r\n \"protothema.gr\",\r\n \"newsbomb.gr\",\r\n \"youweekly.gr\",\r\n \"vrisko.gr\",\r\n \"index.hu\",\r\n \"meteo.gr\",\r\n \"fantacalcio.it\"\r\n ],\r\n \"id\": \"25dd408a-0a98-4e93-992c-abd320255cd1\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-47sehv\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"pik.bg\",\r\n \"prosport.ro\",\r\n \"sciencedaily.com\"\r\n ],\r\n \"id\": \"d96f380c-c76f-47d8-a1f7-bd4063792ade\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1lgi3ta\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"sdna.gr\",\r\n \"makeleio.gr\"\r\n ],\r\n \"id\": \"af566db1-982c-4837-8f0b-96a341702520\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.gdpr-trigger\",\r\n \"presence\": \"div#gdpr\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"digisport.ro\"\r\n ],\r\n \"id\": \"91a1519c-51f4-4d9a-bea3-06a2fff7d892\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1tfx6ee\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"startlap.hu\",\r\n \"nosalty.hu\",\r\n \"24.hu\",\r\n \"nlc.hu\"\r\n ],\r\n \"id\": \"4fc95ff9-b785-49ee-8a8c-e789665a1643\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.NT2yCg\",\r\n \"presence\": \"div.e4Qmjw\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"canva.com\"\r\n ],\r\n \"id\": \"5a03b949-b86c-4ee5-9824-97fb59849cb8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#privacy-cp-wall-accept\",\r\n \"presence\": \"div.privacy-cp-wall\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"corriere.it\"\r\n ],\r\n \"id\": \"b90700d6-32a6-44e2-adf4-46acd54089c9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#privacy-cp-wall-accept\",\r\n \"presence\": \"div.privacy-cp-wall\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"gazzetta.it\"\r\n ],\r\n \"id\": \"32be6ed7-749d-448f-975d-10b4d1e6e482\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1u05hh5\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"researchgate.net\"\r\n ],\r\n \"id\": \"e060a13e-e0ae-4081-8d9f-29a0c17bec79\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.primary_2xk2l\",\r\n \"presence\": \"div.container_1gQfi\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"dailymail.co.uk\"\r\n ],\r\n \"id\": \"4d4b2924-6e28-4d39-9a02-e31690746ab2\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.osano-cm-accept-all\",\r\n \"presence\": \"div.osano-cm-dialog__buttons\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"osano_consentmanager\",\r\n \"value\": \"29z_AY1nTFjK9XMxsOgqw44fXSaxiin_dEBjbDS7Xgl2GM5AONcjcMtZVKd_7n7XCTO5hym727ANlN292apKnDkgIe8dhCSSZpmDhDN7guy_H9a0nFHng1IsTphnnPGMG-iLZ6sS9OmdxmAq8K_mehLsgQWFmfhi5K07-UmLlGoVGx19IxTDiqf-aJmp5dbLV8kxLYjgbTFnTJ2JWUcwJYR57TX9iezs-BprQopNCs5CGWW9NOFrv-uhWhwTsKNLygKH7cMwdnp7RHTSX41-FCLSh10=\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"wiley.com\"\r\n ],\r\n \"id\": \"7e45fd5f-63b5-4090-9120-b467b0252358\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.gdpr-agree-btn\",\r\n \"optOut\": \"div.gdpr-reject-btn\",\r\n \"presence\": \"div#GDPR-cookies-notice\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"alibaba.com\"\r\n ],\r\n \"id\": \"c35bdda6-611d-4742-8f6e-4aebb574e675\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.kcLcLu\",\r\n \"presence\": \"div.gmjWml\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"turbopages.org\"\r\n ],\r\n \"id\": \"6ecb9b79-3666-479c-8798-5cf46702799f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"span.nrk-sr\",\r\n \"presence\": \"div.nrk-masthead__info-banner--cookie\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"nrkno-cookie-information\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"nrk.no\"\r\n ],\r\n \"id\": \"15cdab7d-a018-4c88-9352-5eacd4c30d1d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1v69bou\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"sapo.pt\"\r\n ],\r\n \"id\": \"f29b0658-24ab-40fd-92fb-11850b2aaab5\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"yleconsent\",\r\n \"value\": \"v1|\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"yle.fi\"\r\n ],\r\n \"id\": \"9a727363-cec6-4641-99ab-80550b89da5b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.ok\",\r\n \"presence\": \"div#checkUPcookies\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"pravda.com.ua\"\r\n ],\r\n \"id\": \"7498f52b-4273-4cfb-a22a-c9df9b84adc2\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-w0lt3s\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"iefimerida.gr\"\r\n ],\r\n \"id\": \"aeff05cc-c287-4596-89b6-3559eced1133\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-wum6af\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"record.pt\"\r\n ],\r\n \"id\": \"8b2a32a7-5a38-4ffc-abc5-d5827e16008b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.gdpr-trigger\",\r\n \"presence\": \"div#gdpr\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"digi24.ro\"\r\n ],\r\n \"id\": \"55ed573b-2cf3-42a3-8010-d397b161568b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.CookieConsent__primaryButton___Th47k\",\r\n \"presence\": \"div.CookieConsent__root___3GjU8\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"svt.se\"\r\n ],\r\n \"id\": \"87b229aa-567e-4549-895f-b6c0065dfdc5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1rx6u69\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"gazzetta.gr\"\r\n ],\r\n \"id\": \"7eed4367-8f21-4359-aae9-cf9017ae5efc\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-k9c0va\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"foreca.fi\"\r\n ],\r\n \"id\": \"f35220db-4382-446a-af55-063f309ccf5a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1tdle7a\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"iol.pt\"\r\n ],\r\n \"id\": \"290d1140-dc1b-4d97-85dd-d970753ae441\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.allow-all-cookies-button\",\r\n \"optOut\": \"button.mt-2\",\r\n \"presence\": \"div.consent-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ilmatieteenlaitos.fi\"\r\n ],\r\n \"id\": \"0cfab897-c0f5-4415-b5a1-bac258d1c764\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-kc1iqc\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"telex.hu\"\r\n ],\r\n \"id\": \"24df6217-9ed9-4a67-9811-ff9c8f8faaf2\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#c-p-bn\",\r\n \"presence\": \"div#cc_div\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"shmu.sk\"\r\n ],\r\n \"id\": \"43135a56-c361-4d88-82dd-cf17a077a6a5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#cookieBtn\",\r\n \"presence\": \"div.c-alert__box\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"tsn.ua\"\r\n ],\r\n \"id\": \"04608cf9-0f96-433b-bc26-6aa9f5ebdfc8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-8zhqum\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"hvg.hu\"\r\n ],\r\n \"id\": \"83758fdc-5bdc-4d8b-b783-840f16d30f1c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-wum6af\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"cmjornal.pt\"\r\n ],\r\n \"id\": \"e400d28c-7f73-4449-8749-5f96509eb688\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1ruupc0\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"444.hu\"\r\n ],\r\n \"id\": \"eaf913b1-b628-4a57-9a1c-65bcf27f33a9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"i.fa-times\",\r\n \"presence\": \"div.cookieWarning\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"kukaj.io\"\r\n ],\r\n \"id\": \"8f251e48-f45b-4051-bc53-0851d0d411f5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.notificationButton\",\r\n \"presence\": \"div.polopolyNotification\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"smhi.se\"\r\n ],\r\n \"id\": \"27d9194f-f908-4cb8-8f57-ddb899a06a4e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-fxmqu\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"publico.pt\"\r\n ],\r\n \"id\": \"36dc7cc5-a043-4259-a9bd-07913ff208bb\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.js_cookie-monster-agree\",\r\n \"optOut\": \"a.js_cookie-monster-disagree\",\r\n \"presence\": \"div#js_cookie-monster\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ceneo.pl\"\r\n ],\r\n \"id\": \"35db03d9-02c9-4558-a31b-aa4a20d6dbaa\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.js-accept-cookies\",\r\n \"presence\": \"div.jsGdprNoticeHolder\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"polovniautomobili.com\"\r\n ],\r\n \"id\": \"9a6465c6-7955-4dff-9483-70635ba5fbda\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button.css-zpqvhj \",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"sorozatbarat.club\"\r\n ],\r\n \"id\": \"fbff28bd-84e5-4f82-8023-2f00d772e7e8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.RMlTST\",\r\n \"optOut\": \"button.WEzN4V\",\r\n \"presence\": \"div.aspXfg\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"wix.com\"\r\n ],\r\n \"id\": \"aa245048-507c-11ed-bdc3-0242ac120002\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.submitAll\",\r\n \"presence\": \"div.submitButton\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CookieConsent\",\r\n \"value\": \"{stamp:%27D1AamT1V80siZwfIuWr5SREHguKw3sySW3+Q+Nb2mPwfRB9so2m1Sg==%27%2Cnecessary:true%2Cpreferences:false%2Cstatistics:false%2Cmarketing:false%2Cver:5%2Cutc:1664957253372%2Cregion:%27ro%27}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"dr.dk\"\r\n ],\r\n \"id\": \"9def1218-9201-4b49-bf69-ea203aeab2b3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1wjnr64\",\r\n \"presence\": \"div.qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"idokep.hu\"\r\n ],\r\n \"id\": \"1dc10ed2-800f-484c-9df1-6f99d8cd2584\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-choice-dialog\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPgbQkAPgbQkAEsABCHUCjCoAP_AAH_AAA6gHQpB7D7FbSFCyP55aLsAMAhXRkCEAqQAAASFAmABQAKQIBQCkkAQFAygBCACAAAgIAJBAQIMCAgACUEBQABAAAEEAAAABAAIIAAAgAEAAAAIAAACAIAAEAAIQAAAEAAAmQhAAIIACAAABAAAAAAAAAAAAAAAAgdCgHsLsVtIUJI_GkoswAgCFdGQIQCoAAAAIUCQAAAApAgBAKQQBAADKAEIAAAACAgAgEBAAgACAABQQFAAEAAAAAAAAAAAAggAACAAQAAAAAAAAIAgAAQAAhAAAAAAACJCEAAggAIAAAAAAAAAAAAAAAAAAACAAA.cqAADVgAAAA%22%2C%221~2052.2072.70.89.93.108.122.149.2202.162.167.196.2253.241.2299.259.2331.2357.311.317.323.2373.338.358.2415.415.440.449.2506.2526.482.486.494.495.2568.2571.2575.540.574.2624.2677.2707.817.827.2898.864.981.1031.1048.1051.1067.1095.1097.1127.1171.1201.1205.1211.1276.1301.1365.1415.1449.1570.1577.1616.1651.1716.1753.1765.1870.1878.1889.1917.1958.2012%22%2C%228BB76B67-7925-4F57-BFC5-31C47ABF23A9%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"origo.hu\"\r\n ],\r\n \"id\": \"f236d4c4-1f2f-4b62-a4ec-5d9388d9150e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.rodo-popup-agree\",\r\n \"presence\": \"div.rodo-popup-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"interia.pl\"\r\n ],\r\n \"id\": \"ef9bacb9-d248-4129-baed-354a45277750\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"button#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"div#CybotCookiebotDialogBodyButtons\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CookieConsent\",\r\n \"value\": \"{stamp:%27QmpmezEASR\/DS4\/lr1mawVaNO945EW6sk0Hi0cjL1CFS1A36t8Os1Q==%27%2Cnecessary:true%2Cpreferences:false%2Cstatistics:false%2Cmarketing:false%2Cver:3%2Cutc:1665042549753%2Ciab2:%27CPgbQkAPgbQkACGABBENCjCgAAAAAE_AAAAAAAASCAJMNW4gC7EscCbQMIoEQIwrCQqgUAEFAMLRAYAODgp2VgE-sIEACAUARgRAhxBRgQCAAACAJCIAJAiwQCIAiAQAAgARCIQAEDAIKACwMAgABANAxRCgAECQgyICIpTAgIgSCAlsqEEoK5DTCAOssAKDRGxUACJAABSAAJCwcAwRICViwQJMUb5ACMEKAUSoVoAA.YAAAAAAAAAAA%27%2Cgacm:%271~%27%2Cregion:%27ro%27}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"ekstrabladet.dk\"\r\n ],\r\n \"id\": \"7c2c6827-7d60-49a5-b4c1-877ba7fcd2e7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.iubenda-cs-accept-btn\",\r\n \"presence\": \"div#iubenda-cs-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"repubblica.it\"\r\n ],\r\n \"id\": \"5c862324-6a7c-43d8-ae34-47c5e7d355f6\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-choice-dialog\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPgbQkAPgbQkAEsABCBGCjCgAAAAAAAAAAIwAAAPaQD2F2K2kKEkfjaUWYIQBCujKEIBUBAEAEKBIAAAAUgQAgFIIAgABlACEAAAABAQAQCAgAQABAAAoICgACAAAAAQAAAAAAQQAABAAIAAACAAAAEAQAAAAAQAAAAAAABEhCAAQSQEAAAAAAAAAAAAAAAAAAABAAAAAAAAAIAA.YAAAAAAAAAA%22%2C%221~%22%2C%226C13DA30-0357-4AD8-8594-DBD146A8DF46%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"blitz.bg\"\r\n ],\r\n \"id\": \"7850dc20-f121-417b-9eb0-d91a0a8d6a8c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-primary-button\",\r\n \"presence\": \"div.fc-footer-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPgejgAPgejgAEsABCBGCkCgAAAAAAAAAAIwAAAOhQD2F2K2kKEkfjSUWYAQBCujKEIhUAAAAECBIAAAAUgQAgFIIAgAAlACAAAAABAQAQCAgAQABAAAoACgAAAAAAAAAAAAAAQQAABAAIAAAAAAAAEAQAAIAAQAAAAAAABEhCAAQQAEAAAAAAAAAAAAAAAAAAABAAA%22%2C%221~%22%2C%2232D9A01E-9678-40B0-BD1B-AD0CDC174758%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"fakti.bg\"\r\n ],\r\n \"id\": \"abc3a8c3-6a15-48a6-83eb-4f7762df9270\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons-container\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPgocUAPgocUAEsABCFICkCgAAAAAAAAAApAAAAQWQD2F2q2kKEkfjSUWYgURCurIEIhcAAAAEKBoAAAAUgQAgFIMIgABlACEAAAABAQAQCAgAQABAAAoICgAAAAAAAAAAAAAAQQACBAAIAAAAAAAAEAQAAoAAQAAAAAAABEhCAAQQAEIAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAIAAA.YAAAAAAAAAA%22%2C%221~%22%2C%22AC745442-DC8E-42A9-AAB5-970367496CBA%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"seiska.fi\"\r\n ],\r\n \"id\": \"57d6c05c-44b6-4e02-91b6-ef91cca6a311\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-hxv78t\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"irishmirror.ie\"\r\n ],\r\n \"id\": \"3cab2d97-d9eb-4254-97aa-441ad90ada69\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cmptxt_btn_yes\",\r\n \"presence\": \"div.cmpboxinner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"novosti.rs\"\r\n ],\r\n \"id\": \"53f117b7-5d9d-4387-8507-95fb64a4da86\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-primary-button\",\r\n \"presence\": \"div.fc-footer-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPgocUAPgocUAEsABCBGCkCgAAAAAAAAAAIwAAAOhQD2F2K2kKEkfjSUWYAQBCujKEIhUAAAAECBIAAAAUgQAgFIIAgAAlACAAAAABAQAQCAgAQABAAAoACgAAAAAAAAAAAAAAQQAABAAIAAAAAAAAEAQAAIAAQAAAAAAABEhCAAQQAEAAAAAAAAAAAAAAAAAAABAAA%22%2C%221~%22%2C%222259601D-A3C8-4B02-80F2-AF6ADF73E293%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bazar.bg\"\r\n ],\r\n \"id\": \"a7cbcc28-fa3f-46cc-9010-613e5031549b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#almacmp-modalConfirmBtn\",\r\n \"presence\": \"div.almacmp-controls\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"etuovi.com\"\r\n ],\r\n \"id\": \"a2cc4223-39e8-4e02-804b-fa9eb4a4ce65\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1tydlop\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"noticiasaominuto.com\"\r\n ],\r\n \"id\": \"cbd5d112-8190-40d1-ad4a-6e8f6aa17c5a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-47sehv\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"telegraf.rs\"\r\n ],\r\n \"id\": \"6785afba-648d-46cf-8633-3d9c433c79ff\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"button#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"div#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"CookieConsent\",\r\n \"value\": \"{stamp:%27hd4jdgROnfr2iNroDX0BfGjqyNhsMI7fLXjrkfZEp9hRGFDoFPyv7w==%27%2Cnecessary:true%2Cpreferences:false%2Cstatistics:false%2Cmarketing:false%2Cmethod:%27explicit%27%2Cver:1%2Cutc:1674227765106%2Cregion:%27ro%27}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"profesia.sk\"\r\n ],\r\n \"id\": \"346b2412-6aef-4eea-b2d5-8b74b4917f4a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1ysvf99\",\r\n \"presence\": \"div.qc-cmp2-summary-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"flashback.org\"\r\n ],\r\n \"id\": \"14d5418a-4df6-4dfe-9c81-fd07669e0f9c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#almacmp-modalConfirmBtn\",\r\n \"presence\": \"div#almacmp-footer--layer1\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ampparit.com\"\r\n ],\r\n \"id\": \"679d7000-cd04-4c35-bbf8-21263a2ff357\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-k8o10q\",\r\n \"presence\": \"div#qc-cmp2-ui\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"videa.hu\"\r\n ],\r\n \"id\": \"6fed31af-f6d2-4452-88ef-8f2ca61c6f19\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button#modalConfirmBtn \",\r\n \"presence\": \"div.gravitoCMP-background-overlay\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"hitta.se\"\r\n ],\r\n \"id\": \"1998c717-cd3a-48a0-a9f3-67f790f98879\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-47sehv\",\r\n \"presence\": \"div.qc-cmp2-summary-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"espreso.co.rs\"\r\n ],\r\n \"id\": \"ed6379a2-717a-4571-9a81-abb0c5b29098\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"#cmpbntyestxt\",\r\n \"optOut\": \"#cmpbntnotxt\",\r\n \"presence\": \"div.cmpboxbtns\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"sourceforge.net\"\r\n ],\r\n \"id\": \"786f7ae5-548a-43d4-97a2-7b8efb5ed7a7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div#gdpr-single-choice-overlay\",\r\n \"presence\": \"button.wt-btn--filled\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"p\",\r\n \"value\": \"eyJnZHByX3RwIjoxLCJnZHByX3AiOjF9\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"etsy.com\"\r\n ],\r\n \"id\": \"e5706488-f2e9-47bd-a178-4d569ca26ce8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cmp-intro_acceptAll\",\r\n \"presence\": \"div.cmp-popup_popup\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"onet.pl\",\r\n \"fakt.pl\",\r\n \"plejada.pl\",\r\n \"medonet.pl\",\r\n \"businessinsider.com.pl\"\r\n ],\r\n \"id\": \"be15b898-a203-45cb-8200-b9b36d2af17b\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"notice_gdpr_prefs\",\r\n \"value\": \"0:\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"flickr.com\"\r\n ],\r\n \"id\": \"22d33421-6872-41bc-9316-adba6a9af18e\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"OPTOUTMULTI_TYPE\",\r\n \"value\": \"A\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"autodesk.com\"\r\n ],\r\n \"id\": \"24fdb694-9d8f-4049-a2f1-583465e711a5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#consent-accept-button\",\r\n \"presence\": \"div.sc-36lcln-0\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"xing.com\"\r\n ],\r\n \"id\": \"f82cabad-efa5-4637-8906-9fb842cd6556\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"span.flex-button\",\r\n \"presence\": \"section.jsx-539809080\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"notion.so\"\r\n ],\r\n \"id\": \"85205acd-fa99-438e-b353-2b40adfdf868\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"button#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"div#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"aruba.it\"\r\n ],\r\n \"id\": \"469218de-6bea-475f-be47-734a412b4fa7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#AcceptButton-cookie-banner\",\r\n \"presence\": \"div#cookie-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"prisjakt.nu\"\r\n ],\r\n \"id\": \"6950df9f-2529-4cdc-ab8b-9ba788b6d2f7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1k3zih6\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [],\r\n \"optOut\": [\r\n {\r\n \"name\": \"addtl_consent\",\r\n \"value\": \"1~\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"capital.gr\"\r\n ],\r\n \"id\": \"8e9824be-3535-4d17-8e64-ae9628a0611d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#btnInfo\",\r\n \"presence\": \"div#info-modal\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"limundo.com\"\r\n ],\r\n \"id\": \"5df00bf4-ecbc-4521-862b-db1453be23c9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"span.sd-cmp-3cRQ2\",\r\n \"presence\": \"div#sd-cmp\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"forocoches.com\"\r\n ],\r\n \"id\": \"a0698f6e-dd50-4773-9aad-eb23688383f3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.accept-btn\",\r\n \"optOut\": \"div.refuse-btn\",\r\n \"presence\": \"div.cookie-pop\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"tencent.com\"\r\n ],\r\n \"id\": \"e964784f-f4f4-4e71-b973-8c68ef6f49d7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#truste-consent-button\",\r\n \"optOut\": \"button#truste-consent-required\",\r\n \"presence\": \"div#truste-consent-content\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ibm.com\"\r\n ],\r\n \"id\": \"97c1b39b-6573-4dbc-83a4-3af02a416f0a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fZYtinTR\",\r\n \"presence\": \"div.CoZ9Nu8Z\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"FC2_GDPR\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"fc2.com\"\r\n ],\r\n \"id\": \"5669aa13-d825-45b6-9a65-14904ecd9ee9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.cmc-cookie-policy-banner__close\",\r\n \"presence\": \"div#cmc-cookie-policy-banner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cmc_gdpr_hide\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"coinmarketcap.com\"\r\n ],\r\n \"id\": \"faccc72f-fab0-4dd8-82a5-4b97774a0058\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div#accept-choices\",\r\n \"presence\": \"div.sn-inner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"w3schools.com\"\r\n ],\r\n \"id\": \"230b6c9b-c717-4a88-9d64-9a892091d53d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#truste-consent-button\",\r\n \"optOut\": \"button#truste-consent-required\",\r\n \"presence\": \"div#truste-consent-track\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"nginx.com\"\r\n ],\r\n \"id\": \"aaace84f-069e-4713-85f0-7a2f419c34eb\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"a.n-messaging-banner__button\",\r\n \"presence\": \"div.consent-container\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"optout\",\r\n \"value\": \"0\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"optout\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"indiatimes.com\"\r\n ],\r\n \"id\": \"e991cb20-0597-4f14-aabc-214a2e493f90\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#_evidon-accept-button\",\r\n \"optOut\": \"button#_evidon-decline-button\",\r\n \"presence\": \"div#_evidon_banner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"_tt_enable_cookie\",\r\n \"value\": \"1\"\r\n }\r\n ],\r\n \"optOut\": []\r\n },\r\n \"domains\": [\r\n \"eventbrite.com\"\r\n ],\r\n \"id\": \"25d7b523-6e33-414a-a1e8-b9f27c7e8a37\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#okck\",\r\n \"presence\": \"div#toast-container\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"ck\",\r\n \"value\": \"4\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"ck\",\r\n \"value\": \"0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"ilovepdf.com\"\r\n ],\r\n \"id\": \"137eec59-6b48-413c-bb45-33420aabdc87\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#CybotCookiebotDialogBodyButtonAccept\",\r\n \"presence\": \"div#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"themeforest.net\",\r\n \"cpanel.net\",\r\n \"envato.com\"\r\n ],\r\n \"id\": \"f9b5b6d8-50be-486e-bc59-a45733be492c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.welcome__button--accept\",\r\n \"optOut\": \"button.welcome__button--decline\",\r\n \"presence\": \"div.welcome__cookie-notice\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"wetransfer.com\"\r\n ],\r\n \"id\": \"8b43c95f-2617-4541-a88d-ebae3b819bee\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-quk35p\",\r\n \"presence\": \"div.css-103nllw\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"healthline.com\"\r\n ],\r\n \"id\": \"b62e5259-f7f5-4319-8175-e39266b8a031\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.close\",\r\n \"presence\": \"div.ReadPolicy\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"huawei.com\"\r\n ],\r\n \"id\": \"645dca4f-49b6-456e-8aa1-40dfdb35acb7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#_evidon-accept-button\",\r\n \"optOut\": \"button#_evidon-decline-button\",\r\n \"presence\": \"div#_evidon_banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"pubmatic.com\"\r\n ],\r\n \"id\": \"35142692-9d67-4f16-9512-36673e35c43d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.CookieBanner_button__P6xCl\",\r\n \"presence\": \"section.CookieBanner_notificationBar__nn49h\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"kaspersky.com\"\r\n ],\r\n \"id\": \"1e504f8f-4377-4de7-90e4-cbbfaa30f54d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#tbp-consent-confirm\",\r\n \"presence\": \"div.tbp-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"taboola.com\"\r\n ],\r\n \"id\": \"cade9499-6c59-4939-90e7-e5e029ce0446\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cookiepolicycontinue\",\r\n \"presence\": \"div#cookieMessage_content\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"oup.com\"\r\n ],\r\n \"id\": \"2e0b3da1-4072-4e1b-b561-939789c90f4d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#cx_button_close\",\r\n \"presence\": \"div#cx_bottom_banner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"show_gdpr_consent_messaging\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"nbcnews.com\"\r\n ],\r\n \"id\": \"fecfb874-fff9-4d91-af27-885729a4556d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cta--is-1\",\r\n \"presence\": \"div.cookie-banner__cta\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"and_cba_EN_US\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"android.com\"\r\n ],\r\n \"id\": \"35a8e0de-9308-4dfe-abc9-764a9e3833ec\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cc-dismiss\",\r\n \"presence\": \"div.cc-window\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"dnsmadeeasy.com\"\r\n ],\r\n \"id\": \"581b7d96-d1a4-43a8-9498-5650f9234e10\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#gdpr-modal-agree\",\r\n \"presence\": \"div.modal-window\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"gdpr_agreed\",\r\n \"value\": \"4\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"usnews.com\"\r\n ],\r\n \"id\": \"d9ebe530-4e45-4315-b628-c2d8c81c48a5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"span.js-dismiss-cookie-banner\",\r\n \"presence\": \"div.DesignSystem\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"academia.edu\"\r\n ],\r\n \"id\": \"41001e78-302d-44da-bd82-a690d1968a9f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cu-privacy-notice-close\",\r\n \"presence\": \"div#cu-privacy-notice\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cuPivacyNotice\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"columbia.edu\"\r\n ],\r\n \"id\": \"2952e826-d897-431b-866d-f8ed95f4db64\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.consent-btn\",\r\n \"presence\": \"div.cookie-consent\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"geeksforgeeks.org\"\r\n ],\r\n \"id\": \"f3d0e044-1dc7-4d6e-9477-2724c5115ba4\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.accept-consent\",\r\n \"presence\": \"section#cookieconsentpopup\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"consent_cookie\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"worldbank.org\"\r\n ],\r\n \"id\": \"efe049ac-d21c-4fa6-b559-5c47b5ce307b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#close_feedback_btn\",\r\n \"presence\": \"div#feedback-bar\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"cricbuzz.com\"\r\n ],\r\n \"id\": \"22187533-0a56-4679-9244-f11b2c58083a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.color-secondary\",\r\n \"presence\": \"div.footer_footer__ZK5Q_\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"__cookie__agree\",\r\n \"value\": \"Y\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"sberdevices.ru\"\r\n ],\r\n \"id\": \"c8c58b5e-45d1-4442-ad53-b89f3603586f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cookie-accept-btn\",\r\n \"presence\": \"div.cookie-consent-area\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"typeform.com\"\r\n ],\r\n \"id\": \"c0bef9bb-67e2-4038-bfe9-795085f37719\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.js-opt-in-consent-button\",\r\n \"presence\": \"div.opt-in-modal__inner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"wpengine.com\"\r\n ],\r\n \"id\": \"f89481a5-e087-4210-aeee-36bba9764a87\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#cookie-policy-button-accept\",\r\n \"presence\": \"div#modal\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"_cookies_accepted\",\r\n \"value\": \"all\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"_cookies_accepted\",\r\n \"value\": \"essential\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"ubuntu.com\"\r\n ],\r\n \"id\": \"b11001c6-7a9c-40a7-8595-3c8a9c1e7416\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#privacy-consent-button\",\r\n \"presence\": \"div#privacy-consent\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"vox.com\"\r\n ],\r\n \"id\": \"e552ade4-c254-4e8b-823f-8307e4b21fb3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#ccc-notify-accept\",\r\n \"optOut\": \"button#ccc-notify-dismiss\",\r\n \"presence\": \"div#ccc\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"_hjFirstSeen\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"ox.ac.uk\"\r\n ],\r\n \"id\": \"982a2a33-f052-4acd-936e-d3b0f7bf7596\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.c-button\",\r\n \"presence\": \"div#cookiebanner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"psychologytoday.com\"\r\n ],\r\n \"id\": \"fcd83d7a-0cbc-4cc4-ac1f-609a303ea5a3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.consent--accept\",\r\n \"presence\": \"div.consent-banner-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ring.com\"\r\n ],\r\n \"id\": \"081717d3-32c8-40e4-8bc1-ddd5495dbddf\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.accept-cookies\",\r\n \"presence\": \"div.cookie-notice\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"_hjFirstSeen\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"evernote.com\"\r\n ],\r\n \"id\": \"a78db283-f6a6-4e80-83d1-65becec1df21\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"span.cookiePolicy-close\",\r\n \"presence\": \"div.cookiePolicy\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"gearbest.com\"\r\n ],\r\n \"id\": \"0100a0ae-2e3a-4dcd-8761-596193f6be8d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"span.b-policy-info__cross\",\r\n \"presence\": \"div.b-policy-info\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieAgree\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"reg.ru\"\r\n ],\r\n \"id\": \"783a1b6f-616d-4f9d-a84b-b0f7954e3e24\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#cookie-policy-btn\",\r\n \"presence\": \"div.cookie\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ucla.edu\"\r\n ],\r\n \"id\": \"955c6f08-e07d-4b05-be04-5de69f49753b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#_evidon-banner-acceptbutton\",\r\n \"presence\": \"div#_evidon-banner-content\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"lenovo.com\"\r\n ],\r\n \"id\": \"21e686fb-7250-4fbc-ab5c-6a6bf67775d3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.button--default\",\r\n \"presence\": \"div#cookie-notification\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"mdpi_cookies_accepted\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"mdpi.com\"\r\n ],\r\n \"id\": \"4f9c6403-4953-48aa-a8d7-d7acf177d437\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#_evidon-decline-button\",\r\n \"presence\": \"div#_evidon-message\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"crunchyroll.com\"\r\n ],\r\n \"id\": \"85aec7ab-1cc2-4651-b3fc-0cbae9a241f2\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-ZtxlD\",\r\n \"optOut\": \"a.css-jMqFMB\",\r\n \"presence\": \"div.css-fPpfoE\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"uber.com\"\r\n ],\r\n \"id\": \"663e3527-0018-4851-912d-c75a9d610881\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#ccc-notify-accept\",\r\n \"optOut\": \"button#ccc-notify-reject\",\r\n \"presence\": \"div#ccc\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"tapad.com\"\r\n ],\r\n \"id\": \"026010ef-311b-4c3e-aa1d-cb1470591c6a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#unic-agree\",\r\n \"presence\": \"div.unic-bar\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"sharethrough.com\"\r\n ],\r\n \"id\": \"d448486a-ad5c-45dd-b661-c22a82869036\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"span#cookie-banner-close\",\r\n \"presence\": \"div#cookie-banner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"hideCookieBanner\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"substack.com\"\r\n ],\r\n \"id\": \"09f72241-e3be-4156-a8ac-ecf47145854f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#et_cookie_consent_allow\",\r\n \"presence\": \"div.et_cookie_consent\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"et_cookies\",\r\n \"value\": \"on\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"et_cookies\",\r\n \"value\": \"off\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"elegantthemes.com\"\r\n ],\r\n \"id\": \"ae4a00ba-e49c-4376-a5a7-67d9b647342d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cookie-consent-banner-opt-out__action\",\r\n \"presence\": \"div.cookie-consent-banner-opt-out\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"mercadolibre.com.ar\"\r\n ],\r\n \"id\": \"b6fda273-20bf-4e49-8a2b-fe2afdcc12da\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.closeButton\",\r\n \"presence\": \"div.cookieBanner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"anchor.fm\"\r\n ],\r\n \"id\": \"475b40e3-18d5-457e-a084-76faa301c753\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.button-accept-cookie\",\r\n \"presence\": \"div.banner-overlay\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"analytics_accepted\",\r\n \"value\": \"true\"\r\n },\r\n {\r\n \"name\": \"cookie_accepted\",\r\n \"value\": \"true\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookies_denied\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"aboutcookies.org\"\r\n ],\r\n \"id\": \"2de3ce99-ba76-417e-851c-87e0c4035180\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.js-gdpr-cookie-acceptLink\",\r\n \"presence\": \"div.widget-GdprCookieBanner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"CookieBanner_Closed\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"jamanetwork.com\"\r\n ],\r\n \"id\": \"6026221f-dd61-493e-aea8-cdf38198f401\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#nhsuk-cookie-banner__link_accept_analytics\",\r\n \"optOut\": \"button#nhsuk-cookie-banner__link_accept\",\r\n \"presence\": \"div#nhsuk-cookie-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"www.nhs.uk\"\r\n ],\r\n \"id\": \"e6444889-fd2f-412a-b25d-41a6b6daaf3a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.button_b1lbbqqt\",\r\n \"presence\": \"div.modalStyle_modrqv7\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"nikkei.com\"\r\n ],\r\n \"id\": \"546792b9-f201-4e94-97f6-d5680abfe529\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-primary-button\",\r\n \"presence\": \"div.fc-consent-root\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"17track.net\"\r\n ],\r\n \"id\": \"a620f10c-033a-47c1-ad2b-a8fd1df9e504\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cc-dismiss\",\r\n \"presence\": \"div.cc-window\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieconsent_status\",\r\n \"value\": \"dismiss\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"duke.edu\"\r\n ],\r\n \"id\": \"d614e3e1-1282-4c56-801d-525263028933\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#cookie_action_close_header\",\r\n \"presence\": \"div#cookie-law-info-bar\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"viewed_cookie_policy\",\r\n \"value\": \"yes\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"usc.edu\"\r\n ],\r\n \"id\": \"5cedc12d-d79c-4be8-9e45-e98905147090\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.btn-accept\",\r\n \"presence\": \"div#gdpr-new-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"aliexpress.com\"\r\n ],\r\n \"id\": \"80851a39-2183-49e4-99f4-16d6189bff1e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button.cookieBarButtons\",\r\n \"presence\": \"div#cookieBar\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"google-analytics.com\"\r\n ],\r\n \"id\": \"286028cd-7cb7-4c71-91a7-ae1a23df8a31\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div.global-alert-banner button[action-type=ACCEPT]\",\r\n \"optOut\": \"div.global-alert-banner button[action-type=DENY]\",\r\n \"presence\": \"div.global-alert-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"linkedin.com\"\r\n ],\r\n \"id\": \"f1ddc6f6-8927-4205-8003-1f6e84ae22b0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button[data-a-target=consent-banner-accept]\",\r\n \"presence\": \"div.consent-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"twitch.tv\"\r\n ],\r\n \"id\": \"542aafba-9f44-4321-88e5-ece333073360\"\r\n },\r\n {\r\n \"click\": {\r\n \"hide\": \"div#onetrust-consent-sdk\",\r\n \"optIn\": \"button#onetrust-accept-btn-handler\",\r\n \"optOut\": \"button.ot-pc-refuse-all-handler\",\r\n \"presence\": \"div#onetrust-banner-sdk\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"OptanonAlertBoxClosed\",\r\n \"value\": \"2022-08-09T16:49:23.845Z\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"getpocket.com\"\r\n ],\r\n \"id\": \"c2f6b05e-73dc-4df4-bb54-68215bb9f176\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"wa_cb\",\r\n \"value\": \"1_2022_08_09_\"\r\n }\r\n ],\r\n \"optOut\": []\r\n },\r\n \"domains\": [\r\n \"whatsapp.com\"\r\n ],\r\n \"id\": \"0f4b1486-7d4b-4f73-8ec9-122daca9ebf4\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"sensitive_pixel_option\",\r\n \"value\": \"yes\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"wordpress.com\"\r\n ],\r\n \"id\": \"709f87b8-b010-4d32-b32d-3f265963c6a5\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"eu_cookie\",\r\n \"value\": \"{%22opted%22:true%2C%22nonessential%22:false}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"reddit.com\"\r\n ],\r\n \"id\": \"c5243e7c-eb86-4a9d-947c-7129e99fbd72\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".btn--primary\",\r\n \"presence\": \"div#cookie-consent\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookie_consent_essential\",\r\n \"value\": \"true\"\r\n },\r\n {\r\n \"name\": \"cookie_consent_marketing\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"opera.com\"\r\n ],\r\n \"id\": \"09884f7b-ef06-45dd-9bda-960b5c6b7232\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \".a8c-cookie-banner-accept-all-button\",\r\n \"presence\": \".a8c-cookie-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"gravatar.com\"\r\n ],\r\n \"id\": \"4b835605-77e0-457a-8409-8d499ee1131c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#accept-button\",\r\n \"optOut\": \"a#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"div#c-right\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"yettel.bg\"\r\n ],\r\n \"id\": \"4ec170c6-933c-4612-9907-80bc8ef6039c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.btn-primary\",\r\n \"presence\": \"div.alert-secondary\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"privacy_cookie\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bakecaincontrii.com\"\r\n ],\r\n \"id\": \"a8984148-8c62-4dd5-8350-4e0da48f1829\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.mat-button-base\",\r\n \"presence\": \"section#cookie-banner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookie-banner-acceptance-state\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"migros.ch\"\r\n ],\r\n \"id\": \"cd670e2a-b764-4722-8ff2-a136350a60d8\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#didomi-notice-agree-button\",\r\n \"presence\": \"div#buttons\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"euconsent-v2\",\r\n \"value\": \"CPhJZsAPhJZsAAHABBENClCsAP_AAH_AAAiQJFNf_X__b2_r-_5_f_t0eY1P9_7__-0zjhfdl-8N3f_X_L8X52M7vF36tq4KuR4ku3LBIUdlHOHcTUmw6okVryPsbk2cr7NKJ7PEmnMbOydYGH9_n1_z-ZKY7___f_7z_v-v___3____7-3f3__5___-__e_V__9zfn9_____9vP___9v-_9__________3_7997BIQAkw1biALsSxwJtowigRAjCsJDqBQAUUAwtEBhA6uCnZXAT6wgQAIBQBGBECHAFGDAIAAAIAkIiAkCPBAIACIBAACABUIhAARsAgoALAwCAAUA0LFGKAIQJCDIgIilMCAiRIKCeyoQSg_0NMIQ6ywAoNH_FQgIlACFYEQkLByHBEgJeLJAsxRvkAIwQoBRKhWoAAAA.f_gAD_gAAAAA\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"euconsent-v2\",\r\n \"value\": \"CPhJZsAPhJZsAAHABBENClCgAAAAAH_AAAiQAAASEAJMNW4gC7EscCbaMIoEQIwrCQ6gUAFFAMLRAYQOrgp2VwE-sIEACAUARgRAhwBRgwCAAACAJCIgJAjwQCAAiAQAAgAVCIQAEbAIKACwMAgAFANCxRigCECQgyICIpTAgIkSCgnsqEEoP9DTCEOssAKDR_xUICJQAhWBEJCwchwRICXiyQLMUb5ACMEKAUSoVqAA.YAAAD_gAAAAA\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"trendings.es\"\r\n ],\r\n \"id\": \"13dff385-bb91-414f-8fa3-88ae99621aba\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.btn-cta-lg\",\r\n \"optOut\": \"button.btn-secondary-lg\",\r\n \"presence\": \"div.cookie-button-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"roblox.com\"\r\n ],\r\n \"id\": \"1e55c3c3-63d2-45b2-8871-dfae1396c1a0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"div._euwdl0\",\r\n \"presence\": \"footer._nlniiu\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"2gis.ru\"\r\n ],\r\n \"id\": \"2a737c68-b3ed-4883-b02e-4cff18b538b4\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"button.portal-button\",\r\n \"presence\": \"div.CookieWall\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"mozzartsport.com\"\r\n ],\r\n \"id\": \"7b299edf-ea10-472a-a3d9-cd622bde1ef9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#acceptButton\",\r\n \"optOut\": \"button#declineButton\",\r\n \"presence\": \"div.coi-button-group\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"yousee.dk\"\r\n ],\r\n \"id\": \"31ef8068-58b4-400b-81cd-a027a16a2305\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.blue\",\r\n \"presence\": \"div.cookie-popup\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"marketing\",\r\n \"value\": \"1\"\r\n },\r\n {\r\n \"name\": \"analytic\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"supersport.hr\"\r\n ],\r\n \"id\": \"7eb3fb43-94c5-49da-80ca-1647f8398eec\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"buton#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"button#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"div#CybotCookiebotDialogBodyButtons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"berlingske.dk\"\r\n ],\r\n \"id\": \"d7c0b359-8cdb-433c-b501-6fb2fdad0dc0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optOut\": \"a#ensCall\",\r\n \"presence\": \"div.td-modal-cookie-content\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"td.com\"\r\n ],\r\n \"id\": \"eb274e15-29fe-4004-9e6d-27046bd0b82d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cc_btn_accept_all\",\r\n \"presence\": \"div.cc_banner-wrapper\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieconsent_dismissed\",\r\n \"value\": \"yes\"\r\n }\r\n ],\r\n \"optOut\": []\r\n },\r\n \"domains\": [\r\n \"online-filmek.me\"\r\n ],\r\n \"id\": \"c200bbbd-3075-48ee-a507-e5934eb52567\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.A\",\r\n \"optOut\": \"a.R\",\r\n \"presence\": \"div#CkC\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"sfr.fr\"\r\n ],\r\n \"id\": \"dc867098-30ca-4a28-82b4-71abac243dab\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cookies-button\",\r\n \"presence\": \"div#cookies-overlay\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"approve\",\r\n \"value\": \"yes\"\r\n }\r\n ],\r\n \"optOut\": []\r\n },\r\n \"domains\": [\r\n \"linker.hr\"\r\n ],\r\n \"id\": \"10b58019-5dce-4018-b83d-8eef8c7564a7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cookies-banner-button\",\r\n \"presence\": \"div#cookies-banner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookiesAgreement\",\r\n \"value\": \"1\"\r\n }\r\n ],\r\n \"optOut\": []\r\n },\r\n \"domains\": [\r\n \"dnevnik.bg\"\r\n ],\r\n \"id\": \"74f56970-6ebe-4802-914e-72aeca0e13b3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.button--primary\",\r\n \"presence\": \"div.modal__body\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"CONSENTMGR\",\r\n \"value\": \"c1:1%7Cc6:1%7Cc7:1%7Cc15:1%7Cconsent:true%7Cts:1665653237583\"\r\n }\r\n ],\r\n \"optOut\": []\r\n },\r\n \"domains\": [\r\n \"swisscom.ch\"\r\n ],\r\n \"id\": \"3c03e0ec-aa84-48a1-83ca-0bf07d24e20e\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#truste-consent-button\",\r\n \"optOut\": \"div#truste-consent-required\",\r\n \"presence\": \"div.truste-consent-content\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"samsung.com\"\r\n ],\r\n \"id\": \"943a1f39-57c2-430e-b91d-81a659d9b036\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.js-cookies-hide\",\r\n \"presence\": \"div.cookie-label__btn-set\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieApprove\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"nv.ua\"\r\n ],\r\n \"id\": \"e8aa7447-2f27-4b65-bbdb-dcecc30c6c1f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.consent-accept\",\r\n \"presence\": \"div#consent-accept\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"s_cc\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"dnb.no\"\r\n ],\r\n \"id\": \"0b8969b6-d5a1-4358-967b-524a2c998233\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"a#CybotCookiebotDialogBodyLevelButtonLevelOptinDeclineAll\",\r\n \"presence\": \"div#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"e-boks.com\"\r\n ],\r\n \"id\": \"a1bb84f0-e248-4576-8cd5-722622120a7d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#cn-accept-cookie\",\r\n \"presence\": \"div#cookie-notice\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookie_notice_accepted\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"danas.rs\"\r\n ],\r\n \"id\": \"d6ee6a87-66ad-4c35-88ac-966d4fff81f7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.kitt-cookie-warning__close\",\r\n \"presence\": \"div.kitt-cookie-warning__content\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"sbrf.pers_notice\",\r\n \"value\": \"1\"\r\n }\r\n ],\r\n \"optOut\": []\r\n },\r\n \"domains\": [\r\n \"sberbank.ru\"\r\n ],\r\n \"id\": \"40746884-0f10-4e12-ab0d-2a4410b1e0f9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#kmcc-accept-all-cookies\",\r\n \"presence\": \"div.kmcc-cookie-bar__footer__preferences__second\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"legal_cookie\",\r\n \"value\": \"%7B%22cookies%22%3A%7B%22functional_cookie%22%3A%22true%22%2C%22analyzing_cookie%22%3A%22true%22%7D%2C%22cookie_log_id%22%3A%2225634502%22%2C%22cookie_version%22%3A20200608%7D\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"legal_cookie\",\r\n \"value\": \"%7B%22cookies%22%3A%7B%22functional_cookie%22%3A%22true%22%2C%22analyzing_cookie%22%3A%22false%22%2C%22marketing_cookie%22%3A%22false%22%7D%2C%22cookie_log_id%22%3A%2225634476%22%2C%22cookie_version%22%3A20200608%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"meteo.be\"\r\n ],\r\n \"id\": \"67e420b4-05e6-4c01-a999-0d48dba85362\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#didomi-notice-agree-button\",\r\n \"presence\": \"div#buttons\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [],\r\n \"optOut\": [\r\n {\r\n \"name\": \"euconsent-v2\",\r\n \"value\": \"CPgrvQAPgrvQAAHABBENCkCgAAAAAAAAAAAAAAAAAAAA.YAAAAAAAAAAA\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"story.hr\"\r\n ],\r\n \"id\": \"735b925d-388b-4df5-b66e-2907674cd126\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [],\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPgrvQAPgrvQAEsABCSKCkCgAAAAAAAAACRQAAAOhQD2F2K2kKEkfjSUWYAQBCujIEIhUAAAAECBIAAAAUgQAgFIIAgAAlACAAAAABAQAQCAgAQABAAAgACgAAAAAAAAAAAAAAQQAABAAIAAAAAAAAAAQAAIAAQAAAAAAABEhCAAQSAEAAAAAAAAAAAAAAAAAAABAAA.YAAAAAAAAAA%22%2C%221~%22%2C%229EBAD907-6709-4964-8B38-C2711989FD29%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"modrykonik.sk\"\r\n ],\r\n \"id\": \"97909ba0-2fbd-410e-b2b9-3e165930ac61\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.as-js-optin\",\r\n \"presence\": \"div.as-oil-l-wrapper-layout-max-width\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [],\r\n \"optOut\": [\r\n {\r\n \"name\": \"oil_data\",\r\n \"value\": \"{%22opt_in%22:true%2C%22version%22:%221.2.5-RELEASE%22%2C%22localeVariantName%22:%22de_01%22%2C%22localeVariantVersion%22:1%2C%22customPurposes%22:[]%2C%22consentString%22:%22BPgtAGPPgtAGPBQABBDECKAAAABCVY_KfQrC0oWQ3LTh5AAkALqlgRiFSEAIHYRE4AIRZUACSAEggHMoyUBIA8RAAERATIJABBgQEAISkAOAAIAgIpACEAgYAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf____3____8A%22%2C%22configVersion%22:0}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"tagesanzeiger.ch\"\r\n ],\r\n \"id\": \"c9ea0470-beac-43b9-aed1-d77042592d83\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-k8o10q\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": []\r\n },\r\n \"domains\": [\r\n \"sport-fm.gr\"\r\n ],\r\n \"id\": \"d88020d5-47d0-4926-8375-d279870f1663\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.orejime-Notice-saveButton\",\r\n \"optOut\": \"button.orejime-Notice-declineButton\",\r\n \"presence\": \"div.orejime-Notice\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"fedconsent\",\r\n \"value\": \"{\\\"essential\\\":true,\\\"functional\\\":true,\\\"matomo\\\":true}\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"fedconsent\",\r\n \"value\": \"{\\\"essential\\\":true,\\\"functional\\\":true,\\\"matomo\\\":false}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"belgium.be\"\r\n ],\r\n \"id\": \"98fcb883-013a-4858-a181-28c87e399c42\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#cookieAccept\",\r\n \"presence\": \"div#cookieContent\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"publi24.ro\"\r\n ],\r\n \"id\": \"8d64659e-ece2-476d-a1c6-eec9e4070647\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#cmp-btn-accept\",\r\n \"presence\": \"div#cmp-consent\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"wetter.com\"\r\n ],\r\n \"id\": \"8e274753-a1ee-4e02-b131-fa937ab10aea\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#startsiden-gdpr-disclaimer-confirm\",\r\n \"presence\": \"div#startsiden-gdpr-disclaimer\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"startsiden-gdpr-disclaimer\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"startsiden.no\",\r\n \"abcnyheter.no\"\r\n ],\r\n \"id\": \"00e9de62-0fc4-4266-8e8c-106da3a624c0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#truste-consent-button\",\r\n \"optOut\": \"button#truste-consent-required\",\r\n \"presence\": \"div#truste-consent-buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"weather.com\"\r\n ],\r\n \"id\": \"8ef284ed-21d1-4825-8068-34ac22e5232d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.noticeButton\",\r\n \"presence\": \"div.privacyNotification\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"cbc.ca\"\r\n ],\r\n \"id\": \"f69ced41-7e20-4b39-84a2-0fa62e7df3ef\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#CybotCookiebotDialogBodyButtonAccept\",\r\n \"optOut\": \"a#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"div#CybotCookiebotDialog\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"mondo.rs\"\r\n ],\r\n \"id\": \"f1502dfd-a03c-4ca1-ab5d-488b2e09b644\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.js-cookies-info-accept\",\r\n \"optOut\": \"a.js-cookies-info-reject\",\r\n \"presence\": \"div.cookies-info__buttons\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"alza.sk\"\r\n ],\r\n \"id\": \"93b1a4a4-49e2-4160-98ae-e2311123c7f9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.coi-banner__accept\",\r\n \"optOut\": \"button#declineButton\",\r\n \"presence\": \"div.coi-button-group\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"dmi.dk\"\r\n ],\r\n \"id\": \"db64d8f2-1d35-4b57-a274-452efbcf2589\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.close-accept\",\r\n \"presence\": \"div.woahbar\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"theweathernetwork.com\"\r\n ],\r\n \"id\": \"3cfb8e27-c0b3-46c0-bee2-f4eef9c2a548\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cookiebtn\",\r\n \"presence\": \"div.button\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"watson.ch\"\r\n ],\r\n \"id\": \"3f8bdfb5-b898-4c25-ad68-6e10dab38e45\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons-container\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPgocUAPgocUAEsABBHRCkCoAP_AAH_AAA6IIqNd_H__bX9n-f7_6ft0eY1f9_r37uQzDhfNs-8F3L_W_LwX_2E7NF36tq4KmR4ku1LBIQNtHMnUDUmxaokVrzHsak2cpyNKJ7BkknsZe2dYGFtPm5lD-QKZ7_5_d3f52T_9_9v-39z33913v3d_3-_12Ljd_599H_v_fR_bc_Kft_5-_8v8_____3_BFNjv43bra_s7x-d9R5ujRGr6v5S_VwGYcI4tGjgE5fy35WAf6wnYAu3RpWBEyPElmpAJCAhIAk4gKk2LREipaIZBACTFEBpBDYMkgtjLywKAgkh8XEoKwBTPL3NxIzvOSW_HdN_2_uW63uut-Jl63wcuhcbP3Fto21t7wAjLBwThmpJWvFpn53_-mZwAAA.cAAAD_gAAAA%22%2C%221~2052.2056.2064.20.2068.2069.2070.2072.2074.2084.39.2088.2090.43.46.2103.55.57.2107.2109.61.2115.70.2124.2130.83.2133.2137.89.2140.93.2145.2147.2150.108.2156.117.2166.122.124.2177.131.135.2183.136.2186.143.144.147.149.2202.2205.159.162.2213.167.2216.171.2219.2220.2222.2224.2225.2234.192.196.202.2253.211.2264.218.228.230.2279.2282.239.241.2292.2299.2305.259.2309.2312.266.2315.2316.272.2322.2325.2328.2331.2334.286.2335.2336.2337.291.2343.2354.2357.2358.2359.311.317.2370.322.323.2373.326.327.2376.2377.338.2387.2392.2394.2400.2403.2405.2406.358.2407.2411.2414.2415.367.2416.2418.370.371.2425.2427.385.389.2440.394.397.2447.2453.407.2459.2461.413.2462.415.2465.2468.2472.424.2477.430.2481.2484.436.2486.2488.2493.445.2496.2497.449.2498.2499.2501.453.2506.2510.2511.2517.469.2526.2527.482.2532.2534.486.2535.491.2542.494.495.501.503.2552.505.2555.2559.2563.2564.2567.2568.2569.522.2571.523.2572.2575.2577.2583.2584.540.2589.2596.2597.550.2601.2602.2604.2605.559.2608.2609.2610.2612.2614.568.2618.2621.574.2624.576.2628.2629.584.2633.2634.587.2636.591.2642.2643.2645.2646.2647.2650.2651.2652.2656.2657.2658.2660.2661.2669.2670.624.2677.2681.2684.2686.2687.2689.2690.2695.2698.2707.2713.2714.2729.2739.2767.2768.2770.2772.733.2784.737.2787.2791.2792.2793.745.2798.2801.2805.2812.2813.2814.2816.2817.2818.2821.2822.2824.2827.2830.2831.2832.2834.787.2838.2839.2840.2844.2846.798.2847.2849.2850.802.803.2852.2854.2856.2860.2862.2863.2865.817.2867.820.2869.821.2872.2873.2874.2875.827.2876.829.2878.2880.2881.2882.2883.2884.2886.2887.839.2888.2889.2891.2893.2894.2895.2897.2898.2900.2901.2908.2909.2911.2912.864.2913.2914.867.2916.2917.2918.2919.2920.2922.874.2923.2924.2927.2929.2930.2931.2935.2939.2940.2941.2942.2947.899.2949.2950.904.2956.2961.2962.2963.2964.2965.2966.2968.2970.922.2972.2973.2974.2975.2979.931.2980.2981.2983.2985.2986.938.2987.2991.2994.2995.2997.2999.3000.3002.3003.3005.3008.3009.3010.3012.3016.3017.3018.3019.3023.3024.3025.979.3028.981.3030.985.3034.3037.3038.3043.3045.3048.1003.3052.3053.3055.3058.3059.3063.3065.3066.3068.3070.1024.3072.3073.3074.1027.3075.3076.3077.3078.1031.1033.1040.3089.3090.3093.1046.3094.3095.1048.3097.1051.3099.1053.3104.3106.3109.3112.1067.3117.3118.3119.3120.3124.3126.3127.3128.3130.1085.3133.3135.3136.3137.1092.1095.1097.3145.1099.3149.3150.3151.3153.3154.1107.3155.3162.3163.3167.3172.3173.1127.3180.3182.1135.3183.3184.3185.3187.3188.3189.3190.1143.3194.3196.1149.3197.1152.3209.1162.3210.3211.1166.3214.3215.3217.1171.3219.3222.3223.3225.3226.3227.3228.3230.3231.3232.1186.3234.3235.1188.3236.3237.3238.3240.3241.3244.3245.1201.3250.3251.1205.3253.3254.3257.1211.3260.1215.3266.1220.3268.3270.3272.1226.1227.1230.3281.3286.3288.3290.3292.3293.3295.3296.1252.3300.1268.1270.3322.1276.1284.1286.1290.1301.1307.1312.1345.1356.1364.1365.1375.1403.1415.1416.1419.1440.1442.1449.1455.1456.1465.1495.1512.1516.1525.1540.1548.1555.1558.1564.1570.1577.1579.1583.1584.1591.1603.1616.1638.1651.1653.1660.1665.1667.1677.1678.1682.1697.1699.1703.1712.1716.1720.1721.1725.1732.1745.1750.1753.1765.1769.1782.1786.1800.1808.1810.1825.1827.1832.1838.1840.1842.1843.1845.1859.1866.1870.1878.1880.1889.1898.1899.1911.1917.1929.1942.1944.1958.1962.1963.1964.1967.1968.1969.1978.2003.2007.2008.2012.2027.2035.2039.2044.2047%22%2C%221B663699-8278-41B8-9A73-C98E45F0FDDF%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"UA_ADS\",\r\n \"value\": \"{\\\"\/21617374708\/novilist.hr\/home_break_v2\\\":5,\\\"\/21617374708\/novilist.hr\/content_v1\\\":5,\\\"\/21617374708\/novilist.hr\/content_v2\\\":5,\\\"\/21617374708\/novilist.hr\/content_v3\\\":5,\\\"\/21617374708\/novilist.hr\/content_parallax_v1\\\":5,\\\"\/21617374708\/novilist.hr\/home_break_v1\\\":5,\\\"\/21617374708\/novilist.hr\/home_break_v3\\\":5,\\\"\/21617374708\/novilist.hr\/home_sidebar_v1\\\":4,\\\"\/21617374708\/novilist.hr\/home_sidebar_v2\\\":5,\\\"\/21617374708\/novilist.hr\/home_sidebar_v3\\\":5,\\\"\/21617374708\/novilist.hr\/home_wallpaper_left\\\":4,\\\"\/21617374708\/novilist.hr\/home_wallpaper_right\\\":4,\\\"\/21617374708\/novilist.hr\/life_content_v1\\\":5,\\\"\/21617374708\/novilist.hr\/life_content_v2\\\":5,\\\"\/21617374708\/novilist.hr\/life_content_v3\\\":5,\\\"\/21617374708\/novilist.hr\/life_sidebar_v1\\\":5,\\\"\/21617374708\/novilist.hr\/life_home_top_v1\\\":5,\\\"\/21617374708\/novilist.hr\/life_wallpaper_left\\\":5,\\\"\/21617374708\/novilist.hr\/life_wallpaper_right\\\":5,\\\"\/21617374708\/novilist.hr\/gospodarstvo_billboard_v1\\\":5,\\\"\/21617374708\/novilist.hr\/vijesti_billboard_v1\\\":5,\\\"\/21617374708\/novilist.hr\/gallery_v1\\\":5,\\\"\/21617374708\/novilist.hr\/gallery_v2\\\":5,\\\"\/21617374708\/novilist.hr\/gallery_v3\\\":5,\\\"\/21617374708\/novilist.hr\/gallery_inarticle_v1\\\":5,\\\"\/21617374708\/novilist.hr\/gallery_inarticle_v2\\\":5,\\\"\/21617374708\/novilist.hr\/gallery_inarticle_v3\\\":5,\\\"\/21617374708\/novilist.hr\/ticker_desktop_v1\\\":4,\\\"\/21617374708\/novilist.hr\/sport_content_v1\\\":5,\\\"\/21617374708\/novilist.hr\/sport_parallax_v1\\\":5,\\\"\/21617374708\/novilist.hr\/home_top_v1\\\":3,\\\"\/21617374708\/novilist.hr\/teads_v1\\\":5}\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"novilist.hr\"\r\n ],\r\n \"id\": \"4e504e16-4d0e-4a57-bb3c-b6349d1a5653\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.gdpr-lmd-button--main\",\r\n \"presence\": \"div.gdpr-lmd-wall\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"lemonde.fr\"\r\n ],\r\n \"id\": \"9182a6f2-eddc-43b2-991e-8b3a204e4eac\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button\",\r\n \"presence\": \"div.gdprcookie\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookieControlPrefs\",\r\n \"value\": \"[\\\"essential\\\"]\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"zamunda.net\"\r\n ],\r\n \"id\": \"03f54762-dc2a-4cf9-bf1b-412a68dbf0db\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#save-all-action\",\r\n \"optOut\": \"button#save-necessary-action\",\r\n \"presence\": \"div.iOMuvDyuoMKpVRzmevldA3KUmWI2t3RJ\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"veikkaus.fi\"\r\n ],\r\n \"id\": \"afa1eaa5-68fa-4fe8-97f0-3176c0eb6557\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll\",\r\n \"optOut\": \"button#CybotCookiebotDialogBodyButtonDecline\",\r\n \"presence\": \"div#CybotCookiebotDialogBodyButtonsWrapper\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"bt.dk\"\r\n ],\r\n \"id\": \"3b604615-6d14-4ac8-b184-b9421b99a07c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-cta-consent\",\r\n \"presence\": \"div.fc-footer-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [],\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPgejgAPgejgAEsABCHRCkCgAAAAAAAAAA6IAAAOhQD2F2K2kKEkfjSUWYAQBCujIEIhUAAAAECBIAAAAUgQAgFIIAgAAlACAAAAABAQAQCAgAQABAAAoACgAAAAAAAQAAAAAAQQAABAAIAAAAAAAAEAQAAIAAQAAAAAAABEhCAAQQQEAAAABAAAAAAAQAAAAAABAAA.YAAAAAAAAAA%22%2C%221~%22%2C%22EA05584B-4C95-44E8-A602-F0B836FF2176%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"dnevno.hr\"\r\n ],\r\n \"id\": \"032bda24-ffad-4283-a36b-e1c1dd0c46d7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#ocm-button.ocm-button--accept-all\",\r\n \"optOut\": \"button.ocm-button--continue\",\r\n \"presence\": \"div.ocm-footer\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"op.fi\"\r\n ],\r\n \"id\": \"86b4e119-8e5c-471d-bf80-c65489277135\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.eu-cookie-compliance-secondary-button\",\r\n \"optOut\": \"button.eu-cookie-compliance-default-button\",\r\n \"presence\": \"div#popup-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookie-agreed\",\r\n \"value\": \"1\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie-agreed\",\r\n \"value\": \"0\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"gsis.gr\"\r\n ],\r\n \"id\": \"c3f6134c-061a-4828-ab89-b6f12015a1f3\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#usage_notice_button\",\r\n \"presence\": \"div.button\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"_accept_usage\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"censor.net\"\r\n ],\r\n \"id\": \"4e5a6503-adb4-4e4d-8113-f29a93bf11a7\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cookie-banner__button\",\r\n \"presence\": \"div#cookie-banner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"tv2samtykke\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"tv2.no\"\r\n ],\r\n \"id\": \"b2a31215-49cc-4edb-801d-09d6ec435848\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-1j8kkja\",\r\n \"presence\": \"div.qc-cmp2-summary-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cto_bundle\",\r\n \"value\": \"cETUWl96SkZFS1NJQUlCR3lQZlZ1WUt3Z3BaMW9qVEU1U25BaFRXdiUyQk1FeDhIWUJkVmh1b1VqY2dTbTB4bmRsaHliT3FZcFhXNFF6bU5HeSUyQkFiSVBBZ1NtVWR0b0w3T3pVR3dUTUhKeWJtSElWJTJCVDlRREl4dSUyRmRJdDBiM2M0VUs3S3R4UXI3Sjd5NENTUHFXcll1MEFGejJLdyUzRCUzRA\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"euconsent-v2\",\r\n \"value\": \"CPgfy_fPgfy_fAKAnAELCkCgAAAAAH_AAAyIAAASIAJMNW4gC7MscGbQMIoEQIwrCQqgUAEFAMLRAYAODgp2VgE-sIEACAUARgRAhwBRgQCAAASAJCIAJAiwQAAAiAQAAgARCIQAMDAILACwMAgABANAxRCgAECQgyICIpTAgKgSCA1sqEEoLpDTCAOssAKCRGxUACIJARWAAICwcAwRICViwQJMUb5ACMEKAUSoVqIAAAAA.YAAAAAAAAAAA\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"sport24.gr\"\r\n ],\r\n \"id\": \"02a059b8-09a4-488c-afa0-e208c4b8b84d\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cookie-close\",\r\n \"presence\": \"div#cookiesBar\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookieBarSeen\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"olx.ua\"\r\n ],\r\n \"id\": \"9944d588-486e-4f01-a5b9-17d1e9cc744a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-pp-ok\",\r\n \"presence\": \"div.css-pp-box\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"dagbladet.no\",\r\n \"sol.no\"\r\n ],\r\n \"id\": \"ebbaf3a6-10bb-4cd3-8294-5b095984e21a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button\",\r\n \"presence\": \"div.fucking-eu-cookies\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"fucking-eu-cookies\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bazos.sk\",\r\n \"bazos.cz\"\r\n ],\r\n \"id\": \"5cb47b97-2d77-4301-af71-0ec0b4aa0e9c\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.CookieConsent_button__FJrIc\",\r\n \"presence\": \"div.CookieConsent_holder__ZsT3G\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"FCNEC\",\r\n \"value\": \"%5B%5B%22AKsRol-hQ0GC8scayCxS70oIbLW375lxZBB419tjfwu1fOoWf-QLr5NOEOgKKLPu5Em82ydIwG4STH2DOpPVM65ZS_y2CCQlVsaUDCu-KZl2LrrYKqTftQDxBBuO-EmausJD4fO6ZHNWJEHTyKM8wJgrIadkxR4m9g%3D%3D%22%5D%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"FCCDCF\",\r\n \"value\": \"%5Bnull%2Cnull%2Cnull%2C%5B%22CPgX9oAPgX9oAEsABCENCjCgAAAAAAAAABpYAAAOhQD2F2K2kKEkfjSUWYAQBCujIEIBUAAAAECBIAAAAUgQAgFIIAgAAlACAAAAABAQAQCAgAQABAAAoACgACAAAAAAAAAAAAQQAABAAIAAAAAAAAEAQAAAAAQAAAAAAABEhCAAQQAEAAAAAAAAAAAAAAAAAAABAAA.YAAAAAAAAAA%22%2C%221~%22%2C%22D2A36A86-FAFB-4BB0-951A-2BA964C5C018%22%5D%2Cnull%2Cnull%2C%5B%5D%5D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"kupujemprodajem.com\"\r\n ],\r\n \"id\": \"944c0cf0-271b-41f8-b0ca-3fa67db7af29\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#accept-all\",\r\n \"optOut\": \"button#accept-essential\",\r\n \"presence\": \"div.actions\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"policy_level\",\r\n \"value\": \"%7B%22essential%22%3A%22true%22%2C%22performance%22%3A%22false%22%2C%22preference%22%3A%22false%22%2C%22targeting%22%3A%22false%22%7D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"skroutz.gr\"\r\n ],\r\n \"id\": \"fc149ac6-5a51-49fb-bb14-2c2dce8f6628\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#almacmp-modalConfirmBtn\",\r\n \"presence\": \"div.almacmp-controls\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"iltalehti.fi\"\r\n ],\r\n \"id\": \"23fcab4a-3b43-4d73-bb9e-57d86121141b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.agree-btn\",\r\n \"presence\": \"div.message-column main-container\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"wsj.com\"\r\n ],\r\n \"id\": \"edc06db8-5073-11ed-bdc3-0242ac120002\"\r\n },\r\n {\r\n \"click\": {},\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"bolConsentChoices\",\r\n \"value\": \"source#OFC|version#6|int-tran#false|ext-tran#false|int-beh#false|ext-beh#false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"bol.com\"\r\n ],\r\n \"id\": \"637c8bb9-b2f6-493e-86c2-f1954aa6a96a\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.js-cookies-hide\",\r\n \"presence\": \"div.cookie-label\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"is_agree\",\r\n \"value\": \"1\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"gordonua.com\"\r\n ],\r\n \"id\": \"165aa126-a069-4c6f-b6f9-c27f607e35cd\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.css-fuqsbe\",\r\n \"presence\": \"div#qc-cmp2-container\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [],\r\n \"optOut\": [\r\n {\r\n \"name\": \"addtl_consent\",\r\n \"value\": \"1~\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"promotions.hu\"\r\n ],\r\n \"id\": \"1991acaa-66fd-4929-8aa8-5d1d223703b9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cookies-agree\",\r\n \"presence\": \"div.cookies-notify\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"frognews.bg\"\r\n ],\r\n \"id\": \"81742e04-b9e3-41e6-a255-0b326f3e28f9\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.info-link-close\",\r\n \"presence\": \"div#privacy-policy-link-wrapper\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"PRIVACY_POLICY_INFO_2018_OPT_OUT\",\r\n \"value\": \"yes\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"uio.no\"\r\n ],\r\n \"id\": \"05ebb139-d68d-44fa-86a4-9b728131490f\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#AcceptCookieLawButton\",\r\n \"optOut\": \"button#RejectCookieLawButton\",\r\n \"presence\": \"div.cookie-banner\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"BDK_CookieLawAccepted\",\r\n \"value\": \"true\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"BDK_CookieLawAccepted\",\r\n \"value\": \"false\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"borger.dk\"\r\n ],\r\n \"id\": \"f28b8cb3-c0fc-493c-a8b9-6b147babaf19\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a#finansavisen-gdpr-disclaimer-confirm\",\r\n \"presence\": \"div#finansavisen-gdpr-disclaimer\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"finansavisen.no\"\r\n ],\r\n \"id\": \"1779bdc8-3544-4a2f-ba38-026f6533d665\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#modalConfirmBtn\",\r\n \"presence\": \"div#gravitoCMP-modal-layer1\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"verkkouutiset.fi\"\r\n ],\r\n \"id\": \"de4a2156-043d-431f-976c-03c2cd94ce2b\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"a.cc-cookie-accept\",\r\n \"presence\": \"div.cc-cookies\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cc_cookie_accept\",\r\n \"value\": \"cc_cookie_accept\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"petel.bg\",\r\n \"sagepub.com\"\r\n ],\r\n \"id\": \"d4117908-bde7-4950-a41b-188ebd8331ad\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.rounded-button--tertiary\",\r\n \"presence\": \"div.legal-consent\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"mal_consent_gdpr_remarketing\",\r\n \"value\": \"t\"\r\n },\r\n {\r\n \"name\": \"mal_consent_gdpr_personalization\",\r\n \"value\": \"t\"\r\n }\r\n ],\r\n \"optOut\": [\r\n {\r\n \"name\": \"mal_consent_gdpr_remarketing\",\r\n \"value\": \"f\"\r\n },\r\n {\r\n \"name\": \"mal_consent_gdpr_personalization\",\r\n \"value\": \"f\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"mall.sk\"\r\n ],\r\n \"id\": \"16f5fdfe-81a4-435b-a3d2-5e20d697e1f0\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"p.cookie-policy-btn\",\r\n \"presence\": \"div.cookie-policy\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"cookiePolicyConfirmation\",\r\n \"value\": \"true\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"halooglasi.com\"\r\n ],\r\n \"id\": \"a5b18ba3-6821-41c7-b9f3-c69352618b72\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#accept-cookies\",\r\n \"optOut\": \"button#decline-cookies\",\r\n \"presence\": \"div#cookie-popup\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"ah.nl\"\r\n ],\r\n \"id\": \"0793f76e-8e2c-46ec-9aac-111829c4c3ec\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.coi-banner__accept\",\r\n \"presence\": \"div#coi-banner-wrapper\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"telia.fi\"\r\n ],\r\n \"id\": \"c7991afb-190d-4c47-8080-5e2006a13dfd\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button\",\r\n \"presence\": \"div.politic_confidel\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"today.ua\"\r\n ],\r\n \"id\": \"3c907093-136f-41e1-b0e8-b6613cab8510\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.cookie-alert-extended-button\",\r\n \"optOut\": \"button.cookie-alert-decline-button\",\r\n \"presence\": \"div.cookie-alert-extended-controls\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"lidl.sk\"\r\n ],\r\n \"id\": \"fd966b51-7e62-402c-8963-70a5bc537605\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button#cookie-banner-initial-accept-all\",\r\n \"presence\": \"div.cookie-initial-buttons\"\r\n },\r\n \"cookies\": {\r\n \"optIn\": [\r\n {\r\n \"name\": \"SSLB\",\r\n \"value\": \"1\"\r\n },\r\n {\r\n \"name\": \"s_cc\",\r\n \"value\": \"true\"\r\n }\r\n ],\r\n \"optOut\": []\r\n },\r\n \"domains\": [\r\n \"swedbank.se\"\r\n ],\r\n \"id\": \"f3afe889-1fbe-4c24-b62d-ca1bd6c5f636\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button[name=accept_cookie]\",\r\n \"optOut\": \"button[name=decline_cookie]\",\r\n \"presence\": \".js-cookie-notification__form\"\r\n },\r\n \"cookies\": {\r\n \"optOut\": [\r\n {\r\n \"name\": \"cookie-preferences\",\r\n \"value\": \"eyJmdW5jdGlvbmFsIjp0cnVlLCJhbmFseXRpY2FsIjp0cnVlLCJtYXJrZXRpbmciOmZhbHNlfQ%3D%3D\"\r\n }\r\n ]\r\n },\r\n \"domains\": [\r\n \"coolblue.nl\"\r\n ],\r\n \"id\": \"1bf6fa4e-7882-41cf-b5e1-ef3c91485006\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.fc-button\",\r\n \"presence\": \"div.fc-consent-root\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"offnews.bg\"\r\n ],\r\n \"id\": \"bd5a5d6d-172e-4386-8698-1c28e83e38e5\"\r\n },\r\n {\r\n \"click\": {\r\n \"optIn\": \"button.iubenda-cs-accept-btn\",\r\n \"presence\": \"div#iubenda-cs-banner\"\r\n },\r\n \"cookies\": {},\r\n \"domains\": [\r\n \"lastampa.it\"\r\n ],\r\n \"id\": \"999c2b81-8c59-43d0-b73e-cd76b71a2b7c\"\r\n }\r\n]", + "title": "Cookie Banner Reduction rules" + }, + "network.cookie.maxageCap": { + "value": 15552000, + "title": "Limit maximum cookie lifetime to 180 days" + } + } + }, + "Resist Fingerprinting": { + "description": "Advanced Fingerprinting protection. This might break websites. For best results make sure to enable \"Privacy\" hardenings.", + "preferences": { + "privacy.fingerprintingProtection": { + "value": true, + "title": "Enable Suspected Fingerprinters Protection (FPP)", + "shouldBeDefault": true + }, + "privacy.fingerprintingProtection.pbmode": { + "value": true, + "title": "Enable Suspected Fingerprinters Protection (FPP) (Private browsing)", + "shouldBeDefault": true + }, + "privacy.reduceTimerPrecision": { + "value": true, + "title": "Reduce timer precision", + "shouldBeDefault": true + }, + "webgl.disabled": { + "value": true, + "title": "Disable WebGL (Web Graphics Library)", + "requireUserOptIn": true + }, + "privacy.partition.network_state.ocsp_cache": { + "value": true, + "title": "Enable OCSP cache", + "shouldBeDefault": true + }, + "privacy.partition.network_state.ocsp_cache.pbmode": { + "value": true, + "title": "Enable OCSP cache (Private browsing)", + "shouldBeDefault": true + }, + "gfx.bundled-fonts.activate": { + "value": 1, + "title": "Always load bundled fonts" + }, + "privacy.fingerprintingProtection.overrides": { + "value": "+AllTargets,-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt,-CSSPrefersColorScheme,-FrameRate,-JSDateTimeUTC", + "title": "Harden Fingerprinting Protection Rules" + }, + "privacy.fingerprintingProtection.granularOverrides": { + "value": "[{\"firstPartyDomain\":\"google.ad\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ae\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.al\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.am\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.as\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.at\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.az\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ba\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.be\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.bf\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.bg\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.bi\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.bj\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.bs\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.bt\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.by\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ca\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.cat\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.cd\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.cf\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.cg\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ch\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ci\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.cl\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.cm\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.cn\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.ao\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.bw\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.ck\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.cr\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.id\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.il\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.in\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.jp\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.ke\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.kr\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.ls\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.ma\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.mz\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.nz\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.th\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.tz\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.ug\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.uk\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.uz\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.ve\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.vi\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.za\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.zm\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.co.zw\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.af\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.ag\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.ar\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.au\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.bd\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.bh\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.bn\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.bo\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.br\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.bz\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.co\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.cu\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.cy\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.do\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.ec\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.eg\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.et\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.fj\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.gh\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.gi\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.gt\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.hk\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.jm\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.kh\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.kw\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.lb\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.ly\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.mm\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.mt\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.mx\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.my\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.na\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.ng\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.ni\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.np\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.om\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.pa\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.pe\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.pg\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.ph\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.pk\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.pr\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.py\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.qa\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.sa\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.sb\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.sg\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.sl\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.sv\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.tj\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.tr\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.tw\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.ua\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.uy\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.vc\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.com.vn\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.cv\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.cz\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.de\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.dj\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.dk\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.dm\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.dz\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ee\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.es\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.fi\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.fm\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.fr\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ga\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ge\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.gg\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.gl\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.gm\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.gr\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.gy\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.hn\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.hr\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ht\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.hu\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ie\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.im\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.iq\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.is\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.it\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.je\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.jo\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.kg\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ki\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.kz\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.la\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.li\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.lk\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.lt\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.lu\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.lv\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.md\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.me\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.mg\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.mk\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ml\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.mn\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.mu\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.mv\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.mw\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ne\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.nl\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.no\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.nr\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.nu\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.pl\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.pn\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ps\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.pt\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ro\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.rs\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ru\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.rw\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.sc\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.se\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.sh\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.si\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.sk\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.sn\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.so\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.sm\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.sr\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.st\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.td\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.tg\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.tl\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.tm\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.tn\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.to\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.tt\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.vu\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"google.ws\",\"overrides\":\"+CanvasRandomization,-JSDateTimeUTC\"},{\"firstPartyDomain\":\"aa.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.ae\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.ca\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.cn\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.co.jp\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.co.uk\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.co.za\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.com.au\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.com.be\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.com.br\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.com.mx\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.com.tr\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.de\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.eg\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.es\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.fr\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.ie\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.in\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.it\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.nl\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.pl\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.sa\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.se\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"amazon.sg\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"animepahe.ru\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"apple.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"apple.com.cn\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"apple.news\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt,+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"arcticfoxes.net\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"aria.im\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"bitcoinist.org\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"brave.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"bsky.app\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"cakepay.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt,-WebGLRenderInfo\"},{\"firstPartyDomain\":\"chatwave.org\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"chipotle.com\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"cinny.in\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"citybbq.com\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"cloudflare.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"cvs.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"discord.com\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"discord.gg\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt,+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"doordash.com\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"duesen.chat\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"element.io\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"epicgames.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"favicon.io\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"flieger.chat\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"g24.at\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"gemeinsam.jetzt\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"gitlab.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"gnulinux.club\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"goo.gl\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt,+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC,+JSLocale\"},{\"firstPartyDomain\":\"gsi.go.jp\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"harkins.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"hot-chilli.im\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"icloud.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"icloud.com.cn\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"jerseymikes.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"kosmikdog.eu\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"kroger.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"letterboxd.com\",\"overrides\":\"-ScreenRect\"},{\"firstPartyDomain\":\"medium.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"megacloud.blog\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"megacloud.store\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"moviezapiya.fun\",\"overrides\":\"-WebGLRenderInfo,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"mtrx.nz\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"neat.chat\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"nitro.chat\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"nope.chat\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"nperf.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"oblak.be\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"pcriot.org\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"pendora.io\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"pogo.com\",\"thirdPartyDomain\":\"pogospike.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasExtractionFromThirdPartiesIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"photopea.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"pogospike.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"pornhub.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"proton.me\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"reddit.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"redditmedia.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"rezka-ua.in\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"riverside.fm\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"rollenspiel.chat\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"salespanel.io\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"socialnetwork24.com\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"southwest.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"stacksocial.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"studichat.de\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"synod.im\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"t.co\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt,+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC,+JSLocale\"},{\"firstPartyDomain\":\"the-apothecary.club\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"tiktok.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"tileman.io\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"transfem.dev\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"unredacted.org\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"usps.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"utwente.io\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"viliusle.github.io\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"watchduty.org\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"we2.ee\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"x.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"yahoo.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"yatrix.org\",\"overrides\":\"-JSDateTimeUTC\"},{\"firstPartyDomain\":\"youtu.be\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt,+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC,+JSLocale\"},{\"firstPartyDomain\":\"youtube-nocookie.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"zoho.com\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"zoho.com.au\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"zoho.eu\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"zoho.in\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"zoho.jp\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"zoho.sa\",\"overrides\":\"+CanvasExtractionBeforeUserInputIsBlocked,+CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"apple.com\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"apple.news\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"bsky.app\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"cdn-apple.com\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"cdninstagram.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"cloudflare.com\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"discord.gg\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC,+JSLocale\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"favicon.io\",\"overrides\":\"+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"goo.gl\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC,+JSLocale\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"googlevideo.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC,+JSLocale\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"gravatar.com\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC,+JSLocale\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"instagram.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"licdn.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"linkedin.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"loginwithamazon.com\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"megacloud.blog\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasExtractionFromThirdPartiesIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"megacloud.store\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasExtractionFromThirdPartiesIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"moviezapiya.fun\",\"overrides\":\"+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"pinimg.com\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"pinterest.com\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"pornhub.com\",\"overrides\":\"-CanvasExtractionBeforeUserInputIsBlocked,-CanvasExtractionFromThirdPartiesIsBlocked,-CanvasImageExtractionPrompt\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"recaptcha.net\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"recaptcha.net.cn\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"recaptcha-cn.net\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"redd.it\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"reddit.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"redditmedia.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"redditstatic.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"t.co\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC,+JSLocale\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"tileman.io\",\"overrides\":\"+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"tiktok.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"twitter.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"twimg.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC,+JSLocale\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"vimeo.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"vimeocdn.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"x.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"youtu.be\",\"overrides\":\"+CSSPrefersColorScheme,+FrameRate,+JSDateTimeUTC,+JSLocale\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"youtube.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"},{\"firstPartyDomain\":\"*\",\"thirdPartyDomain\":\"youtube-nocookie.com\",\"overrides\":\"+CSSPrefersColorScheme,+JSDateTimeUTC\"}]", + "title": "Harden Fingerprinting Granular Protection Rules" + }, + "privacy.resistFingerprinting.randomization.daily_reset.enabled": { + "value": true, + "title": "Reset the fingerprinting randomization key daily" + }, + "privacy.resistFingerprinting.randomization.daily_reset.private.enabled": { + "value": true, + "title": "Reset the fingerprinting randomization key daily (Private browsing)" + } + } + }, + "Attack Surface Reduction": { + "preferences": { + "pdfjs.enableScripting": { + "value": false, + "title": "Disable PDFJS scripting" + }, + "pdfjs.enableXfa": { + "value": false, + "title": "Disable PDFJS XFA" + }, + "mathml.disabled": { + "value": true, + "title": "Disable MathML (Mathematical Markup Language)" + }, + "gfx.font_rendering.graphite.enabled": { + "value": false, + "title": "Disable graphite" + }, + "javascript.options.ion": { + "value": false, + "title": "Disable Ion" + }, + "javascript.options.baselinejit": { + "value": false, + "title": "Disable baseline JIT" + }, + "javascript.options.jit_trustedprincipals": { + "value": false, + "title": "Disable JIT trustedprincipals" + }, + "javascript.options.asmjs": { + "value": false, + "title": "Disable asm.js" + }, + "javascript.options.wasm": { + "value": false, + "title": "Disable web assembly", + "requireUserOptIn": true + }, + "accessibility.force_disabled": { + "value": 1, + "title": "Disable Accessibility Services" + } + } + }, + "Disk Avoidance": { + "description": "Control what data to persist to disk", + "preferences": { + "browser.cache.disk.enable": { + "value": false, + "title": "Disable disk cache", + "description": "Don't cache data to disk. Might impact performance negatively", + "requireUserOptIn": true + }, + "browser.privatebrowsing.forceMediaMemoryCache": { + "value": true, + "title": "Set media cache in Private Browsing to in-memory" + }, + "media.memory_cache_max_size": { + "value": 65536, + "title": "Increase in-memory media cache maximum size" + }, + "browser.sessionstore.privacy_level": { + "value": 2, + "title": "Disable storing extra session data", + "description": "Extra session data such as form content, cookies and POST data" + } + } + }, + "PDFJS": { + "preferences": { + "pdfjs.enableSignatureEditor": { + "value": true, + "title": "Enable the ability to add signatures" + }, + "pdfjs.enablePermissions": { + "value": false, + "title": "Never allow documents to prevent copying text", + "shouldBeDefault": true + }, + "pdfjs.externalLinkTarget": { + "value": 2, + "title": "Open external links in new tabs/windows" + }, + "pdfjs.sidebarViewOnLoad": { + "value": 2, + "title": "Show sidebar by default when viewing PDFs" + } + } + }, + "Miscelaneous": { + "preferences": { + "permissions.manager.defaultsUrl": { + "value": "", + "title": "Remove special permissions for certain mozilla domains" + }, + "network.IDN_show_punycode": { + "value": true, + "title": "Use Punycode in Internationalized Domain Names to eliminate possible spoofing" + }, + "extensions.postDownloadThirdPartyPrompt": { + "value": false, + "title": "Disable bypassing 3rd party extension install prompts" + }, + "layout.css.visited_links_enabled": { + "value": false, + "title": "Disable coloring of visited links", + "requireUserOptIn": true + }, + "media.autoplay.default": { + "value": 5, + "title": "Block media autoplay by default" + } + } + } + } +} \ No newline at end of file diff --git a/app/assets/scripts/readability.min.js.gz b/app/assets/scripts/readability.min.js.gz deleted file mode 120000 index 93576576..00000000 --- a/app/assets/scripts/readability.min.js.gz +++ /dev/null @@ -1 +0,0 @@ -../../js/readability/dist/readability.min.js.gz \ No newline at end of file diff --git a/app/build.yaml b/app/build.yaml index 55bba0f3..5965b238 100644 --- a/app/build.yaml +++ b/app/build.yaml @@ -11,6 +11,13 @@ targets: dialect: sqlite options: version: "3.45" + known_functions: + lexo_rank_next: "text (int, text null)" + lexo_rank_previous: "text (int, text null)" + lexo_rank_reorder_after: "text (text null, text null)" modules: - json1 - fts5 + json_serializable: + options: + explicit_to_json: true \ No newline at end of file diff --git a/app/devtools_options.yaml b/app/devtools_options.yaml index fa0b357c..f592d85a 100644 --- a/app/devtools_options.yaml +++ b/app/devtools_options.yaml @@ -1,3 +1,4 @@ description: This file stores settings for Dart & Flutter DevTools. documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states extensions: + - drift: true \ No newline at end of file diff --git a/app/ios/Flutter/Generated.xcconfig b/app/ios/Flutter/Generated.xcconfig deleted file mode 100644 index a351d19c..00000000 --- a/app/ios/Flutter/Generated.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// This is a generated file; do not edit or check into version control. -FLUTTER_ROOT=/home/fafre/Software/flutter -FLUTTER_APPLICATION_PATH=/home/fafre/Repos/kagi_bang_bang/app -COCOAPODS_PARALLEL_CODE_SIGN=true -FLUTTER_TARGET=lib/main.dart -FLUTTER_BUILD_DIR=build -FLUTTER_BUILD_NAME=0.6.2 -FLUTTER_BUILD_NUMBER=14 -EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 -EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 -DART_OBFUSCATION=false -TRACK_WIDGET_CREATION=true -TREE_SHAKE_ICONS=false -PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/app/ios/Flutter/flutter_export_environment.sh b/app/ios/Flutter/flutter_export_environment.sh deleted file mode 100755 index 3925d609..00000000 --- a/app/ios/Flutter/flutter_export_environment.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh -# This is a generated file; do not edit or check into version control. -export "FLUTTER_ROOT=/home/fafre/Software/flutter" -export "FLUTTER_APPLICATION_PATH=/home/fafre/Repos/kagi_bang_bang/app" -export "COCOAPODS_PARALLEL_CODE_SIGN=true" -export "FLUTTER_TARGET=lib/main.dart" -export "FLUTTER_BUILD_DIR=build" -export "FLUTTER_BUILD_NAME=0.6.2" -export "FLUTTER_BUILD_NUMBER=14" -export "DART_OBFUSCATION=false" -export "TRACK_WIDGET_CREATION=true" -export "TREE_SHAKE_ICONS=false" -export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/app/ios/Runner/GeneratedPluginRegistrant.h b/app/ios/Runner/GeneratedPluginRegistrant.h deleted file mode 100644 index 7a890927..00000000 --- a/app/ios/Runner/GeneratedPluginRegistrant.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GeneratedPluginRegistrant_h -#define GeneratedPluginRegistrant_h - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface GeneratedPluginRegistrant : NSObject -+ (void)registerWithRegistry:(NSObject*)registry; -@end - -NS_ASSUME_NONNULL_END -#endif /* GeneratedPluginRegistrant_h */ diff --git a/app/ios/Runner/GeneratedPluginRegistrant.m b/app/ios/Runner/GeneratedPluginRegistrant.m deleted file mode 100644 index f8d2398b..00000000 --- a/app/ios/Runner/GeneratedPluginRegistrant.m +++ /dev/null @@ -1,98 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#import "GeneratedPluginRegistrant.h" - -#if __has_include() -#import -#else -@import file_picker; -#endif - -#if __has_include() -#import -#else -@import flutter_inappwebview_ios; -#endif - -#if __has_include() -#import -#else -@import flutter_pdf_text; -#endif - -#if __has_include() -#import -#else -@import flutter_secure_storage; -#endif - -#if __has_include() -#import -#else -@import flutter_sharing_intent; -#endif - -#if __has_include() -#import -#else -@import home_widget; -#endif - -#if __has_include() -#import -#else -@import package_info_plus; -#endif - -#if __has_include() -#import -#else -@import path_provider_foundation; -#endif - -#if __has_include() -#import -#else -@import share_plus; -#endif - -#if __has_include() -#import -#else -@import shared_preferences_foundation; -#endif - -#if __has_include() -#import -#else -@import sqlite3_flutter_libs; -#endif - -#if __has_include() -#import -#else -@import url_launcher_ios; -#endif - -@implementation GeneratedPluginRegistrant - -+ (void)registerWithRegistry:(NSObject*)registry { - [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; - [InAppWebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppWebViewFlutterPlugin"]]; - [PdfTextPlugin registerWithRegistrar:[registry registrarForPlugin:@"PdfTextPlugin"]]; - [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; - [FlutterSharingIntentPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSharingIntentPlugin"]]; - [HomeWidgetPlugin registerWithRegistrar:[registry registrarForPlugin:@"HomeWidgetPlugin"]]; - [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; - [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; - [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; - [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; - [Sqlite3FlutterLibsPlugin registerWithRegistrar:[registry registrarForPlugin:@"Sqlite3FlutterLibsPlugin"]]; - [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; -} - -@end diff --git a/app/js/readability/dist/readability.min.js b/app/js/readability/dist/readability.min.js deleted file mode 100644 index 669dad5f..00000000 --- a/app/js/readability/dist/readability.min.js +++ /dev/null @@ -1,3 +0,0 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.ReadabilityBundle=t():e.ReadabilityBundle=t()}(this,(()=>(()=>{var e={804:e=>{var t={unlikelyCandidates:/-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i,okMaybeItsACandidate:/and|article|body|column|content|main|shadow/i};function i(e){return(!e.style||"none"!=e.style.display)&&!e.hasAttribute("hidden")&&(!e.hasAttribute("aria-hidden")||"true"!=e.getAttribute("aria-hidden")||e.className&&e.className.indexOf&&-1!==e.className.indexOf("fallback-image"))}e.exports=function(e,n={}){"function"==typeof n&&(n={visibilityChecker:n});var r={minScore:20,minContentLength:140,visibilityChecker:i};n=Object.assign(r,n);var a=e.querySelectorAll("p, pre, article"),o=e.querySelectorAll("div > br");if(o.length){var s=new Set(a);[].forEach.call(o,(function(e){s.add(e.parentNode)})),a=Array.from(s)}var l=0;return[].some.call(a,(function(e){if(!n.visibilityChecker(e))return!1;var i=e.className+" "+e.id;if(t.unlikelyCandidates.test(i)&&!t.okMaybeItsACandidate.test(i))return!1;if(e.matches("li p"))return!1;var r=e.textContent.trim().length;return!(rn.minScore}))}},238:e=>{function t(e,t){if(t&&t.documentElement)e=t,t=arguments[2];else if(!e||!e.documentElement)throw new Error("First argument to Readability constructor should be a document object.");if(t=t||{},this._doc=e,this._docJSDOMParser=this._doc.firstChild.__JSDOMParser__,this._articleTitle=null,this._articleByline=null,this._articleDir=null,this._articleSiteName=null,this._attempts=[],this._debug=!!t.debug,this._maxElemsToParse=t.maxElemsToParse||this.DEFAULT_MAX_ELEMS_TO_PARSE,this._nbTopCandidates=t.nbTopCandidates||this.DEFAULT_N_TOP_CANDIDATES,this._charThreshold=t.charThreshold||this.DEFAULT_CHAR_THRESHOLD,this._classesToPreserve=this.CLASSES_TO_PRESERVE.concat(t.classesToPreserve||[]),this._keepClasses=!!t.keepClasses,this._serializer=t.serializer||function(e){return e.innerHTML},this._disableJSONLD=!!t.disableJSONLD,this._allowedVideoRegex=t.allowedVideoRegex||this.REGEXPS.videos,this._flags=this.FLAG_STRIP_UNLIKELYS|this.FLAG_WEIGHT_CLASSES|this.FLAG_CLEAN_CONDITIONALLY,this._debug){let e=function(e){if(e.nodeType==e.TEXT_NODE)return`${e.nodeName} ("${e.textContent}")`;let t=Array.from(e.attributes||[],(function(e){return`${e.name}="${e.value}"`})).join(" ");return`<${e.localName} ${t}>`};this.log=function(){if("undefined"!=typeof console){let t=Array.from(arguments,(t=>t&&t.nodeType==this.ELEMENT_NODE?e(t):t));t.unshift("Reader: (Readability)"),console.log.apply(console,t)}else if("undefined"!=typeof dump){var t=Array.prototype.map.call(arguments,(function(t){return t&&t.nodeName?e(t):t})).join(" ");dump("Reader: (Readability) "+t+"\n")}}}else this.log=function(){}}t.prototype={FLAG_STRIP_UNLIKELYS:1,FLAG_WEIGHT_CLASSES:2,FLAG_CLEAN_CONDITIONALLY:4,ELEMENT_NODE:1,TEXT_NODE:3,DEFAULT_MAX_ELEMS_TO_PARSE:0,DEFAULT_N_TOP_CANDIDATES:5,DEFAULT_TAGS_TO_SCORE:"section,h2,h3,h4,h5,h6,p,td,pre".toUpperCase().split(","),DEFAULT_CHAR_THRESHOLD:500,REGEXPS:{unlikelyCandidates:/-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i,okMaybeItsACandidate:/and|article|body|column|content|main|shadow/i,positive:/article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i,negative:/-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget/i,extraneous:/print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i,byline:/byline|author|dateline|writtenby|p-author/i,replaceFonts:/<(\/?)font[^>]*>/gi,normalize:/\s{2,}/g,videos:/\/\/(www\.)?((dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq)\.com|(archive|upload\.wikimedia)\.org|player\.twitch\.tv)/i,shareElements:/(\b|_)(share|sharedaddy)(\b|_)/i,nextLink:/(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i,prevLink:/(prev|earl|old|new|<|«)/i,tokenize:/\W+/g,whitespace:/^\s*$/,hasContent:/\S$/,hashUrl:/^#.+/,srcsetUrl:/(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g,b64DataUrl:/^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i,commas:/\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g,jsonLdArticleTypes:/^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/},UNLIKELY_ROLES:["menu","menubar","complementary","navigation","alert","alertdialog","dialog"],DIV_TO_P_ELEMS:new Set(["BLOCKQUOTE","DL","DIV","IMG","OL","P","PRE","TABLE","UL"]),ALTER_TO_DIV_EXCEPTIONS:["DIV","ARTICLE","SECTION","P"],PRESENTATIONAL_ATTRIBUTES:["align","background","bgcolor","border","cellpadding","cellspacing","frame","hspace","rules","style","valign","vspace"],DEPRECATED_SIZE_ATTRIBUTE_ELEMS:["TABLE","TH","TD","HR","PRE"],PHRASING_ELEMS:["ABBR","AUDIO","B","BDO","BR","BUTTON","CITE","CODE","DATA","DATALIST","DFN","EM","EMBED","I","IMG","INPUT","KBD","LABEL","MARK","MATH","METER","NOSCRIPT","OBJECT","OUTPUT","PROGRESS","Q","RUBY","SAMP","SCRIPT","SELECT","SMALL","SPAN","STRONG","SUB","SUP","TEXTAREA","TIME","VAR","WBR"],CLASSES_TO_PRESERVE:["page"],HTML_ESCAPE_MAP:{lt:"<",gt:">",amp:"&",quot:'"',apos:"'"},_postProcessContent:function(e){this._fixRelativeUris(e),this._simplifyNestedElements(e),this._keepClasses||this._cleanClasses(e)},_removeNodes:function(e,t){if(this._docJSDOMParser&&e._isLiveNodeList)throw new Error("Do not pass live node lists to _removeNodes");for(var i=e.length-1;i>=0;i--){var n=e[i],r=n.parentNode;r&&(t&&!t.call(this,n,i,e)||r.removeChild(n))}},_replaceNodeTags:function(e,t){if(this._docJSDOMParser&&e._isLiveNodeList)throw new Error("Do not pass live node lists to _replaceNodeTags");for(const i of e)this._setNodeTag(i,t)},_forEachNode:function(e,t){Array.prototype.forEach.call(e,t,this)},_findNode:function(e,t){return Array.prototype.find.call(e,t,this)},_someNode:function(e,t){return Array.prototype.some.call(e,t,this)},_everyNode:function(e,t){return Array.prototype.every.call(e,t,this)},_concatNodeLists:function(){var e=Array.prototype.slice,t=e.call(arguments).map((function(t){return e.call(t)}));return Array.prototype.concat.apply([],t)},_getAllNodesWithTag:function(e,t){return e.querySelectorAll?e.querySelectorAll(t.join(",")):[].concat.apply([],t.map((function(t){var i=e.getElementsByTagName(t);return Array.isArray(i)?i:Array.from(i)})))},_cleanClasses:function(e){var t=this._classesToPreserve,i=(e.getAttribute("class")||"").split(/\s+/).filter((function(e){return-1!=t.indexOf(e)})).join(" ");for(i?e.setAttribute("class",i):e.removeAttribute("class"),e=e.firstElementChild;e;e=e.nextElementSibling)this._cleanClasses(e)},_fixRelativeUris:function(e){var t=this._doc.baseURI,i=this._doc.documentURI;function n(e){if(t==i&&"#"==e.charAt(0))return e;try{return new URL(e,t).href}catch(e){}return e}var r=this._getAllNodesWithTag(e,["a"]);this._forEachNode(r,(function(e){var t=e.getAttribute("href");if(t)if(0===t.indexOf("javascript:"))if(1===e.childNodes.length&&e.childNodes[0].nodeType===this.TEXT_NODE){var i=this._doc.createTextNode(e.textContent);e.parentNode.replaceChild(i,e)}else{for(var r=this._doc.createElement("span");e.firstChild;)r.appendChild(e.firstChild);e.parentNode.replaceChild(r,e)}else e.setAttribute("href",n(t))}));var a=this._getAllNodesWithTag(e,["img","picture","figure","video","audio","source"]);this._forEachNode(a,(function(e){var t=e.getAttribute("src"),i=e.getAttribute("poster"),r=e.getAttribute("srcset");if(t&&e.setAttribute("src",n(t)),i&&e.setAttribute("poster",n(i)),r){var a=r.replace(this.REGEXPS.srcsetUrl,(function(e,t,i,r){return n(t)+(i||"")+r}));e.setAttribute("srcset",a)}}))},_simplifyNestedElements:function(e){for(var t=e;t;){if(t.parentNode&&["DIV","SECTION"].includes(t.tagName)&&(!t.id||!t.id.startsWith("readability"))){if(this._isElementWithoutContent(t)){t=this._removeAndGetNext(t);continue}if(this._hasSingleTagInsideElement(t,"DIV")||this._hasSingleTagInsideElement(t,"SECTION")){for(var i=t.children[0],n=0;n»] /.test(t))n=/ [\\\/>»] /.test(t),r(t=i.replace(/(.*)[\|\-\\\/>»] .*/gi,"$1"))<3&&(t=i.replace(/[^\|\-\\\/>»]*[\|\-\\\/>»](.*)/gi,"$1"));else if(-1!==t.indexOf(": ")){var a=this._concatNodeLists(e.getElementsByTagName("h1"),e.getElementsByTagName("h2")),o=t.trim();this._someNode(a,(function(e){return e.textContent.trim()===o}))||(r(t=i.substring(i.lastIndexOf(":")+1))<3?t=i.substring(i.indexOf(":")+1):r(i.substr(0,i.indexOf(":")))>5&&(t=i))}else if(t.length>150||t.length<15){var s=e.getElementsByTagName("h1");1===s.length&&(t=this._getInnerText(s[0]))}var l=r(t=t.trim().replace(this.REGEXPS.normalize," "));return l<=4&&(!n||l!=r(i.replace(/[\|\-\\\/>»]+/g,""))-1)&&(t=i),t},_prepDocument:function(){var e=this._doc;this._removeNodes(this._getAllNodesWithTag(e,["style"])),e.body&&this._replaceBrs(e.body),this._replaceNodeTags(this._getAllNodesWithTag(e,["font"]),"SPAN")},_nextNode:function(e){for(var t=e;t&&t.nodeType!=this.ELEMENT_NODE&&this.REGEXPS.whitespace.test(t.textContent);)t=t.nextSibling;return t},_replaceBrs:function(e){this._forEachNode(this._getAllNodesWithTag(e,["br"]),(function(e){for(var t=e.nextSibling,i=!1;(t=this._nextNode(t))&&"BR"==t.tagName;){i=!0;var n=t.nextSibling;t.parentNode.removeChild(t),t=n}if(i){var r=this._doc.createElement("p");for(e.parentNode.replaceChild(r,e),t=r.nextSibling;t;){if("BR"==t.tagName){var a=this._nextNode(t.nextSibling);if(a&&"BR"==a.tagName)break}if(!this._isPhrasingContent(t))break;var o=t.nextSibling;r.appendChild(t),t=o}for(;r.lastChild&&this._isWhitespace(r.lastChild);)r.removeChild(r.lastChild);"P"===r.parentNode.tagName&&this._setNodeTag(r.parentNode,"DIV")}}))},_setNodeTag:function(e,t){if(this.log("_setNodeTag",e,t),this._docJSDOMParser)return e.localName=t.toLowerCase(),e.tagName=t.toUpperCase(),e;for(var i=e.ownerDocument.createElement(t);e.firstChild;)i.appendChild(e.firstChild);e.parentNode.replaceChild(i,e),e.readability&&(i.readability=e.readability);for(var n=0;n!i.includes(e))).join(" ").length/n.join(" ").length:0},_checkByline:function(e,t){if(this._articleByline)return!1;if(void 0!==e.getAttribute)var i=e.getAttribute("rel"),n=e.getAttribute("itemprop");return!(!("author"===i||n&&-1!==n.indexOf("author")||this.REGEXPS.byline.test(t))||!this._isValidByline(e.textContent))&&(this._articleByline=e.textContent.trim(),!0)},_getNodeAncestors:function(e,t){t=t||0;for(var i=0,n=[];e.parentNode&&(n.push(e.parentNode),!t||++i!==t);)e=e.parentNode;return n},_grabArticle:function(e){this.log("**** grabArticle ****");var t=this._doc,i=null!==e;if(!(e=e||this._doc.body))return this.log("No body found in document. Abort."),null;for(var n=e.innerHTML;;){this.log("Starting grabArticle loop");var r=this._flagIsActive(this.FLAG_STRIP_UNLIKELYS),a=[],o=this._doc.documentElement;let X=!0;for(;o;){"HTML"===o.tagName&&(this._articleLang=o.getAttribute("lang"));var s=o.className+" "+o.id;if(this._isProbablyVisible(o))if("true"!=o.getAttribute("aria-modal")||"dialog"!=o.getAttribute("role"))if(this._checkByline(o,s))o=this._removeAndGetNext(o);else if(X&&this._headerDuplicatesTitle(o))this.log("Removing header: ",o.textContent.trim(),this._articleTitle.trim()),X=!1,o=this._removeAndGetNext(o);else{if(r){if(this.REGEXPS.unlikelyCandidates.test(s)&&!this.REGEXPS.okMaybeItsACandidate.test(s)&&!this._hasAncestorTag(o,"table")&&!this._hasAncestorTag(o,"code")&&"BODY"!==o.tagName&&"A"!==o.tagName){this.log("Removing unlikely candidate - "+s),o=this._removeAndGetNext(o);continue}if(this.UNLIKELY_ROLES.includes(o.getAttribute("role"))){this.log("Removing content with role "+o.getAttribute("role")+" - "+s),o=this._removeAndGetNext(o);continue}}if("DIV"!==o.tagName&&"SECTION"!==o.tagName&&"HEADER"!==o.tagName&&"H1"!==o.tagName&&"H2"!==o.tagName&&"H3"!==o.tagName&&"H4"!==o.tagName&&"H5"!==o.tagName&&"H6"!==o.tagName||!this._isElementWithoutContent(o)){if(-1!==this.DEFAULT_TAGS_TO_SCORE.indexOf(o.tagName)&&a.push(o),"DIV"===o.tagName){for(var l=null,c=o.firstChild;c;){var h=c.nextSibling;if(this._isPhrasingContent(c))null!==l?l.appendChild(c):this._isWhitespace(c)||(l=t.createElement("p"),o.replaceChild(l,c),l.appendChild(c));else if(null!==l){for(;l.lastChild&&this._isWhitespace(l.lastChild);)l.removeChild(l.lastChild);l=null}c=h}if(this._hasSingleTagInsideElement(o,"P")&&this._getLinkDensity(o)<.25){var d=o.children[0];o.parentNode.replaceChild(d,o),o=d,a.push(o)}else this._hasChildBlockElement(o)||(o=this._setNodeTag(o,"P"),a.push(o))}o=this._getNextNode(o)}else o=this._removeAndGetNext(o)}else o=this._removeAndGetNext(o);else this.log("Removing hidden node - "+s),o=this._removeAndGetNext(o)}var u=[];this._forEachNode(a,(function(e){if(e.parentNode&&void 0!==e.parentNode.tagName){var t=this._getInnerText(e);if(!(t.length<25)){var i=this._getNodeAncestors(e,5);if(0!==i.length){var n=0;n+=1,n+=t.split(this.REGEXPS.commas).length,n+=Math.min(Math.floor(t.length/100),3),this._forEachNode(i,(function(e,t){if(e.tagName&&e.parentNode&&void 0!==e.parentNode.tagName){if(void 0===e.readability&&(this._initializeNode(e),u.push(e)),0===t)var i=1;else i=1===t?2:3*t;e.readability.contentScore+=n/i}}))}}}}));for(var m=[],g=0,p=u.length;gE.readability.contentScore){m.splice(N,0,f),m.length>this._nbTopCandidates&&m.pop();break}}}var T,b=m[0]||null,y=!1;if(null===b||"BODY"===b.tagName){for(b=t.createElement("DIV"),y=!0;e.firstChild;)this.log("Moving child out:",e.firstChild),b.appendChild(e.firstChild);e.appendChild(b),this._initializeNode(b)}else if(b){for(var A=[],v=1;v=.75&&A.push(this._getNodeAncestors(m[v]));if(A.length>=3)for(T=b.parentNode;"BODY"!==T.tagName;){for(var S=0,C=0;C=3){b=T;break}T=T.parentNode}b.readability||this._initializeNode(b),T=b.parentNode;for(var L=b.readability.contentScore,x=L/3;"BODY"!==T.tagName;)if(T.readability){var R=T.readability.contentScore;if(RL){b=T;break}L=T.readability.contentScore,T=T.parentNode}else T=T.parentNode;for(T=b.parentNode;"BODY"!=T.tagName&&1==T.children.length;)T=(b=T).parentNode;b.readability||this._initializeNode(b)}var D=t.createElement("DIV");i&&(D.id="readability-content");for(var I=Math.max(10,.2*b.readability.contentScore),O=(T=b.parentNode).children,w=0,P=O.length;w=I)k=!0;else if("P"===M.nodeName){var U=this._getLinkDensity(M),H=this._getInnerText(M),G=H.length;(G>80&&U<.25||G<80&&G>0&&0===U&&-1!==H.search(/\.( |$)/))&&(k=!0)}}k&&(this.log("Appending node:",M),-1===this.ALTER_TO_DIV_EXCEPTIONS.indexOf(M.nodeName)&&(this.log("Altering sibling:",M,"to div."),M=this._setNodeTag(M,"DIV")),D.appendChild(M),O=T.children,w-=1,P-=1)}if(this._debug&&this.log("Article content pre-prep: "+D.innerHTML),this._prepArticle(D),this._debug&&this.log("Article content post-prep: "+D.innerHTML),y)b.id="readability-page-1",b.className="page";else{var F=t.createElement("DIV");for(F.id="readability-page-1",F.className="page";D.firstChild;)F.appendChild(D.firstChild);D.appendChild(F)}this._debug&&this.log("Article content after paging: "+D.innerHTML);var W=!0,z=this._getInnerText(D,!0).length;if(z0&&e.length<100)},_unescapeHtmlEntities:function(e){if(!e)return e;var t=this.HTML_ESCAPE_MAP;return e.replace(/&(quot|amp|apos|lt|gt);/g,(function(e,i){return t[i]})).replace(/&#(?:x([0-9a-z]{1,4})|([0-9]{1,4}));/gi,(function(e,t,i){var n=parseInt(t||i,t?16:10);return String.fromCharCode(n)}))},_getJSONLD:function(e){var t,i=this._getAllNodesWithTag(e,["script"]);return this._forEachNode(i,(function(e){if(!t&&"application/ld+json"===e.getAttribute("type"))try{var i=e.textContent.replace(/^\s*\s*$/g,""),n=JSON.parse(i);if(!n["@context"]||!n["@context"].match(/^https?\:\/\/schema\.org$/))return;if(!n["@type"]&&Array.isArray(n["@graph"])&&(n=n["@graph"].find((function(e){return(e["@type"]||"").match(this.REGEXPS.jsonLdArticleTypes)}))),!n||!n["@type"]||!n["@type"].match(this.REGEXPS.jsonLdArticleTypes))return;if(t={},"string"==typeof n.name&&"string"==typeof n.headline&&n.name!==n.headline){var r=this._getArticleTitle(),a=this._textSimilarity(n.name,r)>.75,o=this._textSimilarity(n.headline,r)>.75;t.title=o&&!a?n.headline:n.name}else"string"==typeof n.name?t.title=n.name.trim():"string"==typeof n.headline&&(t.title=n.headline.trim());return n.author&&("string"==typeof n.author.name?t.byline=n.author.name.trim():Array.isArray(n.author)&&n.author[0]&&"string"==typeof n.author[0].name&&(t.byline=n.author.filter((function(e){return e&&"string"==typeof e.name})).map((function(e){return e.name.trim()})).join(", "))),"string"==typeof n.description&&(t.excerpt=n.description.trim()),n.publisher&&"string"==typeof n.publisher.name&&(t.siteName=n.publisher.name.trim()),void("string"==typeof n.datePublished&&(t.datePublished=n.datePublished.trim()))}catch(e){this.log(e.message)}})),t||{}},_getArticleMetadata:function(e){var t={},i={},n=this._doc.getElementsByTagName("meta"),r=/\s*(article|dc|dcterm|og|twitter)\s*:\s*(author|creator|description|published_time|title|site_name)\s*/gi,a=/^\s*(?:(dc|dcterm|og|twitter|weibo:(article|webpage))\s*[\.:]\s*)?(author|creator|description|title|site_name)\s*$/i;return this._forEachNode(n,(function(e){var t=e.getAttribute("name"),n=e.getAttribute("property"),o=e.getAttribute("content");if(o){var s=null,l=null;n&&(s=n.match(r))&&(l=s[0].toLowerCase().replace(/\s/g,""),i[l]=o.trim()),!s&&t&&a.test(t)&&(l=t,o&&(l=l.toLowerCase().replace(/\s/g,"").replace(/\./g,":"),i[l]=o.trim()))}})),t.title=e.title||i["dc:title"]||i["dcterm:title"]||i["og:title"]||i["weibo:article:title"]||i["weibo:webpage:title"]||i.title||i["twitter:title"],t.title||(t.title=this._getArticleTitle()),t.byline=e.byline||i["dc:creator"]||i["dcterm:creator"]||i.author,t.excerpt=e.excerpt||i["dc:description"]||i["dcterm:description"]||i["og:description"]||i["weibo:article:description"]||i["weibo:webpage:description"]||i.description||i["twitter:description"],t.siteName=e.siteName||i["og:site_name"],t.publishedTime=e.datePublished||i["article:published_time"]||null,t.title=this._unescapeHtmlEntities(t.title),t.byline=this._unescapeHtmlEntities(t.byline),t.excerpt=this._unescapeHtmlEntities(t.excerpt),t.siteName=this._unescapeHtmlEntities(t.siteName),t.publishedTime=this._unescapeHtmlEntities(t.publishedTime),t},_isSingleImage:function(e){return"IMG"===e.tagName||1===e.children.length&&""===e.textContent.trim()&&this._isSingleImage(e.children[0])},_unwrapNoscriptImages:function(e){var t=Array.from(e.getElementsByTagName("img"));this._forEachNode(t,(function(e){for(var t=0;t0&&r>i)return!1;if(e.parentNode.tagName===t&&(!n||n(e.parentNode)))return!0;e=e.parentNode,r++}return!1},_getRowAndColumnCount:function(e){for(var t=0,i=0,n=e.getElementsByTagName("tr"),r=0;r0)n._readabilityDataTable=!0;else{if(["col","colgroup","tfoot","thead","th"].some((function(e){return!!n.getElementsByTagName(e)[0]})))this.log("Data table because found data-y descendant"),n._readabilityDataTable=!0;else if(n.getElementsByTagName("table")[0])n._readabilityDataTable=!1;else{var a=this._getRowAndColumnCount(n);a.rows>=10||a.columns>4?n._readabilityDataTable=!0:n._readabilityDataTable=a.rows*a.columns>10}}}else n._readabilityDataTable=!1;else n._readabilityDataTable=!1}},_fixLazyImages:function(e){this._forEachNode(this._getAllNodesWithTag(e,["img","picture","figure"]),(function(e){if(e.src&&this.REGEXPS.b64DataUrl.test(e.src)){if("image/svg+xml"===this.REGEXPS.b64DataUrl.exec(e.src)[1])return;for(var t=!1,i=0;in+=this._getInnerText(e,!0).length)),n/i},_cleanConditionally:function(e,t){this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)&&this._removeNodes(this._getAllNodesWithTag(e,[t]),(function(e){var i=function(e){return e._readabilityDataTable},n="ul"===t||"ol"===t;if(!n){var r=0,a=this._getAllNodesWithTag(e,["ul","ol"]);this._forEachNode(a,(e=>r+=this._getInnerText(e).length)),n=r/this._getInnerText(e).length>.9}if("table"===t&&i(e))return!1;if(this._hasAncestorTag(e,"table",-1,i))return!1;if(this._hasAncestorTag(e,"code"))return!1;var o=this._getClassWeight(e);this.log("Cleaning Conditionally",e);if(o+0<0)return!0;if(this._getCharCount(e,",")<10){for(var s=e.getElementsByTagName("p").length,l=e.getElementsByTagName("img").length,c=e.getElementsByTagName("li").length-100,h=e.getElementsByTagName("input").length,d=this._getTextDensity(e,["h1","h2","h3","h4","h5","h6"]),u=0,m=this._getAllNodesWithTag(e,["object","embed","iframe"]),g=0;g1&&s/l<.5&&!this._hasAncestorTag(e,"figure")||!n&&c>s||h>Math.floor(s/3)||!n&&d<.9&&_<25&&(0===l||l>2)&&!this._hasAncestorTag(e,"figure")||!n&&o<25&&f>.2||o>=25&&f>.5||1===u&&_<75||u>1;if(n&&N){for(var E=0;E1)return N}if(l==e.getElementsByTagName("li").length)return!1}return N}return!1}))},_cleanMatchedNodes:function(e,t){for(var i=this._getNextNode(e,!0),n=this._getNextNode(e);n&&n!=i;)n=t.call(this,n,n.className+" "+n.id)?this._removeAndGetNext(n):this._getNextNode(n)},_cleanHeaders:function(e){let t=this._getAllNodesWithTag(e,["h1","h2"]);this._removeNodes(t,(function(e){let t=this._getClassWeight(e)<0;return t&&this.log("Removing header with low class weight:",e),t}))},_headerDuplicatesTitle:function(e){if("H1"!=e.tagName&&"H2"!=e.tagName)return!1;var t=this._getInnerText(e,!1);return this.log("Evaluating similarity of header:",t,this._articleTitle),this._textSimilarity(this._articleTitle,t)>.75},_flagIsActive:function(e){return(this._flags&e)>0},_removeFlag:function(e){this._flags=this._flags&~e},_isProbablyVisible:function(e){return(!e.style||"none"!=e.style.display)&&(!e.style||"hidden"!=e.style.visibility)&&!e.hasAttribute("hidden")&&(!e.hasAttribute("aria-hidden")||"true"!=e.getAttribute("aria-hidden")||e.className&&e.className.indexOf&&-1!==e.className.indexOf("fallback-image"))},parse:function(){if(this._maxElemsToParse>0){var e=this._doc.getElementsByTagName("*").length;if(e>this._maxElemsToParse)throw new Error("Aborting parsing document; "+e+" elements found")}this._unwrapNoscriptImages(this._doc);var t=this._disableJSONLD?{}:this._getJSONLD(this._doc);this._removeScripts(this._doc),this._prepDocument();var i=this._getArticleMetadata(t);this._articleTitle=i.title;var n=this._grabArticle();if(!n)return null;if(this.log("Grabbed: "+n.innerHTML),this._postProcessContent(n),!i.excerpt){var r=n.getElementsByTagName("p");r.length>0&&(i.excerpt=r[0].textContent.trim())}var a=n.textContent;return{title:this._articleTitle,byline:i.byline||this._articleByline,dir:this._articleDir,lang:this._articleLang,content:this._serializer(n),textContent:a,length:a.length,excerpt:i.excerpt,siteName:i.siteName||this._articleSiteName,publishedTime:i.publishedTime}}},e.exports=t},396:(e,t,i)=>{var n=i(238),r=i(804);e.exports={Readability:n,isProbablyReaderable:r}},838:function(e){ -/*! @license DOMPurify 3.1.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.1.6/LICENSE */ -e.exports=function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:i,getPrototypeOf:n,getOwnPropertyDescriptor:r}=Object;let{freeze:a,seal:o,create:s}=Object,{apply:l,construct:c}="undefined"!=typeof Reflect&&Reflect;a||(a=function(e){return e}),o||(o=function(e){return e}),l||(l=function(e,t,i){return e.apply(t,i)}),c||(c=function(e,t){return new e(...t)});const h=y(Array.prototype.forEach),d=y(Array.prototype.pop),u=y(Array.prototype.push),m=y(String.prototype.toLowerCase),g=y(String.prototype.toString),p=y(String.prototype.match),f=y(String.prototype.replace),_=y(String.prototype.indexOf),N=y(String.prototype.trim),E=y(Object.prototype.hasOwnProperty),T=y(RegExp.prototype.test),b=A(TypeError);function y(e){return function(t){for(var i=arguments.length,n=new Array(i>1?i-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:m;t&&t(e,null);let a=n.length;for(;a--;){let t=n[a];if("string"==typeof t){const e=r(t);e!==t&&(i(n)||(n[a]=e),t=e)}e[t]=!0}return e}function S(e){for(let t=0;t/gm),F=o(/\${[\w\W]*}/gm),W=o(/^data-[\-\w.\u00B7-\uFFFF]/),z=o(/^aria-[\-\w]+$/),j=o(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),X=o(/^(?:\w+script|data):/i),Y=o(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),V=o(/^html$/i),$=o(/^[a-z][.\w]*(-[.\w]+)+$/i);var q=Object.freeze({__proto__:null,MUSTACHE_EXPR:H,ERB_EXPR:G,TMPLIT_EXPR:F,DATA_ATTR:W,ARIA_ATTR:z,IS_ALLOWED_URI:j,IS_SCRIPT_OR_DATA:X,ATTR_WHITESPACE:Y,DOCTYPE_NAME:V,CUSTOM_ELEMENT:$});const K={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},J=function(){return"undefined"==typeof window?null:window},Z=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let i=null;const n="data-tt-policy-suffix";t&&t.hasAttribute(n)&&(i=t.getAttribute(n));const r="dompurify"+(i?"#"+i:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}};function Q(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:J();const i=e=>Q(e);if(i.version="3.1.6",i.removed=[],!t||!t.document||t.document.nodeType!==K.document)return i.isSupported=!1,i;let{document:n}=t;const r=n,o=r.currentScript,{DocumentFragment:l,HTMLTemplateElement:c,Node:y,Element:A,NodeFilter:S,NamedNodeMap:H=t.NamedNodeMap||t.MozNamedAttrMap,HTMLFormElement:G,DOMParser:F,trustedTypes:W}=t,z=A.prototype,X=L(z,"cloneNode"),Y=L(z,"remove"),$=L(z,"nextSibling"),ee=L(z,"childNodes"),te=L(z,"parentNode");if("function"==typeof c){const e=n.createElement("template");e.content&&e.content.ownerDocument&&(n=e.content.ownerDocument)}let ie,ne="";const{implementation:re,createNodeIterator:ae,createDocumentFragment:oe,getElementsByTagName:se}=n,{importNode:le}=r;let ce={};i.isSupported="function"==typeof e&&"function"==typeof te&&re&&void 0!==re.createHTMLDocument;const{MUSTACHE_EXPR:he,ERB_EXPR:de,TMPLIT_EXPR:ue,DATA_ATTR:me,ARIA_ATTR:ge,IS_SCRIPT_OR_DATA:pe,ATTR_WHITESPACE:fe,CUSTOM_ELEMENT:_e}=q;let{IS_ALLOWED_URI:Ne}=q,Ee=null;const Te=v({},[...x,...R,...D,...O,...P]);let be=null;const ye=v({},[...M,...k,...B,...U]);let Ae=Object.seal(s(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ve=null,Se=null,Ce=!0,Le=!0,xe=!1,Re=!0,De=!1,Ie=!0,Oe=!1,we=!1,Pe=!1,Me=!1,ke=!1,Be=!1,Ue=!0,He=!1;const Ge="user-content-";let Fe=!0,We=!1,ze={},je=null;const Xe=v({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ye=null;const Ve=v({},["audio","video","img","source","image","track"]);let $e=null;const qe=v({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Ke="http://www.w3.org/1998/Math/MathML",Je="http://www.w3.org/2000/svg",Ze="http://www.w3.org/1999/xhtml";let Qe=Ze,et=!1,tt=null;const it=v({},[Ke,Je,Ze],g);let nt=null;const rt=["application/xhtml+xml","text/html"],at="text/html";let ot=null,st=null;const lt=n.createElement("form"),ct=function(e){return e instanceof RegExp||e instanceof Function},ht=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!st||st!==e){if(e&&"object"==typeof e||(e={}),e=C(e),nt=-1===rt.indexOf(e.PARSER_MEDIA_TYPE)?at:e.PARSER_MEDIA_TYPE,ot="application/xhtml+xml"===nt?g:m,Ee=E(e,"ALLOWED_TAGS")?v({},e.ALLOWED_TAGS,ot):Te,be=E(e,"ALLOWED_ATTR")?v({},e.ALLOWED_ATTR,ot):ye,tt=E(e,"ALLOWED_NAMESPACES")?v({},e.ALLOWED_NAMESPACES,g):it,$e=E(e,"ADD_URI_SAFE_ATTR")?v(C(qe),e.ADD_URI_SAFE_ATTR,ot):qe,Ye=E(e,"ADD_DATA_URI_TAGS")?v(C(Ve),e.ADD_DATA_URI_TAGS,ot):Ve,je=E(e,"FORBID_CONTENTS")?v({},e.FORBID_CONTENTS,ot):Xe,ve=E(e,"FORBID_TAGS")?v({},e.FORBID_TAGS,ot):{},Se=E(e,"FORBID_ATTR")?v({},e.FORBID_ATTR,ot):{},ze=!!E(e,"USE_PROFILES")&&e.USE_PROFILES,Ce=!1!==e.ALLOW_ARIA_ATTR,Le=!1!==e.ALLOW_DATA_ATTR,xe=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Re=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,De=e.SAFE_FOR_TEMPLATES||!1,Ie=!1!==e.SAFE_FOR_XML,Oe=e.WHOLE_DOCUMENT||!1,Me=e.RETURN_DOM||!1,ke=e.RETURN_DOM_FRAGMENT||!1,Be=e.RETURN_TRUSTED_TYPE||!1,Pe=e.FORCE_BODY||!1,Ue=!1!==e.SANITIZE_DOM,He=e.SANITIZE_NAMED_PROPS||!1,Fe=!1!==e.KEEP_CONTENT,We=e.IN_PLACE||!1,Ne=e.ALLOWED_URI_REGEXP||j,Qe=e.NAMESPACE||Ze,Ae=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ct(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Ae.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ct(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Ae.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Ae.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),De&&(Le=!1),ke&&(Me=!0),ze&&(Ee=v({},P),be=[],!0===ze.html&&(v(Ee,x),v(be,M)),!0===ze.svg&&(v(Ee,R),v(be,k),v(be,U)),!0===ze.svgFilters&&(v(Ee,D),v(be,k),v(be,U)),!0===ze.mathMl&&(v(Ee,O),v(be,B),v(be,U))),e.ADD_TAGS&&(Ee===Te&&(Ee=C(Ee)),v(Ee,e.ADD_TAGS,ot)),e.ADD_ATTR&&(be===ye&&(be=C(be)),v(be,e.ADD_ATTR,ot)),e.ADD_URI_SAFE_ATTR&&v($e,e.ADD_URI_SAFE_ATTR,ot),e.FORBID_CONTENTS&&(je===Xe&&(je=C(je)),v(je,e.FORBID_CONTENTS,ot)),Fe&&(Ee["#text"]=!0),Oe&&v(Ee,["html","head","body"]),Ee.table&&(v(Ee,["tbody"]),delete ve.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');ie=e.TRUSTED_TYPES_POLICY,ne=ie.createHTML("")}else void 0===ie&&(ie=Z(W,o)),null!==ie&&"string"==typeof ne&&(ne=ie.createHTML(""));a&&a(e),st=e}},dt=v({},["mi","mo","mn","ms","mtext"]),ut=v({},["foreignobject","annotation-xml"]),mt=v({},["title","style","font","a","script"]),gt=v({},[...R,...D,...I]),pt=v({},[...O,...w]),ft=function(e){let t=te(e);t&&t.tagName||(t={namespaceURI:Qe,tagName:"template"});const i=m(e.tagName),n=m(t.tagName);return!!tt[e.namespaceURI]&&(e.namespaceURI===Je?t.namespaceURI===Ze?"svg"===i:t.namespaceURI===Ke?"svg"===i&&("annotation-xml"===n||dt[n]):Boolean(gt[i]):e.namespaceURI===Ke?t.namespaceURI===Ze?"math"===i:t.namespaceURI===Je?"math"===i&&ut[n]:Boolean(pt[i]):e.namespaceURI===Ze?!(t.namespaceURI===Je&&!ut[n])&&!(t.namespaceURI===Ke&&!dt[n])&&!pt[i]&&(mt[i]||!gt[i]):!("application/xhtml+xml"!==nt||!tt[e.namespaceURI]))},_t=function(e){u(i.removed,{element:e});try{te(e).removeChild(e)}catch(t){Y(e)}},Nt=function(e,t){try{u(i.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){u(i.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e&&!be[e])if(Me||ke)try{_t(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},Et=function(e){let t=null,i=null;if(Pe)e=""+e;else{const t=p(e,/^[\r\n\t ]+/);i=t&&t[0]}"application/xhtml+xml"===nt&&Qe===Ze&&(e=''+e+"");const r=ie?ie.createHTML(e):e;if(Qe===Ze)try{t=(new F).parseFromString(r,nt)}catch(e){}if(!t||!t.documentElement){t=re.createDocument(Qe,"template",null);try{t.documentElement.innerHTML=et?ne:r}catch(e){}}const a=t.body||t.documentElement;return e&&i&&a.insertBefore(n.createTextNode(i),a.childNodes[0]||null),Qe===Ze?se.call(t,Oe?"html":"body")[0]:Oe?t.documentElement:a},Tt=function(e){return ae.call(e.ownerDocument||e,e,S.SHOW_ELEMENT|S.SHOW_COMMENT|S.SHOW_TEXT|S.SHOW_PROCESSING_INSTRUCTION|S.SHOW_CDATA_SECTION,null)},bt=function(e){return e instanceof G&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof H)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},yt=function(e){return"function"==typeof y&&e instanceof y},At=function(e,t,n){ce[e]&&h(ce[e],(e=>{e.call(i,t,n,st)}))},vt=function(e){let t=null;if(At("beforeSanitizeElements",e,null),bt(e))return _t(e),!0;const n=ot(e.nodeName);if(At("uponSanitizeElement",e,{tagName:n,allowedTags:Ee}),e.hasChildNodes()&&!yt(e.firstElementChild)&&T(/<[/\w]/g,e.innerHTML)&&T(/<[/\w]/g,e.textContent))return _t(e),!0;if(e.nodeType===K.progressingInstruction)return _t(e),!0;if(Ie&&e.nodeType===K.comment&&T(/<[/\w]/g,e.data))return _t(e),!0;if(!Ee[n]||ve[n]){if(!ve[n]&&Ct(n)){if(Ae.tagNameCheck instanceof RegExp&&T(Ae.tagNameCheck,n))return!1;if(Ae.tagNameCheck instanceof Function&&Ae.tagNameCheck(n))return!1}if(Fe&&!je[n]){const t=te(e)||e.parentNode,i=ee(e)||e.childNodes;if(i&&t)for(let n=i.length-1;n>=0;--n){const r=X(i[n],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,$(e))}}return _t(e),!0}return e instanceof A&&!ft(e)?(_t(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!T(/<\/no(script|embed|frames)/i,e.innerHTML)?(De&&e.nodeType===K.text&&(t=e.textContent,h([he,de,ue],(e=>{t=f(t,e," ")})),e.textContent!==t&&(u(i.removed,{element:e.cloneNode()}),e.textContent=t)),At("afterSanitizeElements",e,null),!1):(_t(e),!0)},St=function(e,t,i){if(Ue&&("id"===t||"name"===t)&&(i in n||i in lt))return!1;if(Le&&!Se[t]&&T(me,t));else if(Ce&&T(ge,t));else if(!be[t]||Se[t]){if(!(Ct(e)&&(Ae.tagNameCheck instanceof RegExp&&T(Ae.tagNameCheck,e)||Ae.tagNameCheck instanceof Function&&Ae.tagNameCheck(e))&&(Ae.attributeNameCheck instanceof RegExp&&T(Ae.attributeNameCheck,t)||Ae.attributeNameCheck instanceof Function&&Ae.attributeNameCheck(t))||"is"===t&&Ae.allowCustomizedBuiltInElements&&(Ae.tagNameCheck instanceof RegExp&&T(Ae.tagNameCheck,i)||Ae.tagNameCheck instanceof Function&&Ae.tagNameCheck(i))))return!1}else if($e[t]);else if(T(Ne,f(i,fe,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==_(i,"data:")||!Ye[e])if(xe&&!T(pe,f(i,fe,"")));else if(i)return!1;return!0},Ct=function(e){return"annotation-xml"!==e&&p(e,_e)},Lt=function(e){At("beforeSanitizeAttributes",e,null);const{attributes:t}=e;if(!t)return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:be};let r=t.length;for(;r--;){const a=t[r],{name:o,namespaceURI:s,value:l}=a,c=ot(o);let u="value"===o?l:N(l);if(n.attrName=c,n.attrValue=u,n.keepAttr=!0,n.forceKeepAttr=void 0,At("uponSanitizeAttribute",e,n),u=n.attrValue,Ie&&T(/((--!?|])>)|<\/(style|title)/i,u)){Nt(o,e);continue}if(n.forceKeepAttr)continue;if(Nt(o,e),!n.keepAttr)continue;if(!Re&&T(/\/>/i,u)){Nt(o,e);continue}De&&h([he,de,ue],(e=>{u=f(u,e," ")}));const m=ot(e.nodeName);if(St(m,c,u)){if(!He||"id"!==c&&"name"!==c||(Nt(o,e),u=Ge+u),ie&&"object"==typeof W&&"function"==typeof W.getAttributeType)if(s);else switch(W.getAttributeType(m,c)){case"TrustedHTML":u=ie.createHTML(u);break;case"TrustedScriptURL":u=ie.createScriptURL(u)}try{s?e.setAttributeNS(s,o,u):e.setAttribute(o,u),bt(e)?_t(e):d(i.removed)}catch(e){}}}At("afterSanitizeAttributes",e,null)},xt=function e(t){let i=null;const n=Tt(t);for(At("beforeSanitizeShadowDOM",t,null);i=n.nextNode();)At("uponSanitizeShadowNode",i,null),vt(i)||(i.content instanceof l&&e(i.content),Lt(i));At("afterSanitizeShadowDOM",t,null)};return i.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,a=null,o=null,s=null;if(et=!e,et&&(e="\x3c!--\x3e"),"string"!=typeof e&&!yt(e)){if("function"!=typeof e.toString)throw b("toString is not a function");if("string"!=typeof(e=e.toString()))throw b("dirty is not a string, aborting")}if(!i.isSupported)return e;if(we||ht(t),i.removed=[],"string"==typeof e&&(We=!1),We){if(e.nodeName){const t=ot(e.nodeName);if(!Ee[t]||ve[t])throw b("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof y)n=Et("\x3c!----\x3e"),a=n.ownerDocument.importNode(e,!0),a.nodeType===K.element&&"BODY"===a.nodeName||"HTML"===a.nodeName?n=a:n.appendChild(a);else{if(!Me&&!De&&!Oe&&-1===e.indexOf("<"))return ie&&Be?ie.createHTML(e):e;if(n=Et(e),!n)return Me?null:Be?ne:""}n&&Pe&&_t(n.firstChild);const c=Tt(We?e:n);for(;o=c.nextNode();)vt(o)||(o.content instanceof l&&xt(o.content),Lt(o));if(We)return e;if(Me){if(ke)for(s=oe.call(n.ownerDocument);n.firstChild;)s.appendChild(n.firstChild);else s=n;return(be.shadowroot||be.shadowrootmode)&&(s=le.call(r,s,!0)),s}let d=Oe?n.outerHTML:n.innerHTML;return Oe&&Ee["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&T(V,n.ownerDocument.doctype.name)&&(d="\n"+d),De&&h([he,de,ue],(e=>{d=f(d,e," ")})),ie&&Be?ie.createHTML(d):d},i.setConfig=function(){ht(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),we=!0},i.clearConfig=function(){st=null,we=!1},i.isValidAttribute=function(e,t,i){st||ht({});const n=ot(e),r=ot(t);return St(n,r,i)},i.addHook=function(e,t){"function"==typeof t&&(ce[e]=ce[e]||[],u(ce[e],t))},i.removeHook=function(e){if(ce[e])return d(ce[e])},i.removeHooks=function(e){ce[e]&&(ce[e]=[])},i.removeAllHooks=function(){ce={}},i}return Q()}()}},t={};function i(n){var r=t[n];if(void 0!==r)return r.exports;var a=t[n]={exports:{}};return e[n].call(a.exports,a,a.exports,i),a.exports}i.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return i.d(t,{a:t}),t},i.d=(e,t)=>{for(var n in t)i.o(t,n)&&!i.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),i.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var n={};return(()=>{"use strict";i.r(n),i.d(n,{applyReaderable:()=>o,isReaderable:()=>a});var e=i(396),t=i(838),r=i.n(t);function a(){return(0,e.isProbablyReaderable)(document)}function o(){var t,i=document.cloneNode(!0),n=(t=i,new e.Readability(t).parse()),a=r().sanitize(n.content);a&&(document.body.innerHTML='
'+a+"
")}window.isReaderable=a,window.applyReaderable=o})(),n})())); \ No newline at end of file diff --git a/app/js/readability/dist/readability.min.js.gz b/app/js/readability/dist/readability.min.js.gz deleted file mode 100644 index 477fe80e..00000000 Binary files a/app/js/readability/dist/readability.min.js.gz and /dev/null differ diff --git a/app/js/readability/dist/stats.json b/app/js/readability/dist/stats.json deleted file mode 100644 index 3feaf288..00000000 --- a/app/js/readability/dist/stats.json +++ /dev/null @@ -1,1362 +0,0 @@ -{ - "hash": "8d23fa454595d8aa7895", - "version": "5.92.1", - "time": 1715, - "builtAt": 1720514525488, - "publicPath": "auto", - "outputPath": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/dist", - "assetsByChunkName": { - "main": [ - "readability.min.js" - ] - }, - "assets": [ - { - "type": "asset", - "name": "readability.min.js", - "size": 55954, - "emitted": true, - "comparedForEmit": false, - "cached": false, - "info": { - "javascriptModule": false, - "minimized": true, - "related": { - "gzipped": "readability.min.js.gz" - }, - "size": 55954 - }, - "chunkNames": [ - "main" - ], - "chunkIdHints": [], - "auxiliaryChunkNames": [], - "auxiliaryChunkIdHints": [], - "filteredRelated": 0, - "related": [ - { - "type": "gzipped", - "name": "readability.min.js.gz", - "size": 18947, - "emitted": true, - "comparedForEmit": false, - "cached": false, - "info": { - "compressed": true, - "size": 18947 - }, - "chunkNames": [], - "chunkIdHints": [], - "auxiliaryChunkNames": [], - "auxiliaryChunkIdHints": [], - "related": {}, - "chunks": [], - "auxiliaryChunks": [], - "isOverSizeLimit": false - } - ], - "chunks": [ - 792 - ], - "auxiliaryChunks": [], - "isOverSizeLimit": false - } - ], - "chunks": [ - { - "rendered": true, - "initial": true, - "entry": true, - "recorded": false, - "size": 157907, - "sizes": { - "javascript": 156970, - "runtime": 937 - }, - "names": [ - "main" - ], - "idHints": [], - "runtime": [ - "main" - ], - "files": [ - "readability.min.js" - ], - "auxiliaryFiles": [], - "hash": "55ea9d503085bb5260ad", - "childrenByOrder": {}, - "id": 792, - "siblings": [], - "parents": [], - "children": [], - "modules": [ - { - "type": "module", - "moduleType": "javascript/auto", - "layer": null, - "size": 4162, - "sizes": { - "javascript": 4162 - }, - "built": true, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability-readerable.js", - "name": "./node_modules/@mozilla/readability/Readability-readerable.js", - "nameForCondition": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability-readerable.js", - "index": 3, - "preOrderIndex": 3, - "index2": 1, - "postOrderIndex": 1, - "cacheable": true, - "optional": false, - "orphan": false, - "dependent": true, - "issuer": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "issuerName": "./node_modules/@mozilla/readability/index.js", - "issuerPath": [ - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "name": "./src/index.js", - "id": 954 - }, - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "name": "./node_modules/@mozilla/readability/index.js", - "id": 396 - } - ], - "failed": false, - "errors": 0, - "warnings": 0, - "id": 804, - "issuerId": 396, - "chunks": [ - 792 - ], - "assets": [], - "reasons": [ - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability-readerable.js", - "module": "./node_modules/@mozilla/readability/Readability-readerable.js", - "moduleName": "./node_modules/@mozilla/readability/Readability-readerable.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability-readerable.js", - "resolvedModule": "./node_modules/@mozilla/readability/Readability-readerable.js", - "type": "cjs self exports reference", - "active": true, - "explanation": "", - "userRequest": null, - "loc": "107:2-16", - "moduleId": 804, - "resolvedModuleId": 804 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "module": "./node_modules/@mozilla/readability/index.js", - "moduleName": "./node_modules/@mozilla/readability/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "resolvedModule": "./node_modules/@mozilla/readability/index.js", - "type": "cjs require", - "active": true, - "explanation": "", - "userRequest": "./Readability-readerable", - "loc": "3:27-62", - "moduleId": 396, - "resolvedModuleId": 396 - } - ], - "usedExports": null, - "providedExports": null, - "optimizationBailout": [ - "CommonJS bailout: module.exports is used directly at 107:2-16", - "Statement (VariableDeclaration) with side effects in source code at 22:0-27:2", - "ModuleConcatenation bailout: Module is not an ECMAScript module" - ], - "depth": 2 - }, - { - "type": "module", - "moduleType": "javascript/auto", - "layer": null, - "size": 84033, - "sizes": { - "javascript": 84033 - }, - "built": true, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability.js", - "name": "./node_modules/@mozilla/readability/Readability.js", - "nameForCondition": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability.js", - "index": 2, - "preOrderIndex": 2, - "index2": 0, - "postOrderIndex": 0, - "cacheable": true, - "optional": false, - "orphan": false, - "dependent": true, - "issuer": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "issuerName": "./node_modules/@mozilla/readability/index.js", - "issuerPath": [ - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "name": "./src/index.js", - "id": 954 - }, - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "name": "./node_modules/@mozilla/readability/index.js", - "id": 396 - } - ], - "failed": false, - "errors": 0, - "warnings": 0, - "id": 238, - "issuerId": 396, - "chunks": [ - 792 - ], - "assets": [], - "reasons": [ - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability.js", - "module": "./node_modules/@mozilla/readability/Readability.js", - "moduleName": "./node_modules/@mozilla/readability/Readability.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability.js", - "resolvedModule": "./node_modules/@mozilla/readability/Readability.js", - "type": "cjs self exports reference", - "active": true, - "explanation": "", - "userRequest": null, - "loc": "2313:2-16", - "moduleId": 238, - "resolvedModuleId": 238 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "module": "./node_modules/@mozilla/readability/index.js", - "moduleName": "./node_modules/@mozilla/readability/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "resolvedModule": "./node_modules/@mozilla/readability/index.js", - "type": "cjs require", - "active": true, - "explanation": "", - "userRequest": "./Readability", - "loc": "2:18-42", - "moduleId": 396, - "resolvedModuleId": 396 - } - ], - "usedExports": null, - "providedExports": null, - "optimizationBailout": [ - "CommonJS bailout: module.exports is used directly at 2313:2-16", - "Statement (ExpressionStatement) with side effects in source code at 98:0-2309:2", - "ModuleConcatenation bailout: Module is not an ECMAScript module" - ], - "depth": 2 - }, - { - "type": "module", - "moduleType": "javascript/auto", - "layer": null, - "size": 226, - "sizes": { - "javascript": 226 - }, - "built": true, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "name": "./node_modules/@mozilla/readability/index.js", - "nameForCondition": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "index": 1, - "preOrderIndex": 1, - "index2": 2, - "postOrderIndex": 2, - "cacheable": true, - "optional": false, - "orphan": false, - "dependent": true, - "issuer": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "issuerName": "./src/index.js", - "issuerPath": [ - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "name": "./src/index.js", - "id": 954 - } - ], - "failed": false, - "errors": 0, - "warnings": 0, - "id": 396, - "issuerId": 954, - "chunks": [ - 792 - ], - "assets": [], - "reasons": [ - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "module": "./node_modules/@mozilla/readability/index.js", - "moduleName": "./node_modules/@mozilla/readability/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "resolvedModule": "./node_modules/@mozilla/readability/index.js", - "type": "cjs self exports reference", - "active": true, - "explanation": "", - "userRequest": null, - "loc": "5:0-14", - "moduleId": 396, - "resolvedModuleId": 396 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "module": "./src/index.js", - "moduleName": "./src/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "resolvedModule": "./src/index.js", - "type": "harmony side effect evaluation", - "active": true, - "explanation": "", - "userRequest": "@mozilla/readability", - "loc": "1:0-73", - "moduleId": 954, - "resolvedModuleId": 954 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "module": "./src/index.js", - "moduleName": "./src/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "resolvedModule": "./src/index.js", - "type": "harmony import specifier", - "active": true, - "explanation": "", - "userRequest": "@mozilla/readability", - "loc": "4:9-29", - "moduleId": 954, - "resolvedModuleId": 954 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "module": "./src/index.js", - "moduleName": "./src/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "resolvedModule": "./src/index.js", - "type": "harmony import specifier", - "active": true, - "explanation": "", - "userRequest": "@mozilla/readability", - "loc": "7:19-30", - "moduleId": 954, - "resolvedModuleId": 954 - } - ], - "usedExports": null, - "providedExports": null, - "optimizationBailout": [ - "CommonJS bailout: module.exports is used directly at 5:0-14", - "Statement (VariableDeclaration) with side effects in source code at 2:0-43", - "ModuleConcatenation bailout: Module is not an ECMAScript module" - ], - "depth": 1 - }, - { - "type": "module", - "moduleType": "javascript/auto", - "layer": null, - "size": 67137, - "sizes": { - "javascript": 67137 - }, - "built": true, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "name": "./node_modules/dompurify/dist/purify.js", - "nameForCondition": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "index": 4, - "preOrderIndex": 4, - "index2": 3, - "postOrderIndex": 3, - "cacheable": true, - "optional": false, - "orphan": false, - "dependent": true, - "issuer": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "issuerName": "./src/index.js", - "issuerPath": [ - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "name": "./src/index.js", - "id": 954 - } - ], - "failed": false, - "errors": 0, - "warnings": 0, - "id": 838, - "issuerId": 954, - "chunks": [ - 792 - ], - "assets": [], - "reasons": [ - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "module": "./src/index.js", - "moduleName": "./src/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "resolvedModule": "./src/index.js", - "type": "harmony side effect evaluation", - "active": true, - "explanation": "", - "userRequest": "dompurify", - "loc": "2:0-34", - "moduleId": 954, - "resolvedModuleId": 954 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "module": "./src/index.js", - "moduleName": "./src/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "resolvedModule": "./src/index.js", - "type": "harmony import specifier", - "active": true, - "explanation": "", - "userRequest": "dompurify", - "loc": "14:15-33", - "moduleId": 954, - "resolvedModuleId": 954 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "module": "./node_modules/dompurify/dist/purify.js", - "moduleName": "./node_modules/dompurify/dist/purify.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "resolvedModule": "./node_modules/dompurify/dist/purify.js", - "type": "cjs self exports reference", - "active": true, - "explanation": "", - "userRequest": null, - "loc": "4:65-79", - "moduleId": 838, - "resolvedModuleId": 838 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "module": "./node_modules/dompurify/dist/purify.js", - "moduleName": "./node_modules/dompurify/dist/purify.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "resolvedModule": "./node_modules/dompurify/dist/purify.js", - "type": "cjs self exports reference", - "active": true, - "explanation": "", - "userRequest": null, - "loc": "7:3-7", - "moduleId": 838, - "resolvedModuleId": 838 - } - ], - "usedExports": null, - "providedExports": null, - "optimizationBailout": [ - "CommonJS bailout: this is used directly at 7:3-7", - "CommonJS bailout: module.exports is used directly at 4:65-79", - "Statement (ExpressionStatement) with side effects in source code at 3:0-1561:4", - "ModuleConcatenation bailout: Module is not an ECMAScript module" - ], - "depth": 1 - }, - { - "type": "module", - "moduleType": "javascript/auto", - "layer": null, - "size": 1412, - "sizes": { - "javascript": 1412 - }, - "built": true, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "name": "./src/index.js", - "nameForCondition": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "index": 0, - "preOrderIndex": 0, - "index2": 4, - "postOrderIndex": 4, - "cacheable": true, - "optional": false, - "orphan": false, - "dependent": false, - "issuer": null, - "issuerName": null, - "issuerPath": null, - "failed": false, - "errors": 0, - "warnings": 0, - "id": 954, - "issuerId": null, - "chunks": [ - 792 - ], - "assets": [], - "reasons": [ - { - "moduleIdentifier": null, - "module": null, - "moduleName": null, - "resolvedModuleIdentifier": null, - "resolvedModule": null, - "type": "entry", - "active": true, - "explanation": "", - "userRequest": "./src/index.js", - "loc": "main", - "moduleId": null, - "resolvedModuleId": null - }, - { - "moduleIdentifier": null, - "module": null, - "moduleName": null, - "resolvedModuleIdentifier": null, - "resolvedModule": null, - "type": null, - "active": true, - "explanation": "used as library export", - "userRequest": null, - "moduleId": null, - "resolvedModuleId": null - } - ], - "usedExports": true, - "providedExports": [ - "applyReaderable", - "isReaderable" - ], - "optimizationBailout": [ - "Statement (ExpressionStatement) with side effects in source code at 33:0-35", - "ModuleConcatenation bailout: Cannot concat with ./node_modules/@mozilla/readability/index.js: Module is not an ECMAScript module", - "ModuleConcatenation bailout: Cannot concat with ./node_modules/dompurify/dist/purify.js: Module is not an ECMAScript module" - ], - "depth": 0 - }, - { - "type": "module", - "moduleType": "runtime", - "layer": null, - "size": 267, - "sizes": { - "runtime": 267 - }, - "built": false, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "webpack/runtime/compat get default export", - "name": "webpack/runtime/compat get default export", - "nameForCondition": null, - "index": null, - "preOrderIndex": null, - "index2": null, - "postOrderIndex": null, - "optional": false, - "orphan": false, - "dependent": false, - "failed": false, - "errors": 0, - "warnings": 0, - "id": "", - "chunks": [ - 792 - ], - "assets": [], - "reasons": [], - "usedExports": null, - "providedExports": [], - "optimizationBailout": [], - "depth": null - }, - { - "type": "module", - "moduleType": "runtime", - "layer": null, - "size": 308, - "sizes": { - "runtime": 308 - }, - "built": false, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "webpack/runtime/define property getters", - "name": "webpack/runtime/define property getters", - "nameForCondition": null, - "index": null, - "preOrderIndex": null, - "index2": null, - "postOrderIndex": null, - "optional": false, - "orphan": false, - "dependent": false, - "failed": false, - "errors": 0, - "warnings": 0, - "id": "", - "chunks": [ - 792 - ], - "assets": [], - "reasons": [], - "usedExports": null, - "providedExports": [], - "optimizationBailout": [], - "depth": null - }, - { - "type": "module", - "moduleType": "runtime", - "layer": null, - "size": 88, - "sizes": { - "runtime": 88 - }, - "built": false, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "webpack/runtime/hasOwnProperty shorthand", - "name": "webpack/runtime/hasOwnProperty shorthand", - "nameForCondition": null, - "index": null, - "preOrderIndex": null, - "index2": null, - "postOrderIndex": null, - "optional": false, - "orphan": false, - "dependent": false, - "failed": false, - "errors": 0, - "warnings": 0, - "id": "", - "chunks": [ - 792 - ], - "assets": [], - "reasons": [], - "usedExports": null, - "providedExports": [], - "optimizationBailout": [], - "depth": null - }, - { - "type": "module", - "moduleType": "runtime", - "layer": null, - "size": 274, - "sizes": { - "runtime": 274 - }, - "built": false, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "webpack/runtime/make namespace object", - "name": "webpack/runtime/make namespace object", - "nameForCondition": null, - "index": null, - "preOrderIndex": null, - "index2": null, - "postOrderIndex": null, - "optional": false, - "orphan": false, - "dependent": false, - "failed": false, - "errors": 0, - "warnings": 0, - "id": "", - "chunks": [ - 792 - ], - "assets": [], - "reasons": [], - "usedExports": null, - "providedExports": [], - "optimizationBailout": [], - "depth": null - } - ], - "origins": [ - { - "module": "", - "moduleIdentifier": "", - "moduleName": "", - "loc": "main", - "request": "./src/index.js" - } - ] - } - ], - "modules": [ - { - "type": "module", - "moduleType": "javascript/auto", - "layer": null, - "size": 1412, - "sizes": { - "javascript": 1412 - }, - "built": true, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "name": "./src/index.js", - "nameForCondition": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "index": 0, - "preOrderIndex": 0, - "index2": 4, - "postOrderIndex": 4, - "cacheable": true, - "optional": false, - "orphan": false, - "issuer": null, - "issuerName": null, - "issuerPath": null, - "failed": false, - "errors": 0, - "warnings": 0, - "id": 954, - "issuerId": null, - "chunks": [ - 792 - ], - "assets": [], - "reasons": [ - { - "moduleIdentifier": null, - "module": null, - "moduleName": null, - "resolvedModuleIdentifier": null, - "resolvedModule": null, - "type": "entry", - "active": true, - "explanation": "", - "userRequest": "./src/index.js", - "loc": "main", - "moduleId": null, - "resolvedModuleId": null - }, - { - "moduleIdentifier": null, - "module": null, - "moduleName": null, - "resolvedModuleIdentifier": null, - "resolvedModule": null, - "type": null, - "active": true, - "explanation": "used as library export", - "userRequest": null, - "moduleId": null, - "resolvedModuleId": null - } - ], - "usedExports": true, - "providedExports": [ - "applyReaderable", - "isReaderable" - ], - "optimizationBailout": [ - "Statement (ExpressionStatement) with side effects in source code at 33:0-35", - "ModuleConcatenation bailout: Cannot concat with ./node_modules/@mozilla/readability/index.js: Module is not an ECMAScript module", - "ModuleConcatenation bailout: Cannot concat with ./node_modules/dompurify/dist/purify.js: Module is not an ECMAScript module" - ], - "depth": 0 - }, - { - "type": "module", - "moduleType": "javascript/auto", - "layer": null, - "size": 226, - "sizes": { - "javascript": 226 - }, - "built": true, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "name": "./node_modules/@mozilla/readability/index.js", - "nameForCondition": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "index": 1, - "preOrderIndex": 1, - "index2": 2, - "postOrderIndex": 2, - "cacheable": true, - "optional": false, - "orphan": false, - "issuer": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "issuerName": "./src/index.js", - "issuerPath": [ - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "name": "./src/index.js", - "id": 954 - } - ], - "failed": false, - "errors": 0, - "warnings": 0, - "id": 396, - "issuerId": 954, - "chunks": [ - 792 - ], - "assets": [], - "reasons": [ - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "module": "./node_modules/@mozilla/readability/index.js", - "moduleName": "./node_modules/@mozilla/readability/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "resolvedModule": "./node_modules/@mozilla/readability/index.js", - "type": "cjs self exports reference", - "active": true, - "explanation": "", - "userRequest": null, - "loc": "5:0-14", - "moduleId": 396, - "resolvedModuleId": 396 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "module": "./src/index.js", - "moduleName": "./src/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "resolvedModule": "./src/index.js", - "type": "harmony side effect evaluation", - "active": true, - "explanation": "", - "userRequest": "@mozilla/readability", - "loc": "1:0-73", - "moduleId": 954, - "resolvedModuleId": 954 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "module": "./src/index.js", - "moduleName": "./src/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "resolvedModule": "./src/index.js", - "type": "harmony import specifier", - "active": true, - "explanation": "", - "userRequest": "@mozilla/readability", - "loc": "4:9-29", - "moduleId": 954, - "resolvedModuleId": 954 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "module": "./src/index.js", - "moduleName": "./src/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "resolvedModule": "./src/index.js", - "type": "harmony import specifier", - "active": true, - "explanation": "", - "userRequest": "@mozilla/readability", - "loc": "7:19-30", - "moduleId": 954, - "resolvedModuleId": 954 - } - ], - "usedExports": null, - "providedExports": null, - "optimizationBailout": [ - "CommonJS bailout: module.exports is used directly at 5:0-14", - "Statement (VariableDeclaration) with side effects in source code at 2:0-43", - "ModuleConcatenation bailout: Module is not an ECMAScript module" - ], - "depth": 1 - }, - { - "type": "module", - "moduleType": "javascript/auto", - "layer": null, - "size": 67137, - "sizes": { - "javascript": 67137 - }, - "built": true, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "name": "./node_modules/dompurify/dist/purify.js", - "nameForCondition": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "index": 4, - "preOrderIndex": 4, - "index2": 3, - "postOrderIndex": 3, - "cacheable": true, - "optional": false, - "orphan": false, - "issuer": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "issuerName": "./src/index.js", - "issuerPath": [ - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "name": "./src/index.js", - "id": 954 - } - ], - "failed": false, - "errors": 0, - "warnings": 0, - "id": 838, - "issuerId": 954, - "chunks": [ - 792 - ], - "assets": [], - "reasons": [ - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "module": "./src/index.js", - "moduleName": "./src/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "resolvedModule": "./src/index.js", - "type": "harmony side effect evaluation", - "active": true, - "explanation": "", - "userRequest": "dompurify", - "loc": "2:0-34", - "moduleId": 954, - "resolvedModuleId": 954 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "module": "./src/index.js", - "moduleName": "./src/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "resolvedModule": "./src/index.js", - "type": "harmony import specifier", - "active": true, - "explanation": "", - "userRequest": "dompurify", - "loc": "14:15-33", - "moduleId": 954, - "resolvedModuleId": 954 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "module": "./node_modules/dompurify/dist/purify.js", - "moduleName": "./node_modules/dompurify/dist/purify.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "resolvedModule": "./node_modules/dompurify/dist/purify.js", - "type": "cjs self exports reference", - "active": true, - "explanation": "", - "userRequest": null, - "loc": "4:65-79", - "moduleId": 838, - "resolvedModuleId": 838 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "module": "./node_modules/dompurify/dist/purify.js", - "moduleName": "./node_modules/dompurify/dist/purify.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/dompurify/dist/purify.js", - "resolvedModule": "./node_modules/dompurify/dist/purify.js", - "type": "cjs self exports reference", - "active": true, - "explanation": "", - "userRequest": null, - "loc": "7:3-7", - "moduleId": 838, - "resolvedModuleId": 838 - } - ], - "usedExports": null, - "providedExports": null, - "optimizationBailout": [ - "CommonJS bailout: this is used directly at 7:3-7", - "CommonJS bailout: module.exports is used directly at 4:65-79", - "Statement (ExpressionStatement) with side effects in source code at 3:0-1561:4", - "ModuleConcatenation bailout: Module is not an ECMAScript module" - ], - "depth": 1 - }, - { - "type": "module", - "moduleType": "javascript/auto", - "layer": null, - "size": 84033, - "sizes": { - "javascript": 84033 - }, - "built": true, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability.js", - "name": "./node_modules/@mozilla/readability/Readability.js", - "nameForCondition": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability.js", - "index": 2, - "preOrderIndex": 2, - "index2": 0, - "postOrderIndex": 0, - "cacheable": true, - "optional": false, - "orphan": false, - "issuer": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "issuerName": "./node_modules/@mozilla/readability/index.js", - "issuerPath": [ - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "name": "./src/index.js", - "id": 954 - }, - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "name": "./node_modules/@mozilla/readability/index.js", - "id": 396 - } - ], - "failed": false, - "errors": 0, - "warnings": 0, - "id": 238, - "issuerId": 396, - "chunks": [ - 792 - ], - "assets": [], - "reasons": [ - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability.js", - "module": "./node_modules/@mozilla/readability/Readability.js", - "moduleName": "./node_modules/@mozilla/readability/Readability.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability.js", - "resolvedModule": "./node_modules/@mozilla/readability/Readability.js", - "type": "cjs self exports reference", - "active": true, - "explanation": "", - "userRequest": null, - "loc": "2313:2-16", - "moduleId": 238, - "resolvedModuleId": 238 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "module": "./node_modules/@mozilla/readability/index.js", - "moduleName": "./node_modules/@mozilla/readability/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "resolvedModule": "./node_modules/@mozilla/readability/index.js", - "type": "cjs require", - "active": true, - "explanation": "", - "userRequest": "./Readability", - "loc": "2:18-42", - "moduleId": 396, - "resolvedModuleId": 396 - } - ], - "usedExports": null, - "providedExports": null, - "optimizationBailout": [ - "CommonJS bailout: module.exports is used directly at 2313:2-16", - "Statement (ExpressionStatement) with side effects in source code at 98:0-2309:2", - "ModuleConcatenation bailout: Module is not an ECMAScript module" - ], - "depth": 2 - }, - { - "type": "module", - "moduleType": "javascript/auto", - "layer": null, - "size": 4162, - "sizes": { - "javascript": 4162 - }, - "built": true, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability-readerable.js", - "name": "./node_modules/@mozilla/readability/Readability-readerable.js", - "nameForCondition": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability-readerable.js", - "index": 3, - "preOrderIndex": 3, - "index2": 1, - "postOrderIndex": 1, - "cacheable": true, - "optional": false, - "orphan": false, - "issuer": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "issuerName": "./node_modules/@mozilla/readability/index.js", - "issuerPath": [ - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/babel-loader/lib/index.js??ruleSet[1].rules[0].use!/home/fafre/Repos/kagi_bang_bang/app/js/readability/src/index.js", - "name": "./src/index.js", - "id": 954 - }, - { - "identifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "name": "./node_modules/@mozilla/readability/index.js", - "id": 396 - } - ], - "failed": false, - "errors": 0, - "warnings": 0, - "id": 804, - "issuerId": 396, - "chunks": [ - 792 - ], - "assets": [], - "reasons": [ - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability-readerable.js", - "module": "./node_modules/@mozilla/readability/Readability-readerable.js", - "moduleName": "./node_modules/@mozilla/readability/Readability-readerable.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/Readability-readerable.js", - "resolvedModule": "./node_modules/@mozilla/readability/Readability-readerable.js", - "type": "cjs self exports reference", - "active": true, - "explanation": "", - "userRequest": null, - "loc": "107:2-16", - "moduleId": 804, - "resolvedModuleId": 804 - }, - { - "moduleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "module": "./node_modules/@mozilla/readability/index.js", - "moduleName": "./node_modules/@mozilla/readability/index.js", - "resolvedModuleIdentifier": "/home/fafre/Repos/kagi_bang_bang/app/js/readability/node_modules/@mozilla/readability/index.js", - "resolvedModule": "./node_modules/@mozilla/readability/index.js", - "type": "cjs require", - "active": true, - "explanation": "", - "userRequest": "./Readability-readerable", - "loc": "3:27-62", - "moduleId": 396, - "resolvedModuleId": 396 - } - ], - "usedExports": null, - "providedExports": null, - "optimizationBailout": [ - "CommonJS bailout: module.exports is used directly at 107:2-16", - "Statement (VariableDeclaration) with side effects in source code at 22:0-27:2", - "ModuleConcatenation bailout: Module is not an ECMAScript module" - ], - "depth": 2 - }, - { - "type": "module", - "moduleType": "runtime", - "layer": null, - "size": 267, - "sizes": { - "runtime": 267 - }, - "built": false, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "webpack/runtime/compat get default export", - "name": "webpack/runtime/compat get default export", - "nameForCondition": null, - "index": null, - "preOrderIndex": null, - "index2": null, - "postOrderIndex": null, - "optional": false, - "orphan": false, - "failed": false, - "errors": 0, - "warnings": 0, - "id": "", - "chunks": [ - 792 - ], - "assets": [], - "reasons": [], - "usedExports": null, - "providedExports": [], - "optimizationBailout": [], - "depth": null - }, - { - "type": "module", - "moduleType": "runtime", - "layer": null, - "size": 308, - "sizes": { - "runtime": 308 - }, - "built": false, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "webpack/runtime/define property getters", - "name": "webpack/runtime/define property getters", - "nameForCondition": null, - "index": null, - "preOrderIndex": null, - "index2": null, - "postOrderIndex": null, - "optional": false, - "orphan": false, - "failed": false, - "errors": 0, - "warnings": 0, - "id": "", - "chunks": [ - 792 - ], - "assets": [], - "reasons": [], - "usedExports": null, - "providedExports": [], - "optimizationBailout": [], - "depth": null - }, - { - "type": "module", - "moduleType": "runtime", - "layer": null, - "size": 88, - "sizes": { - "runtime": 88 - }, - "built": false, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "webpack/runtime/hasOwnProperty shorthand", - "name": "webpack/runtime/hasOwnProperty shorthand", - "nameForCondition": null, - "index": null, - "preOrderIndex": null, - "index2": null, - "postOrderIndex": null, - "optional": false, - "orphan": false, - "failed": false, - "errors": 0, - "warnings": 0, - "id": "", - "chunks": [ - 792 - ], - "assets": [], - "reasons": [], - "usedExports": null, - "providedExports": [], - "optimizationBailout": [], - "depth": null - }, - { - "type": "module", - "moduleType": "runtime", - "layer": null, - "size": 274, - "sizes": { - "runtime": 274 - }, - "built": false, - "codeGenerated": true, - "buildTimeExecuted": false, - "cached": false, - "identifier": "webpack/runtime/make namespace object", - "name": "webpack/runtime/make namespace object", - "nameForCondition": null, - "index": null, - "preOrderIndex": null, - "index2": null, - "postOrderIndex": null, - "optional": false, - "orphan": false, - "failed": false, - "errors": 0, - "warnings": 0, - "id": "", - "chunks": [ - 792 - ], - "assets": [], - "reasons": [], - "usedExports": null, - "providedExports": [], - "optimizationBailout": [], - "depth": null - } - ], - "entrypoints": { - "main": { - "name": "main", - "chunks": [ - 792 - ], - "assets": [ - { - "name": "readability.min.js", - "size": 55954 - } - ], - "filteredAssets": 0, - "assetsSize": 55954, - "auxiliaryAssets": [], - "filteredAuxiliaryAssets": 0, - "auxiliaryAssetsSize": 0, - "children": {}, - "childAssets": {}, - "isOverSizeLimit": false - } - }, - "namedChunkGroups": { - "main": { - "name": "main", - "chunks": [ - 792 - ], - "assets": [ - { - "name": "readability.min.js", - "size": 55954 - } - ], - "filteredAssets": 0, - "assetsSize": 55954, - "auxiliaryAssets": [], - "filteredAuxiliaryAssets": 0, - "auxiliaryAssetsSize": 0, - "children": {}, - "childAssets": {}, - "isOverSizeLimit": false - } - }, - "errors": [], - "errorsCount": 0, - "warnings": [], - "warningsCount": 0, - "children": [] -} \ No newline at end of file diff --git a/app/js/readability/src/index.js b/app/js/readability/src/index.js deleted file mode 100644 index 44d820dc..00000000 --- a/app/js/readability/src/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import { Readability, isProbablyReaderable } from '@mozilla/readability'; -import DOMPurify from 'dompurify'; - -function isReaderable() { - return isProbablyReaderable(document); -} - -function parseReaderable(doc) { - const reader = new Readability(doc); - const article = reader.parse(); - return article; -} - -function applyReaderable() { - const clonedDoc = document.cloneNode(true); - const article = parseReaderable(clonedDoc); - const markup = DOMPurify.sanitize(article.content); - - // console.log('article title: ', parsed.title); - // console.log('HTML string of processed article content: ', parsed.content); - // console.log('text content of the article, with all the HTML tags removed: ', parsed.textContent); - // console.log('length of an article, in characters: ', parsed.length); - // console.log('article description, or short excerpt from the content: ', parsed.excerpt); - // console.log('author metadata: ', parsed.byline); - // console.log('content direction: ', parsed.dir); - // console.log('name of the site: ', parsed.siteName); - // console.log('content language: ', parsed.lang); - // console.log('published time: ', parsed.publishedTime); - - if (markup) { - document.body.innerHTML = '
' + markup + '
'; - } -} - -// Attach functions to the window object -window.isReaderable = isReaderable; -window.applyReaderable = applyReaderable; - -export { isReaderable, applyReaderable }; diff --git a/app/lib/core/error_observer.dart b/app/lib/core/error_observer.dart index 39cc61cc..972935a4 100644 --- a/app/lib/core/error_observer.dart +++ b/app/lib/core/error_observer.dart @@ -1,5 +1,5 @@ -import 'package:lensai/core/logger.dart'; import 'package:riverpod/riverpod.dart'; +import 'package:weblibre/core/logger.dart'; class ErrorObserver extends ProviderObserver { const ErrorObserver(); diff --git a/app/lib/core/extension/result_async.dart b/app/lib/core/extension/result_async.dart deleted file mode 100644 index e9448764..00000000 --- a/app/lib/core/extension/result_async.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:exceptions/exceptions.dart'; -import 'package:riverpod/riverpod.dart'; - -extension AsyncExtension on Result { - AsyncValue toAsyncValue() { - return switch (this) { - Success(value: final value) => AsyncValue.data(value), - // TODO: Handle this case. - Failure( - error: final error, - ) => - AsyncError(error.message, error.stackTrace ?? StackTrace.empty), - }; - } -} diff --git a/app/lib/core/http_error_handler.dart b/app/lib/core/http_error_handler.dart index 96c545e0..cbd5ff08 100644 --- a/app/lib/core/http_error_handler.dart +++ b/app/lib/core/http_error_handler.dart @@ -2,30 +2,24 @@ import 'package:exceptions/exceptions.dart'; import 'package:http/http.dart'; import 'package:universal_io/io.dart'; -ErrorMessage handleHttpError( - Exception exception, - StackTrace stackTrace, -) { +ErrorMessage handleHttpError(Exception exception, StackTrace stackTrace) { return switch (exception) { SocketException() => const ErrorMessage( - source: 'http', - message: 'Could not contact remote service', - ), + source: 'http', + message: 'Could not contact remote service', + ), HttpException() => const ErrorMessage( - source: 'http', - message: 'Web request returned error', - ), + source: 'http', + message: 'Web request returned error', + ), FormatException() => const ErrorMessage( - source: 'http', - message: 'Bad response format', - ), + source: 'http', + message: 'Bad response format', + ), ClientException() => const ErrorMessage( - source: 'http', - message: 'Could not contact remote service', - ), - _ => ErrorMessage.fromException( - exception, - stackTrace, - ) + source: 'http', + message: 'Could not contact remote service', + ), + _ => ErrorMessage.fromException(exception, stackTrace), }; } diff --git a/app/lib/core/logger.dart b/app/lib/core/logger.dart index 55058ea0..b2201889 100644 --- a/app/lib/core/logger.dart +++ b/app/lib/core/logger.dart @@ -1,3 +1,15 @@ import 'package:logger/logger.dart'; -final logger = Logger(); +class _DebugLogFilter extends LogFilter { + @override + bool shouldLog(LogEvent event) { + //Log all events + return true; + } +} + +final loggerMemory = MemoryOutput( + bufferSize: 255, + secondOutput: ConsoleOutput(), +); +final logger = Logger(filter: _DebugLogFilter(), output: loggerMemory); diff --git a/app/lib/core/providers.dart b/app/lib/core/providers.dart deleted file mode 100644 index 5df5c93b..00000000 --- a/app/lib/core/providers.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:go_router/go_router.dart'; -import 'package:lensai/core/routing/routes.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'providers.g.dart'; - -@Riverpod(keepAlive: true) -GoRouter router(RouterRef ref) => GoRouter( - debugLogDiagnostics: true, - routes: $appRoutes, - ); diff --git a/app/lib/core/providers.g.dart b/app/lib/core/providers.g.dart deleted file mode 100644 index 5d5f133f..00000000 --- a/app/lib/core/providers.g.dart +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'providers.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$routerHash() => r'4ad45f430b1f43e427e21122d16bd761ca04ae0e'; - -/// See also [router]. -@ProviderFor(router) -final routerProvider = Provider.internal( - router, - name: r'routerProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$routerHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef RouterRef = ProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/core/providers/defaults.dart b/app/lib/core/providers/defaults.dart new file mode 100644 index 00000000..06a035cc --- /dev/null +++ b/app/lib/core/providers/defaults.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'defaults.g.dart'; + +@Riverpod(keepAlive: true) +Color lightSeedColorFallback(Ref ref) => const Color(0xFF167C80); + +@Riverpod(keepAlive: true) +Color darkSeedColorFallback(Ref ref) => const Color(0xFF167C80); + +@Riverpod(keepAlive: true) +Uri docsUri(Ref ref) => Uri.parse('https://docs.weblibre.eu/'); diff --git a/app/lib/core/providers/defaults.g.dart b/app/lib/core/providers/defaults.g.dart new file mode 100644 index 00000000..b640b896 --- /dev/null +++ b/app/lib/core/providers/defaults.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'defaults.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$lightSeedColorFallbackHash() => + r'af151d3293d372f5df451aa8c3a75136377b6ce1'; + +/// See also [lightSeedColorFallback]. +@ProviderFor(lightSeedColorFallback) +final lightSeedColorFallbackProvider = Provider.internal( + lightSeedColorFallback, + name: r'lightSeedColorFallbackProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$lightSeedColorFallbackHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef LightSeedColorFallbackRef = ProviderRef; +String _$darkSeedColorFallbackHash() => + r'9418cf71e3d80c8a2142e0d3a8820989b29bff86'; + +/// See also [darkSeedColorFallback]. +@ProviderFor(darkSeedColorFallback) +final darkSeedColorFallbackProvider = Provider.internal( + darkSeedColorFallback, + name: r'darkSeedColorFallbackProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$darkSeedColorFallbackHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef DarkSeedColorFallbackRef = ProviderRef; +String _$docsUriHash() => r'6456efcf97ddc7ee87a67e2d3380f7241e3d76ea'; + +/// See also [docsUri]. +@ProviderFor(docsUri) +final docsUriProvider = Provider.internal( + docsUri, + name: r'docsUriProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$docsUriHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef DocsUriRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/core/providers/format.dart b/app/lib/core/providers/format.dart new file mode 100644 index 00000000..abc15c2e --- /dev/null +++ b/app/lib/core/providers/format.dart @@ -0,0 +1,19 @@ +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'format.g.dart'; + +@Riverpod(keepAlive: true) +class Format extends _$Format { + String fullDateTimeWithTimezone(DateTime date) { + final pattern = DateFormat('yMMMMd').addPattern('Hm'); + + return pattern.format(date); + } + + @override + Future build() async { + await initializeDateFormatting(); + } +} diff --git a/app/lib/features/content_block/domain/repositories/host.g.dart b/app/lib/core/providers/format.g.dart similarity index 52% rename from app/lib/features/content_block/domain/repositories/host.g.dart rename to app/lib/core/providers/format.g.dart index f0acb812..a2fcf636 100644 --- a/app/lib/features/content_block/domain/repositories/host.g.dart +++ b/app/lib/core/providers/format.g.dart @@ -1,25 +1,25 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'host.dart'; +part of 'format.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** -String _$hostRepositoryHash() => r'b28362146b75391f55fb353fc6fde72dc1d2fcb9'; +String _$formatHash() => r'fe7fcdd19b512784a36f3838c57701030475e429'; -/// See also [HostRepository]. -@ProviderFor(HostRepository) -final hostRepositoryProvider = NotifierProvider.internal( - HostRepository.new, - name: r'hostRepositoryProvider', +/// See also [Format]. +@ProviderFor(Format) +final formatProvider = AsyncNotifierProvider.internal( + Format.new, + name: r'formatProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$hostRepositoryHash, + : _$formatHash, dependencies: null, allTransitiveDependencies: null, ); -typedef _$HostRepository = Notifier; +typedef _$Format = AsyncNotifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/core/providers/global_drop.dart b/app/lib/core/providers/global_drop.dart new file mode 100644 index 00000000..7fbe66f6 --- /dev/null +++ b/app/lib/core/providers/global_drop.dart @@ -0,0 +1,21 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/data/models/drag_data.dart'; + +part 'global_drop.g.dart'; + +@Riverpod() +class WillAcceptDrop extends _$WillAcceptDrop { + // ignore: use_setters_to_change_properties api decision + void setData(DropTargetData data) { + state = data; + } + + void clear() { + state = null; + } + + @override + DropTargetData? build() { + return null; + } +} diff --git a/app/lib/core/providers/global_drop.g.dart b/app/lib/core/providers/global_drop.g.dart new file mode 100644 index 00000000..5bb3e188 --- /dev/null +++ b/app/lib/core/providers/global_drop.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'global_drop.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$willAcceptDropHash() => r'97b47784ef9101757602b4408d1f545ef2308830'; + +/// See also [WillAcceptDrop]. +@ProviderFor(WillAcceptDrop) +final willAcceptDropProvider = + AutoDisposeNotifierProvider.internal( + WillAcceptDrop.new, + name: r'willAcceptDropProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$willAcceptDropHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$WillAcceptDrop = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/core/providers/router.dart b/app/lib/core/providers/router.dart new file mode 100644 index 00000000..963ac6da --- /dev/null +++ b/app/lib/core/providers/router.dart @@ -0,0 +1,35 @@ +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/user/domain/repositories/onboarding.dart'; + +part 'router.g.dart'; + +@Riverpod(keepAlive: true) +Future router(Ref ref) async { + String? initialLocation; + + final onboardingMandatory = await ref + .read(onboardingRepositoryProvider.notifier) + .isOutdated(); + + if (onboardingMandatory) { + final current = await ref + .read(onboardingRepositoryProvider.notifier) + .getCurrentRevision(); + + final route = OnboardingRoute( + currentRevision: current ?? -1, + targetRevision: OnboardingRepository.targetRevision, + ); + + initialLocation = route.location; + } + + return GoRouter( + debugLogDiagnostics: true, + routes: $appRoutes, + initialLocation: initialLocation, + ); +} diff --git a/app/lib/core/providers/router.g.dart b/app/lib/core/providers/router.g.dart new file mode 100644 index 00000000..fdd8fdc9 --- /dev/null +++ b/app/lib/core/providers/router.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'router.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$routerHash() => r'ce7cc5b5f236781a06867a852a08ed03d3582022'; + +/// See also [router]. +@ProviderFor(router) +final routerProvider = FutureProvider.internal( + router, + name: r'routerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$routerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef RouterRef = FutureProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/core/routing/routes.bangs.dart b/app/lib/core/routing/routes.bangs.dart new file mode 100644 index 00000000..ca299a72 --- /dev/null +++ b/app/lib/core/routing/routes.bangs.dart @@ -0,0 +1,72 @@ +part of 'routes.dart'; + +@TypedGoRoute( + name: 'BangRoute', + path: '/bangs', + routes: [ + TypedGoRoute( + name: 'BangSearchRoute', + path: 'search/:searchText', + ), + TypedGoRoute( + name: 'BangCategoryRoute', + path: 'category/:category', + routes: [ + TypedGoRoute( + name: 'BangSubCategoryRoute', + path: ':subCategory', + ), + ], + ), + ], +) +class BangCategoriesRoute extends GoRouteData with _$BangCategoriesRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const BangCategoriesScreen(); + } +} + +class BangCategoryRoute extends GoRouteData with _$BangCategoryRoute { + final String category; + + const BangCategoryRoute({required this.category}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return BangListScreen(category: category); + } +} + +class BangSubCategoryRoute extends GoRouteData with _$BangSubCategoryRoute { + final String category; + final String subCategory; + + const BangSubCategoryRoute({ + required this.category, + required this.subCategory, + }); + + @override + Widget build(BuildContext context, GoRouterState state) { + return BangListScreen(category: category, subCategory: subCategory); + } +} + +class BangSearchRoute extends GoRouteData with _$BangSearchRoute { + static const String emptySearchText = ' '; + + //This should be nullable but isnt allowed by go_router + final String searchText; + + const BangSearchRoute({this.searchText = BangSearchRoute.emptySearchText}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return BangSearchScreen( + initialSearchText: (searchText.isEmpty || searchText == emptySearchText) + ? null + : searchText, + ); + } +} diff --git a/app/lib/core/routing/routes.browser.dart b/app/lib/core/routing/routes.browser.dart new file mode 100644 index 00000000..bf91db53 --- /dev/null +++ b/app/lib/core/routing/routes.browser.dart @@ -0,0 +1,145 @@ +part of 'routes.dart'; + +@TypedGoRoute( + name: BrowserRoute.name, + path: '/', + routes: [ + TypedGoRoute(name: 'WebPageRoute', path: 'page/:url'), + TypedGoRoute( + name: 'SearchRoute', + path: 'search/:tabType/:searchText', + ), + TypedGoRoute(name: 'TorProxyRoute', path: 'tor_proxy'), + TypedGoRoute( + name: 'ContextMenuRoute', + path: 'context_menu', + ), + TypedGoRoute( + name: 'ContainerListRoute', + path: 'containers', + routes: [ + TypedGoRoute( + name: 'ContainerCreateRoute', + path: 'create', + ), + TypedGoRoute( + name: 'ContainerEditRoute', + path: 'edit', + ), + ], + ), + TypedGoRoute( + name: 'TabTreeRoute', + path: 'tab_tree/:rootTabId', + ), + ], +) +class BrowserRoute extends GoRouteData with _$BrowserRoute { + static const name = 'BrowserRoute'; + + @override + Widget build(BuildContext context, GoRouterState state) { + return const BrowserScreen(); + } +} + +class WebPageRoute extends GoRouteData with _$WebPageRoute { + final String url; + final WebPageInfo? $extra; + + const WebPageRoute({required this.url, required this.$extra}); + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return DialogPage( + builder: (_) => WebPageDialog(url: Uri.parse(url), precachedInfo: $extra), + ); + } +} + +enum TabType { regular, private, child } + +class SearchRoute extends GoRouteData with _$SearchRoute { + static const String emptySearchText = ' '; + + final TabType tabType; + + //This should be nullable but isnt allowed by go_router + final String searchText; + + const SearchRoute({ + required this.tabType, + this.searchText = SearchRoute.emptySearchText, + }); + + @override + Widget build(BuildContext context, GoRouterState state) { + return SearchScreen( + tabType: tabType, + initialSearchText: (searchText.isEmpty || searchText == emptySearchText) + ? null + : searchText, + ); + } +} + +class TorProxyRoute extends GoRouteData with _$TorProxyRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const TorProxyScreen(); + } +} + +class ContainerListRoute extends GoRouteData with _$ContainerListRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const ContainerListScreen(); + } +} + +class ContainerEditRoute extends GoRouteData with _$ContainerEditRoute { + final ContainerData $extra; + + ContainerEditRoute(this.$extra); + + @override + Widget build(BuildContext context, GoRouterState state) { + return ContainerEditScreen.edit(initialContainer: $extra); + } +} + +class ContainerCreateRoute extends GoRouteData with _$ContainerCreateRoute { + final ContainerData $extra; + + ContainerCreateRoute(this.$extra); + + @override + Widget build(BuildContext context, GoRouterState state) { + return ContainerEditScreen.create(initialContainer: $extra); + } +} + +class ContextMenuRoute extends GoRouteData with _$ContextMenuRoute { + final String $extra; + + const ContextMenuRoute(this.$extra); + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return DialogPage( + builder: (_) => + ContextMenuDialog(hitResult: HitResultJson.fromJson($extra)), + ); + } +} + +class TabTreeRoute extends GoRouteData with _$TabTreeRoute { + final String rootTabId; + + const TabTreeRoute(this.rootTabId); + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return DialogPage(builder: (_) => TabTreeDialog(rootTabId)); + } +} diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index c006a4f8..1cb5f9c4 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -1,160 +1,69 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:lensai/core/routing/dialog_page.dart'; -import 'package:lensai/features/about/presentation/screens/about.dart'; -import 'package:lensai/features/bangs/presentation/screens/categories.dart'; -import 'package:lensai/features/bangs/presentation/screens/list.dart'; -import 'package:lensai/features/bangs/presentation/screens/search.dart'; -import 'package:lensai/features/chat_archive/presentation/screens/detail.dart'; -import 'package:lensai/features/chat_archive/presentation/screens/list.dart'; -import 'package:lensai/features/chat_archive/presentation/screens/search.dart'; -import 'package:lensai/features/search_browser/presentation/screens/browser.dart'; -import 'package:lensai/features/settings/presentation/screens/settings.dart'; -import 'package:lensai/features/topics/presentation/screens/topic_list.dart'; +import 'package:weblibre/core/routing/widgets/dialog_page.dart'; +import 'package:weblibre/data/models/web_page_info.dart'; +import 'package:weblibre/features/about/presentation/screens/about.dart'; +import 'package:weblibre/features/bangs/presentation/screens/categories.dart'; +import 'package:weblibre/features/bangs/presentation/screens/list.dart'; +import 'package:weblibre/features/bangs/presentation/screens/search.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/screens/browser.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/screens/search.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_edit.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.dart'; +import 'package:weblibre/features/onboarding/presentation/onboarding.dart'; +import 'package:weblibre/features/settings/presentation/screens/bang_settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/general_settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.dart'; +import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart'; +import 'package:weblibre/features/settings/presentation/screens/web_engine_settings.dart'; +import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart'; +import 'package:weblibre/features/web_feed/presentation/add_feed_dialog.dart'; +import 'package:weblibre/features/web_feed/presentation/screens/feed_article.dart'; +import 'package:weblibre/features/web_feed/presentation/screens/feed_article_list.dart'; +import 'package:weblibre/features/web_feed/presentation/screens/feed_edit.dart'; +import 'package:weblibre/features/web_feed/presentation/screens/feed_list.dart'; +import 'package:weblibre/features/web_feed/presentation/select_feed_dialog.dart'; part 'routes.g.dart'; +part 'routes.settings.dart'; +part 'routes.browser.dart'; +part 'routes.bangs.dart'; +part 'routes.feeds.dart'; -@TypedGoRoute( - name: 'KagiRoute', - path: '/', - routes: [ - TypedGoRoute( - name: 'AboutRoute', - path: 'about', - ), - TypedGoRoute( - name: 'BangRoute', - path: 'bangs', - routes: [ - TypedGoRoute( - name: 'BangSearchRoute', - path: 'search', - ), - TypedGoRoute( - name: 'BangCategoryRoute', - path: 'category/:category', - routes: [ - TypedGoRoute( - name: 'BangSubCategoryRoute', - path: ':subCategory', - ), - ], - ), - ], - ), - TypedGoRoute( - name: 'TopicsRoute', - path: 'topics', - ), - ], -) -class KagiRoute extends GoRouteData { - @override - Widget build(BuildContext context, GoRouterState state) { - return const KagiScreen(); - } -} - -class AboutRoute extends GoRouteData { +@TypedGoRoute(name: 'AboutRoute', path: '/about') +class AboutRoute extends GoRouteData with _$AboutRoute { @override Page buildPage(BuildContext context, GoRouterState state) { return DialogPage(builder: (_) => const AboutDialogScreen()); } } -class BangCategoriesRoute extends GoRouteData { - @override - Widget build(BuildContext context, GoRouterState state) { - return const BangCategoriesScreen(); - } -} +@TypedGoRoute( + name: 'OnboardingRoute', + path: '/onboarding/:currentRevision/:targetRevision', +) +class OnboardingRoute extends GoRouteData with _$OnboardingRoute { + final int currentRevision; + final int targetRevision; -class BangCategoryRoute extends GoRouteData { - final String category; - - const BangCategoryRoute({required this.category}); - - @override - Widget build(BuildContext context, GoRouterState state) { - return BangListScreen(category: category); - } -} - -class BangSubCategoryRoute extends GoRouteData { - final String category; - final String subCategory; - - const BangSubCategoryRoute({ - required this.category, - required this.subCategory, + const OnboardingRoute({ + required this.currentRevision, + required this.targetRevision, }); @override Widget build(BuildContext context, GoRouterState state) { - return BangListScreen(category: category, subCategory: subCategory); - } -} - -class BangSearchRoute extends GoRouteData { - @override - Widget build(BuildContext context, GoRouterState state) { - return const BangSearchScreen(); - } -} - -class TopicListRoute extends GoRouteData { - @override - Widget build(BuildContext context, GoRouterState state) { - return const TopicListScreen(); - } -} - -@TypedGoRoute( - name: 'SettingsRoute', - path: '/settings', -) -class SettingsRoute extends GoRouteData { - @override - Widget build(BuildContext context, GoRouterState state) { - return const SettingsScreen(); - } -} - -@TypedGoRoute( - name: 'ChatArchiveListRoute', - path: '/chat_archive', - routes: [ - TypedGoRoute( - name: 'ChatArchiveSearchRoute', - path: 'search', - ), - TypedGoRoute( - name: 'ChatArchiveDetailRoute', - path: 'detail/:fileName', - ), - ], -) -class ChatArchiveListRoute extends GoRouteData { - @override - Widget build(BuildContext context, GoRouterState state) { - return const ChatArchiveListScreen(); - } -} - -class ChatArchiveSearchRoute extends GoRouteData { - @override - Widget build(BuildContext context, GoRouterState state) { - return const ChatArchiveSearchScreen(); - } -} - -class ChatArchiveDetailRoute extends GoRouteData { - final String fileName; - - const ChatArchiveDetailRoute({required this.fileName}); - - @override - Widget build(BuildContext context, GoRouterState state) { - return ChatArchiveDetailScreen(fileName); + return OnboardingScreen( + currentRevision: currentRevision, + targetRevision: targetRevision, + ); } } diff --git a/app/lib/core/routing/routes.feeds.dart b/app/lib/core/routing/routes.feeds.dart new file mode 100644 index 00000000..264da9c0 --- /dev/null +++ b/app/lib/core/routing/routes.feeds.dart @@ -0,0 +1,106 @@ +part of 'routes.dart'; + +@TypedGoRoute( + name: 'FeedListRoute', + path: '/feeds', + routes: [ + TypedGoRoute(name: FeedAddRoute.name, path: 'add'), + TypedGoRoute( + name: 'FeedArticleListRoute', + path: 'articles/:feedId', + ), + TypedGoRoute( + name: 'FeedArticleRoute', + path: 'article/:articleId', + ), + TypedGoRoute( + name: 'FeedCreateRoute', + path: 'create/:feedId', + ), + TypedGoRoute( + name: 'SelectFeedDialogRoute', + path: 'available/:feedsJson', + ), + TypedGoRoute(name: 'FeedEditRoute', path: 'edit/:feedId'), + ], +) +class FeedListRoute extends GoRouteData with _$FeedListRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const FeedListScreen(); + } +} + +class FeedCreateRoute extends GoRouteData with _$FeedCreateRoute { + final Uri feedId; + + FeedCreateRoute({required this.feedId}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return FeedEditScreen.create(feedId: feedId); + } +} + +class SelectFeedDialogRoute extends GoRouteData with _$SelectFeedDialogRoute { + final String feedsJson; + + const SelectFeedDialogRoute({required this.feedsJson}); + + @override + Page buildPage(BuildContext context, GoRouterState state) { + final feedUris = Set.from( + (jsonDecode(feedsJson) as List).map( + (url) => Uri.parse(url as String), + ), + ); + + return DialogPage(builder: (_) => SelectFeedDialog(feedUris: feedUris)); + } +} + +class FeedEditRoute extends GoRouteData with _$FeedEditRoute { + final Uri feedId; + + const FeedEditRoute({required this.feedId}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return FeedEditScreen.edit(feedId: feedId); + } +} + +class FeedAddRoute extends GoRouteData with _$FeedAddRoute { + final Uri? $extra; + + static const name = 'FeedAddRoute'; + + const FeedAddRoute({this.$extra}); + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return DialogPage(builder: (_) => AddFeedDialog(initialUri: $extra)); + } +} + +class FeedArticleListRoute extends GoRouteData with _$FeedArticleListRoute { + final Uri feedId; + + FeedArticleListRoute({required this.feedId}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return FeedArticleListScreen(feedId: feedId); + } +} + +class FeedArticleRoute extends GoRouteData with _$FeedArticleRoute { + final String articleId; + + FeedArticleRoute({required this.articleId}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return FeedArticleScreen(articleId: articleId); + } +} diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 4e7a1c44..a261c31d 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -7,272 +7,877 @@ part of 'routes.dart'; // ************************************************************************** List get $appRoutes => [ - $kagiRoute, - $settingsRoute, - $chatArchiveListRoute, - ]; + $aboutRoute, + $onboardingRoute, + $settingsRoute, + $browserRoute, + $bangCategoriesRoute, + $feedListRoute, +]; -RouteBase get $kagiRoute => GoRouteData.$route( - path: '/', - name: 'KagiRoute', - factory: $KagiRouteExtension._fromState, +RouteBase get $aboutRoute => GoRouteData.$route( + path: '/about', + name: 'AboutRoute', + + factory: _$AboutRoute._fromState, +); + +mixin _$AboutRoute on GoRouteData { + static AboutRoute _fromState(GoRouterState state) => AboutRoute(); + + @override + String get location => GoRouteData.$location('/about'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $onboardingRoute => GoRouteData.$route( + path: '/onboarding/:currentRevision/:targetRevision', + name: 'OnboardingRoute', + + factory: _$OnboardingRoute._fromState, +); + +mixin _$OnboardingRoute on GoRouteData { + static OnboardingRoute _fromState(GoRouterState state) => OnboardingRoute( + currentRevision: int.parse(state.pathParameters['currentRevision']!)!, + targetRevision: int.parse(state.pathParameters['targetRevision']!)!, + ); + + OnboardingRoute get _self => this as OnboardingRoute; + + @override + String get location => GoRouteData.$location( + '/onboarding/${Uri.encodeComponent(_self.currentRevision.toString())}/${Uri.encodeComponent(_self.targetRevision.toString())}', + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $settingsRoute => GoRouteData.$route( + path: '/settings', + name: 'SettingsRoute', + + factory: _$SettingsRoute._fromState, + routes: [ + GoRouteData.$route( + path: 'general', + name: 'GeneralSettingsRoute', + + factory: _$GeneralSettingsRoute._fromState, + ), + GoRouteData.$route( + path: 'bang', + name: 'BangSettingsRoute', + + factory: _$BangSettingsRoute._fromState, + ), + GoRouteData.$route( + path: 'web_engine', + name: 'WebEngineSettingsRoute', + + factory: _$WebEngineSettingsRoute._fromState, routes: [ GoRouteData.$route( - path: 'about', - name: 'AboutRoute', - factory: $AboutRouteExtension._fromState, - ), - GoRouteData.$route( - path: 'bangs', - name: 'BangRoute', - factory: $BangCategoriesRouteExtension._fromState, + path: 'hardening', + name: 'WebEngineHardeningRoute', + + factory: _$WebEngineHardeningRoute._fromState, routes: [ GoRouteData.$route( - path: 'search', - name: 'BangSearchRoute', - factory: $BangSearchRouteExtension._fromState, - ), - GoRouteData.$route( - path: 'category/:category', - name: 'BangCategoryRoute', - factory: $BangCategoryRouteExtension._fromState, - routes: [ - GoRouteData.$route( - path: ':subCategory', - name: 'BangSubCategoryRoute', - factory: $BangSubCategoryRouteExtension._fromState, - ), - ], + path: 'group/:group', + name: 'WebEngineHardeningGroupRoute', + + factory: _$WebEngineHardeningGroupRoute._fromState, ), ], ), + ], + ), + ], +); + +mixin _$SettingsRoute on GoRouteData { + static SettingsRoute _fromState(GoRouterState state) => SettingsRoute(); + + @override + String get location => GoRouteData.$location('/settings'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin _$GeneralSettingsRoute on GoRouteData { + static GeneralSettingsRoute _fromState(GoRouterState state) => + GeneralSettingsRoute(); + + @override + String get location => GoRouteData.$location('/settings/general'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin _$BangSettingsRoute on GoRouteData { + static BangSettingsRoute _fromState(GoRouterState state) => + BangSettingsRoute(); + + @override + String get location => GoRouteData.$location('/settings/bang'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin _$WebEngineSettingsRoute on GoRouteData { + static WebEngineSettingsRoute _fromState(GoRouterState state) => + WebEngineSettingsRoute(); + + @override + String get location => GoRouteData.$location('/settings/web_engine'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin _$WebEngineHardeningRoute on GoRouteData { + static WebEngineHardeningRoute _fromState(GoRouterState state) => + WebEngineHardeningRoute(); + + @override + String get location => + GoRouteData.$location('/settings/web_engine/hardening'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin _$WebEngineHardeningGroupRoute on GoRouteData { + static WebEngineHardeningGroupRoute _fromState(GoRouterState state) => + WebEngineHardeningGroupRoute(group: state.pathParameters['group']!); + + WebEngineHardeningGroupRoute get _self => + this as WebEngineHardeningGroupRoute; + + @override + String get location => GoRouteData.$location( + '/settings/web_engine/hardening/group/${Uri.encodeComponent(_self.group)}', + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $browserRoute => GoRouteData.$route( + path: '/', + name: 'BrowserRoute', + + factory: _$BrowserRoute._fromState, + routes: [ + GoRouteData.$route( + path: 'page/:url', + name: 'WebPageRoute', + + factory: _$WebPageRoute._fromState, + ), + GoRouteData.$route( + path: 'search/:tabType/:searchText', + name: 'SearchRoute', + + factory: _$SearchRoute._fromState, + ), + GoRouteData.$route( + path: 'tor_proxy', + name: 'TorProxyRoute', + + factory: _$TorProxyRoute._fromState, + ), + GoRouteData.$route( + path: 'context_menu', + name: 'ContextMenuRoute', + + factory: _$ContextMenuRoute._fromState, + ), + GoRouteData.$route( + path: 'containers', + name: 'ContainerListRoute', + + factory: _$ContainerListRoute._fromState, + routes: [ GoRouteData.$route( - path: 'topics', - name: 'TopicsRoute', - factory: $TopicListRouteExtension._fromState, + path: 'create', + name: 'ContainerCreateRoute', + + factory: _$ContainerCreateRoute._fromState, + ), + GoRouteData.$route( + path: 'edit', + name: 'ContainerEditRoute', + + factory: _$ContainerEditRoute._fromState, ), ], - ); + ), + GoRouteData.$route( + path: 'tab_tree/:rootTabId', + name: 'TabTreeRoute', -extension $KagiRouteExtension on KagiRoute { - static KagiRoute _fromState(GoRouterState state) => KagiRoute(); + factory: _$TabTreeRoute._fromState, + ), + ], +); - String get location => GoRouteData.$location( - '/', - ); +mixin _$BrowserRoute on GoRouteData { + static BrowserRoute _fromState(GoRouterState state) => BrowserRoute(); + @override + String get location => GoRouteData.$location('/'); + + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override void replace(BuildContext context) => context.replace(location); } -extension $AboutRouteExtension on AboutRoute { - static AboutRoute _fromState(GoRouterState state) => AboutRoute(); +mixin _$WebPageRoute on GoRouteData { + static WebPageRoute _fromState(GoRouterState state) => WebPageRoute( + url: state.pathParameters['url']!, + $extra: state.extra as WebPageInfo?, + ); + WebPageRoute get _self => this as WebPageRoute; + + @override + String get location => + GoRouteData.$location('/page/${Uri.encodeComponent(_self.url)}'); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} + +mixin _$SearchRoute on GoRouteData { + static SearchRoute _fromState(GoRouterState state) => SearchRoute( + tabType: _$TabTypeEnumMap._$fromName(state.pathParameters['tabType']!)!, + searchText: + state.pathParameters['searchText'] ?? SearchRoute.emptySearchText, + ); + + SearchRoute get _self => this as SearchRoute; + + @override String get location => GoRouteData.$location( - '/about', - ); + '/search/${Uri.encodeComponent(_$TabTypeEnumMap[_self.tabType]!)}/${Uri.encodeComponent(_self.searchText)}', + ); + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override void replace(BuildContext context) => context.replace(location); } -extension $BangCategoriesRouteExtension on BangCategoriesRoute { +const _$TabTypeEnumMap = { + TabType.regular: 'regular', + TabType.private: 'private', + TabType.child: 'child', +}; + +mixin _$TorProxyRoute on GoRouteData { + static TorProxyRoute _fromState(GoRouterState state) => TorProxyRoute(); + + @override + String get location => GoRouteData.$location('/tor_proxy'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin _$ContextMenuRoute on GoRouteData { + static ContextMenuRoute _fromState(GoRouterState state) => + ContextMenuRoute(state.extra as String); + + ContextMenuRoute get _self => this as ContextMenuRoute; + + @override + String get location => GoRouteData.$location('/context_menu'); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} + +mixin _$ContainerListRoute on GoRouteData { + static ContainerListRoute _fromState(GoRouterState state) => + ContainerListRoute(); + + @override + String get location => GoRouteData.$location('/containers'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin _$ContainerCreateRoute on GoRouteData { + static ContainerCreateRoute _fromState(GoRouterState state) => + ContainerCreateRoute(state.extra as ContainerData); + + ContainerCreateRoute get _self => this as ContainerCreateRoute; + + @override + String get location => GoRouteData.$location('/containers/create'); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} + +mixin _$ContainerEditRoute on GoRouteData { + static ContainerEditRoute _fromState(GoRouterState state) => + ContainerEditRoute(state.extra as ContainerData); + + ContainerEditRoute get _self => this as ContainerEditRoute; + + @override + String get location => GoRouteData.$location('/containers/edit'); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} + +mixin _$TabTreeRoute on GoRouteData { + static TabTreeRoute _fromState(GoRouterState state) => + TabTreeRoute(state.pathParameters['rootTabId']!); + + TabTreeRoute get _self => this as TabTreeRoute; + + @override + String get location => GoRouteData.$location( + '/tab_tree/${Uri.encodeComponent(_self.rootTabId)}', + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +extension on Map { + T? _$fromName(String? value) => + entries.where((element) => element.value == value).firstOrNull?.key; +} + +RouteBase get $bangCategoriesRoute => GoRouteData.$route( + path: '/bangs', + name: 'BangRoute', + + factory: _$BangCategoriesRoute._fromState, + routes: [ + GoRouteData.$route( + path: 'search/:searchText', + name: 'BangSearchRoute', + + factory: _$BangSearchRoute._fromState, + ), + GoRouteData.$route( + path: 'category/:category', + name: 'BangCategoryRoute', + + factory: _$BangCategoryRoute._fromState, + routes: [ + GoRouteData.$route( + path: ':subCategory', + name: 'BangSubCategoryRoute', + + factory: _$BangSubCategoryRoute._fromState, + ), + ], + ), + ], +); + +mixin _$BangCategoriesRoute on GoRouteData { static BangCategoriesRoute _fromState(GoRouterState state) => BangCategoriesRoute(); - String get location => GoRouteData.$location( - '/bangs', - ); + @override + String get location => GoRouteData.$location('/bangs'); + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override void replace(BuildContext context) => context.replace(location); } -extension $BangSearchRouteExtension on BangSearchRoute { - static BangSearchRoute _fromState(GoRouterState state) => BangSearchRoute(); +mixin _$BangSearchRoute on GoRouteData { + static BangSearchRoute _fromState(GoRouterState state) => BangSearchRoute( + searchText: + state.pathParameters['searchText'] ?? BangSearchRoute.emptySearchText, + ); + BangSearchRoute get _self => this as BangSearchRoute; + + @override String get location => GoRouteData.$location( - '/bangs/search', - ); + '/bangs/search/${Uri.encodeComponent(_self.searchText)}', + ); + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override void replace(BuildContext context) => context.replace(location); } -extension $BangCategoryRouteExtension on BangCategoryRoute { - static BangCategoryRoute _fromState(GoRouterState state) => BangCategoryRoute( - category: state.pathParameters['category']!, - ); +mixin _$BangCategoryRoute on GoRouteData { + static BangCategoryRoute _fromState(GoRouterState state) => + BangCategoryRoute(category: state.pathParameters['category']!); + BangCategoryRoute get _self => this as BangCategoryRoute; + + @override String get location => GoRouteData.$location( - '/bangs/category/${Uri.encodeComponent(category)}', - ); + '/bangs/category/${Uri.encodeComponent(_self.category)}', + ); + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override void replace(BuildContext context) => context.replace(location); } -extension $BangSubCategoryRouteExtension on BangSubCategoryRoute { +mixin _$BangSubCategoryRoute on GoRouteData { static BangSubCategoryRoute _fromState(GoRouterState state) => BangSubCategoryRoute( category: state.pathParameters['category']!, subCategory: state.pathParameters['subCategory']!, ); - String get location => GoRouteData.$location( - '/bangs/category/${Uri.encodeComponent(category)}/${Uri.encodeComponent(subCategory)}', - ); + BangSubCategoryRoute get _self => this as BangSubCategoryRoute; + @override + String get location => GoRouteData.$location( + '/bangs/category/${Uri.encodeComponent(_self.category)}/${Uri.encodeComponent(_self.subCategory)}', + ); + + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override void replace(BuildContext context) => context.replace(location); } -extension $TopicListRouteExtension on TopicListRoute { - static TopicListRoute _fromState(GoRouterState state) => TopicListRoute(); +RouteBase get $feedListRoute => GoRouteData.$route( + path: '/feeds', + name: 'FeedListRoute', - String get location => GoRouteData.$location( - '/topics', - ); + factory: _$FeedListRoute._fromState, + routes: [ + GoRouteData.$route( + path: 'add', + name: 'FeedAddRoute', + factory: _$FeedAddRoute._fromState, + ), + GoRouteData.$route( + path: 'articles/:feedId', + name: 'FeedArticleListRoute', + + factory: _$FeedArticleListRoute._fromState, + ), + GoRouteData.$route( + path: 'article/:articleId', + name: 'FeedArticleRoute', + + factory: _$FeedArticleRoute._fromState, + ), + GoRouteData.$route( + path: 'create/:feedId', + name: 'FeedCreateRoute', + + factory: _$FeedCreateRoute._fromState, + ), + GoRouteData.$route( + path: 'available/:feedsJson', + name: 'SelectFeedDialogRoute', + + factory: _$SelectFeedDialogRoute._fromState, + ), + GoRouteData.$route( + path: 'edit/:feedId', + name: 'FeedEditRoute', + + factory: _$FeedEditRoute._fromState, + ), + ], +); + +mixin _$FeedListRoute on GoRouteData { + static FeedListRoute _fromState(GoRouterState state) => FeedListRoute(); + + @override + String get location => GoRouteData.$location('/feeds'); + + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override void replace(BuildContext context) => context.replace(location); } -RouteBase get $settingsRoute => GoRouteData.$route( - path: '/settings', - name: 'SettingsRoute', - factory: $SettingsRouteExtension._fromState, - ); +mixin _$FeedAddRoute on GoRouteData { + static FeedAddRoute _fromState(GoRouterState state) => + FeedAddRoute($extra: state.extra as Uri?); -extension $SettingsRouteExtension on SettingsRoute { - static SettingsRoute _fromState(GoRouterState state) => SettingsRoute(); + FeedAddRoute get _self => this as FeedAddRoute; + @override + String get location => GoRouteData.$location('/feeds/add'); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} + +mixin _$FeedArticleListRoute on GoRouteData { + static FeedArticleListRoute _fromState(GoRouterState state) => + FeedArticleListRoute(feedId: Uri.parse(state.pathParameters['feedId']!)!); + + FeedArticleListRoute get _self => this as FeedArticleListRoute; + + @override String get location => GoRouteData.$location( - '/settings', - ); + '/feeds/articles/${Uri.encodeComponent(_self.feedId.toString())}', + ); + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override void replace(BuildContext context) => context.replace(location); } -RouteBase get $chatArchiveListRoute => GoRouteData.$route( - path: '/chat_archive', - name: 'ChatArchiveListRoute', - factory: $ChatArchiveListRouteExtension._fromState, - routes: [ - GoRouteData.$route( - path: 'search', - name: 'ChatArchiveSearchRoute', - factory: $ChatArchiveSearchRouteExtension._fromState, - ), - GoRouteData.$route( - path: 'detail/:fileName', - name: 'ChatArchiveDetailRoute', - factory: $ChatArchiveDetailRouteExtension._fromState, - ), - ], - ); +mixin _$FeedArticleRoute on GoRouteData { + static FeedArticleRoute _fromState(GoRouterState state) => + FeedArticleRoute(articleId: state.pathParameters['articleId']!); -extension $ChatArchiveListRouteExtension on ChatArchiveListRoute { - static ChatArchiveListRoute _fromState(GoRouterState state) => - ChatArchiveListRoute(); + FeedArticleRoute get _self => this as FeedArticleRoute; + @override String get location => GoRouteData.$location( - '/chat_archive', - ); + '/feeds/article/${Uri.encodeComponent(_self.articleId)}', + ); + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override void replace(BuildContext context) => context.replace(location); } -extension $ChatArchiveSearchRouteExtension on ChatArchiveSearchRoute { - static ChatArchiveSearchRoute _fromState(GoRouterState state) => - ChatArchiveSearchRoute(); +mixin _$FeedCreateRoute on GoRouteData { + static FeedCreateRoute _fromState(GoRouterState state) => + FeedCreateRoute(feedId: Uri.parse(state.pathParameters['feedId']!)!); + FeedCreateRoute get _self => this as FeedCreateRoute; + + @override String get location => GoRouteData.$location( - '/chat_archive/search', - ); + '/feeds/create/${Uri.encodeComponent(_self.feedId.toString())}', + ); + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override void replace(BuildContext context) => context.replace(location); } -extension $ChatArchiveDetailRouteExtension on ChatArchiveDetailRoute { - static ChatArchiveDetailRoute _fromState(GoRouterState state) => - ChatArchiveDetailRoute( - fileName: state.pathParameters['fileName']!, - ); +mixin _$SelectFeedDialogRoute on GoRouteData { + static SelectFeedDialogRoute _fromState(GoRouterState state) => + SelectFeedDialogRoute(feedsJson: state.pathParameters['feedsJson']!); + SelectFeedDialogRoute get _self => this as SelectFeedDialogRoute; + + @override String get location => GoRouteData.$location( - '/chat_archive/detail/${Uri.encodeComponent(fileName)}', - ); + '/feeds/available/${Uri.encodeComponent(_self.feedsJson)}', + ); + @override void go(BuildContext context) => context.go(location); + @override Future push(BuildContext context) => context.push(location); + @override void pushReplacement(BuildContext context) => context.pushReplacement(location); + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin _$FeedEditRoute on GoRouteData { + static FeedEditRoute _fromState(GoRouterState state) => + FeedEditRoute(feedId: Uri.parse(state.pathParameters['feedId']!)!); + + FeedEditRoute get _self => this as FeedEditRoute; + + @override + String get location => GoRouteData.$location( + '/feeds/edit/${Uri.encodeComponent(_self.feedId.toString())}', + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override void replace(BuildContext context) => context.replace(location); } diff --git a/app/lib/core/routing/routes.settings.dart b/app/lib/core/routing/routes.settings.dart new file mode 100644 index 00000000..db98116f --- /dev/null +++ b/app/lib/core/routing/routes.settings.dart @@ -0,0 +1,76 @@ +part of 'routes.dart'; + +@TypedGoRoute( + name: 'SettingsRoute', + path: '/settings', + routes: [ + TypedGoRoute( + name: 'GeneralSettingsRoute', + path: 'general', + ), + TypedGoRoute(name: 'BangSettingsRoute', path: 'bang'), + TypedGoRoute( + name: 'WebEngineSettingsRoute', + path: 'web_engine', + routes: [ + TypedGoRoute( + name: 'WebEngineHardeningRoute', + path: 'hardening', + routes: [ + TypedGoRoute( + name: 'WebEngineHardeningGroupRoute', + path: 'group/:group', + ), + ], + ), + ], + ), + ], +) +class SettingsRoute extends GoRouteData with _$SettingsRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const SettingsScreen(); + } +} + +class GeneralSettingsRoute extends GoRouteData with _$GeneralSettingsRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const GeneralSettingsScreen(); + } +} + +class BangSettingsRoute extends GoRouteData with _$BangSettingsRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const BangSettingsScreen(); + } +} + +class WebEngineSettingsRoute extends GoRouteData with _$WebEngineSettingsRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const WebEngineSettingsScreen(); + } +} + +class WebEngineHardeningRoute extends GoRouteData + with _$WebEngineHardeningRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const WebEngineHardeningScreen(); + } +} + +class WebEngineHardeningGroupRoute extends GoRouteData + with _$WebEngineHardeningGroupRoute { + final String group; + + const WebEngineHardeningGroupRoute({required this.group}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return WebEngineHardeningGroupScreen(groupName: group); + } +} diff --git a/app/lib/core/routing/dialog_page.dart b/app/lib/core/routing/widgets/dialog_page.dart similarity index 73% rename from app/lib/core/routing/dialog_page.dart rename to app/lib/core/routing/widgets/dialog_page.dart index 4a598c87..09ef3489 100644 --- a/app/lib/core/routing/dialog_page.dart +++ b/app/lib/core/routing/widgets/dialog_page.dart @@ -27,14 +27,14 @@ class DialogPage extends Page { @override Route createRoute(BuildContext context) => DialogRoute( - context: context, - settings: this, - builder: builder, - anchorPoint: anchorPoint, - barrierColor: barrierColor, - barrierDismissible: barrierDismissible, - barrierLabel: barrierLabel, - useSafeArea: useSafeArea, - themes: themes, - ); + context: context, + settings: this, + builder: builder, + anchorPoint: anchorPoint, + barrierColor: barrierColor, + barrierDismissible: barrierDismissible, + barrierLabel: barrierLabel, + useSafeArea: useSafeArea, + themes: themes, + ); } diff --git a/app/lib/data/database/converters/color.dart b/app/lib/data/database/converters/color.dart new file mode 100644 index 00000000..ba12581d --- /dev/null +++ b/app/lib/data/database/converters/color.dart @@ -0,0 +1,32 @@ +import 'dart:ui'; + +import 'package:drift/drift.dart'; +import 'package:json_annotation/json_annotation.dart'; + +class ColorJsonConverter implements JsonConverter { + const ColorJsonConverter(); + + @override + Color fromJson(int from) { + return Color(from); + } + + @override + int toJson(Color value) { + return value.toARGB32(); + } +} + +class ColorConverter extends TypeConverter { + const ColorConverter(); + + @override + Color fromSql(int fromDb) { + return Color(fromDb); + } + + @override + int toSql(Color value) { + return value.toARGB32(); + } +} diff --git a/app/lib/data/database/converters/icon_data.dart b/app/lib/data/database/converters/icon_data.dart new file mode 100644 index 00000000..ee42abd4 --- /dev/null +++ b/app/lib/data/database/converters/icon_data.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:flutter/widgets.dart'; +import 'package:json_annotation/json_annotation.dart'; + +class IconDataJsonConverter + implements JsonConverter> { + const IconDataJsonConverter(); + + @override + IconData fromJson(Map json) { + return IconData( + json['codePoint'] as int, + fontFamily: json['fontFamily'] as String, + fontPackage: json['fontPackage'] as String, + ); + } + + @override + Map toJson(IconData iconData) { + return { + 'codePoint': iconData.codePoint, + 'fontFamily': iconData.fontFamily, + 'fontPackage': iconData.fontPackage, + }; + } +} + +class IconDataTypeConverter implements TypeConverter { + const IconDataTypeConverter(); + + @override + IconData fromSql(String fromDb) { + final json = jsonDecode(fromDb) as Map; + return const IconDataJsonConverter().fromJson(json); + } + + @override + String toSql(IconData value) { + assert( + value.fontFamily != null, + 'Font family must be provided to identify icon', + ); + + return jsonEncode(const IconDataJsonConverter().toJson(value)); + } +} diff --git a/app/lib/data/database/converters/string_list.dart b/app/lib/data/database/converters/string_list.dart new file mode 100644 index 00000000..15e42a17 --- /dev/null +++ b/app/lib/data/database/converters/string_list.dart @@ -0,0 +1,17 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; + +class StringListConverter extends TypeConverter, String> { + const StringListConverter(); + + @override + List fromSql(String fromDb) { + return jsonDecode(fromDb) as List; + } + + @override + String toSql(List value) { + return jsonEncode(value); + } +} diff --git a/app/lib/data/database/converters/uri.dart b/app/lib/data/database/converters/uri.dart new file mode 100644 index 00000000..e49d2d85 --- /dev/null +++ b/app/lib/data/database/converters/uri.dart @@ -0,0 +1,30 @@ +import 'package:drift/drift.dart'; +import 'package:weblibre/utils/uri_parser.dart' as uri_parser; + +class UriConverter extends TypeConverter { + const UriConverter(); + + @override + Uri fromSql(String fromDb) { + return uri_parser.tryParseUrl(fromDb, eagerParsing: true)!; + } + + @override + String toSql(Uri value) { + return value.toString(); + } +} + +class UriConverterNullable extends TypeConverter { + const UriConverterNullable(); + + @override + Uri? fromSql(String? fromDb) { + return uri_parser.tryParseUrl(fromDb, eagerParsing: true); + } + + @override + String? toSql(Uri? value) { + return value?.toString(); + } +} diff --git a/app/lib/extensions/database_table_size.dart b/app/lib/data/database/extensions/database_table_size.dart similarity index 88% rename from app/lib/extensions/database_table_size.dart rename to app/lib/data/database/extensions/database_table_size.dart index f9efb902..f5c949d0 100644 --- a/app/lib/extensions/database_table_size.dart +++ b/app/lib/data/database/extensions/database_table_size.dart @@ -1,6 +1,6 @@ import 'package:drift/drift.dart'; -extension TableSIze on DatabaseConnectionUser { +extension TableSize on DatabaseConnectionUser { SingleSelectable tableSize(TableInfo table) { return customSelect( 'SELECT SUM(pgsize) /(1024.0 * 1024.0)AS pgsize_mb FROM dbstat WHERE name = ?1', diff --git a/app/lib/data/database/functions/lexo_rank_functions.dart b/app/lib/data/database/functions/lexo_rank_functions.dart new file mode 100644 index 00000000..aecd7522 --- /dev/null +++ b/app/lib/data/database/functions/lexo_rank_functions.dart @@ -0,0 +1,59 @@ +import 'package:lexo_rank/lexo_rank.dart'; +import 'package:lexo_rank/lexo_rank/lexo_rank_bucket.dart'; +import 'package:nullability/nullability.dart'; +import 'package:sqlite3/common.dart'; + +String _nextRankOrMiddle(List args) { + final parsedBucket = LexoRankBucket.resolve(args[0]! as int); + + if (args[1] != null) { + final parsedRank = LexoRank.parse(args[1]! as String); + + return parsedRank.genNext().value; + } else { + return LexoRank.middle(bucket: parsedBucket).value; + } +} + +String _previousRankOrMiddle(List args) { + final parsedBucket = LexoRankBucket.resolve(args[0]! as int); + + if (args[1] != null) { + final parsedRank = LexoRank.parse(args[1]! as String); + + return parsedRank.genPrev().value; + } else { + return LexoRank.middle(bucket: parsedBucket).value; + } +} + +String _reorderAfter(List args) { + final first = args[0].mapNotNull((arg) => LexoRank.parse(arg as String)); + final last = args[1].mapNotNull((arg) => LexoRank.parse(arg as String)); + + if (first == null) { + throw Exception('Tab not found'); + } else if (last == null) { + return first.genNext().value; + } else { + return first.genBetween(last).value; + } +} + +void registerLexorankFunctions(CommonDatabase database) { + database.createFunction( + functionName: 'lexo_rank_next', + argumentCount: const AllowedArgumentCount(2), + function: _nextRankOrMiddle, + ); + database.createFunction( + functionName: 'lexo_rank_previous', + argumentCount: const AllowedArgumentCount(2), + function: _previousRankOrMiddle, + ); + database.createFunction( + functionName: 'lexo_rank_reorder_after', + argumentCount: const AllowedArgumentCount(2), + function: _reorderAfter, + ); +} diff --git a/app/lib/data/models/drag_data.dart b/app/lib/data/models/drag_data.dart new file mode 100644 index 00000000..6a07cedc --- /dev/null +++ b/app/lib/data/models/drag_data.dart @@ -0,0 +1,32 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +sealed class DropTargetData with FastEquatable {} + +final class ContainerDropData extends DropTargetData { + final String tabId; + + ContainerDropData(this.tabId); + + @override + List get hashParameters => [tabId]; +} + +final class DeleteDropData extends DropTargetData { + final String tabId; + + DeleteDropData(this.tabId); + + @override + List get hashParameters => [tabId]; +} + +sealed class DragTargetData with FastEquatable {} + +final class TabDragData extends DragTargetData { + final String tabId; + + TabDragData(this.tabId); + + @override + List get hashParameters => [tabId]; +} diff --git a/app/lib/data/models/received_intent_parameter.dart b/app/lib/data/models/received_intent_parameter.dart new file mode 100644 index 00000000..db5c0b16 --- /dev/null +++ b/app/lib/data/models/received_intent_parameter.dart @@ -0,0 +1,11 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +class ReceivedIntentParameter with FastEquatable { + final String? content; + final String? tool; + + ReceivedIntentParameter(this.content, this.tool); + + @override + List get hashParameters => [content, tool]; +} diff --git a/app/lib/data/models/web_page_info.dart b/app/lib/data/models/web_page_info.dart new file mode 100644 index 00000000..8856e5af --- /dev/null +++ b/app/lib/data/models/web_page_info.dart @@ -0,0 +1,22 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart'; + +part 'web_page_info.g.dart'; + +@CopyWith() +class WebPageInfo with FastEquatable { + final Uri url; + final String? title; + final BrowserIcon? favicon; + final Set? feeds; + + bool get isPageInfoComplete => + title.isNotEmpty && favicon != null && feeds != null; + + WebPageInfo({required this.url, this.title, this.favicon, this.feeds}); + + @override + List get hashParameters => [url, title, favicon, feeds]; +} diff --git a/app/lib/data/models/web_page_info.g.dart b/app/lib/data/models/web_page_info.g.dart new file mode 100644 index 00000000..296ae1fd --- /dev/null +++ b/app/lib/data/models/web_page_info.g.dart @@ -0,0 +1,88 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'web_page_info.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$WebPageInfoCWProxy { + WebPageInfo url(Uri url); + + WebPageInfo title(String? title); + + WebPageInfo favicon(BrowserIcon? favicon); + + WebPageInfo feeds(Set? feeds); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebPageInfo(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// WebPageInfo(...).copyWith(id: 12, name: "My name") + /// ```` + WebPageInfo call({ + Uri url, + String? title, + BrowserIcon? favicon, + Set? feeds, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfWebPageInfo.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfWebPageInfo.copyWith.fieldName(...)` +class _$WebPageInfoCWProxyImpl implements _$WebPageInfoCWProxy { + const _$WebPageInfoCWProxyImpl(this._value); + + final WebPageInfo _value; + + @override + WebPageInfo url(Uri url) => this(url: url); + + @override + WebPageInfo title(String? title) => this(title: title); + + @override + WebPageInfo favicon(BrowserIcon? favicon) => this(favicon: favicon); + + @override + WebPageInfo feeds(Set? feeds) => this(feeds: feeds); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebPageInfo(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// WebPageInfo(...).copyWith(id: 12, name: "My name") + /// ```` + WebPageInfo call({ + Object? url = const $CopyWithPlaceholder(), + Object? title = const $CopyWithPlaceholder(), + Object? favicon = const $CopyWithPlaceholder(), + Object? feeds = const $CopyWithPlaceholder(), + }) { + return WebPageInfo( + url: url == const $CopyWithPlaceholder() + ? _value.url + // ignore: cast_nullable_to_non_nullable + : url as Uri, + title: title == const $CopyWithPlaceholder() + ? _value.title + // ignore: cast_nullable_to_non_nullable + : title as String?, + favicon: favicon == const $CopyWithPlaceholder() + ? _value.favicon + // ignore: cast_nullable_to_non_nullable + : favicon as BrowserIcon?, + feeds: feeds == const $CopyWithPlaceholder() + ? _value.feeds + // ignore: cast_nullable_to_non_nullable + : feeds as Set?, + ); + } +} + +extension $WebPageInfoCopyWith on WebPageInfo { + /// Returns a callable class that can be used as follows: `instanceOfWebPageInfo.copyWith(...)` or like so:`instanceOfWebPageInfo.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$WebPageInfoCWProxy get copyWith => _$WebPageInfoCWProxyImpl(this); +} diff --git a/app/lib/domain/entities/equatable_image.dart b/app/lib/domain/entities/equatable_image.dart new file mode 100644 index 00000000..c76a6aa4 --- /dev/null +++ b/app/lib/domain/entities/equatable_image.dart @@ -0,0 +1,16 @@ +import 'dart:ui'; + +class EquatableImage { + final Image value; + final int _imageHash; + + EquatableImage(this.value, {required int hash}) : _imageHash = hash; + + @override + int get hashCode => _imageHash.hashCode; + + @override + bool operator ==(Object other) { + return other is EquatableImage && other._imageHash == _imageHash; + } +} diff --git a/app/lib/domain/entities/received_parameter.dart b/app/lib/domain/entities/received_parameter.dart deleted file mode 100644 index 0f4b1697..00000000 --- a/app/lib/domain/entities/received_parameter.dart +++ /dev/null @@ -1,6 +0,0 @@ -class ReceivedParameter { - final String? content; - final String? tool; - - ReceivedParameter(this.content, this.tool); -} diff --git a/app/lib/domain/entities/web_page_info.dart b/app/lib/domain/entities/web_page_info.dart deleted file mode 100644 index 89464a2d..00000000 --- a/app/lib/domain/entities/web_page_info.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; - -class WebPageInfo { - final Uri url; - final String? title; - final Favicon? favicon; - - WebPageInfo({ - required this.url, - required this.title, - required this.favicon, - }); - - factory WebPageInfo.fromJson(Map json) { - return WebPageInfo( - url: Uri.parse(json['url'] as String), - title: json['title'] as String?, - favicon: switch (json['favicon']) { - final Map favicon => Favicon.fromMap(favicon), - _ => null - }, - ); - } - - Map toJson() { - return { - 'url': url.toString(), - 'title': title, - 'favicon': favicon?.toJson(), - }; - } -} diff --git a/app/lib/domain/services/app_initialization.dart b/app/lib/domain/services/app_initialization.dart index 30e22a2c..44aff073 100644 --- a/app/lib/domain/services/app_initialization.dart +++ b/app/lib/domain/services/app_initialization.dart @@ -1,17 +1,11 @@ import 'dart:async'; import 'package:exceptions/exceptions.dart'; -import 'package:lensai/features/about/data/repositories/package_info_repository.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; -import 'package:lensai/features/bangs/domain/repositories/sync.dart'; -import 'package:lensai/features/content_block/data/models/host.dart'; -import 'package:lensai/features/content_block/domain/repositories/sync.dart'; -import 'package:lensai/features/search_browser/domain/services/session.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/topics/data/providers.dart'; -import 'package:lensai/features/web_view/domain/providers.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/providers/format.dart'; +import 'package:weblibre/features/about/data/repositories/package_info_repository.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; +import 'package:weblibre/features/bangs/domain/repositories/sync.dart'; part 'app_initialization.g.dart'; @@ -25,78 +19,32 @@ class AppInitializationService extends _$AppInitializationService { Future _initPackageInfo() { //Ensure Package info is loaded - state = Result.success( - ( - initialized: false, - stage: 'Loading Package Info...', - errors: List.empty(), - ), - ); + state = Result.success(( + initialized: false, + stage: 'Loading Package Info...', + errors: List.empty(), + )); return ref.read(packageInfoProvider.future); } Future>> _initBangs() { - state = Result.success( - ( - initialized: false, - stage: 'Synchronizing Bangs...', - errors: List.empty(), - ), - ); + state = Result.success(( + initialized: false, + stage: 'Synchronizing Bangs...', + errors: List.empty(), + )); return ref .read(bangSyncRepositoryProvider.notifier) .syncBangGroups(syncInterval: const Duration(days: 7)); } - FutureOr>> _initHosts(Settings settings) { - if (settings.enableContentBlocking) { - state = Result.success( - ( - initialized: false, - stage: 'Synchronizing Ad & Content Blocking Lists...', - errors: List.empty(), - ), - ); - - return ref.read(hostSyncRepositoryProvider.notifier).syncHostSources( - sources: settings.enableHostList, - syncInterval: const Duration(days: 7), - ); - } - - return {}; - } - - Future _initIncognito(Settings settings) async { - state = Result.success( - ( - initialized: false, - stage: 'Enforcing Privacy...', - errors: List.empty(), - ), - ); - - if (settings.incognitoMode) { - await ref.read(sessionServiceProvider.notifier).clearAllData(); - //Delete all unsasigned tabs - await ref.read(tabDatabaseProvider).tabDao.deleteTopicTabs(null); - } - - if (settings.kagiSession case final String session) { - if (session.isNotEmpty) { - await ref.read(sessionServiceProvider.notifier).setKagiSession(session); - } - } - } - Future initialize() async { state = await Result.fromAsync(() async { - final settings = await ref.read(settingsRepositoryProvider.future); final errors = []; - unawaited(ref.read(readerabilityScriptProvider.future)); + await ref.read(formatProvider.future); await _initPackageInfo(); @@ -105,22 +53,17 @@ class AppInitializationService extends _$AppInitializationService { result.onFailure(errors.add); } - final hostSyncResults = await _initHosts(settings); - for (final MapEntry(value: result) in hostSyncResults.entries) { - result.onFailure(errors.add); - } - - await _initIncognito(settings); - return (initialized: true, stage: null, errors: errors); }); } @override Result<({bool initialized, String? stage, List errors})> - build() { - return Result.success( - (initialized: false, stage: null, errors: List.empty()), - ); + build() { + return Result.success(( + initialized: false, + stage: null, + errors: List.empty(), + )); } } diff --git a/app/lib/domain/services/app_initialization.g.dart b/app/lib/domain/services/app_initialization.g.dart index 809183f6..558293e8 100644 --- a/app/lib/domain/services/app_initialization.g.dart +++ b/app/lib/domain/services/app_initialization.g.dart @@ -7,28 +7,27 @@ part of 'app_initialization.dart'; // ************************************************************************** String _$appInitializationServiceHash() => - r'dadcf205d32efa61eb2a0ff1c9b3d49658b76a56'; + r'5e537f916825d6c5f660cf62ff133c2ef00ab091'; /// See also [AppInitializationService]. @ProviderFor(AppInitializationService) -final appInitializationServiceProvider = NotifierProvider< - AppInitializationService, - Result< - ({ - bool initialized, - String? stage, - List errors - })>>.internal( - AppInitializationService.new, - name: r'appInitializationServiceProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$appInitializationServiceHash, - dependencies: null, - allTransitiveDependencies: null, -); +final appInitializationServiceProvider = + NotifierProvider< + AppInitializationService, + Result<({bool initialized, String? stage, List errors})> + >.internal( + AppInitializationService.new, + name: r'appInitializationServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$appInitializationServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); -typedef _$AppInitializationService = Notifier< - Result<({bool initialized, String? stage, List errors})>>; +typedef _$AppInitializationService = + Notifier< + Result<({bool initialized, String? stage, List errors})> + >; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/domain/services/generic_website.dart b/app/lib/domain/services/generic_website.dart index 24f01f35..799594bf 100644 --- a/app/lib/domain/services/generic_website.dart +++ b/app/lib/domain/services/generic_website.dart @@ -1,146 +1,383 @@ +import 'dart:async'; +import 'dart:ui'; + import 'package:exceptions/exceptions.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.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/http.dart' as http; -import 'package:lensai/core/http_error_handler.dart'; -import 'package:lensai/domain/entities/web_page_info.dart'; -import 'package:lensai/extensions/web_uri_favicon.dart'; -import 'package:lensai/features/web_view/utils/favicon_helper.dart'; +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:universal_io/io.dart'; +import 'package:weblibre/core/http_error_handler.dart'; +import 'package:weblibre/data/models/web_page_info.dart'; +import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart'; +import 'package:weblibre/features/user/domain/repositories/cache.dart'; +import 'package:weblibre/features/web_feed/utils/feed_finder.dart'; +import 'package:weblibre/presentation/controllers/website_title.dart'; +import 'package:weblibre/utils/lru_cache.dart'; part 'generic_website.g.dart'; -@Riverpod(keepAlive: true) -class GenericWebsiteService extends _$GenericWebsiteService { - late http.Client _client; - final Map _httpsCache; +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, +}; - GenericWebsiteService() : _httpsCache = {}; +Iterable sizesToList(String? sizes) sync* { + if (sizes != null) { + final splitted = sizes + .split(' ') + .where((size) => size.contains('x')) + .toList(); - @override - void build() { - _client = http.Client(); - } + 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]); - static Iterable _extractFavicons(Uri url, Document document) sync* { - final links = document.querySelectorAll( - 'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]', - ); - - for (final link in links) { - final href = link.attributes['href']; - if (href != null) { - // Attempt to parse height and width if available - int? height; - int? width; - if (link.attributes['sizes'] case final String sizes) { - final dimensions = sizes.split('x'); - if (dimensions.length == 2) { - height = int.tryParse(dimensions[0]); - width = int.tryParse(dimensions[1]); - } + if (width != null && height != null) { + yield ResourceSize(height: height, width: width); } - - yield Favicon( - url: WebUri.uri(url.resolve(href)), - rel: link.attributes['rel'], - width: width, - height: height, - ); } } } +} - Future> getInfo(Uri url) async { - return Result.fromAsync( - () async { - final response = - await _client.get(url).timeout(const Duration(seconds: 10)); - return await compute( - (args) { - final document = html_parser.parse(args[0]); - final url = Uri.parse(args[1]); +@Riverpod(keepAlive: true) +class GenericWebsiteService extends _$GenericWebsiteService { + final GeckoIconService _iconsService; - final title = document.querySelector('title')?.text; - final favicon = choseFavicon(_extractFavicons(url, document)) ?? - //In case the icon can not get extracted, we use the resolver - //of duckduckgo - Favicon( - url: WebUri.uri(url.genericFavicon()), - ); + //Global icon cache + late CacheRepository _cacheRepository; + //Local decoded icon cache + final LRUCache _browserIconCache; - return WebPageInfo(url: url, title: title, favicon: favicon) - .toJson(); - }, - [response.body, url.toString()], - ).then(WebPageInfo.fromJson); - }, - exceptionHandler: handleHttpError, + GenericWebsiteService() + : _iconsService = GeckoIconService(), + _browserIconCache = LRUCache(50); + + @override + void build() { + _cacheRepository = ref.watch(cacheRepositoryProvider.notifier); + } + + static Map _serializeResource(Resource resource) { + return { + 'url': resource.url, + 'type': resource.type, + 'sizes': resource.sizes.nonNulls.map((s) => [s.height, s.width]).toList(), + 'mimeType': resource.mimeType, + 'maskable': resource.maskable, + }; + } + + static Resource _deserializeResource(Map resource) { + return Resource( + url: resource['url'] as String, + type: resource['type'] as IconType, + mimeType: resource['mimeType'] as String?, + sizes: (resource['sizes'] as List>) + .map((s) => ResourceSize(height: s[0], width: s[1])) + .toList(), + maskable: resource['maskable'] as bool, ); } - Future> getFaviconBytes(Uri url) { - return getInfo(url).then( - (result) => result.flatMapAsync( - (info) async { - if (info.favicon != null) { - return _client - .get(info.favicon!.url) - .timeout(const Duration(seconds: 10)) - .then((response) { - if (response.statusCode == 200) { - return response.bodyBytes; - } else { - return null; - } - }); + static Uri _resolveRelativeUri(Uri baseUri, Uri uri) { + if (!uri.isAbsolute) { + return baseUri.resolveUri(uri); + } + return uri; + } + + static List _extractIcons(Uri baseUrl, Document document) { + final List icons = []; + + void collectLinkIcons(String rel) { + final links = document.querySelectorAll('link[rel="$rel"]'); + for (final link in links) { + final href = link.attributes['href']; + final type = _typeMap[rel]; + final mimeType = link.attributes['type']; + if (href != null && type != null) { + if (Uri.tryParse(href) case final Uri url) { + icons.add( + Resource( + url: _resolveRelativeUri(baseUrl, url).toString(), + type: type, + sizes: sizesToList(link.attributes['sizes']).toList(), + mimeType: mimeType.isNotEmpty ? mimeType : null, + maskable: false, + ), + ); + } + } + } + } + + 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, + ), + ); + } + } + } + } + + 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; + } + + Future> fetchPageInfo({ + required Uri url, + required bool isImageRequest, + required int? proxyPort, + }) { + return Result.fromAsync(() async { + final result = await compute((args) async { + final [String urlString, bool isImageRequest, int? proxyPort] = args; + + final httpClient = HttpClient(); + if (proxyPort != null) { + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(InternetAddress.loopbackIPv4, proxyPort), + ]); + } + + final client = IOClient(httpClient); + try { + final baseUri = Uri.parse(urlString); + final response = await client + .get(baseUri) + .timeout(const Duration(seconds: 15)); + + //When this is a request for an icon and we hit an image, directly return it + if (isImageRequest) { + final contentType = response.headers['content-type']; + if (contentType?.contains('image/') == true) { + return { + 'imageBytes': [response.bodyBytes], + }; + } } - return null; - }, - exceptionHandler: handleHttpError, - ), - ); + final document = html_parser.parse(response.body); + + final title = document.querySelector('title')?.text; + final resources = _extractIcons(baseUri, document); + final feeds = await FeedFinder( + url: baseUri, + document: document, + ).parse(); + + return { + 'title': title, + 'resources': resources.map(_serializeResource).toList(), + 'feeds': feeds.map((uri) => uri.toString()).toList(), + }; + } finally { + client.close(); + } + }, [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 resources = (result['resources']! as List>) + .map(_deserializeResource) + .toList(); + + final favicon = + await getCachedIcon(url) ?? + await loadIcon(url: url, resources: resources); + + return WebPageInfo( + url: url, + title: (result['title'] as String?)?.trim(), + favicon: favicon, + feeds: Set.from( + (result['feeds']! as List).map((url) => Uri.tryParse(url)), + ), + ); + }, exceptionHandler: handleHttpError); } - Future tryUpgradeToHttps(Uri httpUri) async { - if (httpUri.isScheme('https')) { - return httpUri; - } else if (httpUri.isScheme('http')) { - final cached = _httpsCache[httpUri.host]; - if (cached != null) { - return cached ? httpUri.replace(scheme: 'https') : null; + Future getCachedIcon(Uri url) async { + if (url.scheme.startsWith('http')) { + final cachedBrowserIcon = _browserIconCache.get(url.origin); + if (cachedBrowserIcon != null) { + return cachedBrowserIcon; } - var sslAvailable = false; - - try { - final context = SecurityContext.defaultContext; - - final socket = await SecureSocket.connect( - httpUri.host, - 443, - context: context, - timeout: const Duration(seconds: 3), + final cachedIcon = await _cacheRepository.getCachedIcon(url.origin); + if (cachedIcon != null) { + return _browserIconCache.set( + url.origin, + await BrowserIcon.fromBytes( + cachedIcon, + dominantColor: null, + source: IconSource.disk, + ), ); - - await socket.close(); - - sslAvailable = true; - } catch (_) { - sslAvailable = false; - } - - _httpsCache[httpUri.host] = sslAvailable; - - if (sslAvailable) { - return httpUri.replace(scheme: 'https'); } } return null; } + + Future loadIcon({ + required Uri url, + required List resources, + bool isPrivate = false, + bool waitOnNetworkLoad = true, + }) async { + final result = await _iconsService.loadIcon( + url: url, + resources: resources, + isPrivate: isPrivate, + waitOnNetworkLoad: waitOnNetworkLoad, + ); + + // unawaited(_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 getUrlIcon(List urlList) async { + for (final url in urlList) { + final cachedIcon = await getCachedIcon(url); + + if (cachedIcon != null) { + return cachedIcon; + } + + final result = await ref.read( + pageInfoProvider(url, isImageRequest: true).future, + ); + + if (result.favicon case final BrowserIcon favicon) { + //If it was a `isImageRequest` hit, we need to cache it at this point + if (!_browserIconCache.contains(url.origin)) { + _browserIconCache.set(url.origin, favicon); + } + + return favicon; + } + } + + return null; + } + + // Future tryUpgradeToHttps(Uri httpUri) async { + // if (httpUri.isScheme('https')) { + // return httpUri; + // } else if (httpUri.isScheme('http')) { + // final cached = _httpsCache[httpUri.host]; + // if (cached != null) { + // return cached ? httpUri.replace(scheme: 'https') : null; + // } + + // var sslAvailable = false; + + // try { + // final context = SecurityContext.defaultContext; + + // final socket = await SecureSocket.connect( + // httpUri.host, + // 443, + // context: context, + // timeout: const Duration(seconds: 3), + // ); + + // await socket.close(); + + // sslAvailable = true; + // } catch (_) { + // sslAvailable = false; + // } + + // _httpsCache[httpUri.host] = sslAvailable; + + // if (sslAvailable) { + // return httpUri.replace(scheme: 'https'); + // } + // } + + // return null; + // } } diff --git a/app/lib/domain/services/generic_website.g.dart b/app/lib/domain/services/generic_website.g.dart index 1ac6b5ae..7d56be38 100644 --- a/app/lib/domain/services/generic_website.g.dart +++ b/app/lib/domain/services/generic_website.g.dart @@ -7,21 +7,21 @@ part of 'generic_website.dart'; // ************************************************************************** String _$genericWebsiteServiceHash() => - r'517304ca95e1ef8377d4c1a3bd06fef9c68c92cd'; + r'19ce8c27e316d9220152f6ad9cfa8024d191c0f0'; /// See also [GenericWebsiteService]. @ProviderFor(GenericWebsiteService) final genericWebsiteServiceProvider = NotifierProvider.internal( - GenericWebsiteService.new, - name: r'genericWebsiteServiceProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$genericWebsiteServiceHash, - dependencies: null, - allTransitiveDependencies: null, -); + GenericWebsiteService.new, + name: r'genericWebsiteServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$genericWebsiteServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); typedef _$GenericWebsiteService = Notifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/core/extension/date_time.dart b/app/lib/extensions/date_time.dart similarity index 100% rename from app/lib/core/extension/date_time.dart rename to app/lib/extensions/date_time.dart diff --git a/app/lib/extensions/duartion.dart b/app/lib/extensions/duartion.dart new file mode 100644 index 00000000..5bda5e77 --- /dev/null +++ b/app/lib/extensions/duartion.dart @@ -0,0 +1,9 @@ +extension DurationX on Duration { + String formatTimeZoneOffset() { + final hours = inHours.abs().toString().padLeft(2, '0'); + final minutes = (inMinutes.abs() % 60).toString().padLeft(2, '0'); + final sign = isNegative ? '-' : '+'; + + return '$sign$hours$minutes'; + } +} diff --git a/app/lib/extensions/image.dart b/app/lib/extensions/image.dart new file mode 100644 index 00000000..005cc941 --- /dev/null +++ b/app/lib/extensions/image.dart @@ -0,0 +1,19 @@ +import 'dart:ui'; + +import 'package:fast_equatable/hash.dart'; + +extension ImageHash on Image { + Future calculateHash() async { + final byteData = await toByteData(); + if (byteData != null) { + final digest = secureHash(byteData); + return digest; + } + + return null; + } + + // Future toEquatable() { + // return EquatableImage.calculate(this); + // } +} diff --git a/app/lib/extensions/media_query.dart b/app/lib/extensions/media_query.dart new file mode 100644 index 00000000..4c8c0bb6 --- /dev/null +++ b/app/lib/extensions/media_query.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +extension RelativeSafeArea on MediaQueryData { + double relativeSafeArea() { + return 1.0 - (padding.top / size.height); + } +} diff --git a/app/lib/extensions/string.dart b/app/lib/extensions/string.dart new file mode 100644 index 00000000..688e6331 --- /dev/null +++ b/app/lib/extensions/string.dart @@ -0,0 +1,4 @@ +extension StringExtension on String { + String toCapitalized() => + isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}'; +} diff --git a/app/lib/extensions/uri.dart b/app/lib/extensions/uri.dart new file mode 100644 index 00000000..ad1b9e56 --- /dev/null +++ b/app/lib/extensions/uri.dart @@ -0,0 +1,3 @@ +extension UriX on Uri { + Uri get base => Uri.parse('$scheme://$authority'); +} diff --git a/app/lib/extensions/web_uri_favicon.dart b/app/lib/extensions/web_uri_favicon.dart deleted file mode 100644 index 7689db60..00000000 --- a/app/lib/extensions/web_uri_favicon.dart +++ /dev/null @@ -1,4 +0,0 @@ -extension UriFavicon on Uri { - Uri genericFavicon() => - Uri.parse('https://icons.duckduckgo.com/ip3/$host.ico'); -} diff --git a/app/lib/features/about/data/repositories/package_info_repository.dart b/app/lib/features/about/data/repositories/package_info_repository.dart index 25341f62..519e240a 100644 --- a/app/lib/features/about/data/repositories/package_info_repository.dart +++ b/app/lib/features/about/data/repositories/package_info_repository.dart @@ -1,9 +1,10 @@ import 'package:package_info_plus/package_info_plus.dart'; +import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'package_info_repository.g.dart'; @Riverpod(keepAlive: true) -Future packageInfo(PackageInfoRef ref) { +Future packageInfo(Ref ref) { return PackageInfo.fromPlatform(); } diff --git a/app/lib/features/about/data/repositories/package_info_repository.g.dart b/app/lib/features/about/data/repositories/package_info_repository.g.dart index d0b6c152..37c37ee0 100644 --- a/app/lib/features/about/data/repositories/package_info_repository.g.dart +++ b/app/lib/features/about/data/repositories/package_info_repository.g.dart @@ -6,19 +6,22 @@ part of 'package_info_repository.dart'; // RiverpodGenerator // ************************************************************************** -String _$packageInfoHash() => r'f7bd90882137aac7a9f28e9d10eae6efeee8c9d0'; +String _$packageInfoHash() => r'cc57db7b4684ab0d5df0f050b8ea045a3658e89a'; /// See also [packageInfo]. @ProviderFor(packageInfo) final packageInfoProvider = FutureProvider.internal( packageInfo, name: r'packageInfoProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$packageInfoHash, + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$packageInfoHash, dependencies: null, allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef PackageInfoRef = FutureProviderRef; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/about/presentation/screens/about.dart b/app/lib/features/about/presentation/screens/about.dart index 30a6ce07..acf3bff7 100644 --- a/app/lib/features/about/presentation/screens/about.dart +++ b/app/lib/features/about/presentation/screens/about.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/about/data/repositories/package_info_repository.dart'; +import 'package:weblibre/features/about/data/repositories/package_info_repository.dart'; class AboutDialogScreen extends HookConsumerWidget { const AboutDialogScreen(); @@ -17,11 +18,11 @@ class AboutDialogScreen extends HookConsumerWidget { return AboutDialog( applicationIcon: SizedBox.square( dimension: IconTheme.of(context).size, - child: Image.asset('assets/icon/icon.png'), + child: SvgPicture.asset('assets/icon/icon.svg'), ), applicationName: packageInfo.appName, applicationVersion: packageInfo.version, - applicationLegalese: 'Copyright © Fabian Freund, 2024', + applicationLegalese: 'Copyright © Fabian Freund, 2025', ); } } diff --git a/app/lib/features/app_widget/domain/services/home_widget.dart b/app/lib/features/app_widget/domain/services/home_widget.dart index 1ad71638..66c3b6bf 100644 --- a/app/lib/features/app_widget/domain/services/home_widget.dart +++ b/app/lib/features/app_widget/domain/services/home_widget.dart @@ -1,23 +1,23 @@ import 'package:home_widget/home_widget.dart'; -import 'package:lensai/domain/entities/received_parameter.dart'; +import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:weblibre/data/models/received_intent_parameter.dart'; part 'home_widget.g.dart'; @Riverpod(keepAlive: true) -FutureOr widgetPinnable(WidgetPinnableRef ref) async { +FutureOr widgetPinnable(Ref ref) async { return await HomeWidget.isRequestPinWidgetSupported() ?? false; } @Riverpod() -Raw> appWidgetLaunchStream( - AppWidgetLaunchStreamRef ref, -) { - // ignore: discarded_futures +Raw> appWidgetLaunchStream(Ref ref) { + // ignore: discarded_futures is used as stream final initialStream = HomeWidget.initiallyLaunchedFromHomeWidget().asStream(); - return ConcatStream([initialStream, HomeWidget.widgetClicked]) - .whereNotNull() - .map((uri) => ReceivedParameter(null, uri.host)); + return ConcatStream([ + initialStream, + HomeWidget.widgetClicked, + ]).whereNotNull().map((uri) => ReceivedIntentParameter(null, uri.host)); } diff --git a/app/lib/features/app_widget/domain/services/home_widget.g.dart b/app/lib/features/app_widget/domain/services/home_widget.g.dart index 6b87905e..04e124e3 100644 --- a/app/lib/features/app_widget/domain/services/home_widget.g.dart +++ b/app/lib/features/app_widget/domain/services/home_widget.g.dart @@ -6,7 +6,7 @@ part of 'home_widget.dart'; // RiverpodGenerator // ************************************************************************** -String _$widgetPinnableHash() => r'c39a8b473a92ff81fa772d7c235b10736e4a49bd'; +String _$widgetPinnableHash() => r'f91041f0f051ad0f00dd765cafea5571ca8c8088'; /// See also [widgetPinnable]. @ProviderFor(widgetPinnable) @@ -20,24 +20,28 @@ final widgetPinnableProvider = FutureProvider.internal( allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef WidgetPinnableRef = FutureProviderRef; String _$appWidgetLaunchStreamHash() => - r'8c5e21346d492f89f546b4e32135a283719e70e1'; + r'ed042d1391ae3dbc26f806d6fa2e9a4a2464ae44'; /// See also [appWidgetLaunchStream]. @ProviderFor(appWidgetLaunchStream) final appWidgetLaunchStreamProvider = - AutoDisposeProvider>>.internal( - appWidgetLaunchStream, - name: r'appWidgetLaunchStreamProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$appWidgetLaunchStreamHash, - dependencies: null, - allTransitiveDependencies: null, -); + AutoDisposeProvider>>.internal( + appWidgetLaunchStream, + name: r'appWidgetLaunchStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$appWidgetLaunchStreamHash, + dependencies: null, + allTransitiveDependencies: null, + ); -typedef AppWidgetLaunchStreamRef - = AutoDisposeProviderRef>>; +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AppWidgetLaunchStreamRef = + AutoDisposeProviderRef>>; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/bangs/data/database/daos/bang.dart b/app/lib/features/bangs/data/database/daos/bang.dart index 0c8b8137..f4a5927c 100644 --- a/app/lib/features/bangs/data/database/daos/bang.dart +++ b/app/lib/features/bangs/data/database/daos/bang.dart @@ -1,7 +1,9 @@ import 'package:drift/drift.dart'; -import 'package:lensai/features/bangs/data/database/database.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/bangs/data/database/database.dart'; +import 'package:weblibre/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; part 'bang.g.dart'; @@ -20,7 +22,10 @@ class BangDao extends DatabaseAccessor with _$BangDaoMixin { SingleSelectable getBangCount({Iterable? groups}) { return db.bang.count( - where: (groups != null) ? (t) => t.group.isInValues(groups) : null, + where: groups.mapNotNull( + (groups) => + (t) => t.group.isInValues(groups), + ), ); } @@ -29,6 +34,7 @@ class BangDao extends DatabaseAccessor with _$BangDaoMixin { } Selectable getBangDataList({ + Iterable? triggers, Iterable? groups, String? domain, String? category, @@ -36,6 +42,9 @@ class BangDao extends DatabaseAccessor with _$BangDaoMixin { bool? orderMostFrequentFirst, }) { final selectable = select(db.bangDataView); + if (triggers != null) { + selectable.where((t) => t.trigger.isIn(triggers)); + } if (groups != null) { selectable.where((t) => t.group.isInValues(groups)); } @@ -91,17 +100,33 @@ class BangDao extends DatabaseAccessor with _$BangDaoMixin { } Selectable queryBangs(String searchString) { - return db.bangQuery(query: db.buildQuery(searchString)); + final ftsQuery = db.buildFtsQuery(searchString); + + if (ftsQuery.isNotEmpty) { + return db.queryBangs(query: ftsQuery); + } else { + return db.queryBangsBasic(query: db.buildLikeQuery(searchString)); + } } - Future upsertBangIcon(String trigger, Uint8List iconData) { - return db.bangIcon.insertOne( - BangIconCompanion.insert( + Future addSearchEntry(String trigger, String searchQuery) { + return db.bangHistory.insertOne( + BangHistoryCompanion.insert( + searchQuery: searchQuery, trigger: trigger, - iconData: iconData, - fetchDate: DateTime.now(), + searchDate: DateTime.now(), + ), + onConflict: DoUpdate( + target: [db.bangHistory.searchQuery], + (old) => BangHistoryCompanion( + trigger: Value(trigger), + searchDate: Value(DateTime.now()), + ), ), - mode: InsertMode.insertOrReplace, ); } + + Future removeSearchEntry(String searchQuery) { + return db.bangHistory.deleteWhere((t) => t.searchQuery.equals(searchQuery)); + } } diff --git a/app/lib/features/bangs/data/database/daos/sync.dart b/app/lib/features/bangs/data/database/daos/sync.dart index c28762b3..31545872 100644 --- a/app/lib/features/bangs/data/database/daos/sync.dart +++ b/app/lib/features/bangs/data/database/daos/sync.dart @@ -1,6 +1,7 @@ import 'package:drift/drift.dart'; -import 'package:lensai/features/bangs/data/database/database.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/bangs/data/database/database.dart'; +import 'package:weblibre/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; part 'sync.g.dart'; @@ -8,7 +9,7 @@ part 'sync.g.dart'; class SyncDao extends DatabaseAccessor with _$SyncDaoMixin { SyncDao(super.db); - SingleOrNullSelectable lastSyncOfGroup(BangGroup group) { + SingleOrNullSelectable getLastSyncOfGroup(BangGroup group) { final query = selectOnly(db.bangSync) ..addColumns([db.bangSync.lastSync]) ..where(db.bangSync.group.equalsValue(group)); @@ -18,12 +19,9 @@ class SyncDao extends DatabaseAccessor with _$SyncDaoMixin { Future upsertLastSyncOfGroup(BangGroup group, DateTime lastSync) { return db.bangSync.insertOne( - BangSyncCompanion.insert( - group: Value(group), - lastSync: lastSync, - ), + BangSyncCompanion.insert(group: Value(group), lastSync: lastSync), onConflict: DoUpdate( - (old) => BangSyncCompanion.custom(lastSync: Variable(lastSync)), + (old) => BangSyncCompanion(lastSync: Value(lastSync)), ), ); } @@ -33,11 +31,9 @@ class SyncDao extends DatabaseAccessor with _$SyncDaoMixin { } Future replaceBangs(Iterable bangs) { - return batch( - (batch) { - batch.replaceAll(db.bang, bangs); - }, - ); + return batch((batch) { + batch.replaceAll(db.bang, bangs); + }); } Future deleteBangs(Iterable triggers) { @@ -50,36 +46,34 @@ class SyncDao extends DatabaseAccessor with _$SyncDaoMixin { required Iterable remoteBangs, required DateTime syncTime, }) async { - final remoteBangMap = - Map.fromEntries(remoteBangs.map((e) => MapEntry(e.trigger, e))); - final localBangMap = - await db.bangDao.getBangList(groups: [group]).get().then( - (bangs) => - Map.fromEntries(bangs.map((e) => MapEntry(e.trigger, e))), - ); + final remoteBangMap = Map.fromEntries( + remoteBangs.map((e) => MapEntry(e.trigger, e)), + ); + final localBangMap = await db.bangDao + .getBangList(groups: [group]) + .get() + .then( + (bangs) => Map.fromEntries(bangs.map((e) => MapEntry(e.trigger, e))), + ); final remoteBangTriggers = remoteBangMap.keys.toSet(); final localBangTriggers = localBangMap.keys.toSet(); final removedBangs = localBangTriggers.difference(remoteBangTriggers); - final addedBangs = remoteBangTriggers.difference(localBangTriggers).map( - (e) => remoteBangMap[e]!, - ); + final addedBangs = remoteBangTriggers + .difference(localBangTriggers) + .map((e) => remoteBangMap[e]!); final changedBangs = remoteBangTriggers .intersection(localBangTriggers) .where((e) => remoteBangMap[e] != localBangMap[e]) - .map( - (e) => remoteBangMap[e]!, - ); + .map((e) => remoteBangMap[e]!); - await db.transaction( - () async { - await deleteBangs(removedBangs); - await insertBangs(addedBangs); - await replaceBangs(changedBangs); - await upsertLastSyncOfGroup(group, syncTime); - }, - ); + await db.transaction(() async { + await deleteBangs(removedBangs); + await insertBangs(addedBangs); + await replaceBangs(changedBangs); + await upsertLastSyncOfGroup(group, syncTime); + }); } } diff --git a/app/lib/features/bangs/data/database/database.dart b/app/lib/features/bangs/data/database/database.dart index a7d29d55..0b4dfefc 100644 --- a/app/lib/features/bangs/data/database/database.dart +++ b/app/lib/features/bangs/data/database/database.dart @@ -1,17 +1,16 @@ import 'package:drift/drift.dart'; -import 'package:lensai/features/bangs/data/database/daos/bang.dart'; -import 'package:lensai/features/bangs/data/database/daos/sync.dart'; -import 'package:lensai/features/bangs/data/database/drift/converters/bang_format.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/query/domain/tokenizer.dart'; +import 'package:weblibre/features/bangs/data/database/daos/bang.dart'; +import 'package:weblibre/features/bangs/data/database/daos/sync.dart'; +import 'package:weblibre/features/bangs/data/database/drift/converters/bang_format.dart'; +import 'package:weblibre/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; +import 'package:weblibre/features/bangs/data/models/search_history_entry.dart'; +import 'package:weblibre/features/search/domain/fts_tokenizer.dart'; part 'database.g.dart'; -@DriftDatabase( - include: {'database.drift'}, - daos: [BangDao, SyncDao], -) +@DriftDatabase(include: {'database.drift'}, daos: [BangDao, SyncDao]) class BangDatabase extends _$BangDatabase with PrefixQueryBuilderMixin { @override final int schemaVersion = 1; @@ -23,10 +22,10 @@ class BangDatabase extends _$BangDatabase with PrefixQueryBuilderMixin { @override MigrationStrategy get migration => MigrationStrategy( - beforeOpen: (details) async { - await customStatement('PRAGMA foreign_keys = ON;'); - }, - ); + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON;'); + }, + ); BangDatabase(super.e); } diff --git a/app/lib/features/bangs/data/database/database.drift b/app/lib/features/bangs/data/database/database.drift index bbe08559..e3881510 100644 --- a/app/lib/features/bangs/data/database/database.drift +++ b/app/lib/features/bangs/data/database/database.drift @@ -1,6 +1,8 @@ -import 'package:lensai/features/bangs/data/database/drift/converters/bang_format.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/bangs/data/database/drift/converters/bang_format.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; +import 'package:weblibre/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/bangs/data/models/search_history_entry.dart'; CREATE TABLE bang ( "trigger" TEXT PRIMARY KEY NOT NULL, @@ -24,10 +26,10 @@ CREATE TABLE bang_frequency ( last_used DATETIME NOT NULL ); -CREATE TABLE bang_icon ( - "trigger" TEXT PRIMARY KEY NOT NULL REFERENCES bang ("trigger") ON DELETE CASCADE, - icon_data BLOB NOT NULL, - fetch_date DATETIME NOT NULL +CREATE TABLE bang_history ( + search_query TEXT UNIQUE NOT NULL, + "trigger" TEXT NOT NULL REFERENCES bang ("trigger"), + search_date DATETIME NOT NULL ); CREATE VIRTUAL TABLE bang_fts @@ -42,14 +44,11 @@ CREATE VIEW bang_data_view WITH BangData AS SELECT b.*, bf.frequency, - bf.last_used, - bi.icon_data + bf.last_used FROM bang b LEFT JOIN - bang_frequency bf ON b."trigger" = bf."trigger" - LEFT JOIN - bang_icon bi ON b."trigger" = bi."trigger"; + bang_frequency bf ON b."trigger" = bf."trigger"; -- Triggers to keep the FTS index up to date. CREATE TRIGGER bang_after_insert AFTER INSERT ON bang BEGIN @@ -74,23 +73,56 @@ END; optimizeFtsIndex: INSERT INTO bang_fts(bang_fts) VALUES ('optimize'); -bangQuery WITH BangData: - SELECT +queryBangs WITH BangData: + WITH weights AS ( + SELECT + -- Customize these weights (higher = more important) + 10.0 AS "trigger", -- Title matches are most important + 5.0 AS website_name -- URL matches are quite important + ) + SELECT b.*, bf.frequency, bf.last_used, - bi.icon_data - FROM + bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank + FROM bang_fts(:query) fts INNER JOIN bang b ON b.rowid = fts.rowid LEFT JOIN bang_frequency bf ON b."trigger" = bf."trigger" + CROSS JOIN + weights + ORDER BY + weighted_rank ASC, + bf.frequency NULLS LAST; + +queryBangsBasic WITH BangData: + WITH weights AS ( + SELECT + -- Customize these weights (higher = more important) + 10.0 AS "trigger", -- Title matches are most important + 5.0 AS website_name -- URL matches are quite important + ) + SELECT + b.*, + bf.frequency, + bf.last_used, + bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank + FROM + bang_fts fts + INNER JOIN + bang b ON b.rowid = fts.rowid LEFT JOIN - bang_icon bi ON b."trigger" = bi."trigger" - ORDER BY - RANK, - bf.frequency NULLS LAST; + bang_frequency bf ON b."trigger" = bf."trigger" + CROSS JOIN + weights + WHERE + fts."trigger" LIKE :query OR + fts.website_name LIKE :query + ORDER BY + weighted_rank ASC, + bf.frequency NULLS LAST; categoriesJson: WITH categories AS ( @@ -113,4 +145,19 @@ categoriesJson: c.category, json(c.sub_categories) ) AS categories_json - FROM categories c; \ No newline at end of file + FROM categories c; + +searchHistoryEntries WITH SearchHistoryEntry: + SELECT * + FROM bang_history + ORDER BY search_date DESC + LIMIT :limit; + +evictHistoryEntries: + DELETE FROM bang_history + WHERE rowid IN ( + SELECT rowid + FROM bang_history + ORDER BY search_date DESC + LIMIT -1 OFFSET :limit + ); \ No newline at end of file diff --git a/app/lib/features/bangs/data/database/database.g.dart b/app/lib/features/bangs/data/database/database.g.dart index 02e423f3..a07460ec 100644 --- a/app/lib/features/bangs/data/database/database.g.dart +++ b/app/lib/features/bangs/data/database/database.g.dart @@ -9,58 +9,82 @@ class BangTable extends Table with TableInfo { final String? _alias; BangTable(this.attachedDatabase, [this._alias]); late final GeneratedColumn trigger = GeneratedColumn( - 'trigger', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'PRIMARY KEY NOT NULL'); + 'trigger', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); late final GeneratedColumnWithTypeConverter group = - GeneratedColumn('group', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL') - .withConverter(BangTable.$convertergroup); + GeneratedColumn( + 'group', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(BangTable.$convertergroup); late final GeneratedColumn websiteName = GeneratedColumn( - 'website_name', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); + 'website_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); late final GeneratedColumn domain = GeneratedColumn( - 'domain', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); + 'domain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); late final GeneratedColumn urlTemplate = GeneratedColumn( - 'url_template', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); + 'url_template', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); late final GeneratedColumn category = GeneratedColumn( - 'category', aliasedName, true, - type: DriftSqlType.string, - requiredDuringInsert: false, - $customConstraints: ''); + 'category', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); late final GeneratedColumn subCategory = GeneratedColumn( - 'sub_category', aliasedName, true, - type: DriftSqlType.string, - requiredDuringInsert: false, - $customConstraints: ''); + 'sub_category', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); late final GeneratedColumnWithTypeConverter?, String> format = - GeneratedColumn('format', aliasedName, true, - type: DriftSqlType.string, - requiredDuringInsert: false, - $customConstraints: '') - .withConverter?>(BangTable.$converterformat); + GeneratedColumn( + 'format', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(BangTable.$converterformat); @override List get $columns => [ - trigger, - group, - websiteName, - domain, - urlTemplate, - category, - subCategory, - format - ]; + trigger, + group, + websiteName, + domain, + urlTemplate, + category, + subCategory, + format, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -72,22 +96,42 @@ class BangTable extends Table with TableInfo { Bang map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return Bang( - websiteName: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}website_name'])!, - domain: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}domain'])!, - trigger: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, - urlTemplate: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}url_template'])!, - group: BangTable.$convertergroup.fromSql(attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}group'])!), - category: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}category']), - subCategory: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}sub_category']), - format: BangTable.$converterformat.fromSql(attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}format'])), + websiteName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}website_name'], + )!, + domain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}domain'], + )!, + trigger: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}trigger'], + )!, + urlTemplate: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_template'], + )!, + group: BangTable.$convertergroup.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}group'], + )!, + ), + category: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category'], + ), + subCategory: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}sub_category'], + ), + format: BangTable.$converterformat.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}format'], + ), + ), ); } @@ -135,11 +179,11 @@ class BangCompanion extends UpdateCompanion { this.subCategory = const Value.absent(), this.format = const Value.absent(), this.rowid = const Value.absent(), - }) : trigger = Value(trigger), - group = Value(group), - websiteName = Value(websiteName), - domain = Value(domain), - urlTemplate = Value(urlTemplate); + }) : trigger = Value(trigger), + group = Value(group), + websiteName = Value(websiteName), + domain = Value(domain), + urlTemplate = Value(urlTemplate); static Insertable custom({ Expression? trigger, Expression? group, @@ -164,16 +208,17 @@ class BangCompanion extends UpdateCompanion { }); } - BangCompanion copyWith( - {Value? trigger, - Value? group, - Value? websiteName, - Value? domain, - Value? urlTemplate, - Value? category, - Value? subCategory, - Value?>? format, - Value? rowid}) { + BangCompanion copyWith({ + Value? trigger, + Value? group, + Value? websiteName, + Value? domain, + Value? urlTemplate, + Value? category, + Value? subCategory, + Value?>? format, + Value? rowid, + }) { return BangCompanion( trigger: trigger ?? this.trigger, group: group ?? this.group, @@ -194,8 +239,9 @@ class BangCompanion extends UpdateCompanion { map['trigger'] = Variable(trigger.value); } if (group.present) { - map['group'] = - Variable(BangTable.$convertergroup.toSql(group.value)); + map['group'] = Variable( + BangTable.$convertergroup.toSql(group.value), + ); } if (websiteName.present) { map['website_name'] = Variable(websiteName.value); @@ -213,8 +259,9 @@ class BangCompanion extends UpdateCompanion { map['sub_category'] = Variable(subCategory.value); } if (format.present) { - map['format'] = - Variable(BangTable.$converterformat.toSql(format.value)); + map['format'] = Variable( + BangTable.$converterformat.toSql(format.value), + ); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -245,16 +292,22 @@ class BangSync extends Table with TableInfo { final String? _alias; BangSync(this.attachedDatabase, [this._alias]); late final GeneratedColumnWithTypeConverter group = - GeneratedColumn('group', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - $customConstraints: 'PRIMARY KEY NOT NULL') - .withConverter(BangSync.$convertergroup); + GeneratedColumn( + 'group', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'PRIMARY KEY NOT NULL', + ).withConverter(BangSync.$convertergroup); late final GeneratedColumn lastSync = GeneratedColumn( - 'last_sync', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); + 'last_sync', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); @override List get $columns => [group, lastSync]; @override @@ -268,10 +321,16 @@ class BangSync extends Table with TableInfo { BangSyncData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return BangSyncData( - group: BangSync.$convertergroup.fromSql(attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}group'])!), - lastSync: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}last_sync'])!, + group: BangSync.$convertergroup.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}group'], + )!, + ), + lastSync: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_sync'], + )!, ); } @@ -300,12 +359,15 @@ class BangSyncData extends DataClass implements Insertable { return map; } - factory BangSyncData.fromJson(Map json, - {ValueSerializer? serializer}) { + factory BangSyncData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return BangSyncData( - group: BangSync.$convertergroup - .fromJson(serializer.fromJson(json['group'])), + group: BangSync.$convertergroup.fromJson( + serializer.fromJson(json['group']), + ), lastSync: serializer.fromJson(json['last_sync']), ); } @@ -319,9 +381,9 @@ class BangSyncData extends DataClass implements Insertable { } BangSyncData copyWith({BangGroup? group, DateTime? lastSync}) => BangSyncData( - group: group ?? this.group, - lastSync: lastSync ?? this.lastSync, - ); + group: group ?? this.group, + lastSync: lastSync ?? this.lastSync, + ); BangSyncData copyWithCompanion(BangSyncCompanion data) { return BangSyncData( group: data.group.present ? data.group.value : this.group, @@ -369,8 +431,10 @@ class BangSyncCompanion extends UpdateCompanion { }); } - BangSyncCompanion copyWith( - {Value? group, Value? lastSync}) { + BangSyncCompanion copyWith({ + Value? group, + Value? lastSync, + }) { return BangSyncCompanion( group: group ?? this.group, lastSync: lastSync ?? this.lastSync, @@ -406,21 +470,30 @@ class BangFrequency extends Table final String? _alias; BangFrequency(this.attachedDatabase, [this._alias]); late final GeneratedColumn trigger = GeneratedColumn( - 'trigger', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: - 'PRIMARY KEY NOT NULL REFERENCES bang("trigger")ON DELETE CASCADE'); + 'trigger', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'PRIMARY KEY NOT NULL REFERENCES bang("trigger")ON DELETE CASCADE', + ); late final GeneratedColumn frequency = GeneratedColumn( - 'frequency', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); + 'frequency', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); late final GeneratedColumn lastUsed = GeneratedColumn( - 'last_used', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); + 'last_used', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); @override List get $columns => [trigger, frequency, lastUsed]; @override @@ -434,12 +507,18 @@ class BangFrequency extends Table BangFrequencyData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return BangFrequencyData( - trigger: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, - frequency: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}frequency'])!, - lastUsed: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}last_used'])!, + trigger: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}trigger'], + )!, + frequency: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}frequency'], + )!, + lastUsed: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_used'], + )!, ); } @@ -457,8 +536,11 @@ class BangFrequencyData extends DataClass final String trigger; final int frequency; final DateTime lastUsed; - const BangFrequencyData( - {required this.trigger, required this.frequency, required this.lastUsed}); + const BangFrequencyData({ + required this.trigger, + required this.frequency, + required this.lastUsed, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -468,8 +550,10 @@ class BangFrequencyData extends DataClass return map; } - factory BangFrequencyData.fromJson(Map json, - {ValueSerializer? serializer}) { + factory BangFrequencyData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return BangFrequencyData( trigger: serializer.fromJson(json['trigger']), @@ -487,13 +571,15 @@ class BangFrequencyData extends DataClass }; } - BangFrequencyData copyWith( - {String? trigger, int? frequency, DateTime? lastUsed}) => - BangFrequencyData( - trigger: trigger ?? this.trigger, - frequency: frequency ?? this.frequency, - lastUsed: lastUsed ?? this.lastUsed, - ); + BangFrequencyData copyWith({ + String? trigger, + int? frequency, + DateTime? lastUsed, + }) => BangFrequencyData( + trigger: trigger ?? this.trigger, + frequency: frequency ?? this.frequency, + lastUsed: lastUsed ?? this.lastUsed, + ); BangFrequencyData copyWithCompanion(BangFrequencyCompanion data) { return BangFrequencyData( trigger: data.trigger.present ? data.trigger.value : this.trigger, @@ -539,9 +625,9 @@ class BangFrequencyCompanion extends UpdateCompanion { required int frequency, required DateTime lastUsed, this.rowid = const Value.absent(), - }) : trigger = Value(trigger), - frequency = Value(frequency), - lastUsed = Value(lastUsed); + }) : trigger = Value(trigger), + frequency = Value(frequency), + lastUsed = Value(lastUsed); static Insertable custom({ Expression? trigger, Expression? frequency, @@ -556,11 +642,12 @@ class BangFrequencyCompanion extends UpdateCompanion { }); } - BangFrequencyCompanion copyWith( - {Value? trigger, - Value? frequency, - Value? lastUsed, - Value? rowid}) { + BangFrequencyCompanion copyWith({ + Value? trigger, + Value? frequency, + Value? lastUsed, + Value? rowid, + }) { return BangFrequencyCompanion( trigger: trigger ?? this.trigger, frequency: frequency ?? this.frequency, @@ -599,171 +686,196 @@ class BangFrequencyCompanion extends UpdateCompanion { } } -class BangIcon extends Table with TableInfo { +class BangHistory extends Table with TableInfo { @override final GeneratedDatabase attachedDatabase; final String? _alias; - BangIcon(this.attachedDatabase, [this._alias]); + BangHistory(this.attachedDatabase, [this._alias]); + late final GeneratedColumn searchQuery = GeneratedColumn( + 'search_query', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'UNIQUE NOT NULL', + ); late final GeneratedColumn trigger = GeneratedColumn( - 'trigger', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: - 'PRIMARY KEY NOT NULL REFERENCES bang("trigger")ON DELETE CASCADE'); - late final GeneratedColumn iconData = GeneratedColumn( - 'icon_data', aliasedName, false, - type: DriftSqlType.blob, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); - late final GeneratedColumn fetchDate = GeneratedColumn( - 'fetch_date', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); + 'trigger', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES bang("trigger")', + ); + late final GeneratedColumn searchDate = GeneratedColumn( + 'search_date', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); @override - List get $columns => [trigger, iconData, fetchDate]; + List get $columns => [searchQuery, trigger, searchDate]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'bang_icon'; + static const String $name = 'bang_history'; @override - Set get $primaryKey => {trigger}; + Set get $primaryKey => const {}; @override - BangIconData map(Map data, {String? tablePrefix}) { + BangHistoryData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return BangIconData( - trigger: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, - iconData: attachedDatabase.typeMapping - .read(DriftSqlType.blob, data['${effectivePrefix}icon_data'])!, - fetchDate: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}fetch_date'])!, + return BangHistoryData( + searchQuery: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}search_query'], + )!, + trigger: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}trigger'], + )!, + searchDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}search_date'], + )!, ); } @override - BangIcon createAlias(String alias) { - return BangIcon(attachedDatabase, alias); + BangHistory createAlias(String alias) { + return BangHistory(attachedDatabase, alias); } @override bool get dontWriteConstraints => true; } -class BangIconData extends DataClass implements Insertable { +class BangHistoryData extends DataClass implements Insertable { + final String searchQuery; final String trigger; - final Uint8List iconData; - final DateTime fetchDate; - const BangIconData( - {required this.trigger, required this.iconData, required this.fetchDate}); + final DateTime searchDate; + const BangHistoryData({ + required this.searchQuery, + required this.trigger, + required this.searchDate, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; + map['search_query'] = Variable(searchQuery); map['trigger'] = Variable(trigger); - map['icon_data'] = Variable(iconData); - map['fetch_date'] = Variable(fetchDate); + map['search_date'] = Variable(searchDate); return map; } - factory BangIconData.fromJson(Map json, - {ValueSerializer? serializer}) { + factory BangHistoryData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return BangIconData( + return BangHistoryData( + searchQuery: serializer.fromJson(json['search_query']), trigger: serializer.fromJson(json['trigger']), - iconData: serializer.fromJson(json['icon_data']), - fetchDate: serializer.fromJson(json['fetch_date']), + searchDate: serializer.fromJson(json['search_date']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { + 'search_query': serializer.toJson(searchQuery), 'trigger': serializer.toJson(trigger), - 'icon_data': serializer.toJson(iconData), - 'fetch_date': serializer.toJson(fetchDate), + 'search_date': serializer.toJson(searchDate), }; } - BangIconData copyWith( - {String? trigger, Uint8List? iconData, DateTime? fetchDate}) => - BangIconData( - trigger: trigger ?? this.trigger, - iconData: iconData ?? this.iconData, - fetchDate: fetchDate ?? this.fetchDate, - ); - BangIconData copyWithCompanion(BangIconCompanion data) { - return BangIconData( + BangHistoryData copyWith({ + String? searchQuery, + String? trigger, + DateTime? searchDate, + }) => BangHistoryData( + searchQuery: searchQuery ?? this.searchQuery, + trigger: trigger ?? this.trigger, + searchDate: searchDate ?? this.searchDate, + ); + BangHistoryData copyWithCompanion(BangHistoryCompanion data) { + return BangHistoryData( + searchQuery: data.searchQuery.present + ? data.searchQuery.value + : this.searchQuery, trigger: data.trigger.present ? data.trigger.value : this.trigger, - iconData: data.iconData.present ? data.iconData.value : this.iconData, - fetchDate: data.fetchDate.present ? data.fetchDate.value : this.fetchDate, + searchDate: data.searchDate.present + ? data.searchDate.value + : this.searchDate, ); } @override String toString() { - return (StringBuffer('BangIconData(') + return (StringBuffer('BangHistoryData(') + ..write('searchQuery: $searchQuery, ') ..write('trigger: $trigger, ') - ..write('iconData: $iconData, ') - ..write('fetchDate: $fetchDate') + ..write('searchDate: $searchDate') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(trigger, $driftBlobEquality.hash(iconData), fetchDate); + int get hashCode => Object.hash(searchQuery, trigger, searchDate); @override bool operator ==(Object other) => identical(this, other) || - (other is BangIconData && + (other is BangHistoryData && + other.searchQuery == this.searchQuery && other.trigger == this.trigger && - $driftBlobEquality.equals(other.iconData, this.iconData) && - other.fetchDate == this.fetchDate); + other.searchDate == this.searchDate); } -class BangIconCompanion extends UpdateCompanion { +class BangHistoryCompanion extends UpdateCompanion { + final Value searchQuery; final Value trigger; - final Value iconData; - final Value fetchDate; + final Value searchDate; final Value rowid; - const BangIconCompanion({ + const BangHistoryCompanion({ + this.searchQuery = const Value.absent(), this.trigger = const Value.absent(), - this.iconData = const Value.absent(), - this.fetchDate = const Value.absent(), + this.searchDate = const Value.absent(), this.rowid = const Value.absent(), }); - BangIconCompanion.insert({ + BangHistoryCompanion.insert({ + required String searchQuery, required String trigger, - required Uint8List iconData, - required DateTime fetchDate, + required DateTime searchDate, this.rowid = const Value.absent(), - }) : trigger = Value(trigger), - iconData = Value(iconData), - fetchDate = Value(fetchDate); - static Insertable custom({ + }) : searchQuery = Value(searchQuery), + trigger = Value(trigger), + searchDate = Value(searchDate); + static Insertable custom({ + Expression? searchQuery, Expression? trigger, - Expression? iconData, - Expression? fetchDate, + Expression? searchDate, Expression? rowid, }) { return RawValuesInsertable({ + if (searchQuery != null) 'search_query': searchQuery, if (trigger != null) 'trigger': trigger, - if (iconData != null) 'icon_data': iconData, - if (fetchDate != null) 'fetch_date': fetchDate, + if (searchDate != null) 'search_date': searchDate, if (rowid != null) 'rowid': rowid, }); } - BangIconCompanion copyWith( - {Value? trigger, - Value? iconData, - Value? fetchDate, - Value? rowid}) { - return BangIconCompanion( + BangHistoryCompanion copyWith({ + Value? searchQuery, + Value? trigger, + Value? searchDate, + Value? rowid, + }) { + return BangHistoryCompanion( + searchQuery: searchQuery ?? this.searchQuery, trigger: trigger ?? this.trigger, - iconData: iconData ?? this.iconData, - fetchDate: fetchDate ?? this.fetchDate, + searchDate: searchDate ?? this.searchDate, rowid: rowid ?? this.rowid, ); } @@ -771,14 +883,14 @@ class BangIconCompanion extends UpdateCompanion { @override Map toColumns(bool nullToAbsent) { final map = {}; + if (searchQuery.present) { + map['search_query'] = Variable(searchQuery.value); + } if (trigger.present) { map['trigger'] = Variable(trigger.value); } - if (iconData.present) { - map['icon_data'] = Variable(iconData.value); - } - if (fetchDate.present) { - map['fetch_date'] = Variable(fetchDate.value); + if (searchDate.present) { + map['search_date'] = Variable(searchDate.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -788,10 +900,10 @@ class BangIconCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('BangIconCompanion(') + return (StringBuffer('BangHistoryCompanion(') + ..write('searchQuery: $searchQuery, ') ..write('trigger: $trigger, ') - ..write('iconData: $iconData, ') - ..write('fetchDate: $fetchDate, ') + ..write('searchDate: $searchDate, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -805,15 +917,21 @@ class BangFts extends Table final String? _alias; BangFts(this.attachedDatabase, [this._alias]); late final GeneratedColumn trigger = GeneratedColumn( - 'trigger', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: ''); + 'trigger', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); late final GeneratedColumn websiteName = GeneratedColumn( - 'website_name', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: ''); + 'website_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); @override List get $columns => [trigger, websiteName]; @override @@ -827,10 +945,14 @@ class BangFts extends Table BangFt map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return BangFt( - trigger: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, - websiteName: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}website_name'])!, + trigger: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}trigger'], + )!, + websiteName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}website_name'], + )!, ); } @@ -858,8 +980,10 @@ class BangFt extends DataClass implements Insertable { return map; } - factory BangFt.fromJson(Map json, - {ValueSerializer? serializer}) { + factory BangFt.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return BangFt( trigger: serializer.fromJson(json['trigger']), @@ -876,14 +1000,15 @@ class BangFt extends DataClass implements Insertable { } BangFt copyWith({String? trigger, String? websiteName}) => BangFt( - trigger: trigger ?? this.trigger, - websiteName: websiteName ?? this.websiteName, - ); + trigger: trigger ?? this.trigger, + websiteName: websiteName ?? this.websiteName, + ); BangFt copyWithCompanion(BangFtsCompanion data) { return BangFt( trigger: data.trigger.present ? data.trigger.value : this.trigger, - websiteName: - data.websiteName.present ? data.websiteName.value : this.websiteName, + websiteName: data.websiteName.present + ? data.websiteName.value + : this.websiteName, ); } @@ -919,8 +1044,8 @@ class BangFtsCompanion extends UpdateCompanion { required String trigger, required String websiteName, this.rowid = const Value.absent(), - }) : trigger = Value(trigger), - websiteName = Value(websiteName); + }) : trigger = Value(trigger), + websiteName = Value(websiteName); static Insertable custom({ Expression? trigger, Expression? websiteName, @@ -933,8 +1058,11 @@ class BangFtsCompanion extends UpdateCompanion { }); } - BangFtsCompanion copyWith( - {Value? trigger, Value? websiteName, Value? rowid}) { + BangFtsCompanion copyWith({ + Value? trigger, + Value? websiteName, + Value? rowid, + }) { return BangFtsCompanion( trigger: trigger ?? this.trigger, websiteName: websiteName ?? this.websiteName, @@ -976,90 +1104,135 @@ class BangDataView extends ViewInfo BangDataView(this.attachedDatabase, [this._alias]); @override List get $columns => [ - trigger, - group, - websiteName, - domain, - urlTemplate, - category, - subCategory, - format, - frequency, - lastUsed, - iconData - ]; + trigger, + group, + websiteName, + domain, + urlTemplate, + category, + subCategory, + format, + frequency, + lastUsed, + ]; @override String get aliasedName => _alias ?? entityName; @override String get entityName => 'bang_data_view'; @override Map get createViewStatements => { - SqlDialect.sqlite: - 'CREATE VIEW bang_data_view AS SELECT b.*, bf.frequency, bf.last_used, bi.icon_data FROM bang AS b LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" LEFT JOIN bang_icon AS bi ON b."trigger" = bi."trigger"', - }; + SqlDialect.sqlite: + 'CREATE VIEW bang_data_view AS SELECT b.*, bf.frequency, bf.last_used FROM bang AS b LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger"', + }; @override BangDataView get asDslTable => this; @override BangData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return BangData( - websiteName: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}website_name'])!, - domain: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}domain'])!, - trigger: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, - urlTemplate: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}url_template'])!, - category: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}category']), - subCategory: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}sub_category']), - format: BangTable.$converterformat.fromSql(attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}format'])), - frequency: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}frequency']), - lastUsed: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}last_used']), - iconData: attachedDatabase.typeMapping - .read(DriftSqlType.blob, data['${effectivePrefix}icon_data']), + websiteName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}website_name'], + )!, + domain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}domain'], + )!, + trigger: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}trigger'], + )!, + urlTemplate: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url_template'], + )!, + category: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category'], + ), + subCategory: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}sub_category'], + ), + format: BangTable.$converterformat.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}format'], + ), + ), + frequency: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}frequency'], + ), + lastUsed: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_used'], + ), ); } late final GeneratedColumn trigger = GeneratedColumn( - 'trigger', aliasedName, false, - type: DriftSqlType.string); + 'trigger', + aliasedName, + false, + type: DriftSqlType.string, + ); late final GeneratedColumnWithTypeConverter group = - GeneratedColumn('group', aliasedName, false, type: DriftSqlType.int) - .withConverter(BangTable.$convertergroup); + GeneratedColumn( + 'group', + aliasedName, + false, + type: DriftSqlType.int, + ).withConverter(BangTable.$convertergroup); late final GeneratedColumn websiteName = GeneratedColumn( - 'website_name', aliasedName, false, - type: DriftSqlType.string); + 'website_name', + aliasedName, + false, + type: DriftSqlType.string, + ); late final GeneratedColumn domain = GeneratedColumn( - 'domain', aliasedName, false, - type: DriftSqlType.string); + 'domain', + aliasedName, + false, + type: DriftSqlType.string, + ); late final GeneratedColumn urlTemplate = GeneratedColumn( - 'url_template', aliasedName, false, - type: DriftSqlType.string); + 'url_template', + aliasedName, + false, + type: DriftSqlType.string, + ); late final GeneratedColumn category = GeneratedColumn( - 'category', aliasedName, true, - type: DriftSqlType.string); + 'category', + aliasedName, + true, + type: DriftSqlType.string, + ); late final GeneratedColumn subCategory = GeneratedColumn( - 'sub_category', aliasedName, true, - type: DriftSqlType.string); + 'sub_category', + aliasedName, + true, + type: DriftSqlType.string, + ); late final GeneratedColumnWithTypeConverter?, String> format = - GeneratedColumn('format', aliasedName, true, - type: DriftSqlType.string) - .withConverter?>(BangTable.$converterformat); + GeneratedColumn( + 'format', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter?>(BangTable.$converterformat); late final GeneratedColumn frequency = GeneratedColumn( - 'frequency', aliasedName, true, - type: DriftSqlType.int); + 'frequency', + aliasedName, + true, + type: DriftSqlType.int, + ); late final GeneratedColumn lastUsed = GeneratedColumn( - 'last_used', aliasedName, true, - type: DriftSqlType.dateTime); - late final GeneratedColumn iconData = GeneratedColumn( - 'icon_data', aliasedName, true, - type: DriftSqlType.blob); + 'last_used', + aliasedName, + true, + type: DriftSqlType.dateTime, + ); @override BangDataView createAlias(String alias) { return BangDataView(attachedDatabase, alias); @@ -1068,7 +1241,7 @@ class BangDataView extends ViewInfo @override Query? get query => null; @override - Set get readTables => const {'bang', 'bang_frequency', 'bang_icon'}; + Set get readTables => const {'bang', 'bang_frequency'}; } abstract class _$BangDatabase extends GeneratedDatabase { @@ -1077,18 +1250,21 @@ abstract class _$BangDatabase extends GeneratedDatabase { late final BangTable bang = BangTable(this); late final BangSync bangSync = BangSync(this); late final BangFrequency bangFrequency = BangFrequency(this); - late final BangIcon bangIcon = BangIcon(this); + late final BangHistory bangHistory = BangHistory(this); late final BangFts bangFts = BangFts(this); late final BangDataView bangDataView = BangDataView(this); late final Trigger bangAfterInsert = Trigger( - 'CREATE TRIGGER bang_after_insert AFTER INSERT ON bang BEGIN INSERT INTO bang_fts ("rowid", "trigger", website_name) VALUES (new."rowid", new."trigger", new.website_name);END', - 'bang_after_insert'); + 'CREATE TRIGGER bang_after_insert AFTER INSERT ON bang BEGIN INSERT INTO bang_fts ("rowid", "trigger", website_name) VALUES (new."rowid", new."trigger", new.website_name);END', + 'bang_after_insert', + ); late final Trigger bangAfterDelete = Trigger( - 'CREATE TRIGGER bang_after_delete AFTER DELETE ON bang BEGIN INSERT INTO bang_fts (bang_fts, "rowid", "trigger", website_name) VALUES (\'delete\', old."rowid", old."trigger", old.website_name);END', - 'bang_after_delete'); + 'CREATE TRIGGER bang_after_delete AFTER DELETE ON bang BEGIN INSERT INTO bang_fts (bang_fts, "rowid", "trigger", website_name) VALUES (\'delete\', old."rowid", old."trigger", old.website_name);END', + 'bang_after_delete', + ); late final Trigger bangAfterUpdate = Trigger( - 'CREATE TRIGGER bang_after_update AFTER UPDATE ON bang BEGIN INSERT INTO bang_fts (bang_fts, "rowid", "trigger", website_name) VALUES (\'delete\', old."rowid", old."trigger", old.website_name);INSERT INTO bang_fts ("rowid", "trigger", website_name) VALUES (new."rowid", new."trigger", new.website_name);END', - 'bang_after_update'); + 'CREATE TRIGGER bang_after_update AFTER UPDATE ON bang BEGIN INSERT INTO bang_fts (bang_fts, "rowid", "trigger", website_name) VALUES (\'delete\', old."rowid", old."trigger", old.website_name);INSERT INTO bang_fts ("rowid", "trigger", website_name) VALUES (new."rowid", new."trigger", new.website_name);END', + 'bang_after_update', + ); late final BangDao bangDao = BangDao(this as BangDatabase); late final SyncDao syncDao = SyncDao(this as BangDatabase); Future optimizeFtsIndex() { @@ -1099,39 +1275,79 @@ abstract class _$BangDatabase extends GeneratedDatabase { ); } - Selectable bangQuery({required String query}) { + Selectable queryBangs({required String query}) { return customSelect( - 'SELECT b.*, bf.frequency, bf.last_used, bi.icon_data FROM bang_fts(?1)AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" LEFT JOIN bang_icon AS bi ON b."trigger" = bi."trigger" ORDER BY RANK, bf.frequency NULLS LAST', - variables: [ - Variable(query) - ], - readsFrom: { - bangFrequency, - bangIcon, - bang, - bangFts, - }).map((QueryRow row) => BangData( - websiteName: row.read('website_name'), - domain: row.read('domain'), - trigger: row.read('trigger'), - urlTemplate: row.read('url_template'), - category: row.readNullable('category'), - subCategory: row.readNullable('sub_category'), - format: BangTable.$converterformat - .fromSql(row.readNullable('format')), - frequency: row.readNullable('frequency'), - lastUsed: row.readNullable('last_used'), - iconData: row.readNullable('icon_data'), - )); + 'WITH weights AS (SELECT 10.0 AS "trigger", 5.0 AS website_name) SELECT b.*, bf.frequency, bf.last_used, bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank FROM bang_fts(?1)AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" CROSS JOIN weights ORDER BY weighted_rank ASC, bf.frequency NULLS LAST', + variables: [Variable(query)], + readsFrom: {bangFrequency, bangFts, bang}, + ).map( + (QueryRow row) => BangData( + websiteName: row.read('website_name'), + domain: row.read('domain'), + trigger: row.read('trigger'), + urlTemplate: row.read('url_template'), + category: row.readNullable('category'), + subCategory: row.readNullable('sub_category'), + format: BangTable.$converterformat.fromSql( + row.readNullable('format'), + ), + frequency: row.readNullable('frequency'), + lastUsed: row.readNullable('last_used'), + ), + ); + } + + Selectable queryBangsBasic({required String query}) { + return customSelect( + 'WITH weights AS (SELECT 10.0 AS "trigger", 5.0 AS website_name) SELECT b.*, bf.frequency, bf.last_used, bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank FROM bang_fts AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" CROSS JOIN weights WHERE fts."trigger" LIKE ?1 OR fts.website_name LIKE ?1 ORDER BY weighted_rank ASC, bf.frequency NULLS LAST', + variables: [Variable(query)], + readsFrom: {bangFrequency, bangFts, bang}, + ).map( + (QueryRow row) => BangData( + websiteName: row.read('website_name'), + domain: row.read('domain'), + trigger: row.read('trigger'), + urlTemplate: row.read('url_template'), + category: row.readNullable('category'), + subCategory: row.readNullable('sub_category'), + format: BangTable.$converterformat.fromSql( + row.readNullable('format'), + ), + frequency: row.readNullable('frequency'), + lastUsed: row.readNullable('last_used'), + ), + ); } Selectable categoriesJson() { return customSelect( - 'WITH categories AS (SELECT b.category, json_group_array(DISTINCT b.sub_category ORDER BY b.sub_category)AS sub_categories FROM bang AS b WHERE b.category IS NOT NULL AND b.sub_category IS NOT NULL GROUP BY b.category ORDER BY b.category) SELECT json_group_object(c.category, json(c.sub_categories)) AS categories_json FROM categories AS c', - variables: [], - readsFrom: { - bang, - }).map((QueryRow row) => row.read('categories_json')); + 'WITH categories AS (SELECT b.category, json_group_array(DISTINCT b.sub_category ORDER BY b.sub_category)AS sub_categories FROM bang AS b WHERE b.category IS NOT NULL AND b.sub_category IS NOT NULL GROUP BY b.category ORDER BY b.category) SELECT json_group_object(c.category, json(c.sub_categories)) AS categories_json FROM categories AS c', + variables: [], + readsFrom: {bang}, + ).map((QueryRow row) => row.read('categories_json')); + } + + Selectable searchHistoryEntries({required int limit}) { + return customSelect( + 'SELECT * FROM bang_history ORDER BY search_date DESC LIMIT ?1', + variables: [Variable(limit)], + readsFrom: {bangHistory}, + ).map( + (QueryRow row) => SearchHistoryEntry( + searchQuery: row.read('search_query'), + trigger: row.read('trigger'), + searchDate: row.read('search_date'), + ), + ); + } + + Future evictHistoryEntries({required int limit}) { + return customUpdate( + 'DELETE FROM bang_history WHERE "rowid" IN (SELECT "rowid" FROM bang_history ORDER BY search_date DESC LIMIT -1 OFFSET ?1)', + variables: [Variable(limit)], + updates: {bangHistory}, + updateKind: UpdateKind.delete, + ); } @override @@ -1139,577 +1355,854 @@ abstract class _$BangDatabase extends GeneratedDatabase { allSchemaEntities.whereType>(); @override List get allSchemaEntities => [ - bang, - bangSync, - bangFrequency, - bangIcon, - bangFts, - bangDataView, - bangAfterInsert, - bangAfterDelete, - bangAfterUpdate - ]; + bang, + bangSync, + bangFrequency, + bangHistory, + bangFts, + bangDataView, + bangAfterInsert, + bangAfterDelete, + bangAfterUpdate, + ]; @override - StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( - [ - WritePropagation( - on: TableUpdateQuery.onTableName('bang', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('bang_frequency', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('bang', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('bang_icon', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('bang', - limitUpdateKind: UpdateKind.insert), - result: [ - TableUpdate('bang_fts', kind: UpdateKind.insert), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('bang', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('bang_fts', kind: UpdateKind.insert), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('bang', - limitUpdateKind: UpdateKind.update), - result: [ - TableUpdate('bang_fts', kind: UpdateKind.insert), - ], - ), - ], - ); + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'bang', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('bang_frequency', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'bang', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('bang_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'bang', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('bang_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'bang', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('bang_fts', kind: UpdateKind.insert)], + ), + ]); } -typedef $BangTableCreateCompanionBuilder = BangCompanion Function({ - required String trigger, - required BangGroup group, - required String websiteName, - required String domain, - required String urlTemplate, - Value category, - Value subCategory, - Value?> format, - Value rowid, -}); -typedef $BangTableUpdateCompanionBuilder = BangCompanion Function({ - Value trigger, - Value group, - Value websiteName, - Value domain, - Value urlTemplate, - Value category, - Value subCategory, - Value?> format, - Value rowid, -}); +typedef $BangTableCreateCompanionBuilder = + BangCompanion Function({ + required String trigger, + required BangGroup group, + required String websiteName, + required String domain, + required String urlTemplate, + Value category, + Value subCategory, + Value?> format, + Value rowid, + }); +typedef $BangTableUpdateCompanionBuilder = + BangCompanion Function({ + Value trigger, + Value group, + Value websiteName, + Value domain, + Value urlTemplate, + Value category, + Value subCategory, + Value?> format, + Value rowid, + }); final class $BangTableReferences extends BaseReferences<_$BangDatabase, BangTable, Bang> { $BangTableReferences(super.$_db, super.$_table, super.$_typedResult); static MultiTypedResultKey> - _bangFrequencyRefsTable(_$BangDatabase db) => - MultiTypedResultKey.fromTable(db.bangFrequency, - aliasName: $_aliasNameGenerator( - db.bang.trigger, db.bangFrequency.trigger)); + _bangFrequencyRefsTable(_$BangDatabase db) => MultiTypedResultKey.fromTable( + db.bangFrequency, + aliasName: $_aliasNameGenerator(db.bang.trigger, db.bangFrequency.trigger), + ); $BangFrequencyProcessedTableManager get bangFrequencyRefs { - final manager = $BangFrequencyTableManager($_db, $_db.bangFrequency) - .filter((f) => f.trigger.trigger($_item.trigger)); + final manager = $BangFrequencyTableManager($_db, $_db.bangFrequency).filter( + (f) => f.trigger.trigger.sqlEquals($_itemColumn('trigger')!), + ); final cache = $_typedResult.readTableOrNull(_bangFrequencyRefsTable($_db)); return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); + manager.$state.copyWith(prefetchedData: cache), + ); } - static MultiTypedResultKey> _bangIconRefsTable( - _$BangDatabase db) => - MultiTypedResultKey.fromTable(db.bangIcon, - aliasName: - $_aliasNameGenerator(db.bang.trigger, db.bangIcon.trigger)); + static MultiTypedResultKey> + _bangHistoryRefsTable(_$BangDatabase db) => MultiTypedResultKey.fromTable( + db.bangHistory, + aliasName: $_aliasNameGenerator(db.bang.trigger, db.bangHistory.trigger), + ); - $BangIconProcessedTableManager get bangIconRefs { - final manager = $BangIconTableManager($_db, $_db.bangIcon) - .filter((f) => f.trigger.trigger($_item.trigger)); + $BangHistoryProcessedTableManager get bangHistoryRefs { + final manager = $BangHistoryTableManager($_db, $_db.bangHistory).filter( + (f) => f.trigger.trigger.sqlEquals($_itemColumn('trigger')!), + ); - final cache = $_typedResult.readTableOrNull(_bangIconRefsTable($_db)); + final cache = $_typedResult.readTableOrNull(_bangHistoryRefsTable($_db)); return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); + manager.$state.copyWith(prefetchedData: cache), + ); } } -class $BangTableFilterComposer - extends FilterComposer<_$BangDatabase, BangTable> { - $BangTableFilterComposer(super.$state); - ColumnFilters get trigger => $state.composableBuilder( - column: $state.table.trigger, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); +class $BangTableFilterComposer extends Composer<_$BangDatabase, BangTable> { + $BangTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get trigger => $composableBuilder( + column: $table.trigger, + builder: (column) => ColumnFilters(column), + ); ColumnWithTypeConverterFilters get group => - $state.composableBuilder( - column: $state.table.group, - builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( - column, - joinBuilders: joinBuilders)); + $composableBuilder( + column: $table.group, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); - ColumnFilters get websiteName => $state.composableBuilder( - column: $state.table.websiteName, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnFilters get websiteName => $composableBuilder( + column: $table.websiteName, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get domain => $state.composableBuilder( - column: $state.table.domain, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnFilters get domain => $composableBuilder( + column: $table.domain, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get urlTemplate => $state.composableBuilder( - column: $state.table.urlTemplate, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnFilters get urlTemplate => $composableBuilder( + column: $table.urlTemplate, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get category => $state.composableBuilder( - column: $state.table.category, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnFilters get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get subCategory => $state.composableBuilder( - column: $state.table.subCategory, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnFilters get subCategory => $composableBuilder( + column: $table.subCategory, + builder: (column) => ColumnFilters(column), + ); ColumnWithTypeConverterFilters?, Set, String> - get format => $state.composableBuilder( - column: $state.table.format, - builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( - column, - joinBuilders: joinBuilders)); + get format => $composableBuilder( + column: $table.format, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); - ComposableFilter bangFrequencyRefs( - ComposableFilter Function($BangFrequencyFilterComposer f) f) { - final $BangFrequencyFilterComposer composer = $state.composerBuilder( - composer: this, - getCurrentColumn: (t) => t.trigger, - referencedTable: $state.db.bangFrequency, - getReferencedColumn: (t) => t.trigger, - builder: (joinBuilder, parentComposers) => $BangFrequencyFilterComposer( - ComposerState($state.db, $state.db.bangFrequency, joinBuilder, - parentComposers))); + Expression bangFrequencyRefs( + Expression Function($BangFrequencyFilterComposer f) f, + ) { + final $BangFrequencyFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.trigger, + referencedTable: $db.bangFrequency, + getReferencedColumn: (t) => t.trigger, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $BangFrequencyFilterComposer( + $db: $db, + $table: $db.bangFrequency, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } - ComposableFilter bangIconRefs( - ComposableFilter Function($BangIconFilterComposer f) f) { - final $BangIconFilterComposer composer = $state.composerBuilder( - composer: this, - getCurrentColumn: (t) => t.trigger, - referencedTable: $state.db.bangIcon, - getReferencedColumn: (t) => t.trigger, - builder: (joinBuilder, parentComposers) => $BangIconFilterComposer( - ComposerState( - $state.db, $state.db.bangIcon, joinBuilder, parentComposers))); + Expression bangHistoryRefs( + Expression Function($BangHistoryFilterComposer f) f, + ) { + final $BangHistoryFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.trigger, + referencedTable: $db.bangHistory, + getReferencedColumn: (t) => t.trigger, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $BangHistoryFilterComposer( + $db: $db, + $table: $db.bangHistory, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } } -class $BangTableOrderingComposer - extends OrderingComposer<_$BangDatabase, BangTable> { - $BangTableOrderingComposer(super.$state); - ColumnOrderings get trigger => $state.composableBuilder( - column: $state.table.trigger, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); +class $BangTableOrderingComposer extends Composer<_$BangDatabase, BangTable> { + $BangTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get trigger => $composableBuilder( + column: $table.trigger, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get group => $state.composableBuilder( - column: $state.table.group, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get group => $composableBuilder( + column: $table.group, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get websiteName => $state.composableBuilder( - column: $state.table.websiteName, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get websiteName => $composableBuilder( + column: $table.websiteName, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get domain => $state.composableBuilder( - column: $state.table.domain, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get domain => $composableBuilder( + column: $table.domain, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get urlTemplate => $state.composableBuilder( - column: $state.table.urlTemplate, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get urlTemplate => $composableBuilder( + column: $table.urlTemplate, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get category => $state.composableBuilder( - column: $state.table.category, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get subCategory => $state.composableBuilder( - column: $state.table.subCategory, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get subCategory => $composableBuilder( + column: $table.subCategory, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get format => $state.composableBuilder( - column: $state.table.format, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get format => $composableBuilder( + column: $table.format, + builder: (column) => ColumnOrderings(column), + ); } -class $BangTableTableManager extends RootTableManager< - _$BangDatabase, - BangTable, - Bang, - $BangTableFilterComposer, - $BangTableOrderingComposer, - $BangTableCreateCompanionBuilder, - $BangTableUpdateCompanionBuilder, - (Bang, $BangTableReferences), - Bang, - PrefetchHooks Function({bool bangFrequencyRefs, bool bangIconRefs})> { +class $BangTableAnnotationComposer extends Composer<_$BangDatabase, BangTable> { + $BangTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get trigger => + $composableBuilder(column: $table.trigger, builder: (column) => column); + + GeneratedColumnWithTypeConverter get group => + $composableBuilder(column: $table.group, builder: (column) => column); + + GeneratedColumn get websiteName => $composableBuilder( + column: $table.websiteName, + builder: (column) => column, + ); + + GeneratedColumn get domain => + $composableBuilder(column: $table.domain, builder: (column) => column); + + GeneratedColumn get urlTemplate => $composableBuilder( + column: $table.urlTemplate, + builder: (column) => column, + ); + + GeneratedColumn get category => + $composableBuilder(column: $table.category, builder: (column) => column); + + GeneratedColumn get subCategory => $composableBuilder( + column: $table.subCategory, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter?, String> get format => + $composableBuilder(column: $table.format, builder: (column) => column); + + Expression bangFrequencyRefs( + Expression Function($BangFrequencyAnnotationComposer a) f, + ) { + final $BangFrequencyAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.trigger, + referencedTable: $db.bangFrequency, + getReferencedColumn: (t) => t.trigger, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $BangFrequencyAnnotationComposer( + $db: $db, + $table: $db.bangFrequency, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression bangHistoryRefs( + Expression Function($BangHistoryAnnotationComposer a) f, + ) { + final $BangHistoryAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.trigger, + referencedTable: $db.bangHistory, + getReferencedColumn: (t) => t.trigger, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $BangHistoryAnnotationComposer( + $db: $db, + $table: $db.bangHistory, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $BangTableTableManager + extends + RootTableManager< + _$BangDatabase, + BangTable, + Bang, + $BangTableFilterComposer, + $BangTableOrderingComposer, + $BangTableAnnotationComposer, + $BangTableCreateCompanionBuilder, + $BangTableUpdateCompanionBuilder, + (Bang, $BangTableReferences), + Bang, + PrefetchHooks Function({bool bangFrequencyRefs, bool bangHistoryRefs}) + > { $BangTableTableManager(_$BangDatabase db, BangTable table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - filteringComposer: $BangTableFilterComposer(ComposerState(db, table)), - orderingComposer: - $BangTableOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value trigger = const Value.absent(), - Value group = const Value.absent(), - Value websiteName = const Value.absent(), - Value domain = const Value.absent(), - Value urlTemplate = const Value.absent(), - Value category = const Value.absent(), - Value subCategory = const Value.absent(), - Value?> format = const Value.absent(), - Value rowid = const Value.absent(), - }) => - BangCompanion( - trigger: trigger, - group: group, - websiteName: websiteName, - domain: domain, - urlTemplate: urlTemplate, - category: category, - subCategory: subCategory, - format: format, - rowid: rowid, - ), - createCompanionCallback: ({ - required String trigger, - required BangGroup group, - required String websiteName, - required String domain, - required String urlTemplate, - Value category = const Value.absent(), - Value subCategory = const Value.absent(), - Value?> format = const Value.absent(), - Value rowid = const Value.absent(), - }) => - BangCompanion.insert( - trigger: trigger, - group: group, - websiteName: websiteName, - domain: domain, - urlTemplate: urlTemplate, - category: category, - subCategory: subCategory, - format: format, - rowid: rowid, - ), + createFilteringComposer: () => + $BangTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $BangTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $BangTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value trigger = const Value.absent(), + Value group = const Value.absent(), + Value websiteName = const Value.absent(), + Value domain = const Value.absent(), + Value urlTemplate = const Value.absent(), + Value category = const Value.absent(), + Value subCategory = const Value.absent(), + Value?> format = const Value.absent(), + Value rowid = const Value.absent(), + }) => BangCompanion( + trigger: trigger, + group: group, + websiteName: websiteName, + domain: domain, + urlTemplate: urlTemplate, + category: category, + subCategory: subCategory, + format: format, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String trigger, + required BangGroup group, + required String websiteName, + required String domain, + required String urlTemplate, + Value category = const Value.absent(), + Value subCategory = const Value.absent(), + Value?> format = const Value.absent(), + Value rowid = const Value.absent(), + }) => BangCompanion.insert( + trigger: trigger, + group: group, + websiteName: websiteName, + domain: domain, + urlTemplate: urlTemplate, + category: category, + subCategory: subCategory, + format: format, + rowid: rowid, + ), withReferenceMapper: (p0) => p0 - .map((e) => - (e.readTable(table), $BangTableReferences(db, table, e))) + .map( + (e) => (e.readTable(table), $BangTableReferences(db, table, e)), + ) .toList(), - prefetchHooksCallback: ( - {bangFrequencyRefs = false, bangIconRefs = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [ - if (bangFrequencyRefs) db.bangFrequency, - if (bangIconRefs) db.bangIcon - ], - addJoins: null, - getPrefetchedDataCallback: (items) async { - return [ - if (bangFrequencyRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: - $BangTableReferences._bangFrequencyRefsTable(db), - managerFromTypedResult: (p0) => - $BangTableReferences(db, table, p0) - .bangFrequencyRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems - .where((e) => e.trigger == item.trigger), - typedResults: items), - if (bangIconRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: - $BangTableReferences._bangIconRefsTable(db), - managerFromTypedResult: (p0) => - $BangTableReferences(db, table, p0).bangIconRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems - .where((e) => e.trigger == item.trigger), - typedResults: items) - ]; + prefetchHooksCallback: + ({bangFrequencyRefs = false, bangHistoryRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (bangFrequencyRefs) db.bangFrequency, + if (bangHistoryRefs) db.bangHistory, + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (bangFrequencyRefs) + await $_getPrefetchedData< + Bang, + BangTable, + BangFrequencyData + >( + currentTable: table, + referencedTable: $BangTableReferences + ._bangFrequencyRefsTable(db), + managerFromTypedResult: (p0) => $BangTableReferences( + db, + table, + p0, + ).bangFrequencyRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.trigger == item.trigger, + ), + typedResults: items, + ), + if (bangHistoryRefs) + await $_getPrefetchedData< + Bang, + BangTable, + BangHistoryData + >( + currentTable: table, + referencedTable: $BangTableReferences + ._bangHistoryRefsTable(db), + managerFromTypedResult: (p0) => $BangTableReferences( + db, + table, + p0, + ).bangHistoryRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.trigger == item.trigger, + ), + typedResults: items, + ), + ]; + }, + ); }, - ); - }, - )); + ), + ); } -typedef $BangTableProcessedTableManager = ProcessedTableManager< - _$BangDatabase, - BangTable, - Bang, - $BangTableFilterComposer, - $BangTableOrderingComposer, - $BangTableCreateCompanionBuilder, - $BangTableUpdateCompanionBuilder, - (Bang, $BangTableReferences), - Bang, - PrefetchHooks Function({bool bangFrequencyRefs, bool bangIconRefs})>; -typedef $BangSyncCreateCompanionBuilder = BangSyncCompanion Function({ - Value group, - required DateTime lastSync, -}); -typedef $BangSyncUpdateCompanionBuilder = BangSyncCompanion Function({ - Value group, - Value lastSync, -}); +typedef $BangTableProcessedTableManager = + ProcessedTableManager< + _$BangDatabase, + BangTable, + Bang, + $BangTableFilterComposer, + $BangTableOrderingComposer, + $BangTableAnnotationComposer, + $BangTableCreateCompanionBuilder, + $BangTableUpdateCompanionBuilder, + (Bang, $BangTableReferences), + Bang, + PrefetchHooks Function({bool bangFrequencyRefs, bool bangHistoryRefs}) + >; +typedef $BangSyncCreateCompanionBuilder = + BangSyncCompanion Function({ + Value group, + required DateTime lastSync, + }); +typedef $BangSyncUpdateCompanionBuilder = + BangSyncCompanion Function({ + Value group, + Value lastSync, + }); -class $BangSyncFilterComposer extends FilterComposer<_$BangDatabase, BangSync> { - $BangSyncFilterComposer(super.$state); +class $BangSyncFilterComposer extends Composer<_$BangDatabase, BangSync> { + $BangSyncFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); ColumnWithTypeConverterFilters get group => - $state.composableBuilder( - column: $state.table.group, - builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( - column, - joinBuilders: joinBuilders)); + $composableBuilder( + column: $table.group, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); - ColumnFilters get lastSync => $state.composableBuilder( - column: $state.table.lastSync, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnFilters get lastSync => $composableBuilder( + column: $table.lastSync, + builder: (column) => ColumnFilters(column), + ); } -class $BangSyncOrderingComposer - extends OrderingComposer<_$BangDatabase, BangSync> { - $BangSyncOrderingComposer(super.$state); - ColumnOrderings get group => $state.composableBuilder( - column: $state.table.group, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); +class $BangSyncOrderingComposer extends Composer<_$BangDatabase, BangSync> { + $BangSyncOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get group => $composableBuilder( + column: $table.group, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get lastSync => $state.composableBuilder( - column: $state.table.lastSync, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get lastSync => $composableBuilder( + column: $table.lastSync, + builder: (column) => ColumnOrderings(column), + ); } -class $BangSyncTableManager extends RootTableManager< - _$BangDatabase, - BangSync, - BangSyncData, - $BangSyncFilterComposer, - $BangSyncOrderingComposer, - $BangSyncCreateCompanionBuilder, - $BangSyncUpdateCompanionBuilder, - (BangSyncData, BaseReferences<_$BangDatabase, BangSync, BangSyncData>), - BangSyncData, - PrefetchHooks Function()> { +class $BangSyncAnnotationComposer extends Composer<_$BangDatabase, BangSync> { + $BangSyncAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumnWithTypeConverter get group => + $composableBuilder(column: $table.group, builder: (column) => column); + + GeneratedColumn get lastSync => + $composableBuilder(column: $table.lastSync, builder: (column) => column); +} + +class $BangSyncTableManager + extends + RootTableManager< + _$BangDatabase, + BangSync, + BangSyncData, + $BangSyncFilterComposer, + $BangSyncOrderingComposer, + $BangSyncAnnotationComposer, + $BangSyncCreateCompanionBuilder, + $BangSyncUpdateCompanionBuilder, + ( + BangSyncData, + BaseReferences<_$BangDatabase, BangSync, BangSyncData>, + ), + BangSyncData, + PrefetchHooks Function() + > { $BangSyncTableManager(_$BangDatabase db, BangSync table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - filteringComposer: $BangSyncFilterComposer(ComposerState(db, table)), - orderingComposer: $BangSyncOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value group = const Value.absent(), - Value lastSync = const Value.absent(), - }) => - BangSyncCompanion( - group: group, - lastSync: lastSync, - ), - createCompanionCallback: ({ - Value group = const Value.absent(), - required DateTime lastSync, - }) => - BangSyncCompanion.insert( - group: group, - lastSync: lastSync, - ), + createFilteringComposer: () => + $BangSyncFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $BangSyncOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $BangSyncAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value group = const Value.absent(), + Value lastSync = const Value.absent(), + }) => BangSyncCompanion(group: group, lastSync: lastSync), + createCompanionCallback: + ({ + Value group = const Value.absent(), + required DateTime lastSync, + }) => BangSyncCompanion.insert(group: group, lastSync: lastSync), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) .toList(), prefetchHooksCallback: null, - )); + ), + ); } -typedef $BangSyncProcessedTableManager = ProcessedTableManager< - _$BangDatabase, - BangSync, - BangSyncData, - $BangSyncFilterComposer, - $BangSyncOrderingComposer, - $BangSyncCreateCompanionBuilder, - $BangSyncUpdateCompanionBuilder, - (BangSyncData, BaseReferences<_$BangDatabase, BangSync, BangSyncData>), - BangSyncData, - PrefetchHooks Function()>; -typedef $BangFrequencyCreateCompanionBuilder = BangFrequencyCompanion Function({ - required String trigger, - required int frequency, - required DateTime lastUsed, - Value rowid, -}); -typedef $BangFrequencyUpdateCompanionBuilder = BangFrequencyCompanion Function({ - Value trigger, - Value frequency, - Value lastUsed, - Value rowid, -}); +typedef $BangSyncProcessedTableManager = + ProcessedTableManager< + _$BangDatabase, + BangSync, + BangSyncData, + $BangSyncFilterComposer, + $BangSyncOrderingComposer, + $BangSyncAnnotationComposer, + $BangSyncCreateCompanionBuilder, + $BangSyncUpdateCompanionBuilder, + (BangSyncData, BaseReferences<_$BangDatabase, BangSync, BangSyncData>), + BangSyncData, + PrefetchHooks Function() + >; +typedef $BangFrequencyCreateCompanionBuilder = + BangFrequencyCompanion Function({ + required String trigger, + required int frequency, + required DateTime lastUsed, + Value rowid, + }); +typedef $BangFrequencyUpdateCompanionBuilder = + BangFrequencyCompanion Function({ + Value trigger, + Value frequency, + Value lastUsed, + Value rowid, + }); final class $BangFrequencyReferences extends BaseReferences<_$BangDatabase, BangFrequency, BangFrequencyData> { $BangFrequencyReferences(super.$_db, super.$_table, super.$_typedResult); static BangTable _triggerTable(_$BangDatabase db) => db.bang.createAlias( - $_aliasNameGenerator(db.bangFrequency.trigger, db.bang.trigger)); + $_aliasNameGenerator(db.bangFrequency.trigger, db.bang.trigger), + ); - $BangTableProcessedTableManager? get trigger { - if ($_item.trigger == null) return null; - final manager = $BangTableTableManager($_db, $_db.bang) - .filter((f) => f.trigger($_item.trigger!)); + $BangTableProcessedTableManager get trigger { + final $_column = $_itemColumn('trigger')!; + + final manager = $BangTableTableManager( + $_db, + $_db.bang, + ).filter((f) => f.trigger.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_triggerTable($_db)); if (item == null) return manager; return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); + manager.$state.copyWith(prefetchedData: [item]), + ); } } class $BangFrequencyFilterComposer - extends FilterComposer<_$BangDatabase, BangFrequency> { - $BangFrequencyFilterComposer(super.$state); - ColumnFilters get frequency => $state.composableBuilder( - column: $state.table.frequency, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + extends Composer<_$BangDatabase, BangFrequency> { + $BangFrequencyFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get frequency => $composableBuilder( + column: $table.frequency, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get lastUsed => $state.composableBuilder( - column: $state.table.lastUsed, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnFilters get lastUsed => $composableBuilder( + column: $table.lastUsed, + builder: (column) => ColumnFilters(column), + ); $BangTableFilterComposer get trigger { - final $BangTableFilterComposer composer = $state.composerBuilder( - composer: this, - getCurrentColumn: (t) => t.trigger, - referencedTable: $state.db.bang, - getReferencedColumn: (t) => t.trigger, - builder: (joinBuilder, parentComposers) => $BangTableFilterComposer( - ComposerState( - $state.db, $state.db.bang, joinBuilder, parentComposers))); + final $BangTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.trigger, + referencedTable: $db.bang, + getReferencedColumn: (t) => t.trigger, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $BangTableFilterComposer( + $db: $db, + $table: $db.bang, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } class $BangFrequencyOrderingComposer - extends OrderingComposer<_$BangDatabase, BangFrequency> { - $BangFrequencyOrderingComposer(super.$state); - ColumnOrderings get frequency => $state.composableBuilder( - column: $state.table.frequency, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + extends Composer<_$BangDatabase, BangFrequency> { + $BangFrequencyOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get frequency => $composableBuilder( + column: $table.frequency, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get lastUsed => $state.composableBuilder( - column: $state.table.lastUsed, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get lastUsed => $composableBuilder( + column: $table.lastUsed, + builder: (column) => ColumnOrderings(column), + ); $BangTableOrderingComposer get trigger { - final $BangTableOrderingComposer composer = $state.composerBuilder( - composer: this, - getCurrentColumn: (t) => t.trigger, - referencedTable: $state.db.bang, - getReferencedColumn: (t) => t.trigger, - builder: (joinBuilder, parentComposers) => $BangTableOrderingComposer( - ComposerState( - $state.db, $state.db.bang, joinBuilder, parentComposers))); + final $BangTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.trigger, + referencedTable: $db.bang, + getReferencedColumn: (t) => t.trigger, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $BangTableOrderingComposer( + $db: $db, + $table: $db.bang, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $BangFrequencyTableManager extends RootTableManager< - _$BangDatabase, - BangFrequency, - BangFrequencyData, - $BangFrequencyFilterComposer, - $BangFrequencyOrderingComposer, - $BangFrequencyCreateCompanionBuilder, - $BangFrequencyUpdateCompanionBuilder, - (BangFrequencyData, $BangFrequencyReferences), - BangFrequencyData, - PrefetchHooks Function({bool trigger})> { +class $BangFrequencyAnnotationComposer + extends Composer<_$BangDatabase, BangFrequency> { + $BangFrequencyAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get frequency => + $composableBuilder(column: $table.frequency, builder: (column) => column); + + GeneratedColumn get lastUsed => + $composableBuilder(column: $table.lastUsed, builder: (column) => column); + + $BangTableAnnotationComposer get trigger { + final $BangTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.trigger, + referencedTable: $db.bang, + getReferencedColumn: (t) => t.trigger, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $BangTableAnnotationComposer( + $db: $db, + $table: $db.bang, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $BangFrequencyTableManager + extends + RootTableManager< + _$BangDatabase, + BangFrequency, + BangFrequencyData, + $BangFrequencyFilterComposer, + $BangFrequencyOrderingComposer, + $BangFrequencyAnnotationComposer, + $BangFrequencyCreateCompanionBuilder, + $BangFrequencyUpdateCompanionBuilder, + (BangFrequencyData, $BangFrequencyReferences), + BangFrequencyData, + PrefetchHooks Function({bool trigger}) + > { $BangFrequencyTableManager(_$BangDatabase db, BangFrequency table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - filteringComposer: - $BangFrequencyFilterComposer(ComposerState(db, table)), - orderingComposer: - $BangFrequencyOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value trigger = const Value.absent(), - Value frequency = const Value.absent(), - Value lastUsed = const Value.absent(), - Value rowid = const Value.absent(), - }) => - BangFrequencyCompanion( - trigger: trigger, - frequency: frequency, - lastUsed: lastUsed, - rowid: rowid, - ), - createCompanionCallback: ({ - required String trigger, - required int frequency, - required DateTime lastUsed, - Value rowid = const Value.absent(), - }) => - BangFrequencyCompanion.insert( - trigger: trigger, - frequency: frequency, - lastUsed: lastUsed, - rowid: rowid, - ), + createFilteringComposer: () => + $BangFrequencyFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $BangFrequencyOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $BangFrequencyAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value trigger = const Value.absent(), + Value frequency = const Value.absent(), + Value lastUsed = const Value.absent(), + Value rowid = const Value.absent(), + }) => BangFrequencyCompanion( + trigger: trigger, + frequency: frequency, + lastUsed: lastUsed, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String trigger, + required int frequency, + required DateTime lastUsed, + Value rowid = const Value.absent(), + }) => BangFrequencyCompanion.insert( + trigger: trigger, + frequency: frequency, + lastUsed: lastUsed, + rowid: rowid, + ), withReferenceMapper: (p0) => p0 - .map((e) => - (e.readTable(table), $BangFrequencyReferences(db, table, e))) + .map( + (e) => ( + e.readTable(table), + $BangFrequencyReferences(db, table, e), + ), + ) .toList(), prefetchHooksCallback: ({trigger = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], - addJoins: < - T extends TableManagerState< + addJoins: + < + T extends TableManagerState< dynamic, dynamic, dynamic, @@ -1719,171 +2212,278 @@ class $BangFrequencyTableManager extends RootTableManager< dynamic, dynamic, dynamic, - dynamic>>(state) { - if (trigger) { - state = state.withJoin( - currentTable: table, - currentColumn: table.trigger, - referencedTable: $BangFrequencyReferences._triggerTable(db), - referencedColumn: - $BangFrequencyReferences._triggerTable(db).trigger, - ) as T; - } + dynamic, + dynamic + > + >(state) { + if (trigger) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.trigger, + referencedTable: $BangFrequencyReferences + ._triggerTable(db), + referencedColumn: $BangFrequencyReferences + ._triggerTable(db) + .trigger, + ) + as T; + } - return state; - }, + return state; + }, getPrefetchedDataCallback: (items) async { return []; }, ); }, - )); + ), + ); } -typedef $BangFrequencyProcessedTableManager = ProcessedTableManager< - _$BangDatabase, - BangFrequency, - BangFrequencyData, - $BangFrequencyFilterComposer, - $BangFrequencyOrderingComposer, - $BangFrequencyCreateCompanionBuilder, - $BangFrequencyUpdateCompanionBuilder, - (BangFrequencyData, $BangFrequencyReferences), - BangFrequencyData, - PrefetchHooks Function({bool trigger})>; -typedef $BangIconCreateCompanionBuilder = BangIconCompanion Function({ - required String trigger, - required Uint8List iconData, - required DateTime fetchDate, - Value rowid, -}); -typedef $BangIconUpdateCompanionBuilder = BangIconCompanion Function({ - Value trigger, - Value iconData, - Value fetchDate, - Value rowid, -}); +typedef $BangFrequencyProcessedTableManager = + ProcessedTableManager< + _$BangDatabase, + BangFrequency, + BangFrequencyData, + $BangFrequencyFilterComposer, + $BangFrequencyOrderingComposer, + $BangFrequencyAnnotationComposer, + $BangFrequencyCreateCompanionBuilder, + $BangFrequencyUpdateCompanionBuilder, + (BangFrequencyData, $BangFrequencyReferences), + BangFrequencyData, + PrefetchHooks Function({bool trigger}) + >; +typedef $BangHistoryCreateCompanionBuilder = + BangHistoryCompanion Function({ + required String searchQuery, + required String trigger, + required DateTime searchDate, + Value rowid, + }); +typedef $BangHistoryUpdateCompanionBuilder = + BangHistoryCompanion Function({ + Value searchQuery, + Value trigger, + Value searchDate, + Value rowid, + }); -final class $BangIconReferences - extends BaseReferences<_$BangDatabase, BangIcon, BangIconData> { - $BangIconReferences(super.$_db, super.$_table, super.$_typedResult); +final class $BangHistoryReferences + extends BaseReferences<_$BangDatabase, BangHistory, BangHistoryData> { + $BangHistoryReferences(super.$_db, super.$_table, super.$_typedResult); - static BangTable _triggerTable(_$BangDatabase db) => db.bang - .createAlias($_aliasNameGenerator(db.bangIcon.trigger, db.bang.trigger)); + static BangTable _triggerTable(_$BangDatabase db) => db.bang.createAlias( + $_aliasNameGenerator(db.bangHistory.trigger, db.bang.trigger), + ); - $BangTableProcessedTableManager? get trigger { - if ($_item.trigger == null) return null; - final manager = $BangTableTableManager($_db, $_db.bang) - .filter((f) => f.trigger($_item.trigger!)); + $BangTableProcessedTableManager get trigger { + final $_column = $_itemColumn('trigger')!; + + final manager = $BangTableTableManager( + $_db, + $_db.bang, + ).filter((f) => f.trigger.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_triggerTable($_db)); if (item == null) return manager; return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); + manager.$state.copyWith(prefetchedData: [item]), + ); } } -class $BangIconFilterComposer extends FilterComposer<_$BangDatabase, BangIcon> { - $BangIconFilterComposer(super.$state); - ColumnFilters get iconData => $state.composableBuilder( - column: $state.table.iconData, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); +class $BangHistoryFilterComposer extends Composer<_$BangDatabase, BangHistory> { + $BangHistoryFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get searchQuery => $composableBuilder( + column: $table.searchQuery, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get fetchDate => $state.composableBuilder( - column: $state.table.fetchDate, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnFilters get searchDate => $composableBuilder( + column: $table.searchDate, + builder: (column) => ColumnFilters(column), + ); $BangTableFilterComposer get trigger { - final $BangTableFilterComposer composer = $state.composerBuilder( - composer: this, - getCurrentColumn: (t) => t.trigger, - referencedTable: $state.db.bang, - getReferencedColumn: (t) => t.trigger, - builder: (joinBuilder, parentComposers) => $BangTableFilterComposer( - ComposerState( - $state.db, $state.db.bang, joinBuilder, parentComposers))); + final $BangTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.trigger, + referencedTable: $db.bang, + getReferencedColumn: (t) => t.trigger, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $BangTableFilterComposer( + $db: $db, + $table: $db.bang, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $BangIconOrderingComposer - extends OrderingComposer<_$BangDatabase, BangIcon> { - $BangIconOrderingComposer(super.$state); - ColumnOrderings get iconData => $state.composableBuilder( - column: $state.table.iconData, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); +class $BangHistoryOrderingComposer + extends Composer<_$BangDatabase, BangHistory> { + $BangHistoryOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get searchQuery => $composableBuilder( + column: $table.searchQuery, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get fetchDate => $state.composableBuilder( - column: $state.table.fetchDate, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get searchDate => $composableBuilder( + column: $table.searchDate, + builder: (column) => ColumnOrderings(column), + ); $BangTableOrderingComposer get trigger { - final $BangTableOrderingComposer composer = $state.composerBuilder( - composer: this, - getCurrentColumn: (t) => t.trigger, - referencedTable: $state.db.bang, - getReferencedColumn: (t) => t.trigger, - builder: (joinBuilder, parentComposers) => $BangTableOrderingComposer( - ComposerState( - $state.db, $state.db.bang, joinBuilder, parentComposers))); + final $BangTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.trigger, + referencedTable: $db.bang, + getReferencedColumn: (t) => t.trigger, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $BangTableOrderingComposer( + $db: $db, + $table: $db.bang, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $BangIconTableManager extends RootTableManager< - _$BangDatabase, - BangIcon, - BangIconData, - $BangIconFilterComposer, - $BangIconOrderingComposer, - $BangIconCreateCompanionBuilder, - $BangIconUpdateCompanionBuilder, - (BangIconData, $BangIconReferences), - BangIconData, - PrefetchHooks Function({bool trigger})> { - $BangIconTableManager(_$BangDatabase db, BangIcon table) - : super(TableManagerState( +class $BangHistoryAnnotationComposer + extends Composer<_$BangDatabase, BangHistory> { + $BangHistoryAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get searchQuery => $composableBuilder( + column: $table.searchQuery, + builder: (column) => column, + ); + + GeneratedColumn get searchDate => $composableBuilder( + column: $table.searchDate, + builder: (column) => column, + ); + + $BangTableAnnotationComposer get trigger { + final $BangTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.trigger, + referencedTable: $db.bang, + getReferencedColumn: (t) => t.trigger, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $BangTableAnnotationComposer( + $db: $db, + $table: $db.bang, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $BangHistoryTableManager + extends + RootTableManager< + _$BangDatabase, + BangHistory, + BangHistoryData, + $BangHistoryFilterComposer, + $BangHistoryOrderingComposer, + $BangHistoryAnnotationComposer, + $BangHistoryCreateCompanionBuilder, + $BangHistoryUpdateCompanionBuilder, + (BangHistoryData, $BangHistoryReferences), + BangHistoryData, + PrefetchHooks Function({bool trigger}) + > { + $BangHistoryTableManager(_$BangDatabase db, BangHistory table) + : super( + TableManagerState( db: db, table: table, - filteringComposer: $BangIconFilterComposer(ComposerState(db, table)), - orderingComposer: $BangIconOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value trigger = const Value.absent(), - Value iconData = const Value.absent(), - Value fetchDate = const Value.absent(), - Value rowid = const Value.absent(), - }) => - BangIconCompanion( - trigger: trigger, - iconData: iconData, - fetchDate: fetchDate, - rowid: rowid, - ), - createCompanionCallback: ({ - required String trigger, - required Uint8List iconData, - required DateTime fetchDate, - Value rowid = const Value.absent(), - }) => - BangIconCompanion.insert( - trigger: trigger, - iconData: iconData, - fetchDate: fetchDate, - rowid: rowid, - ), + createFilteringComposer: () => + $BangHistoryFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $BangHistoryOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $BangHistoryAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value searchQuery = const Value.absent(), + Value trigger = const Value.absent(), + Value searchDate = const Value.absent(), + Value rowid = const Value.absent(), + }) => BangHistoryCompanion( + searchQuery: searchQuery, + trigger: trigger, + searchDate: searchDate, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String searchQuery, + required String trigger, + required DateTime searchDate, + Value rowid = const Value.absent(), + }) => BangHistoryCompanion.insert( + searchQuery: searchQuery, + trigger: trigger, + searchDate: searchDate, + rowid: rowid, + ), withReferenceMapper: (p0) => p0 - .map((e) => - (e.readTable(table), $BangIconReferences(db, table, e))) + .map( + (e) => + (e.readTable(table), $BangHistoryReferences(db, table, e)), + ) .toList(), prefetchHooksCallback: ({trigger = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], - addJoins: < - T extends TableManagerState< + addJoins: + < + T extends TableManagerState< dynamic, dynamic, dynamic, @@ -1893,131 +2493,185 @@ class $BangIconTableManager extends RootTableManager< dynamic, dynamic, dynamic, - dynamic>>(state) { - if (trigger) { - state = state.withJoin( - currentTable: table, - currentColumn: table.trigger, - referencedTable: $BangIconReferences._triggerTable(db), - referencedColumn: - $BangIconReferences._triggerTable(db).trigger, - ) as T; - } + dynamic, + dynamic + > + >(state) { + if (trigger) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.trigger, + referencedTable: $BangHistoryReferences + ._triggerTable(db), + referencedColumn: $BangHistoryReferences + ._triggerTable(db) + .trigger, + ) + as T; + } - return state; - }, + return state; + }, getPrefetchedDataCallback: (items) async { return []; }, ); }, - )); + ), + ); } -typedef $BangIconProcessedTableManager = ProcessedTableManager< - _$BangDatabase, - BangIcon, - BangIconData, - $BangIconFilterComposer, - $BangIconOrderingComposer, - $BangIconCreateCompanionBuilder, - $BangIconUpdateCompanionBuilder, - (BangIconData, $BangIconReferences), - BangIconData, - PrefetchHooks Function({bool trigger})>; -typedef $BangFtsCreateCompanionBuilder = BangFtsCompanion Function({ - required String trigger, - required String websiteName, - Value rowid, -}); -typedef $BangFtsUpdateCompanionBuilder = BangFtsCompanion Function({ - Value trigger, - Value websiteName, - Value rowid, -}); +typedef $BangHistoryProcessedTableManager = + ProcessedTableManager< + _$BangDatabase, + BangHistory, + BangHistoryData, + $BangHistoryFilterComposer, + $BangHistoryOrderingComposer, + $BangHistoryAnnotationComposer, + $BangHistoryCreateCompanionBuilder, + $BangHistoryUpdateCompanionBuilder, + (BangHistoryData, $BangHistoryReferences), + BangHistoryData, + PrefetchHooks Function({bool trigger}) + >; +typedef $BangFtsCreateCompanionBuilder = + BangFtsCompanion Function({ + required String trigger, + required String websiteName, + Value rowid, + }); +typedef $BangFtsUpdateCompanionBuilder = + BangFtsCompanion Function({ + Value trigger, + Value websiteName, + Value rowid, + }); -class $BangFtsFilterComposer extends FilterComposer<_$BangDatabase, BangFts> { - $BangFtsFilterComposer(super.$state); - ColumnFilters get trigger => $state.composableBuilder( - column: $state.table.trigger, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); +class $BangFtsFilterComposer extends Composer<_$BangDatabase, BangFts> { + $BangFtsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get trigger => $composableBuilder( + column: $table.trigger, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get websiteName => $state.composableBuilder( - column: $state.table.websiteName, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnFilters get websiteName => $composableBuilder( + column: $table.websiteName, + builder: (column) => ColumnFilters(column), + ); } -class $BangFtsOrderingComposer - extends OrderingComposer<_$BangDatabase, BangFts> { - $BangFtsOrderingComposer(super.$state); - ColumnOrderings get trigger => $state.composableBuilder( - column: $state.table.trigger, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); +class $BangFtsOrderingComposer extends Composer<_$BangDatabase, BangFts> { + $BangFtsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get trigger => $composableBuilder( + column: $table.trigger, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get websiteName => $state.composableBuilder( - column: $state.table.websiteName, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get websiteName => $composableBuilder( + column: $table.websiteName, + builder: (column) => ColumnOrderings(column), + ); } -class $BangFtsTableManager extends RootTableManager< - _$BangDatabase, - BangFts, - BangFt, - $BangFtsFilterComposer, - $BangFtsOrderingComposer, - $BangFtsCreateCompanionBuilder, - $BangFtsUpdateCompanionBuilder, - (BangFt, BaseReferences<_$BangDatabase, BangFts, BangFt>), - BangFt, - PrefetchHooks Function()> { +class $BangFtsAnnotationComposer extends Composer<_$BangDatabase, BangFts> { + $BangFtsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get trigger => + $composableBuilder(column: $table.trigger, builder: (column) => column); + + GeneratedColumn get websiteName => $composableBuilder( + column: $table.websiteName, + builder: (column) => column, + ); +} + +class $BangFtsTableManager + extends + RootTableManager< + _$BangDatabase, + BangFts, + BangFt, + $BangFtsFilterComposer, + $BangFtsOrderingComposer, + $BangFtsAnnotationComposer, + $BangFtsCreateCompanionBuilder, + $BangFtsUpdateCompanionBuilder, + (BangFt, BaseReferences<_$BangDatabase, BangFts, BangFt>), + BangFt, + PrefetchHooks Function() + > { $BangFtsTableManager(_$BangDatabase db, BangFts table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - filteringComposer: $BangFtsFilterComposer(ComposerState(db, table)), - orderingComposer: $BangFtsOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value trigger = const Value.absent(), - Value websiteName = const Value.absent(), - Value rowid = const Value.absent(), - }) => - BangFtsCompanion( - trigger: trigger, - websiteName: websiteName, - rowid: rowid, - ), - createCompanionCallback: ({ - required String trigger, - required String websiteName, - Value rowid = const Value.absent(), - }) => - BangFtsCompanion.insert( - trigger: trigger, - websiteName: websiteName, - rowid: rowid, - ), + createFilteringComposer: () => + $BangFtsFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $BangFtsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $BangFtsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value trigger = const Value.absent(), + Value websiteName = const Value.absent(), + Value rowid = const Value.absent(), + }) => BangFtsCompanion( + trigger: trigger, + websiteName: websiteName, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String trigger, + required String websiteName, + Value rowid = const Value.absent(), + }) => BangFtsCompanion.insert( + trigger: trigger, + websiteName: websiteName, + rowid: rowid, + ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) .toList(), prefetchHooksCallback: null, - )); + ), + ); } -typedef $BangFtsProcessedTableManager = ProcessedTableManager< - _$BangDatabase, - BangFts, - BangFt, - $BangFtsFilterComposer, - $BangFtsOrderingComposer, - $BangFtsCreateCompanionBuilder, - $BangFtsUpdateCompanionBuilder, - (BangFt, BaseReferences<_$BangDatabase, BangFts, BangFt>), - BangFt, - PrefetchHooks Function()>; +typedef $BangFtsProcessedTableManager = + ProcessedTableManager< + _$BangDatabase, + BangFts, + BangFt, + $BangFtsFilterComposer, + $BangFtsOrderingComposer, + $BangFtsAnnotationComposer, + $BangFtsCreateCompanionBuilder, + $BangFtsUpdateCompanionBuilder, + (BangFt, BaseReferences<_$BangDatabase, BangFts, BangFt>), + BangFt, + PrefetchHooks Function() + >; class $BangDatabaseManager { final _$BangDatabase _db; @@ -2027,7 +2681,7 @@ class $BangDatabaseManager { $BangSyncTableManager(_db, _db.bangSync); $BangFrequencyTableManager get bangFrequency => $BangFrequencyTableManager(_db, _db.bangFrequency); - $BangIconTableManager get bangIcon => - $BangIconTableManager(_db, _db.bangIcon); + $BangHistoryTableManager get bangHistory => + $BangHistoryTableManager(_db, _db.bangHistory); $BangFtsTableManager get bangFts => $BangFtsTableManager(_db, _db.bangFts); } diff --git a/app/lib/features/bangs/data/database/drift/converters/bang_format.dart b/app/lib/features/bangs/data/database/drift/converters/bang_format.dart index 17a2b703..e73e7757 100644 --- a/app/lib/features/bangs/data/database/drift/converters/bang_format.dart +++ b/app/lib/features/bangs/data/database/drift/converters/bang_format.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:drift/drift.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/bangs/data/models/bang.dart'; class BangFormatConverter extends TypeConverter?, String?> { const BangFormatConverter(); diff --git a/app/lib/features/bangs/data/models/bang.dart b/app/lib/features/bangs/data/models/bang.dart index a58af330..defa0324 100644 --- a/app/lib/features/bangs/data/models/bang.dart +++ b/app/lib/features/bangs/data/models/bang.dart @@ -2,27 +2,12 @@ import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:drift/drift.dart' show Expression, Insertable, Value; import 'package:fast_equatable/fast_equatable.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:lensai/features/bangs/data/database/database.dart' +import 'package:weblibre/features/bangs/data/database/database.dart' show BangCompanion; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; part 'bang.g.dart'; -enum BangGroup { - general( - 'https://raw.githubusercontent.com/kagisearch/bangs/main/data/bangs.json', - ), - assistant( - 'https://raw.githubusercontent.com/kagisearch/bangs/main/data/assistant_bangs.json', - ), - kagi( - 'https://raw.githubusercontent.com/kagisearch/bangs/main/data/kagi_bangs.json', - ); - - final String url; - - const BangGroup(this.url); -} - enum BangFormat { ///When the bang is invoked with no query, opens the base path of the URL (/) ///instead of any path given in the template (g., /search) @@ -80,23 +65,22 @@ class Bang with FastEquatable implements Insertable { return (format == null || format!.contains(BangFormat.urlEncodePlaceholder) == true) ? (format == null || - format?.contains(BangFormat.urlEncodeSpaceToPlus) == true) - ? Uri.encodeQueryComponent(input) - : Uri.encodeComponent(input) + format?.contains(BangFormat.urlEncodeSpaceToPlus) == true) + ? Uri.encodeQueryComponent(input) + : Uri.encodeComponent(input) : input; } - Uri getUrl(String? query) { + Uri getTemplateUrl(String? query) { final url = (query != null) ? urlTemplate.replaceAll(_templateQueryPlaceholder, formatQuery(query)) : urlTemplate; var template = Uri.parse(url); if (!template.hasScheme || template.origin.isEmpty) { - template = Uri.https(domain).replace( - path: template.path, - query: template.query, - ); + template = Uri.https( + domain, + ).replace(path: template.path, query: template.query); } return template; @@ -125,20 +109,17 @@ class Bang with FastEquatable implements Insertable { Map toJson() => _$BangToJson(this); - @override - bool get cacheHash => true; - @override List get hashParameters => [ - group, - websiteName, - domain, - trigger, - urlTemplate, - category, - subCategory, - format, - ]; + group, + websiteName, + domain, + trigger, + urlTemplate, + category, + subCategory, + format, + ]; @override Map> toColumns(bool nullToAbsent) { diff --git a/app/lib/features/bangs/data/models/bang.g.dart b/app/lib/features/bangs/data/models/bang.g.dart index bc3f1581..e423d21b 100644 --- a/app/lib/features/bangs/data/models/bang.g.dart +++ b/app/lib/features/bangs/data/models/bang.g.dart @@ -30,10 +30,10 @@ abstract class _$BangCWProxy { /// Bang(...).copyWith(id: 12, name: "My name") /// ```` Bang call({ - String? websiteName, - String? domain, - String? trigger, - String? urlTemplate, + String websiteName, + String domain, + String trigger, + String urlTemplate, BangGroup? group, String? category, String? subCategory, @@ -72,7 +72,6 @@ class _$BangCWProxyImpl implements _$BangCWProxy { Bang format(Set? format) => this(format: format); @override - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Bang(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. /// /// Usage @@ -90,24 +89,22 @@ class _$BangCWProxyImpl implements _$BangCWProxy { Object? format = const $CopyWithPlaceholder(), }) { return Bang( - websiteName: - websiteName == const $CopyWithPlaceholder() || websiteName == null - ? _value.websiteName - // ignore: cast_nullable_to_non_nullable - : websiteName as String, - domain: domain == const $CopyWithPlaceholder() || domain == null + websiteName: websiteName == const $CopyWithPlaceholder() + ? _value.websiteName + // ignore: cast_nullable_to_non_nullable + : websiteName as String, + domain: domain == const $CopyWithPlaceholder() ? _value.domain // ignore: cast_nullable_to_non_nullable : domain as String, - trigger: trigger == const $CopyWithPlaceholder() || trigger == null + trigger: trigger == const $CopyWithPlaceholder() ? _value.trigger // ignore: cast_nullable_to_non_nullable : trigger as String, - urlTemplate: - urlTemplate == const $CopyWithPlaceholder() || urlTemplate == null - ? _value.urlTemplate - // ignore: cast_nullable_to_non_nullable - : urlTemplate as String, + urlTemplate: urlTemplate == const $CopyWithPlaceholder() + ? _value.urlTemplate + // ignore: cast_nullable_to_non_nullable + : urlTemplate as String, group: group == const $CopyWithPlaceholder() ? _value.group // ignore: cast_nullable_to_non_nullable @@ -139,26 +136,26 @@ extension $BangCopyWith on Bang { // ************************************************************************** Bang _$BangFromJson(Map json) => Bang( - websiteName: json['s'] as String, - domain: json['d'] as String, - trigger: json['t'] as String, - urlTemplate: json['u'] as String, - category: json['c'] as String?, - subCategory: json['sc'] as String?, - format: (json['fmt'] as List?) - ?.map((e) => $enumDecode(_$BangFormatEnumMap, e)) - .toSet(), - ); + websiteName: json['s'] as String, + domain: json['d'] as String, + trigger: json['t'] as String, + urlTemplate: json['u'] as String, + category: json['c'] as String?, + subCategory: json['sc'] as String?, + format: (json['fmt'] as List?) + ?.map((e) => $enumDecode(_$BangFormatEnumMap, e)) + .toSet(), +); Map _$BangToJson(Bang instance) => { - 's': instance.websiteName, - 'd': instance.domain, - 't': instance.trigger, - 'u': instance.urlTemplate, - 'c': instance.category, - 'sc': instance.subCategory, - 'fmt': instance.format?.map((e) => _$BangFormatEnumMap[e]!).toList(), - }; + 's': instance.websiteName, + 'd': instance.domain, + 't': instance.trigger, + 'u': instance.urlTemplate, + 'c': instance.category, + 'sc': instance.subCategory, + 'fmt': instance.format?.map((e) => _$BangFormatEnumMap[e]!).toList(), +}; const _$BangFormatEnumMap = { BangFormat.openBasePath: 'open_base_path', diff --git a/app/lib/features/bangs/data/models/bang_data.dart b/app/lib/features/bangs/data/models/bang_data.dart index 282b1c42..ecd24a6a 100644 --- a/app/lib/features/bangs/data/models/bang_data.dart +++ b/app/lib/features/bangs/data/models/bang_data.dart @@ -1,6 +1,6 @@ import 'package:copy_with_extension/copy_with_extension.dart'; -import 'package:drift/drift.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart'; part 'bang_data.g.dart'; @@ -9,7 +9,7 @@ class BangData extends Bang { final int frequency; final DateTime? lastUsed; - final Uint8List? iconData; + final BrowserIcon? icon; BangData({ required super.websiteName, @@ -21,17 +21,14 @@ class BangData extends Bang { super.format, int? frequency, this.lastUsed, - this.iconData, + this.icon, }) : frequency = frequency ?? 0; - @override - bool get cacheHash => true; - @override List get hashParameters => [ - ...super.hashParameters, - frequency, - lastUsed, - iconData, - ]; + ...super.hashParameters, + frequency, + lastUsed, + icon, + ]; } diff --git a/app/lib/features/bangs/data/models/bang_data.g.dart b/app/lib/features/bangs/data/models/bang_data.g.dart index d657568e..92106af5 100644 --- a/app/lib/features/bangs/data/models/bang_data.g.dart +++ b/app/lib/features/bangs/data/models/bang_data.g.dart @@ -25,7 +25,7 @@ abstract class _$BangDataCWProxy { BangData lastUsed(DateTime? lastUsed); - BangData iconData(Uint8List? iconData); + BangData icon(BrowserIcon? icon); /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `BangData(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. /// @@ -34,16 +34,16 @@ abstract class _$BangDataCWProxy { /// BangData(...).copyWith(id: 12, name: "My name") /// ```` BangData call({ - String? websiteName, - String? domain, - String? trigger, - String? urlTemplate, + String websiteName, + String domain, + String trigger, + String urlTemplate, String? category, String? subCategory, Set? format, int? frequency, DateTime? lastUsed, - Uint8List? iconData, + BrowserIcon? icon, }); } @@ -81,10 +81,9 @@ class _$BangDataCWProxyImpl implements _$BangDataCWProxy { BangData lastUsed(DateTime? lastUsed) => this(lastUsed: lastUsed); @override - BangData iconData(Uint8List? iconData) => this(iconData: iconData); + BangData icon(BrowserIcon? icon) => this(icon: icon); @override - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `BangData(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. /// /// Usage @@ -101,27 +100,25 @@ class _$BangDataCWProxyImpl implements _$BangDataCWProxy { Object? format = const $CopyWithPlaceholder(), Object? frequency = const $CopyWithPlaceholder(), Object? lastUsed = const $CopyWithPlaceholder(), - Object? iconData = const $CopyWithPlaceholder(), + Object? icon = const $CopyWithPlaceholder(), }) { return BangData( - websiteName: - websiteName == const $CopyWithPlaceholder() || websiteName == null - ? _value.websiteName - // ignore: cast_nullable_to_non_nullable - : websiteName as String, - domain: domain == const $CopyWithPlaceholder() || domain == null + websiteName: websiteName == const $CopyWithPlaceholder() + ? _value.websiteName + // ignore: cast_nullable_to_non_nullable + : websiteName as String, + domain: domain == const $CopyWithPlaceholder() ? _value.domain // ignore: cast_nullable_to_non_nullable : domain as String, - trigger: trigger == const $CopyWithPlaceholder() || trigger == null + trigger: trigger == const $CopyWithPlaceholder() ? _value.trigger // ignore: cast_nullable_to_non_nullable : trigger as String, - urlTemplate: - urlTemplate == const $CopyWithPlaceholder() || urlTemplate == null - ? _value.urlTemplate - // ignore: cast_nullable_to_non_nullable - : urlTemplate as String, + urlTemplate: urlTemplate == const $CopyWithPlaceholder() + ? _value.urlTemplate + // ignore: cast_nullable_to_non_nullable + : urlTemplate as String, category: category == const $CopyWithPlaceholder() ? _value.category // ignore: cast_nullable_to_non_nullable @@ -142,10 +139,10 @@ class _$BangDataCWProxyImpl implements _$BangDataCWProxy { ? _value.lastUsed // ignore: cast_nullable_to_non_nullable : lastUsed as DateTime?, - iconData: iconData == const $CopyWithPlaceholder() - ? _value.iconData + icon: icon == const $CopyWithPlaceholder() + ? _value.icon // ignore: cast_nullable_to_non_nullable - : iconData as Uint8List?, + : icon as BrowserIcon?, ); } } diff --git a/app/lib/features/bangs/data/models/bang_group.dart b/app/lib/features/bangs/data/models/bang_group.dart new file mode 100644 index 00000000..5a13f4b8 --- /dev/null +++ b/app/lib/features/bangs/data/models/bang_group.dart @@ -0,0 +1,16 @@ +enum BangGroup { + general('https://raw.githubusercontent.com/FaFre/bangs/main/data/bangs.json'), + assistant( + 'https://raw.githubusercontent.com/FaFre/bangs/main/data/assistant_bangs.json', + ), + kagi( + 'https://raw.githubusercontent.com/FaFre/bangs/main/data/kagi_bangs.json', + ), + custom( + 'https://raw.githubusercontent.com/FaFre/bangs/refs/heads/custom/data/custom.json', + ); + + final String url; + + const BangGroup(this.url); +} diff --git a/app/lib/features/bangs/data/models/search_history_entry.dart b/app/lib/features/bangs/data/models/search_history_entry.dart new file mode 100644 index 00000000..ffb97f1c --- /dev/null +++ b/app/lib/features/bangs/data/models/search_history_entry.dart @@ -0,0 +1,16 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +class SearchHistoryEntry with FastEquatable { + final String searchQuery; + final String trigger; + final DateTime searchDate; + + SearchHistoryEntry({ + required this.searchQuery, + required this.trigger, + required this.searchDate, + }); + + @override + List get hashParameters => [searchQuery, trigger, searchDate]; +} diff --git a/app/lib/features/bangs/data/providers.dart b/app/lib/features/bangs/data/providers.dart index 469c0265..a7748fb7 100644 --- a/app/lib/features/bangs/data/providers.dart +++ b/app/lib/features/bangs/data/providers.dart @@ -1,18 +1,19 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; -import 'package:lensai/features/bangs/data/database/database.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; import 'package:universal_io/io.dart'; +import 'package:weblibre/features/bangs/data/database/database.dart'; part 'providers.g.dart'; @Riverpod(keepAlive: true) -BangDatabase bangDatabase(BangDatabaseRef ref) { - return BangDatabase( +BangDatabase bangDatabase(Ref ref) { + final db = BangDatabase( LazyDatabase(() async { // put the database file, called db.sqlite here, into the documents folder // for your app. @@ -34,4 +35,10 @@ BangDatabase bangDatabase(BangDatabaseRef ref) { return NativeDatabase.createInBackground(file); }), ); + + ref.onDispose(() async { + await db.close(); + }); + + return db; } diff --git a/app/lib/features/bangs/data/providers.g.dart b/app/lib/features/bangs/data/providers.g.dart index 3086995b..4c401644 100644 --- a/app/lib/features/bangs/data/providers.g.dart +++ b/app/lib/features/bangs/data/providers.g.dart @@ -6,19 +6,22 @@ part of 'providers.dart'; // RiverpodGenerator // ************************************************************************** -String _$bangDatabaseHash() => r'bbca2edf32faa378dcb6270b12019758d5156662'; +String _$bangDatabaseHash() => r'e31cf4b499300aff65174a16fb74c7dd8ec3d6f0'; /// See also [bangDatabase]. @ProviderFor(bangDatabase) final bangDatabaseProvider = Provider.internal( bangDatabase, name: r'bangDatabaseProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$bangDatabaseHash, + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bangDatabaseHash, dependencies: null, allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef BangDatabaseRef = ProviderRef; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/bangs/data/services/data_source.dart b/app/lib/features/bangs/data/services/data_source.dart new file mode 100644 index 00000000..8a958a34 --- /dev/null +++ b/app/lib/features/bangs/data/services/data_source.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:exceptions/exceptions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/http_error_handler.dart'; +import 'package:weblibre/features/bangs/data/models/bang.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; + +part 'data_source.g.dart'; + +@Riverpod() +class BangDataSourceService extends _$BangDataSourceService { + @override + void build() {} + + Future>> getBangs(Uri url, BangGroup? group) { + return Result.fromAsync(() async { + return await compute((args) async { + final client = http.Client(); + try { + final url = Uri.parse(args[0]); + final response = await client + .get(url) + .timeout(const Duration(seconds: 30)); + + return jsonDecode(utf8.decode(response.bodyBytes)) as List; + } finally { + client.close(); + } + }, [url.toString()]).then( + (json) => json.map((e) { + var bang = Bang.fromJson(e as Map); + + if (group != null) { + bang = bang.copyWith.group(group); + } + + return bang; + }).toList(), + ); + }, exceptionHandler: handleHttpError); + } +} diff --git a/app/lib/features/bangs/data/services/data_source.g.dart b/app/lib/features/bangs/data/services/data_source.g.dart new file mode 100644 index 00000000..7c8220f2 --- /dev/null +++ b/app/lib/features/bangs/data/services/data_source.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_source.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$bangDataSourceServiceHash() => + r'f802b9fb3c467a24d65d170229d37f745ae869e5'; + +/// See also [BangDataSourceService]. +@ProviderFor(BangDataSourceService) +final bangDataSourceServiceProvider = + AutoDisposeNotifierProvider.internal( + BangDataSourceService.new, + name: r'bangDataSourceServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bangDataSourceServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$BangDataSourceService = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/bangs/data/services/source.dart b/app/lib/features/bangs/data/services/source.dart deleted file mode 100644 index 2c69dc54..00000000 --- a/app/lib/features/bangs/data/services/source.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'dart:convert'; - -import 'package:exceptions/exceptions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:lensai/core/http_error_handler.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'source.g.dart'; - -@Riverpod() -class BangSourceService extends _$BangSourceService { - late http.Client _client; - - @override - void build() { - _client = http.Client(); - } - - Future>> getBangs(Uri url, BangGroup? group) async { - return Result.fromAsync( - () async { - final response = await _client.get(url); - return await compute( - (args) => jsonDecode(utf8.decode(args[0])) as List, - [response.bodyBytes], - ).then( - (json) => json.map((e) { - var bang = Bang.fromJson(e as Map); - - if (group != null) { - bang = bang.copyWith.group(group); - } - - return bang; - }).toList(), - ); - }, - exceptionHandler: handleHttpError, - ); - } -} diff --git a/app/lib/features/bangs/data/services/source.g.dart b/app/lib/features/bangs/data/services/source.g.dart deleted file mode 100644 index 5e931684..00000000 --- a/app/lib/features/bangs/data/services/source.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'source.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$bangSourceServiceHash() => r'2f734d563fa20de306a72013526ef1f72f910fe1'; - -/// See also [BangSourceService]. -@ProviderFor(BangSourceService) -final bangSourceServiceProvider = - AutoDisposeNotifierProvider.internal( - BangSourceService.new, - name: r'bangSourceServiceProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangSourceServiceHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$BangSourceService = AutoDisposeNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/bangs/domain/providers.dart b/app/lib/features/bangs/domain/providers.dart deleted file mode 100644 index e869ef78..00000000 --- a/app/lib/features/bangs/domain/providers.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:lensai/features/bangs/data/models/bang.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/domain/repositories/data.dart'; -import 'package:lensai/features/bangs/domain/repositories/sync.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'providers.g.dart'; - -@Riverpod() -Stream bangData(BangDataRef ref, String? trigger) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.watchBang(trigger); -} - -@Riverpod(keepAlive: true) -Stream kagiSearchBangData(KagiSearchBangDataRef ref) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.watchBang('kagi'); -} - -@Riverpod() -Stream>> bangCategories(BangCategoriesRef ref) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.watchCategories(); -} - -@Riverpod() -Stream> bangDataList( - BangDataListRef ref, { - ({ - Iterable? groups, - String? domain, - ({String category, String? subCategory})? categoryFilter, - bool? orderMostFrequentFirst, - })? filter, -}) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.watchBangs( - groups: filter?.groups, - domain: filter?.domain, - categoryFilter: filter?.categoryFilter, - orderMostFrequentFirst: filter?.orderMostFrequentFirst, - ); -} - -@Riverpod() -Stream> frequentBangDataList(FrequentBangDataListRef ref) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.watchFrequentBangs(); -} - -@Riverpod() -Future bangDataEnsureIcon( - BangDataEnsureIconRef ref, - BangData bang, -) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.ensureIconAvailable(bang).then( - (value) => value.value, - ); -} - -@Riverpod() -Stream lastSyncOfGroup( - LastSyncOfGroupRef ref, - BangGroup group, -) { - final repository = ref.watch(bangSyncRepositoryProvider.notifier); - return repository.watchLastSyncOfGroup(group); -} - -@Riverpod() -Stream bangCountOfGroup( - BangCountOfGroupRef ref, - BangGroup group, -) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.watchBangCount(group); -} - -@Riverpod() -Stream bangIconCacheSizeMegabytes(BangIconCacheSizeMegabytesRef ref) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.watchIconCacheSize(); -} diff --git a/app/lib/features/bangs/domain/providers.g.dart b/app/lib/features/bangs/domain/providers.g.dart deleted file mode 100644 index 22de6866..00000000 --- a/app/lib/features/bangs/domain/providers.g.dart +++ /dev/null @@ -1,766 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'providers.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$bangDataHash() => r'd0e96885458be8893c6b19735b8792bdd1b9bcb8'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [bangData]. -@ProviderFor(bangData) -const bangDataProvider = BangDataFamily(); - -/// See also [bangData]. -class BangDataFamily extends Family> { - /// See also [bangData]. - const BangDataFamily(); - - /// See also [bangData]. - BangDataProvider call( - String? trigger, - ) { - return BangDataProvider( - trigger, - ); - } - - @override - BangDataProvider getProviderOverride( - covariant BangDataProvider provider, - ) { - return call( - provider.trigger, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'bangDataProvider'; -} - -/// See also [bangData]. -class BangDataProvider extends AutoDisposeStreamProvider { - /// See also [bangData]. - BangDataProvider( - String? trigger, - ) : this._internal( - (ref) => bangData( - ref as BangDataRef, - trigger, - ), - from: bangDataProvider, - name: r'bangDataProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangDataHash, - dependencies: BangDataFamily._dependencies, - allTransitiveDependencies: BangDataFamily._allTransitiveDependencies, - trigger: trigger, - ); - - BangDataProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.trigger, - }) : super.internal(); - - final String? trigger; - - @override - Override overrideWith( - Stream Function(BangDataRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: BangDataProvider._internal( - (ref) => create(ref as BangDataRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - trigger: trigger, - ), - ); - } - - @override - AutoDisposeStreamProviderElement createElement() { - return _BangDataProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is BangDataProvider && other.trigger == trigger; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, trigger.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin BangDataRef on AutoDisposeStreamProviderRef { - /// The parameter `trigger` of this provider. - String? get trigger; -} - -class _BangDataProviderElement - extends AutoDisposeStreamProviderElement with BangDataRef { - _BangDataProviderElement(super.provider); - - @override - String? get trigger => (origin as BangDataProvider).trigger; -} - -String _$kagiSearchBangDataHash() => - r'4dbeaab0541d7c8214d097523d139eede2e3f975'; - -/// See also [kagiSearchBangData]. -@ProviderFor(kagiSearchBangData) -final kagiSearchBangDataProvider = StreamProvider.internal( - kagiSearchBangData, - name: r'kagiSearchBangDataProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$kagiSearchBangDataHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef KagiSearchBangDataRef = StreamProviderRef; -String _$bangCategoriesHash() => r'dbc63b78208be90c2aa3c3d61b08f98818f4b89d'; - -/// See also [bangCategories]. -@ProviderFor(bangCategories) -final bangCategoriesProvider = - AutoDisposeStreamProvider>>.internal( - bangCategories, - name: r'bangCategoriesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangCategoriesHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef BangCategoriesRef - = AutoDisposeStreamProviderRef>>; -String _$bangDataListHash() => r'c4fbc1676be933b29ec88f503110c119fe8b6d86'; - -/// See also [bangDataList]. -@ProviderFor(bangDataList) -const bangDataListProvider = BangDataListFamily(); - -/// See also [bangDataList]. -class BangDataListFamily extends Family>> { - /// See also [bangDataList]. - const BangDataListFamily(); - - /// See also [bangDataList]. - BangDataListProvider call({ - ({ - ({String category, String? subCategory})? categoryFilter, - String? domain, - Iterable? groups, - bool? orderMostFrequentFirst - })? filter, - }) { - return BangDataListProvider( - filter: filter, - ); - } - - @override - BangDataListProvider getProviderOverride( - covariant BangDataListProvider provider, - ) { - return call( - filter: provider.filter, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'bangDataListProvider'; -} - -/// See also [bangDataList]. -class BangDataListProvider extends AutoDisposeStreamProvider> { - /// See also [bangDataList]. - BangDataListProvider({ - ({ - ({String category, String? subCategory})? categoryFilter, - String? domain, - Iterable? groups, - bool? orderMostFrequentFirst - })? filter, - }) : this._internal( - (ref) => bangDataList( - ref as BangDataListRef, - filter: filter, - ), - from: bangDataListProvider, - name: r'bangDataListProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangDataListHash, - dependencies: BangDataListFamily._dependencies, - allTransitiveDependencies: - BangDataListFamily._allTransitiveDependencies, - filter: filter, - ); - - BangDataListProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.filter, - }) : super.internal(); - - final ({ - ({String category, String? subCategory})? categoryFilter, - String? domain, - Iterable? groups, - bool? orderMostFrequentFirst - })? filter; - - @override - Override overrideWith( - Stream> Function(BangDataListRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: BangDataListProvider._internal( - (ref) => create(ref as BangDataListRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - filter: filter, - ), - ); - } - - @override - AutoDisposeStreamProviderElement> createElement() { - return _BangDataListProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is BangDataListProvider && other.filter == filter; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, filter.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin BangDataListRef on AutoDisposeStreamProviderRef> { - /// The parameter `filter` of this provider. - ({ - ({String category, String? subCategory})? categoryFilter, - String? domain, - Iterable? groups, - bool? orderMostFrequentFirst - })? get filter; -} - -class _BangDataListProviderElement - extends AutoDisposeStreamProviderElement> - with BangDataListRef { - _BangDataListProviderElement(super.provider); - - @override - ({ - ({String category, String? subCategory})? categoryFilter, - String? domain, - Iterable? groups, - bool? orderMostFrequentFirst - })? get filter => (origin as BangDataListProvider).filter; -} - -String _$frequentBangDataListHash() => - r'adf8d0b72adfa8faffadd7f1a294ce3945e22fec'; - -/// See also [frequentBangDataList]. -@ProviderFor(frequentBangDataList) -final frequentBangDataListProvider = - AutoDisposeStreamProvider>.internal( - frequentBangDataList, - name: r'frequentBangDataListProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$frequentBangDataListHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef FrequentBangDataListRef = AutoDisposeStreamProviderRef>; -String _$bangDataEnsureIconHash() => - r'69b59b582a7b165b8076a1478d0d433ec0846ccb'; - -/// See also [bangDataEnsureIcon]. -@ProviderFor(bangDataEnsureIcon) -const bangDataEnsureIconProvider = BangDataEnsureIconFamily(); - -/// See also [bangDataEnsureIcon]. -class BangDataEnsureIconFamily extends Family> { - /// See also [bangDataEnsureIcon]. - const BangDataEnsureIconFamily(); - - /// See also [bangDataEnsureIcon]. - BangDataEnsureIconProvider call( - BangData bang, - ) { - return BangDataEnsureIconProvider( - bang, - ); - } - - @override - BangDataEnsureIconProvider getProviderOverride( - covariant BangDataEnsureIconProvider provider, - ) { - return call( - provider.bang, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'bangDataEnsureIconProvider'; -} - -/// See also [bangDataEnsureIcon]. -class BangDataEnsureIconProvider extends AutoDisposeFutureProvider { - /// See also [bangDataEnsureIcon]. - BangDataEnsureIconProvider( - BangData bang, - ) : this._internal( - (ref) => bangDataEnsureIcon( - ref as BangDataEnsureIconRef, - bang, - ), - from: bangDataEnsureIconProvider, - name: r'bangDataEnsureIconProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangDataEnsureIconHash, - dependencies: BangDataEnsureIconFamily._dependencies, - allTransitiveDependencies: - BangDataEnsureIconFamily._allTransitiveDependencies, - bang: bang, - ); - - BangDataEnsureIconProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.bang, - }) : super.internal(); - - final BangData bang; - - @override - Override overrideWith( - FutureOr Function(BangDataEnsureIconRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: BangDataEnsureIconProvider._internal( - (ref) => create(ref as BangDataEnsureIconRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - bang: bang, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _BangDataEnsureIconProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is BangDataEnsureIconProvider && other.bang == bang; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, bang.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin BangDataEnsureIconRef on AutoDisposeFutureProviderRef { - /// The parameter `bang` of this provider. - BangData get bang; -} - -class _BangDataEnsureIconProviderElement - extends AutoDisposeFutureProviderElement - with BangDataEnsureIconRef { - _BangDataEnsureIconProviderElement(super.provider); - - @override - BangData get bang => (origin as BangDataEnsureIconProvider).bang; -} - -String _$lastSyncOfGroupHash() => r'4906dc24970eccda89d94697f98cf70e241ea4ec'; - -/// See also [lastSyncOfGroup]. -@ProviderFor(lastSyncOfGroup) -const lastSyncOfGroupProvider = LastSyncOfGroupFamily(); - -/// See also [lastSyncOfGroup]. -class LastSyncOfGroupFamily extends Family> { - /// See also [lastSyncOfGroup]. - const LastSyncOfGroupFamily(); - - /// See also [lastSyncOfGroup]. - LastSyncOfGroupProvider call( - BangGroup group, - ) { - return LastSyncOfGroupProvider( - group, - ); - } - - @override - LastSyncOfGroupProvider getProviderOverride( - covariant LastSyncOfGroupProvider provider, - ) { - return call( - provider.group, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'lastSyncOfGroupProvider'; -} - -/// See also [lastSyncOfGroup]. -class LastSyncOfGroupProvider extends AutoDisposeStreamProvider { - /// See also [lastSyncOfGroup]. - LastSyncOfGroupProvider( - BangGroup group, - ) : this._internal( - (ref) => lastSyncOfGroup( - ref as LastSyncOfGroupRef, - group, - ), - from: lastSyncOfGroupProvider, - name: r'lastSyncOfGroupProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$lastSyncOfGroupHash, - dependencies: LastSyncOfGroupFamily._dependencies, - allTransitiveDependencies: - LastSyncOfGroupFamily._allTransitiveDependencies, - group: group, - ); - - LastSyncOfGroupProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.group, - }) : super.internal(); - - final BangGroup group; - - @override - Override overrideWith( - Stream Function(LastSyncOfGroupRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: LastSyncOfGroupProvider._internal( - (ref) => create(ref as LastSyncOfGroupRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - group: group, - ), - ); - } - - @override - AutoDisposeStreamProviderElement createElement() { - return _LastSyncOfGroupProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is LastSyncOfGroupProvider && other.group == group; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, group.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin LastSyncOfGroupRef on AutoDisposeStreamProviderRef { - /// The parameter `group` of this provider. - BangGroup get group; -} - -class _LastSyncOfGroupProviderElement - extends AutoDisposeStreamProviderElement - with LastSyncOfGroupRef { - _LastSyncOfGroupProviderElement(super.provider); - - @override - BangGroup get group => (origin as LastSyncOfGroupProvider).group; -} - -String _$bangCountOfGroupHash() => r'10b363679230c0abf6a6ba7d0be2d0367a7ed215'; - -/// See also [bangCountOfGroup]. -@ProviderFor(bangCountOfGroup) -const bangCountOfGroupProvider = BangCountOfGroupFamily(); - -/// See also [bangCountOfGroup]. -class BangCountOfGroupFamily extends Family> { - /// See also [bangCountOfGroup]. - const BangCountOfGroupFamily(); - - /// See also [bangCountOfGroup]. - BangCountOfGroupProvider call( - BangGroup group, - ) { - return BangCountOfGroupProvider( - group, - ); - } - - @override - BangCountOfGroupProvider getProviderOverride( - covariant BangCountOfGroupProvider provider, - ) { - return call( - provider.group, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'bangCountOfGroupProvider'; -} - -/// See also [bangCountOfGroup]. -class BangCountOfGroupProvider extends AutoDisposeStreamProvider { - /// See also [bangCountOfGroup]. - BangCountOfGroupProvider( - BangGroup group, - ) : this._internal( - (ref) => bangCountOfGroup( - ref as BangCountOfGroupRef, - group, - ), - from: bangCountOfGroupProvider, - name: r'bangCountOfGroupProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangCountOfGroupHash, - dependencies: BangCountOfGroupFamily._dependencies, - allTransitiveDependencies: - BangCountOfGroupFamily._allTransitiveDependencies, - group: group, - ); - - BangCountOfGroupProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.group, - }) : super.internal(); - - final BangGroup group; - - @override - Override overrideWith( - Stream Function(BangCountOfGroupRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: BangCountOfGroupProvider._internal( - (ref) => create(ref as BangCountOfGroupRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - group: group, - ), - ); - } - - @override - AutoDisposeStreamProviderElement createElement() { - return _BangCountOfGroupProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is BangCountOfGroupProvider && other.group == group; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, group.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin BangCountOfGroupRef on AutoDisposeStreamProviderRef { - /// The parameter `group` of this provider. - BangGroup get group; -} - -class _BangCountOfGroupProviderElement - extends AutoDisposeStreamProviderElement with BangCountOfGroupRef { - _BangCountOfGroupProviderElement(super.provider); - - @override - BangGroup get group => (origin as BangCountOfGroupProvider).group; -} - -String _$bangIconCacheSizeMegabytesHash() => - r'2199439f1b8a2692607231836769a10a24405181'; - -/// See also [bangIconCacheSizeMegabytes]. -@ProviderFor(bangIconCacheSizeMegabytes) -final bangIconCacheSizeMegabytesProvider = - AutoDisposeStreamProvider.internal( - bangIconCacheSizeMegabytes, - name: r'bangIconCacheSizeMegabytesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangIconCacheSizeMegabytesHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef BangIconCacheSizeMegabytesRef = AutoDisposeStreamProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/bangs/domain/providers/bangs.dart b/app/lib/features/bangs/domain/providers/bangs.dart new file mode 100644 index 00000000..251b3d7f --- /dev/null +++ b/app/lib/features/bangs/domain/providers/bangs.dart @@ -0,0 +1,77 @@ +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; +import 'package:weblibre/features/bangs/data/models/search_history_entry.dart'; +import 'package:weblibre/features/bangs/domain/repositories/data.dart'; +import 'package:weblibre/features/bangs/domain/repositories/sync.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +part 'bangs.g.dart'; + +@Riverpod(keepAlive: true) +Stream defaultSearchBangData(Ref ref) { + final trigger = ref.watch( + generalSettingsRepositoryProvider.select( + (value) => value.defaultSearchProvider, + ), + ); + + final repository = ref.watch(bangDataRepositoryProvider.notifier); + return repository.watchBang(trigger); +} + +@Riverpod() +Stream bangData(Ref ref, String trigger) { + final repository = ref.watch(bangDataRepositoryProvider.notifier); + return repository.watchBang(trigger); +} + +@Riverpod() +Stream>> bangCategories(Ref ref) { + final repository = ref.watch(bangDataRepositoryProvider.notifier); + return repository.watchCategories(); +} + +@Riverpod() +Stream> bangList( + Ref ref, { + List? triggers, + List? groups, + String? domain, + ({String category, String? subCategory})? categoryFilter, + bool? orderMostFrequentFirst, +}) { + final repository = ref.watch(bangDataRepositoryProvider.notifier); + return repository.watchBangs( + triggers: triggers, + groups: groups, + domain: domain, + categoryFilter: categoryFilter, + orderMostFrequentFirst: orderMostFrequentFirst, + ); +} + +@Riverpod() +Stream> frequentBangList(Ref ref) { + final repository = ref.watch(bangDataRepositoryProvider.notifier); + return repository.watchFrequentBangs(); +} + +@Riverpod() +Stream> searchHistory(Ref ref) { + final repository = ref.watch(bangDataRepositoryProvider.notifier); + return repository.watchSearchHistory(limit: 3); //TODO: make count dynamic +} + +@Riverpod() +Stream lastSyncOfGroup(Ref ref, BangGroup group) { + final repository = ref.watch(bangSyncRepositoryProvider.notifier); + return repository.watchLastSyncOfGroup(group); +} + +@Riverpod() +Stream bangCountOfGroup(Ref ref, BangGroup group) { + final repository = ref.watch(bangDataRepositoryProvider.notifier); + return repository.watchBangCount(group); +} diff --git a/app/lib/features/bangs/domain/providers/bangs.g.dart b/app/lib/features/bangs/domain/providers/bangs.g.dart new file mode 100644 index 00000000..2ae40d73 --- /dev/null +++ b/app/lib/features/bangs/domain/providers/bangs.g.dart @@ -0,0 +1,656 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bangs.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$defaultSearchBangDataHash() => + r'acce519559903505f9623f3631b6f4177a396d39'; + +/// See also [defaultSearchBangData]. +@ProviderFor(defaultSearchBangData) +final defaultSearchBangDataProvider = StreamProvider.internal( + defaultSearchBangData, + name: r'defaultSearchBangDataProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$defaultSearchBangDataHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef DefaultSearchBangDataRef = StreamProviderRef; +String _$bangDataHash() => r'b10d3a74e19d30fc372b9ecbf7146bccd9fdf83b'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [bangData]. +@ProviderFor(bangData) +const bangDataProvider = BangDataFamily(); + +/// See also [bangData]. +class BangDataFamily extends Family> { + /// See also [bangData]. + const BangDataFamily(); + + /// See also [bangData]. + BangDataProvider call(String trigger) { + return BangDataProvider(trigger); + } + + @override + BangDataProvider getProviderOverride(covariant BangDataProvider provider) { + return call(provider.trigger); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'bangDataProvider'; +} + +/// See also [bangData]. +class BangDataProvider extends AutoDisposeStreamProvider { + /// See also [bangData]. + BangDataProvider(String trigger) + : this._internal( + (ref) => bangData(ref as BangDataRef, trigger), + from: bangDataProvider, + name: r'bangDataProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bangDataHash, + dependencies: BangDataFamily._dependencies, + allTransitiveDependencies: BangDataFamily._allTransitiveDependencies, + trigger: trigger, + ); + + BangDataProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.trigger, + }) : super.internal(); + + final String trigger; + + @override + Override overrideWith( + Stream Function(BangDataRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: BangDataProvider._internal( + (ref) => create(ref as BangDataRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + trigger: trigger, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _BangDataProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is BangDataProvider && other.trigger == trigger; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, trigger.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin BangDataRef on AutoDisposeStreamProviderRef { + /// The parameter `trigger` of this provider. + String get trigger; +} + +class _BangDataProviderElement + extends AutoDisposeStreamProviderElement + with BangDataRef { + _BangDataProviderElement(super.provider); + + @override + String get trigger => (origin as BangDataProvider).trigger; +} + +String _$bangCategoriesHash() => r'947fcfd2dffcc7f585c6ed7379d319f4fe72293a'; + +/// See also [bangCategories]. +@ProviderFor(bangCategories) +final bangCategoriesProvider = + AutoDisposeStreamProvider>>.internal( + bangCategories, + name: r'bangCategoriesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bangCategoriesHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef BangCategoriesRef = + AutoDisposeStreamProviderRef>>; +String _$bangListHash() => r'd1e0bb9fa4f523ce516e075c0d149bf7803ebb2b'; + +/// See also [bangList]. +@ProviderFor(bangList) +const bangListProvider = BangListFamily(); + +/// See also [bangList]. +class BangListFamily extends Family>> { + /// See also [bangList]. + const BangListFamily(); + + /// See also [bangList]. + BangListProvider call({ + List? triggers, + List? groups, + String? domain, + ({String category, String? subCategory})? categoryFilter, + bool? orderMostFrequentFirst, + }) { + return BangListProvider( + triggers: triggers, + groups: groups, + domain: domain, + categoryFilter: categoryFilter, + orderMostFrequentFirst: orderMostFrequentFirst, + ); + } + + @override + BangListProvider getProviderOverride(covariant BangListProvider provider) { + return call( + triggers: provider.triggers, + groups: provider.groups, + domain: provider.domain, + categoryFilter: provider.categoryFilter, + orderMostFrequentFirst: provider.orderMostFrequentFirst, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'bangListProvider'; +} + +/// See also [bangList]. +class BangListProvider extends AutoDisposeStreamProvider> { + /// See also [bangList]. + BangListProvider({ + List? triggers, + List? groups, + String? domain, + ({String category, String? subCategory})? categoryFilter, + bool? orderMostFrequentFirst, + }) : this._internal( + (ref) => bangList( + ref as BangListRef, + triggers: triggers, + groups: groups, + domain: domain, + categoryFilter: categoryFilter, + orderMostFrequentFirst: orderMostFrequentFirst, + ), + from: bangListProvider, + name: r'bangListProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bangListHash, + dependencies: BangListFamily._dependencies, + allTransitiveDependencies: BangListFamily._allTransitiveDependencies, + triggers: triggers, + groups: groups, + domain: domain, + categoryFilter: categoryFilter, + orderMostFrequentFirst: orderMostFrequentFirst, + ); + + BangListProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.triggers, + required this.groups, + required this.domain, + required this.categoryFilter, + required this.orderMostFrequentFirst, + }) : super.internal(); + + final List? triggers; + final List? groups; + final String? domain; + final ({String category, String? subCategory})? categoryFilter; + final bool? orderMostFrequentFirst; + + @override + Override overrideWith( + Stream> Function(BangListRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: BangListProvider._internal( + (ref) => create(ref as BangListRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + triggers: triggers, + groups: groups, + domain: domain, + categoryFilter: categoryFilter, + orderMostFrequentFirst: orderMostFrequentFirst, + ), + ); + } + + @override + AutoDisposeStreamProviderElement> createElement() { + return _BangListProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is BangListProvider && + other.triggers == triggers && + other.groups == groups && + other.domain == domain && + other.categoryFilter == categoryFilter && + other.orderMostFrequentFirst == orderMostFrequentFirst; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, triggers.hashCode); + hash = _SystemHash.combine(hash, groups.hashCode); + hash = _SystemHash.combine(hash, domain.hashCode); + hash = _SystemHash.combine(hash, categoryFilter.hashCode); + hash = _SystemHash.combine(hash, orderMostFrequentFirst.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin BangListRef on AutoDisposeStreamProviderRef> { + /// The parameter `triggers` of this provider. + List? get triggers; + + /// The parameter `groups` of this provider. + List? get groups; + + /// The parameter `domain` of this provider. + String? get domain; + + /// The parameter `categoryFilter` of this provider. + ({String category, String? subCategory})? get categoryFilter; + + /// The parameter `orderMostFrequentFirst` of this provider. + bool? get orderMostFrequentFirst; +} + +class _BangListProviderElement + extends AutoDisposeStreamProviderElement> + with BangListRef { + _BangListProviderElement(super.provider); + + @override + List? get triggers => (origin as BangListProvider).triggers; + @override + List? get groups => (origin as BangListProvider).groups; + @override + String? get domain => (origin as BangListProvider).domain; + @override + ({String category, String? subCategory})? get categoryFilter => + (origin as BangListProvider).categoryFilter; + @override + bool? get orderMostFrequentFirst => + (origin as BangListProvider).orderMostFrequentFirst; +} + +String _$frequentBangListHash() => r'2c1ecb7e9416772fc1c32d01d767e1eb4f865975'; + +/// See also [frequentBangList]. +@ProviderFor(frequentBangList) +final frequentBangListProvider = + AutoDisposeStreamProvider>.internal( + frequentBangList, + name: r'frequentBangListProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$frequentBangListHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FrequentBangListRef = AutoDisposeStreamProviderRef>; +String _$searchHistoryHash() => r'7b97798729643de8e44fd8024cdc02f35124b08b'; + +/// See also [searchHistory]. +@ProviderFor(searchHistory) +final searchHistoryProvider = + AutoDisposeStreamProvider>.internal( + searchHistory, + name: r'searchHistoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$searchHistoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SearchHistoryRef = + AutoDisposeStreamProviderRef>; +String _$lastSyncOfGroupHash() => r'23d07f3132ba9bb35a31f74e3a69698d31d4c569'; + +/// See also [lastSyncOfGroup]. +@ProviderFor(lastSyncOfGroup) +const lastSyncOfGroupProvider = LastSyncOfGroupFamily(); + +/// See also [lastSyncOfGroup]. +class LastSyncOfGroupFamily extends Family> { + /// See also [lastSyncOfGroup]. + const LastSyncOfGroupFamily(); + + /// See also [lastSyncOfGroup]. + LastSyncOfGroupProvider call(BangGroup group) { + return LastSyncOfGroupProvider(group); + } + + @override + LastSyncOfGroupProvider getProviderOverride( + covariant LastSyncOfGroupProvider provider, + ) { + return call(provider.group); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'lastSyncOfGroupProvider'; +} + +/// See also [lastSyncOfGroup]. +class LastSyncOfGroupProvider extends AutoDisposeStreamProvider { + /// See also [lastSyncOfGroup]. + LastSyncOfGroupProvider(BangGroup group) + : this._internal( + (ref) => lastSyncOfGroup(ref as LastSyncOfGroupRef, group), + from: lastSyncOfGroupProvider, + name: r'lastSyncOfGroupProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$lastSyncOfGroupHash, + dependencies: LastSyncOfGroupFamily._dependencies, + allTransitiveDependencies: + LastSyncOfGroupFamily._allTransitiveDependencies, + group: group, + ); + + LastSyncOfGroupProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.group, + }) : super.internal(); + + final BangGroup group; + + @override + Override overrideWith( + Stream Function(LastSyncOfGroupRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: LastSyncOfGroupProvider._internal( + (ref) => create(ref as LastSyncOfGroupRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + group: group, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _LastSyncOfGroupProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is LastSyncOfGroupProvider && other.group == group; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, group.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin LastSyncOfGroupRef on AutoDisposeStreamProviderRef { + /// The parameter `group` of this provider. + BangGroup get group; +} + +class _LastSyncOfGroupProviderElement + extends AutoDisposeStreamProviderElement + with LastSyncOfGroupRef { + _LastSyncOfGroupProviderElement(super.provider); + + @override + BangGroup get group => (origin as LastSyncOfGroupProvider).group; +} + +String _$bangCountOfGroupHash() => r'211ffcd7f49b637a7953f913dc5eafda344423f3'; + +/// See also [bangCountOfGroup]. +@ProviderFor(bangCountOfGroup) +const bangCountOfGroupProvider = BangCountOfGroupFamily(); + +/// See also [bangCountOfGroup]. +class BangCountOfGroupFamily extends Family> { + /// See also [bangCountOfGroup]. + const BangCountOfGroupFamily(); + + /// See also [bangCountOfGroup]. + BangCountOfGroupProvider call(BangGroup group) { + return BangCountOfGroupProvider(group); + } + + @override + BangCountOfGroupProvider getProviderOverride( + covariant BangCountOfGroupProvider provider, + ) { + return call(provider.group); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'bangCountOfGroupProvider'; +} + +/// See also [bangCountOfGroup]. +class BangCountOfGroupProvider extends AutoDisposeStreamProvider { + /// See also [bangCountOfGroup]. + BangCountOfGroupProvider(BangGroup group) + : this._internal( + (ref) => bangCountOfGroup(ref as BangCountOfGroupRef, group), + from: bangCountOfGroupProvider, + name: r'bangCountOfGroupProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bangCountOfGroupHash, + dependencies: BangCountOfGroupFamily._dependencies, + allTransitiveDependencies: + BangCountOfGroupFamily._allTransitiveDependencies, + group: group, + ); + + BangCountOfGroupProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.group, + }) : super.internal(); + + final BangGroup group; + + @override + Override overrideWith( + Stream Function(BangCountOfGroupRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: BangCountOfGroupProvider._internal( + (ref) => create(ref as BangCountOfGroupRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + group: group, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _BangCountOfGroupProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is BangCountOfGroupProvider && other.group == group; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, group.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin BangCountOfGroupRef on AutoDisposeStreamProviderRef { + /// The parameter `group` of this provider. + BangGroup get group; +} + +class _BangCountOfGroupProviderElement + extends AutoDisposeStreamProviderElement + with BangCountOfGroupRef { + _BangCountOfGroupProviderElement(super.provider); + + @override + BangGroup get group => (origin as BangCountOfGroupProvider).group; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/bangs/domain/providers/search.dart b/app/lib/features/bangs/domain/providers/search.dart new file mode 100644 index 00000000..24e47160 --- /dev/null +++ b/app/lib/features/bangs/domain/providers/search.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/data/providers.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/domain/repositories/data.dart'; + +part 'search.g.dart'; + +@Riverpod() +class BangSearch extends _$BangSearch { + late StreamController> _streamController; + + Future triggerBangSearch(BangData bang, String searchQuery) async { + final bangDataNotifier = ref.read(bangDataRepositoryProvider.notifier); + + await bangDataNotifier.increaseFrequency(bang.trigger); + await bangDataNotifier.addSearchEntry( + bang.trigger, + searchQuery, + maxEntryCount: 3, + ); //TODO: make count dynamic + + return bang.getTemplateUrl(searchQuery); + } + + Future search(String input) async { + if (input.isNotEmpty) { + await ref.read(bangDatabaseProvider).bangDao.queryBangs(input).get().then( + (value) { + if (!_streamController.isClosed) { + _streamController.add(value); + } + }, + ); + } + } + + @override + Stream> build() { + _streamController = StreamController(); + + ref.onDispose(() async { + await _streamController.close(); + }); + + return _streamController.stream; + } +} + +@Riverpod() +class SeamlessBang extends _$SeamlessBang { + bool _hasSearch = false; + + void search(String input) { + if (input.isNotEmpty) { + if (!_hasSearch) { + _hasSearch = true; + ref.invalidateSelf(); + } + + //Don't block + unawaited(ref.read(bangSearchProvider.notifier).search(input)); + } else if (_hasSearch) { + _hasSearch = false; + ref.invalidateSelf(); + } + } + + @override + AsyncValue> build() { + return _hasSearch + ? ref.watch(bangSearchProvider) + : ref.watch(frequentBangListProvider); + } +} diff --git a/app/lib/features/bangs/domain/providers/search.g.dart b/app/lib/features/bangs/domain/providers/search.g.dart new file mode 100644 index 00000000..1556c548 --- /dev/null +++ b/app/lib/features/bangs/domain/providers/search.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$bangSearchHash() => r'6b7452d48698c01870c5c75f0c10cadaafc481c8'; + +/// See also [BangSearch]. +@ProviderFor(BangSearch) +final bangSearchProvider = + AutoDisposeStreamNotifierProvider>.internal( + BangSearch.new, + name: r'bangSearchProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bangSearchHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$BangSearch = AutoDisposeStreamNotifier>; +String _$seamlessBangHash() => r'8bd7a2cbe4c302ae08f85167290666a7437f8b9b'; + +/// See also [SeamlessBang]. +@ProviderFor(SeamlessBang) +final seamlessBangProvider = + AutoDisposeNotifierProvider< + SeamlessBang, + AsyncValue> + >.internal( + SeamlessBang.new, + name: r'seamlessBangProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$seamlessBangHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$SeamlessBang = AutoDisposeNotifier>>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/bangs/domain/repositories/data.dart b/app/lib/features/bangs/domain/repositories/data.dart index 9c957fe7..cfe2f9ea 100644 --- a/app/lib/features/bangs/domain/repositories/data.dart +++ b/app/lib/features/bangs/domain/repositories/data.dart @@ -1,37 +1,35 @@ import 'dart:convert'; import 'package:drift/drift.dart'; -import 'package:exceptions/exceptions.dart'; -import 'package:lensai/domain/services/generic_website.dart'; -import 'package:lensai/extensions/database_table_size.dart'; -import 'package:lensai/features/bangs/data/database/database.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/data/providers.dart'; -import 'package:lensai/utils/image_helper.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; +import 'package:weblibre/features/bangs/data/models/search_history_entry.dart'; +import 'package:weblibre/features/bangs/data/providers.dart'; part 'data.g.dart'; @Riverpod(keepAlive: true) class BangDataRepository extends _$BangDataRepository { - late BangDatabase _db; - @override - void build() { - _db = ref.watch(bangDatabaseProvider); - } + void build() {} Stream watchBang(String? trigger) { if (trigger != null) { - return _db.bangDao.getBangData(trigger).watchSingleOrNull(); + return ref + .read(bangDatabaseProvider) + .bangDao + .getBangData(trigger) + .watchSingleOrNull(); } else { return Stream.value(null); } } Stream>> watchCategories() { - return _db.categoriesJson().watchSingle().map((json) { + return ref.read(bangDatabaseProvider).categoriesJson().watchSingle().map(( + json, + ) { final decoded = jsonDecode(json) as Map; return decoded.map( (key, value) => MapEntry(key, (value as List).cast()), @@ -40,17 +38,25 @@ class BangDataRepository extends _$BangDataRepository { } Stream watchBangCount(BangGroup group) { - return _db.bangDao.getBangCount(groups: [group]).watchSingle(); + return ref + .read(bangDatabaseProvider) + .bangDao + .getBangCount(groups: [group]) + .watchSingle(); } Stream> watchBangs({ + Iterable? triggers, Iterable? groups, String? domain, ({String category, String? subCategory})? categoryFilter, bool? orderMostFrequentFirst, }) { - return _db.bangDao + return ref + .read(bangDatabaseProvider) + .bangDao .getBangDataList( + triggers: triggers, groups: groups, domain: domain, category: categoryFilter?.category, @@ -61,47 +67,55 @@ class BangDataRepository extends _$BangDataRepository { } Stream> watchFrequentBangs({Iterable? groups}) { - return _db.bangDao.getFrequentBangDataList(groups: groups).watch(); + return ref + .read(bangDatabaseProvider) + .bangDao + .getFrequentBangDataList(groups: groups) + .watch(); + } + + Stream> watchSearchHistory({required int limit}) { + return ref + .read(bangDatabaseProvider) + .searchHistoryEntries(limit: limit) + .watch(); } Future increaseFrequency(String trigger) { - return _db.bangDao.increaseBangFrequency(trigger); + return ref + .read(bangDatabaseProvider) + .bangDao + .increaseBangFrequency(trigger); } - Future> ensureIconAvailable(BangData bang) async { - if (bang.iconData != null) { - return Result.success(bang); - } - - final result = await ref - .read(genericWebsiteServiceProvider.notifier) - .getFaviconBytes(Uri.parse(bang.getUrl('').origin)); - - return result.flatMapAsync( - (faviconBytes) async { - if (faviconBytes != null && await isImageValid(faviconBytes)) { - await _db.bangDao.upsertBangIcon(bang.trigger, faviconBytes); - return bang.copyWith.iconData(faviconBytes); - } - - return bang; - }, - ); + Future addSearchEntry( + String trigger, + String searchQuery, { + required int maxEntryCount, + }) { + final db = ref.read(bangDatabaseProvider); + //Pack in a transaction to bundle rebuilds of watch() queries + return db.transaction(() async { + await db.bangDao.addSearchEntry(trigger, searchQuery); + await db.evictHistoryEntries(limit: maxEntryCount); + }); } - Stream watchIconCacheSize() { - return _db.tableSize(_db.bangIcon).watchSingle(); - } - - Future clearIconData() { - return _db.bangIcon.deleteAll(); + Future removeSearchEntry(String searchQuery) { + return ref + .read(bangDatabaseProvider) + .bangDao + .removeSearchEntry(searchQuery); } Future resetFrequencies() { - return _db.bangFrequency.deleteAll(); + return ref.read(bangDatabaseProvider).bangFrequency.deleteAll(); } Future resetFrequency(String trigger) { - return _db.bangFrequency.deleteWhere((t) => t.trigger.equals(trigger)); + return ref + .read(bangDatabaseProvider) + .bangFrequency + .deleteWhere((t) => t.trigger.equals(trigger)); } } diff --git a/app/lib/features/bangs/domain/repositories/data.g.dart b/app/lib/features/bangs/domain/repositories/data.g.dart index f70405ce..8a997b5f 100644 --- a/app/lib/features/bangs/domain/repositories/data.g.dart +++ b/app/lib/features/bangs/domain/repositories/data.g.dart @@ -7,21 +7,21 @@ part of 'data.dart'; // ************************************************************************** String _$bangDataRepositoryHash() => - r'ef06babcaa07361e37f5cc62a268eb10aed0fc82'; + r'095708cfffe8d0fb604dc02f69e77ae1c050a893'; /// See also [BangDataRepository]. @ProviderFor(BangDataRepository) final bangDataRepositoryProvider = NotifierProvider.internal( - BangDataRepository.new, - name: r'bangDataRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangDataRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); + BangDataRepository.new, + name: r'bangDataRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bangDataRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); typedef _$BangDataRepository = Notifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/bangs/domain/repositories/search.dart b/app/lib/features/bangs/domain/repositories/search.dart deleted file mode 100644 index 5a42fa0b..00000000 --- a/app/lib/features/bangs/domain/repositories/search.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:async'; - -import 'package:lensai/features/bangs/data/database/database.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/data/providers.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'search.g.dart'; - -@Riverpod() -class BangSearch extends _$BangSearch { - late StreamController> _streamController; - late BangDatabase _db; - - Future search(String input) async { - if (input.isNotEmpty) { - await _db.bangDao.queryBangs(input).get().then(_streamController.add); - } - } - - @override - Stream> build() { - _db = ref.watch(bangDatabaseProvider); - _streamController = StreamController(); - - ref.onDispose(() async { - await _streamController.close(); - }); - - return _streamController.stream; - } -} diff --git a/app/lib/features/bangs/domain/repositories/search.g.dart b/app/lib/features/bangs/domain/repositories/search.g.dart deleted file mode 100644 index 24d4be13..00000000 --- a/app/lib/features/bangs/domain/repositories/search.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'search.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$bangSearchHash() => r'abd71f10288ac1f559e66fc11035f9f2b027c353'; - -/// See also [BangSearch]. -@ProviderFor(BangSearch) -final bangSearchProvider = - AutoDisposeStreamNotifierProvider>.internal( - BangSearch.new, - name: r'bangSearchProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$bangSearchHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$BangSearch = AutoDisposeStreamNotifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/bangs/domain/repositories/sync.dart b/app/lib/features/bangs/domain/repositories/sync.dart index 285c5b61..4c9b0538 100644 --- a/app/lib/features/bangs/domain/repositories/sync.dart +++ b/app/lib/features/bangs/domain/repositories/sync.dart @@ -1,30 +1,27 @@ import 'package:drift/isolate.dart'; import 'package:exceptions/exceptions.dart'; -import 'package:lensai/features/bangs/data/database/database.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; -import 'package:lensai/features/bangs/data/providers.dart'; -import 'package:lensai/features/bangs/data/services/source.dart'; import 'package:riverpod/riverpod.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/providers.dart'; +import 'package:weblibre/features/bangs/data/services/data_source.dart'; part 'sync.g.dart'; @Riverpod(keepAlive: true) class BangSyncRepository extends _$BangSyncRepository { - late BangDatabase _db; - - BangSyncRepository(); - static Future> _fetchAndSync({ - required BangSourceService sourceService, + required BangDataSourceService sourceService, required BangDatabase db, required Uri url, required BangGroup group, required Duration? syncInterval, }) async { if (syncInterval != null) { - final lastSync = - await db.syncDao.lastSyncOfGroup(group).getSingleOrNull(); + final lastSync = await db.syncDao + .getLastSyncOfGroup(group) + .getSingleOrNull(); if (lastSync != null && DateTime.now().difference(lastSync) < syncInterval) { @@ -33,16 +30,14 @@ class BangSyncRepository extends _$BangSyncRepository { } final result = await sourceService.getBangs(url, group); - return result.flatMapAsync( - (remoteBangs) async { - await db.syncDao.syncBangs( - group: group, - remoteBangs: remoteBangs, - syncTime: DateTime.now(), - ); - await db.optimizeFtsIndex(); - }, - ); + return result.flatMapAsync((remoteBangs) async { + await db.syncDao.syncBangs( + group: group, + remoteBangs: remoteBangs, + syncTime: DateTime.now(), + ); + await db.optimizeFtsIndex(); + }); } Future> syncBangGroup( @@ -51,22 +46,26 @@ class BangSyncRepository extends _$BangSyncRepository { ) async { try { return Result.success( - await _db.computeWithDatabase( - connect: BangDatabase.new, - computation: (db) async { - final ref = ProviderContainer(); - final result = await _fetchAndSync( - sourceService: ref.read(bangSourceServiceProvider.notifier), - db: db, - url: Uri.parse(group.url), - group: group, - syncInterval: syncInterval, - ); + await ref + .read(bangDatabaseProvider) + .computeWithDatabase( + connect: BangDatabase.new, + computation: (db) async { + final ref = ProviderContainer(); + final result = await _fetchAndSync( + sourceService: ref.read( + bangDataSourceServiceProvider.notifier, + ), + db: db, + url: Uri.parse(group.url), + group: group, + syncInterval: syncInterval, + ); - //Throw if necessary - return result.value; - }, - ), + //Throw if necessary + return result.value; + }, + ), ); } catch (e) { return Result.failure( @@ -80,7 +79,11 @@ class BangSyncRepository extends _$BangSyncRepository { } Stream watchLastSyncOfGroup(BangGroup group) { - return _db.syncDao.lastSyncOfGroup(group).watchSingleOrNull(); + return ref + .read(bangDatabaseProvider) + .syncDao + .getLastSyncOfGroup(group) + .watchSingleOrNull(); } Future>> syncBangGroups({ @@ -92,15 +95,15 @@ class BangSyncRepository extends _$BangSyncRepository { //Run isolated operations final futures = groups.map( - (source) => syncBangGroup(source, syncInterval) - .then((result) => MapEntry(source, result)), + (source) => syncBangGroup( + source, + syncInterval, + ).then((result) => MapEntry(source, result)), ); return Map.fromEntries(await Future.wait(futures)); } @override - void build() { - _db = ref.watch(bangDatabaseProvider); - } + void build() {} } diff --git a/app/lib/features/bangs/domain/repositories/sync.g.dart b/app/lib/features/bangs/domain/repositories/sync.g.dart index a9cc707f..a01afbee 100644 --- a/app/lib/features/bangs/domain/repositories/sync.g.dart +++ b/app/lib/features/bangs/domain/repositories/sync.g.dart @@ -7,21 +7,21 @@ part of 'sync.dart'; // ************************************************************************** String _$bangSyncRepositoryHash() => - r'86933b05d01629c67ed0443df91e991c40136423'; + r'122dd04da82d4124e7c6be5234831bd62242845e'; /// See also [BangSyncRepository]. @ProviderFor(BangSyncRepository) final bangSyncRepositoryProvider = NotifierProvider.internal( - BangSyncRepository.new, - name: r'bangSyncRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangSyncRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); + BangSyncRepository.new, + name: r'bangSyncRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bangSyncRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); typedef _$BangSyncRepository = Notifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/bangs/presentation/screens/categories.dart b/app/lib/features/bangs/presentation/screens/categories.dart index 3ab66e62..74d8b593 100644 --- a/app/lib/features/bangs/presentation/screens/categories.dart +++ b/app/lib/features/bangs/presentation/screens/categories.dart @@ -1,11 +1,11 @@ import 'package:fading_scroll/fading_scroll.dart'; 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:lensai/core/routing/routes.dart'; -import 'package:lensai/features/bangs/domain/providers.dart'; -import 'package:lensai/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; class BangCategoriesScreen extends HookConsumerWidget { const BangCategoriesScreen({super.key}); @@ -19,13 +19,22 @@ class BangCategoriesScreen extends HookConsumerWidget { actions: [ IconButton( onPressed: () async { - await context.push(BangSearchRoute().location); + final trigger = await const BangSearchRoute().push( + context, + ); + + if (trigger != null) { + ref + .read(selectedBangTriggerProvider().notifier) + .setTrigger(trigger); + } }, icon: const Icon(Icons.search), ), ], ), body: categoriesAsync.when( + skipLoadingOnReload: true, data: (categories) { return FadingScroll( fadingSize: 25, @@ -50,9 +59,8 @@ class BangCategoriesScreen extends HookConsumerWidget { (category) => ExpansionPanel( canTapOnHeader: true, isExpanded: expanded.value.contains(category.key), - headerBuilder: (context, isExpanded) => ListTile( - title: Text(category.key), - ), + headerBuilder: (context, isExpanded) => + ListTile(title: Text(category.key)), body: Padding( padding: const EdgeInsets.only(left: 16.0), child: Column( @@ -62,12 +70,10 @@ class BangCategoriesScreen extends HookConsumerWidget { (subCategory) => ListTile( title: Text(subCategory), onTap: () async { - await context.push( - BangSubCategoryRoute( - category: category.key, - subCategory: subCategory, - ).location, - ); + await BangSubCategoryRoute( + category: category.key, + subCategory: subCategory, + ).push(context); }, ), ) diff --git a/app/lib/features/bangs/presentation/screens/list.dart b/app/lib/features/bangs/presentation/screens/list.dart index feeae86b..eda4b3b9 100644 --- a/app/lib/features/bangs/presentation/screens/list.dart +++ b/app/lib/features/bangs/presentation/screens/list.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/routing/routes.dart'; -import 'package:lensai/features/bangs/domain/providers.dart'; -import 'package:lensai/features/bangs/presentation/widgets/bang_details.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/search_browser/domain/entities/sheet.dart'; -import 'package:lensai/features/search_browser/domain/providers.dart'; -import 'package:lensai/presentation/widgets/failure_widget.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/presentation/widgets/bang_details.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; class BangListScreen extends HookConsumerWidget { final String? category; @@ -18,14 +17,9 @@ class BangListScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final bangsAsync = ref.watch( - bangDataListProvider( - filter: ( - categoryFilter: (category != null) - ? (category: category!, subCategory: subCategory) - : null, - domain: null, - groups: null, - orderMostFrequentFirst: null, + bangListProvider( + categoryFilter: category.mapNotNull( + (category) => (category: category, subCategory: subCategory), ), ), ); @@ -33,10 +27,9 @@ class BangListScreen extends HookConsumerWidget { return Scaffold( body: CustomScrollView( slivers: [ - SliverAppBar.medium( - title: Text('$category: $subCategory'), - ), + SliverAppBar.medium(title: Text('$category: $subCategory')), bangsAsync.when( + skipLoadingOnReload: true, data: (bangs) { return SliverList.builder( itemCount: bangs.length, @@ -49,15 +42,11 @@ class BangListScreen extends HookConsumerWidget { .read(selectedBangTriggerProvider().notifier) .setTrigger(bang.trigger); - if (ref.read(bottomSheetProvider) is! CreateTab) { - ref.read(bottomSheetProvider.notifier).show( - CreateTab( - preferredTool: KagiTool.search, - ), - ); - } - - context.go(KagiRoute().location); + SearchRoute( + tabType: + ref.read(selectedTabTypeProvider) ?? + TabType.regular, + ).go(context); }, ); }, diff --git a/app/lib/features/bangs/presentation/screens/search.dart b/app/lib/features/bangs/presentation/screens/search.dart index aa8a4838..141c5a02 100644 --- a/app/lib/features/bangs/presentation/screens/search.dart +++ b/app/lib/features/bangs/presentation/screens/search.dart @@ -5,32 +5,28 @@ 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:lensai/core/routing/routes.dart'; -import 'package:lensai/features/bangs/domain/repositories/search.dart'; -import 'package:lensai/features/bangs/presentation/widgets/bang_details.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/search_browser/domain/entities/sheet.dart'; -import 'package:lensai/features/search_browser/domain/providers.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/presentation/hooks/listenable_callback.dart'; -import 'package:lensai/presentation/widgets/failure_widget.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'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; class BangSearchScreen extends HookConsumerWidget { - const BangSearchScreen({super.key}); + final String? initialSearchText; + + const BangSearchScreen({super.key, this.initialSearchText}); @override Widget build(BuildContext context, WidgetRef ref) { final resultsAsync = ref.watch(bangSearchProvider); - final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), + final incognitoEnabled = ref.watch(incognitoModeEnabledProvider); + + final focusNode = useFocusNode(); + final textEditingController = useTextEditingController( + text: initialSearchText, ); - final textEditingController = useTextEditingController(); - - useListenableCallback(textEditingController, () async { + useListenableCallback(textEditingController, () { unawaited( ref .read(bangSearchProvider.notifier) @@ -42,6 +38,7 @@ class BangSearchScreen extends HookConsumerWidget { appBar: AppBar( title: TextField( enableIMEPersonalizedLearning: !incognitoEnabled, + focusNode: focusNode, controller: textEditingController, autofocus: true, autocorrect: false, @@ -54,6 +51,7 @@ class BangSearchScreen extends HookConsumerWidget { context.pop(); } else { textEditingController.clear(); + focusNode.requestFocus(); } }, icon: const Icon(Icons.clear), @@ -73,17 +71,7 @@ class BangSearchScreen extends HookConsumerWidget { return BangDetails( bang, onTap: () { - ref - .read(selectedBangTriggerProvider().notifier) - .setTrigger(bang.trigger); - - if (ref.read(bottomSheetProvider) is! CreateTab) { - ref.read(bottomSheetProvider.notifier).show( - CreateTab(preferredTool: KagiTool.search), - ); - } - - context.go(KagiRoute().location); + context.pop(bang.trigger); }, ); }, @@ -91,10 +79,7 @@ class BangSearchScreen extends HookConsumerWidget { }, ), error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Bang Search failed', - exception: error, - ), + child: FailureWidget(title: 'Bang Search failed', exception: error), ), loading: () => const SizedBox.shrink(), ), diff --git a/app/lib/features/bangs/presentation/widgets/bang_details.dart b/app/lib/features/bangs/presentation/widgets/bang_details.dart index 362e18a9..ebd1fac9 100644 --- a/app/lib/features/bangs/presentation/widgets/bang_details.dart +++ b/app/lib/features/bangs/presentation/widgets/bang_details.dart @@ -1,13 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/routing/routes.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; -import 'package:lensai/utils/ui_helper.dart' as ui_helper; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; class BangDetails extends HookConsumerWidget { final BangData bangData; @@ -41,7 +37,7 @@ class BangDetails extends HookConsumerWidget { children: [ Row( children: [ - BangIcon(bangData), + UrlIcon([bangData.getTemplateUrl('')], iconSize: 34.0), const SizedBox(width: 12.0), Expanded( child: Column( @@ -66,37 +62,24 @@ class BangDetails extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ FilledButton.tonalIcon( - style: - const ButtonStyle(visualDensity: VisualDensity.compact), + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + ), onPressed: () async { - final url = Uri.parse(bangData.getUrl('').origin); + final url = Uri.parse(bangData.getTemplateUrl('').origin); - final launchExternal = ref.read( - settingsRepositoryProvider.select( - (value) => - (value.valueOrNull ?? Settings.withDefaults()) - .launchUrlExternal, - ), - ); + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: url); - if (launchExternal) { - await ui_helper.launchUrlFeedback(context, url); - } else { - await ref - .read(switchNewTabControllerProvider.notifier) - .add(url); - - if (context.mounted) { - context.go(KagiRoute().location); - } + if (context.mounted) { + BrowserRoute().go(context); } }, label: Text(bangData.domain), icon: const Icon(Icons.open_in_new), ), - const SizedBox( - width: 8, - ), + const SizedBox(width: 8), Expanded( child: Text( '!${bangData.trigger}', diff --git a/app/lib/features/bangs/presentation/widgets/bang_icon.dart b/app/lib/features/bangs/presentation/widgets/bang_icon.dart deleted file mode 100644 index 5b09001a..00000000 --- a/app/lib/features/bangs/presentation/widgets/bang_icon.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/domain/providers.dart'; -import 'package:skeletonizer/skeletonizer.dart'; - -class BangIcon extends HookConsumerWidget { - final double iconSize; - final BangData bangData; - - const BangIcon(this.bangData, {this.iconSize = 34.0, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final bangAsync = ref.watch(bangDataEnsureIconProvider(bangData)); - final bang = bangAsync.valueOrNull ?? bangData; - - return Skeletonizer( - enabled: bangAsync.isLoading, - child: SizedBox.square( - dimension: iconSize, - child: (bang.iconData != null) - ? Image.memory( - bang.iconData!, - height: iconSize, - width: iconSize, - fit: BoxFit.fill, - errorBuilder: (context, error, stackTrace) { - return Icon( - MdiIcons.web, - size: iconSize, - ); - }, - ) - : Icon( - MdiIcons.web, - size: iconSize, - ), - ), - ); - } -} diff --git a/app/lib/features/bangs/presentation/widgets/search_field.dart b/app/lib/features/bangs/presentation/widgets/search_field.dart deleted file mode 100644 index 9647764c..00000000 --- a/app/lib/features/bangs/presentation/widgets/search_field.dart +++ /dev/null @@ -1,209 +0,0 @@ -// The default Material-style Autocomplete options. -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; -import 'package:lensai/features/kagi/domain/repositories/autosuggest.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/speech_to_text_button.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/presentation/widgets/autocomplete.dart'; - -class SearchField extends HookConsumerWidget { - static const defaultMaxOptionsHeight = 158.0; - - final TextEditingController textController; - final BangData? activeBang; - final OptionsViewOpenDirection openDirection; - final double maxOptionsHeight; - final void Function(String)? onFieldSubmitted; - - const SearchField({ - required this.textController, - required this.activeBang, - this.openDirection = OptionsViewOpenDirection.up, - this.maxOptionsHeight = defaultMaxOptionsHeight, - this.onFieldSubmitted, - super.key, - }); - @override - Widget build(BuildContext context, WidgetRef ref) { - final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), - ); - - final optionsStream = ref.watch(autosuggestRepositoryProvider); - - final focusNode = useFocusNode(); - - final quickAnswer = useListenableSelector( - textController, - () => textController.text.endsWith('?'), - ); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ExternalResultsAutocomplete( - textEditingController: textController, - optionsStream: optionsStream, - focusNode: focusNode, - optionsViewOpenDirection: openDirection, - displayStringForOption: - // ignore: avoid_redundant_argument_values - RawAutocomplete.defaultStringForOption, - optionsViewBuilder: (context, onSelected, options) { - return _AutocompleteOptions( - //Must match RawAutocomplete parent - displayStringForOption: RawAutocomplete.defaultStringForOption, - onSelected: onSelected, - options: options, - //Must match RawAutocomplete parent - openDirection: openDirection, - maxOptionsHeight: maxOptionsHeight, - ); - }, - onTextChanged: (textEditingValue) { - ref - .read(autosuggestRepositoryProvider.notifier) - .addQuery(textEditingValue.text); - }, - fieldViewBuilder: ( - context, - textEditingController, - focusNode, - onFieldSubmitted, - ) { - return TextFormField( - controller: textEditingController, - enableIMEPersonalizedLearning: !incognitoEnabled, - focusNode: focusNode, - decoration: InputDecoration( - prefixIcon: (activeBang != null) - ? Padding( - padding: const EdgeInsetsDirectional.all(12.0), - child: BangIcon(activeBang!, iconSize: 24.0), - ) - : null, - label: const Text('Query'), - hintText: 'Ask anything...', - floatingLabelBehavior: FloatingLabelBehavior.always, - suffixIcon: SpeechToTextButton( - onTextReceived: (data) { - textEditingController.text = data.toString(); - }, - ), - ), - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) { - if (value?.isEmpty ?? true) { - return ''; - } - - return null; - }, - onTapOutside: (event) { - focusNode.unfocus(); - }, - onFieldSubmitted: this.onFieldSubmitted, - ); - }, - ), - if (activeBang?.domain.endsWith('kagi.com') == true) - SwitchListTile( - value: quickAnswer, - onChanged: (_) { - if (textController.text.endsWith('?')) { - textController.text = textController.text - .substring(0, textController.text.length - 1); - } else { - textController.text = '${textController.text}?'; - } - }, - contentPadding: EdgeInsets.zero, - title: const Text('Quick Answer'), - secondary: const Icon(MdiIcons.lightningBolt), - ), - ], - ); - } -} - -class _AutocompleteOptions extends StatelessWidget { - const _AutocompleteOptions({ - super.key, - required this.displayStringForOption, - required this.onSelected, - required this.openDirection, - required this.options, - required this.maxOptionsHeight, - }); - - final AutocompleteOptionToString displayStringForOption; - - final AutocompleteOnSelected onSelected; - final OptionsViewOpenDirection openDirection; - - final Iterable options; - final double maxOptionsHeight; - - @override - Widget build(BuildContext context) { - final AlignmentDirectional optionsAlignment = switch (openDirection) { - OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart, - OptionsViewOpenDirection.down => AlignmentDirectional.topStart, - }; - return Align( - alignment: optionsAlignment, - child: Material( - elevation: 4.0, - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxOptionsHeight), - child: ListView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - reverse: switch (openDirection) { - OptionsViewOpenDirection.up => true, - OptionsViewOpenDirection.down => false, - }, - itemCount: options.length, - itemBuilder: (BuildContext context, int index) { - final T option = options.elementAt(index); - return InkWell( - onTap: () { - onSelected(option); - }, - child: Builder( - builder: (BuildContext context) { - final bool highlight = - AutocompleteHighlightedOption.of(context) == index; - // if (highlight) { - // SchedulerBinding.instance.addPostFrameCallback( - // (Duration timeStamp) async { - // await Scrollable.ensureVisible( - // context, - // alignment: 0.5, - // ); - // }, - // debugLabel: 'AutocompleteOptions.ensureVisible', - // ); - // } - return Container( - color: highlight ? Theme.of(context).focusColor : null, - padding: const EdgeInsets.all(16.0), - child: Text(displayStringForOption(option)), - ); - }, - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/app/lib/features/bangs/presentation/widgets/site_search.dart b/app/lib/features/bangs/presentation/widgets/site_search.dart index f597defc..fda6fb8a 100644 --- a/app/lib/features/bangs/presentation/widgets/site_search.dart +++ b/app/lib/features/bangs/presentation/widgets/site_search.dart @@ -1,49 +1,75 @@ +import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/domain/repositories/data.dart'; -import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; -import 'package:lensai/features/bangs/presentation/widgets/search_field.dart'; -import 'package:lensai/features/search_browser/domain/providers.dart'; -import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; -import 'package:lensai/presentation/widgets/selectable_chips.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/domain/providers/search.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/selectable_chips.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; class SiteSearch extends HookConsumerWidget { final String domain; final List availableBangs; + final bool searchInNewTab; const SiteSearch({ required this.domain, required this.availableBangs, super.key, + this.searchInNewTab = true, }); @override Widget build(BuildContext context, WidgetRef ref) { final formKey = useMemoized(() => GlobalKey()); - final selectedBang = ref.watch( - selectedBangDataProvider(domain: domain) - .select((value) => value.valueOrNull), - ); + final searchTextController = useTextEditingController(); + + useListenableCallback(searchTextController, () { + ref + .read(searchSuggestionsProvider().notifier) + .addQuery(searchTextController.text); + }); + + final selectedBang = ref.watch(selectedBangDataProvider(domain: domain)); final activeBang = selectedBang ?? availableBangs.firstOrNull; - final textController = useTextEditingController(); - - Future submitSearch() async { + Future submitSearch(String query) async { if (activeBang != null && (formKey.currentState?.validate() == true)) { - await ref - .read(bangDataRepositoryProvider.notifier) - .increaseFrequency(activeBang.trigger); + final isPrivate = + ref.read(selectedTabStateProvider)?.isPrivate ?? false; - await ref - .read(switchNewTabControllerProvider.notifier) - .add(activeBang.getUrl(textController.text)); + final searchUri = activeBang.getTemplateUrl(query); - ref.read(overlayDialogProvider.notifier).dismiss(); + if (!isPrivate) { + await ref + .read(bangSearchProvider.notifier) + .triggerBangSearch(activeBang, query); + } + + if (searchInNewTab) { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: searchUri, private: isPrivate); + } else { + await ref + .read(tabSessionProvider(tabId: null).notifier) + .loadUrl(url: searchUri); + } + + if (context.mounted) { + context.pop(); + } } } @@ -57,21 +83,19 @@ class SiteSearch extends HookConsumerWidget { width: double.maxFinite, child: SelectableChips( itemId: (bang) => bang.trigger, - itemAvatar: (bang) => BangIcon(bang, iconSize: 20), + itemAvatar: (bang) => + UrlIcon([bang.getTemplateUrl('')], iconSize: 20), itemLabel: (bang) => Text(bang.websiteName), + itemTooltip: (bang) => bang.trigger, availableItems: availableBangs, selectedItem: selectedBang, onSelected: (bang) { ref - .read( - selectedBangTriggerProvider(domain: domain).notifier, - ) + .read(selectedBangTriggerProvider(domain: domain).notifier) .setTrigger(bang.trigger); }, onDeleted: (bang) { - if (ref.read( - selectedBangTriggerProvider(domain: domain), - ) == + if (ref.read(selectedBangTriggerProvider(domain: domain)) == bang.trigger) { ref .read( @@ -83,21 +107,32 @@ class SiteSearch extends HookConsumerWidget { ), ), SearchField( - textController: textController, + textEditingController: searchTextController, activeBang: activeBang, - onFieldSubmitted: (_) async { - await submitSearch(); + onSubmitted: (_) async { + await submitSearch(searchTextController.text); }, + showSuggestions: false, ), - const SizedBox( - height: 12, - ), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: submitSearch, - label: const Text('Search on Site'), - icon: const Icon(MdiIcons.cloudSearch), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 150), + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return CustomScrollView( + shrinkWrap: true, + controller: controller, + slivers: [ + SearchTermSuggestions( + searchTextController: searchTextController, + activeBang: activeBang, + submitSearch: submitSearch, + showHistory: false, + showChips: false, + ), + ], + ); + }, ), ), ], diff --git a/app/lib/features/chat_archive/data/database/daos/search.dart b/app/lib/features/chat_archive/data/database/daos/search.dart deleted file mode 100644 index 1c3b5fb3..00000000 --- a/app/lib/features/chat_archive/data/database/daos/search.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:lensai/features/chat_archive/data/database/database.dart'; - -part 'search.g.dart'; - -@DriftAccessor() -class SearchDao extends DatabaseAccessor - with _$SearchDaoMixin { - SearchDao(super.db); - - Future indexChats(Iterable chats) { - return db.chat.insertAll(chats); - } - - Future deleteAllChats() { - return db.chat.deleteAll(); - } - - Future deleteChat(String fileName) { - return (db.chat.delete()..where((t) => t.fileName.equals(fileName))).go(); - } - - Future upsertChat(ChatCompanion chat) { - return db.chat.insertOne( - chat, - onConflict: DoUpdate( - (old) => ChatCompanion.custom( - content: Variable(chat.content.value), - ), - ), - ); - } - - Selectable queryChats({ - required String matchPrefix, - required String matchSuffix, - required String ellipsis, - required int snippetLength, - required String searchString, - }) { - return db.chatQuery( - query: db.buildQuery(searchString), - snippetLength: snippetLength, - beforeMatch: matchPrefix, - afterMatch: matchSuffix, - ellipsis: ellipsis, - ); - } -} diff --git a/app/lib/features/chat_archive/data/database/daos/search.g.dart b/app/lib/features/chat_archive/data/database/daos/search.g.dart deleted file mode 100644 index b689a778..00000000 --- a/app/lib/features/chat_archive/data/database/daos/search.g.dart +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'search.dart'; - -// ignore_for_file: type=lint -mixin _$SearchDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/chat_archive/data/database/database.dart b/app/lib/features/chat_archive/data/database/database.dart deleted file mode 100644 index 9935957e..00000000 --- a/app/lib/features/chat_archive/data/database/database.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:lensai/features/chat_archive/data/database/daos/search.dart'; -import 'package:lensai/features/query/domain/tokenizer.dart'; - -part 'database.g.dart'; - -@DriftDatabase( - include: {'database.drift'}, - daos: [SearchDao], -) -class ChatSearchDatabase extends _$ChatSearchDatabase - with TrigramQueryBuilderMixin { - @override - final int schemaVersion = 1; - - @override - final int ftsTokenLimit = 6; - @override - final int ftsMinTokenLength = 3; - - ChatSearchDatabase(super.e); -} diff --git a/app/lib/features/chat_archive/data/database/database.drift b/app/lib/features/chat_archive/data/database/database.drift deleted file mode 100644 index 8e9e47e0..00000000 --- a/app/lib/features/chat_archive/data/database/database.drift +++ /dev/null @@ -1,43 +0,0 @@ -CREATE TABLE chat ( - file_name TEXT PRIMARY KEY NOT NULL, - title TEXT NOT NULL, - content TEXT NOT NULL -); - -CREATE VIRTUAL TABLE chat_fts - USING fts5( - title, - content, - content=chat, - tokenize="trigram" - ); - --- Triggers to keep the FTS index up to date. -CREATE TRIGGER chat_after_insert AFTER INSERT ON chat BEGIN - INSERT INTO - chat_fts(rowid, title, content) - VALUES (new.rowid, new.title, new.content); -END; -CREATE TRIGGER chat_after_delete AFTER DELETE ON chat BEGIN - INSERT INTO - chat_fts(chat_fts, rowid, title, content) - VALUES('delete', old.rowid, old.title, old.content); -END; -CREATE TRIGGER chat_after_update AFTER UPDATE ON chat BEGIN - INSERT INTO - chat_fts(chat_fts, rowid, title, content) - VALUES('delete', old.rowid, old.title, old.content); - INSERT INTO - chat_fts(chat_fts, title, content) - VALUES (new.rowid, new.title, new.content); -END; - -chatQuery: - SELECT - c.file_name, - highlight(chat_fts, 0, :beforeMatch, :afterMatch) AS title, - snippet(chat_fts, 1, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS content_snippet - FROM chat_fts(:query) fts - INNER JOIN - chat c ON c.rowid = fts.rowid - ORDER BY RANK; \ No newline at end of file diff --git a/app/lib/features/chat_archive/data/database/database.g.dart b/app/lib/features/chat_archive/data/database/database.g.dart deleted file mode 100644 index a50b8fb0..00000000 --- a/app/lib/features/chat_archive/data/database/database.g.dart +++ /dev/null @@ -1,665 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'database.dart'; - -// ignore_for_file: type=lint -class Chat extends Table with TableInfo { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - Chat(this.attachedDatabase, [this._alias]); - late final GeneratedColumn fileName = GeneratedColumn( - 'file_name', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'PRIMARY KEY NOT NULL'); - late final GeneratedColumn title = GeneratedColumn( - 'title', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); - late final GeneratedColumn content = GeneratedColumn( - 'content', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); - @override - List get $columns => [fileName, title, content]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'chat'; - @override - Set get $primaryKey => {fileName}; - @override - ChatData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return ChatData( - fileName: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}file_name'])!, - title: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}title'])!, - content: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}content'])!, - ); - } - - @override - Chat createAlias(String alias) { - return Chat(attachedDatabase, alias); - } - - @override - bool get dontWriteConstraints => true; -} - -class ChatData extends DataClass implements Insertable { - final String fileName; - final String title; - final String content; - const ChatData( - {required this.fileName, required this.title, required this.content}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['file_name'] = Variable(fileName); - map['title'] = Variable(title); - map['content'] = Variable(content); - return map; - } - - factory ChatData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return ChatData( - fileName: serializer.fromJson(json['file_name']), - title: serializer.fromJson(json['title']), - content: serializer.fromJson(json['content']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'file_name': serializer.toJson(fileName), - 'title': serializer.toJson(title), - 'content': serializer.toJson(content), - }; - } - - ChatData copyWith({String? fileName, String? title, String? content}) => - ChatData( - fileName: fileName ?? this.fileName, - title: title ?? this.title, - content: content ?? this.content, - ); - ChatData copyWithCompanion(ChatCompanion data) { - return ChatData( - fileName: data.fileName.present ? data.fileName.value : this.fileName, - title: data.title.present ? data.title.value : this.title, - content: data.content.present ? data.content.value : this.content, - ); - } - - @override - String toString() { - return (StringBuffer('ChatData(') - ..write('fileName: $fileName, ') - ..write('title: $title, ') - ..write('content: $content') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(fileName, title, content); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ChatData && - other.fileName == this.fileName && - other.title == this.title && - other.content == this.content); -} - -class ChatCompanion extends UpdateCompanion { - final Value fileName; - final Value title; - final Value content; - final Value rowid; - const ChatCompanion({ - this.fileName = const Value.absent(), - this.title = const Value.absent(), - this.content = const Value.absent(), - this.rowid = const Value.absent(), - }); - ChatCompanion.insert({ - required String fileName, - required String title, - required String content, - this.rowid = const Value.absent(), - }) : fileName = Value(fileName), - title = Value(title), - content = Value(content); - static Insertable custom({ - Expression? fileName, - Expression? title, - Expression? content, - Expression? rowid, - }) { - return RawValuesInsertable({ - if (fileName != null) 'file_name': fileName, - if (title != null) 'title': title, - if (content != null) 'content': content, - if (rowid != null) 'rowid': rowid, - }); - } - - ChatCompanion copyWith( - {Value? fileName, - Value? title, - Value? content, - Value? rowid}) { - return ChatCompanion( - fileName: fileName ?? this.fileName, - title: title ?? this.title, - content: content ?? this.content, - rowid: rowid ?? this.rowid, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (fileName.present) { - map['file_name'] = Variable(fileName.value); - } - if (title.present) { - map['title'] = Variable(title.value); - } - if (content.present) { - map['content'] = Variable(content.value); - } - if (rowid.present) { - map['rowid'] = Variable(rowid.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('ChatCompanion(') - ..write('fileName: $fileName, ') - ..write('title: $title, ') - ..write('content: $content, ') - ..write('rowid: $rowid') - ..write(')')) - .toString(); - } -} - -class ChatFts extends Table - with TableInfo, VirtualTableInfo { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - ChatFts(this.attachedDatabase, [this._alias]); - late final GeneratedColumn title = GeneratedColumn( - 'title', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: ''); - late final GeneratedColumn content = GeneratedColumn( - 'content', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: ''); - @override - List get $columns => [title, content]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'chat_fts'; - @override - Set get $primaryKey => const {}; - @override - ChatFt map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return ChatFt( - title: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}title'])!, - content: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}content'])!, - ); - } - - @override - ChatFts createAlias(String alias) { - return ChatFts(attachedDatabase, alias); - } - - @override - bool get dontWriteConstraints => true; - @override - String get moduleAndArgs => - 'fts5(title, content, content=chat, tokenize="trigram")'; -} - -class ChatFt extends DataClass implements Insertable { - final String title; - final String content; - const ChatFt({required this.title, required this.content}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['title'] = Variable(title); - map['content'] = Variable(content); - return map; - } - - factory ChatFt.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return ChatFt( - title: serializer.fromJson(json['title']), - content: serializer.fromJson(json['content']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'title': serializer.toJson(title), - 'content': serializer.toJson(content), - }; - } - - ChatFt copyWith({String? title, String? content}) => ChatFt( - title: title ?? this.title, - content: content ?? this.content, - ); - ChatFt copyWithCompanion(ChatFtsCompanion data) { - return ChatFt( - title: data.title.present ? data.title.value : this.title, - content: data.content.present ? data.content.value : this.content, - ); - } - - @override - String toString() { - return (StringBuffer('ChatFt(') - ..write('title: $title, ') - ..write('content: $content') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(title, content); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ChatFt && - other.title == this.title && - other.content == this.content); -} - -class ChatFtsCompanion extends UpdateCompanion { - final Value title; - final Value content; - final Value rowid; - const ChatFtsCompanion({ - this.title = const Value.absent(), - this.content = const Value.absent(), - this.rowid = const Value.absent(), - }); - ChatFtsCompanion.insert({ - required String title, - required String content, - this.rowid = const Value.absent(), - }) : title = Value(title), - content = Value(content); - static Insertable custom({ - Expression? title, - Expression? content, - Expression? rowid, - }) { - return RawValuesInsertable({ - if (title != null) 'title': title, - if (content != null) 'content': content, - if (rowid != null) 'rowid': rowid, - }); - } - - ChatFtsCompanion copyWith( - {Value? title, Value? content, Value? rowid}) { - return ChatFtsCompanion( - title: title ?? this.title, - content: content ?? this.content, - rowid: rowid ?? this.rowid, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (title.present) { - map['title'] = Variable(title.value); - } - if (content.present) { - map['content'] = Variable(content.value); - } - if (rowid.present) { - map['rowid'] = Variable(rowid.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('ChatFtsCompanion(') - ..write('title: $title, ') - ..write('content: $content, ') - ..write('rowid: $rowid') - ..write(')')) - .toString(); - } -} - -abstract class _$ChatSearchDatabase extends GeneratedDatabase { - _$ChatSearchDatabase(QueryExecutor e) : super(e); - $ChatSearchDatabaseManager get managers => $ChatSearchDatabaseManager(this); - late final Chat chat = Chat(this); - late final ChatFts chatFts = ChatFts(this); - late final Trigger chatAfterInsert = Trigger( - 'CREATE TRIGGER chat_after_insert AFTER INSERT ON chat BEGIN INSERT INTO chat_fts ("rowid", title, content) VALUES (new."rowid", new.title, new.content);END', - 'chat_after_insert'); - late final Trigger chatAfterDelete = Trigger( - 'CREATE TRIGGER chat_after_delete AFTER DELETE ON chat BEGIN INSERT INTO chat_fts (chat_fts, "rowid", title, content) VALUES (\'delete\', old."rowid", old.title, old.content);END', - 'chat_after_delete'); - late final Trigger chatAfterUpdate = Trigger( - 'CREATE TRIGGER chat_after_update AFTER UPDATE ON chat BEGIN INSERT INTO chat_fts (chat_fts, "rowid", title, content) VALUES (\'delete\', old."rowid", old.title, old.content);INSERT INTO chat_fts (chat_fts, title, content) VALUES (new."rowid", new.title, new.content);END', - 'chat_after_update'); - late final SearchDao searchDao = SearchDao(this as ChatSearchDatabase); - Selectable chatQuery( - {required String beforeMatch, - required String afterMatch, - required String ellipsis, - required int snippetLength, - required String query}) { - return customSelect( - 'SELECT c.file_name, highlight(chat_fts, 0, ?1, ?2) AS title, snippet(chat_fts, 1, ?1, ?2, ?3, ?4) AS content_snippet FROM chat_fts(?5)AS fts INNER JOIN chat AS c ON c."rowid" = fts."rowid" ORDER BY RANK', - variables: [ - Variable(beforeMatch), - Variable(afterMatch), - Variable(ellipsis), - Variable(snippetLength), - Variable(query) - ], - readsFrom: { - chat, - chatFts, - }).map((QueryRow row) => ChatQueryResult( - fileName: row.read('file_name'), - title: row.read('title'), - contentSnippet: row.read('content_snippet'), - )); - } - - @override - Iterable> get allTables => - allSchemaEntities.whereType>(); - @override - List get allSchemaEntities => - [chat, chatFts, chatAfterInsert, chatAfterDelete, chatAfterUpdate]; - @override - StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( - [ - WritePropagation( - on: TableUpdateQuery.onTableName('chat', - limitUpdateKind: UpdateKind.insert), - result: [ - TableUpdate('chat_fts', kind: UpdateKind.insert), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('chat', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('chat_fts', kind: UpdateKind.insert), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('chat', - limitUpdateKind: UpdateKind.update), - result: [ - TableUpdate('chat_fts', kind: UpdateKind.insert), - ], - ), - ], - ); -} - -typedef $ChatCreateCompanionBuilder = ChatCompanion Function({ - required String fileName, - required String title, - required String content, - Value rowid, -}); -typedef $ChatUpdateCompanionBuilder = ChatCompanion Function({ - Value fileName, - Value title, - Value content, - Value rowid, -}); - -class $ChatFilterComposer extends FilterComposer<_$ChatSearchDatabase, Chat> { - $ChatFilterComposer(super.$state); - ColumnFilters get fileName => $state.composableBuilder( - column: $state.table.fileName, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get title => $state.composableBuilder( - column: $state.table.title, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get content => $state.composableBuilder( - column: $state.table.content, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); -} - -class $ChatOrderingComposer - extends OrderingComposer<_$ChatSearchDatabase, Chat> { - $ChatOrderingComposer(super.$state); - ColumnOrderings get fileName => $state.composableBuilder( - column: $state.table.fileName, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get title => $state.composableBuilder( - column: $state.table.title, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get content => $state.composableBuilder( - column: $state.table.content, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); -} - -class $ChatTableManager extends RootTableManager< - _$ChatSearchDatabase, - Chat, - ChatData, - $ChatFilterComposer, - $ChatOrderingComposer, - $ChatCreateCompanionBuilder, - $ChatUpdateCompanionBuilder, - (ChatData, BaseReferences<_$ChatSearchDatabase, Chat, ChatData>), - ChatData, - PrefetchHooks Function()> { - $ChatTableManager(_$ChatSearchDatabase db, Chat table) - : super(TableManagerState( - db: db, - table: table, - filteringComposer: $ChatFilterComposer(ComposerState(db, table)), - orderingComposer: $ChatOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value fileName = const Value.absent(), - Value title = const Value.absent(), - Value content = const Value.absent(), - Value rowid = const Value.absent(), - }) => - ChatCompanion( - fileName: fileName, - title: title, - content: content, - rowid: rowid, - ), - createCompanionCallback: ({ - required String fileName, - required String title, - required String content, - Value rowid = const Value.absent(), - }) => - ChatCompanion.insert( - fileName: fileName, - title: title, - content: content, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $ChatProcessedTableManager = ProcessedTableManager< - _$ChatSearchDatabase, - Chat, - ChatData, - $ChatFilterComposer, - $ChatOrderingComposer, - $ChatCreateCompanionBuilder, - $ChatUpdateCompanionBuilder, - (ChatData, BaseReferences<_$ChatSearchDatabase, Chat, ChatData>), - ChatData, - PrefetchHooks Function()>; -typedef $ChatFtsCreateCompanionBuilder = ChatFtsCompanion Function({ - required String title, - required String content, - Value rowid, -}); -typedef $ChatFtsUpdateCompanionBuilder = ChatFtsCompanion Function({ - Value title, - Value content, - Value rowid, -}); - -class $ChatFtsFilterComposer - extends FilterComposer<_$ChatSearchDatabase, ChatFts> { - $ChatFtsFilterComposer(super.$state); - ColumnFilters get title => $state.composableBuilder( - column: $state.table.title, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get content => $state.composableBuilder( - column: $state.table.content, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); -} - -class $ChatFtsOrderingComposer - extends OrderingComposer<_$ChatSearchDatabase, ChatFts> { - $ChatFtsOrderingComposer(super.$state); - ColumnOrderings get title => $state.composableBuilder( - column: $state.table.title, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get content => $state.composableBuilder( - column: $state.table.content, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); -} - -class $ChatFtsTableManager extends RootTableManager< - _$ChatSearchDatabase, - ChatFts, - ChatFt, - $ChatFtsFilterComposer, - $ChatFtsOrderingComposer, - $ChatFtsCreateCompanionBuilder, - $ChatFtsUpdateCompanionBuilder, - (ChatFt, BaseReferences<_$ChatSearchDatabase, ChatFts, ChatFt>), - ChatFt, - PrefetchHooks Function()> { - $ChatFtsTableManager(_$ChatSearchDatabase db, ChatFts table) - : super(TableManagerState( - db: db, - table: table, - filteringComposer: $ChatFtsFilterComposer(ComposerState(db, table)), - orderingComposer: $ChatFtsOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value title = const Value.absent(), - Value content = const Value.absent(), - Value rowid = const Value.absent(), - }) => - ChatFtsCompanion( - title: title, - content: content, - rowid: rowid, - ), - createCompanionCallback: ({ - required String title, - required String content, - Value rowid = const Value.absent(), - }) => - ChatFtsCompanion.insert( - title: title, - content: content, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $ChatFtsProcessedTableManager = ProcessedTableManager< - _$ChatSearchDatabase, - ChatFts, - ChatFt, - $ChatFtsFilterComposer, - $ChatFtsOrderingComposer, - $ChatFtsCreateCompanionBuilder, - $ChatFtsUpdateCompanionBuilder, - (ChatFt, BaseReferences<_$ChatSearchDatabase, ChatFts, ChatFt>), - ChatFt, - PrefetchHooks Function()>; - -class $ChatSearchDatabaseManager { - final _$ChatSearchDatabase _db; - $ChatSearchDatabaseManager(this._db); - $ChatTableManager get chat => $ChatTableManager(_db, _db.chat); - $ChatFtsTableManager get chatFts => $ChatFtsTableManager(_db, _db.chatFts); -} - -class ChatQueryResult { - final String fileName; - final String title; - final String contentSnippet; - ChatQueryResult({ - required this.fileName, - required this.title, - required this.contentSnippet, - }); -} diff --git a/app/lib/features/chat_archive/data/services/file.dart b/app/lib/features/chat_archive/data/services/file.dart deleted file mode 100644 index 314688ca..00000000 --- a/app/lib/features/chat_archive/data/services/file.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart' as path_provider; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:universal_io/io.dart'; -import 'package:watcher/watcher.dart'; - -part 'file.g.dart'; - -@Riverpod() -class ChatArchiveFileService extends _$ChatArchiveFileService { - final Future _archiveDirectoryFuture; - - ChatArchiveFileService() - : _archiveDirectoryFuture = - path_provider.getApplicationDocumentsDirectory().then( - (documentDirectory) => Directory( - path.join(documentDirectory.path, 'archive', 'chat'), - ).create(recursive: true), - ); - - Future> list() { - return _archiveDirectoryFuture.then( - (value) => value - .list() - .where((file) => path.extension(file.path) == '.md') - .toList(), - ); - } - - Future write(String fileName, String contents) async { - final directory = await _archiveDirectoryFuture; - final file = File(path.join(directory.path, fileName)); - - await file.writeAsString(contents, flush: true); - } - - Future read(String fileName) async { - final directory = await _archiveDirectoryFuture; - final file = File(path.join(directory.path, fileName)); - - if (!await file.exists()) { - return null; - } - - return file.readAsString(); - } - - Future delete(String fileName) async { - final directory = await _archiveDirectoryFuture; - final file = File(path.join(directory.path, fileName)); - - if (await file.exists()) { - await file.delete(); - } - } - - Stream _directoryStream() async* { - final watcher = DirectoryWatcher( - await _archiveDirectoryFuture - .then((archiveDirectory) => archiveDirectory.absolute.path), - ); - - yield* watcher.events.where((event) => path.extension(event.path) == '.md'); - } - - @override - Raw> build() { - //We return a Raw stream here and yield* doesnt support broadcast. - //So it is required to use asBroadcastStream here - return _directoryStream().asBroadcastStream(); - } -} diff --git a/app/lib/features/chat_archive/data/services/file.g.dart b/app/lib/features/chat_archive/data/services/file.g.dart deleted file mode 100644 index 37229728..00000000 --- a/app/lib/features/chat_archive/data/services/file.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'file.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$chatArchiveFileServiceHash() => - r'21a60ae20e01cfd4f25f7f744fb9bc0c5a465e09'; - -/// See also [ChatArchiveFileService]. -@ProviderFor(ChatArchiveFileService) -final chatArchiveFileServiceProvider = AutoDisposeNotifierProvider< - ChatArchiveFileService, Raw>>.internal( - ChatArchiveFileService.new, - name: r'chatArchiveFileServiceProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$chatArchiveFileServiceHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$ChatArchiveFileService = AutoDisposeNotifier>>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/chat_archive/domain/entities/chat_entity.dart b/app/lib/features/chat_archive/domain/entities/chat_entity.dart deleted file mode 100644 index 58d0ca6a..00000000 --- a/app/lib/features/chat_archive/domain/entities/chat_entity.dart +++ /dev/null @@ -1,23 +0,0 @@ -class ChatEntity { - static final _namePattern = RegExp(r"^(.*?) - (.*?)\.md$"); - - final String fileName; - - final String? name; - final DateTime? dateTime; - - ChatEntity._(this.fileName, {this.name, this.dateTime}); - - factory ChatEntity.fromFileName(String fileName) { - final match = _namePattern.firstMatch(fileName); - - return ChatEntity._( - fileName, - name: match?.group(1), - dateTime: (match != null) ? DateTime.tryParse(match.group(2)!) : null, - ); - } - - @override - String toString() => name ?? fileName; -} diff --git a/app/lib/features/chat_archive/domain/repositories/archive.dart b/app/lib/features/chat_archive/domain/repositories/archive.dart deleted file mode 100644 index 48fcbda7..00000000 --- a/app/lib/features/chat_archive/domain/repositories/archive.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:exceptions/exceptions.dart'; -import 'package:lensai/features/chat_archive/data/services/file.dart'; -import 'package:lensai/features/chat_archive/domain/entities/chat_entity.dart'; -import 'package:lensai/features/kagi/data/services/chat.dart'; -import 'package:path/path.dart' as path; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:rxdart/rxdart.dart'; - -part 'archive.g.dart'; - -@Riverpod() -class ChatArchiveRepository extends _$ChatArchiveRepository { - Future> listArchivedChats() async { - final files = - await ref.read(chatArchiveFileServiceProvider.notifier).list(); - - return files - .map((file) => ChatEntity.fromFileName(path.basename(file.path))) - .toList(); - } - - Future> archiveChat(String fileName, Uri url) async { - final contentsResult = - await ref.read(kagiChatServiceProvider.notifier).downloadChat(url); - - return contentsResult.flatMapAsync( - (contents) => ref - .read(chatArchiveFileServiceProvider.notifier) - .write(fileName, contents), - ); - } - - Future> readChat(String fileName) async { - final contentsResult = await Result.fromAsync( - () => ref.read(chatArchiveFileServiceProvider.notifier).read(fileName), - ); - - return contentsResult.fold( - (value) => (value == null) - ? Result.failure( - ErrorMessage( - source: 'Chat Archive', - message: 'Chat $fileName not found', - ), - ) - : Result.success(value), - onFailure: Result.failure, - ); - } - - @override - Stream> build() async* { - final fileRepository = ref.watch(chatArchiveFileServiceProvider); - - yield* ConcatStream([ - listArchivedChats().asStream(), - fileRepository.asyncMap( - (_) => listArchivedChats(), - ), - ]); - } -} - -@Riverpod() -Future readArchivedChat( - ReadArchivedChatRef ref, - String fileName, -) async { - final result = - await ref.read(chatArchiveRepositoryProvider.notifier).readChat(fileName); - - return result.value; -} diff --git a/app/lib/features/chat_archive/domain/repositories/archive.g.dart b/app/lib/features/chat_archive/domain/repositories/archive.g.dart deleted file mode 100644 index 45d59204..00000000 --- a/app/lib/features/chat_archive/domain/repositories/archive.g.dart +++ /dev/null @@ -1,176 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'archive.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$readArchivedChatHash() => r'e7d646a0ea22fe24d96e97f86c1d46fe83818508'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [readArchivedChat]. -@ProviderFor(readArchivedChat) -const readArchivedChatProvider = ReadArchivedChatFamily(); - -/// See also [readArchivedChat]. -class ReadArchivedChatFamily extends Family> { - /// See also [readArchivedChat]. - const ReadArchivedChatFamily(); - - /// See also [readArchivedChat]. - ReadArchivedChatProvider call( - String fileName, - ) { - return ReadArchivedChatProvider( - fileName, - ); - } - - @override - ReadArchivedChatProvider getProviderOverride( - covariant ReadArchivedChatProvider provider, - ) { - return call( - provider.fileName, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'readArchivedChatProvider'; -} - -/// See also [readArchivedChat]. -class ReadArchivedChatProvider extends AutoDisposeFutureProvider { - /// See also [readArchivedChat]. - ReadArchivedChatProvider( - String fileName, - ) : this._internal( - (ref) => readArchivedChat( - ref as ReadArchivedChatRef, - fileName, - ), - from: readArchivedChatProvider, - name: r'readArchivedChatProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$readArchivedChatHash, - dependencies: ReadArchivedChatFamily._dependencies, - allTransitiveDependencies: - ReadArchivedChatFamily._allTransitiveDependencies, - fileName: fileName, - ); - - ReadArchivedChatProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.fileName, - }) : super.internal(); - - final String fileName; - - @override - Override overrideWith( - FutureOr Function(ReadArchivedChatRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: ReadArchivedChatProvider._internal( - (ref) => create(ref as ReadArchivedChatRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - fileName: fileName, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _ReadArchivedChatProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is ReadArchivedChatProvider && other.fileName == fileName; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, fileName.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin ReadArchivedChatRef on AutoDisposeFutureProviderRef { - /// The parameter `fileName` of this provider. - String get fileName; -} - -class _ReadArchivedChatProviderElement - extends AutoDisposeFutureProviderElement with ReadArchivedChatRef { - _ReadArchivedChatProviderElement(super.provider); - - @override - String get fileName => (origin as ReadArchivedChatProvider).fileName; -} - -String _$chatArchiveRepositoryHash() => - r'1b84b6f26a7c5f6c27874904b893a979a76e1fec'; - -/// See also [ChatArchiveRepository]. -@ProviderFor(ChatArchiveRepository) -final chatArchiveRepositoryProvider = AutoDisposeStreamNotifierProvider< - ChatArchiveRepository, List>.internal( - ChatArchiveRepository.new, - name: r'chatArchiveRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$chatArchiveRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$ChatArchiveRepository = AutoDisposeStreamNotifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/chat_archive/domain/repositories/search.dart b/app/lib/features/chat_archive/domain/repositories/search.dart deleted file mode 100644 index fe9df7c9..00000000 --- a/app/lib/features/chat_archive/domain/repositories/search.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:lensai/core/logger.dart'; -import 'package:lensai/features/chat_archive/data/database/database.dart'; -import 'package:lensai/features/chat_archive/data/providers.dart'; -import 'package:lensai/features/chat_archive/data/services/file.dart'; -import 'package:lensai/features/chat_archive/domain/entities/chat_entity.dart'; -import 'package:lensai/features/chat_archive/domain/repositories/archive.dart'; -import 'package:lensai/features/chat_archive/utils/markdown_to_text.dart'; -import 'package:path/path.dart' as path; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:watcher/watcher.dart'; - -part 'search.g.dart'; - -@Riverpod() -class ChatArchiveSearchRepository extends _$ChatArchiveSearchRepository { - late Completer _populatedCompleter; - late StreamController> _streamController; - late ChatSearchDatabase _searchDatabase; - - Future _readChat(ChatEntity chat) async { - final contentResult = await ref - .read(chatArchiveRepositoryProvider.notifier) - .readChat(chat.fileName); - - return contentResult.fold( - (content) => ChatCompanion.insert( - fileName: chat.fileName, - title: chat.name!, - content: markdownToText(content), - ), - onFailure: (errorMessage) { - logger.e( - 'Error reading file "${chat.fileName}": ${errorMessage.message}', - error: errorMessage.details, - stackTrace: errorMessage.stackTrace, - ); - - return null; - }, - ); - } - - Future> _availableChats() async { - final availableChats = await ref - .read(chatArchiveRepositoryProvider.notifier) - .listArchivedChats() - .then( - (chats) => Future.wait( - chats.where((chat) => chat.name != null).map(_readChat), - ), - ); - - return availableChats.whereNotNull(); - } - - Future search( - String input, { - int snippetLength = 120, - String matchPrefix = '***', - String matchSuffix = '***', - String ellipsis = '…', - }) async { - if (input.isNotEmpty) { - await _populatedCompleter.future; - await _searchDatabase.searchDao - .queryChats( - searchString: input, - snippetLength: snippetLength, - matchPrefix: matchPrefix, - matchSuffix: matchSuffix, - ellipsis: ellipsis, - ) - .get() - .then(_streamController.add); - } - } - - @override - Stream> build() async* { - _populatedCompleter = Completer(); - _streamController = StreamController(); - _searchDatabase = ref.watch(chatSearchDatabaseProvider); - - ref.onDispose(() async { - await _streamController.close(); - }); - - // populate with initial chats - await _searchDatabase.searchDao.deleteAllChats(); - await _searchDatabase.searchDao.indexChats(await _availableChats()); - _populatedCompleter.complete(); - - final changeStreamSubscription = - ref.watch(chatArchiveFileServiceProvider).listen((event) async { - final chat = ChatEntity.fromFileName(path.basename(event.path)); - - switch (event.type) { - case ChangeType.ADD: - case ChangeType.MODIFY: - if (chat.name != null) { - final companion = await _readChat(chat); - if (companion != null) { - await _searchDatabase.searchDao.upsertChat(companion); - } - } - case ChangeType.REMOVE: - await _searchDatabase.searchDao.deleteChat(chat.fileName); - } - }); - - ref.onDispose(() async { - await changeStreamSubscription.cancel(); - }); - - yield* _streamController.stream; - } -} diff --git a/app/lib/features/chat_archive/domain/repositories/search.g.dart b/app/lib/features/chat_archive/domain/repositories/search.g.dart deleted file mode 100644 index 707fb37a..00000000 --- a/app/lib/features/chat_archive/domain/repositories/search.g.dart +++ /dev/null @@ -1,28 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'search.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$chatArchiveSearchRepositoryHash() => - r'6cb31ab2c4a37561ec17a60dae3bb4ecd3893744'; - -/// See also [ChatArchiveSearchRepository]. -@ProviderFor(ChatArchiveSearchRepository) -final chatArchiveSearchRepositoryProvider = AutoDisposeStreamNotifierProvider< - ChatArchiveSearchRepository, List>.internal( - ChatArchiveSearchRepository.new, - name: r'chatArchiveSearchRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$chatArchiveSearchRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$ChatArchiveSearchRepository - = AutoDisposeStreamNotifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/chat_archive/presentation/screens/detail.dart b/app/lib/features/chat_archive/presentation/screens/detail.dart deleted file mode 100644 index a62180b4..00000000 --- a/app/lib/features/chat_archive/presentation/screens/detail.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/routing/routes.dart'; -import 'package:lensai/features/chat_archive/data/services/file.dart'; -import 'package:lensai/features/chat_archive/domain/entities/chat_entity.dart'; -import 'package:lensai/features/chat_archive/domain/repositories/archive.dart'; -import 'package:lensai/features/chat_archive/utils/markdown_to_text.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; -import 'package:lensai/presentation/widgets/failure_widget.dart'; -import 'package:lensai/utils/ui_helper.dart' as ui_helper; -import 'package:skeletonizer/skeletonizer.dart'; - -class ChatArchiveDetailScreen extends HookConsumerWidget { - final String fileName; - - const ChatArchiveDetailScreen(this.fileName, {super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final chatAsync = ref.watch(readArchivedChatProvider(fileName)); - - final entity = useMemoized(() => ChatEntity.fromFileName(fileName)); - - return Scaffold( - appBar: AppBar( - title: Text(entity.toString()), - actions: [ - MenuAnchor( - builder: (context, controller, child) { - return IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - icon: const Icon(Icons.more_vert), - ); - }, - menuChildren: [ - MenuItemButton( - onPressed: () async { - if (chatAsync.valueOrNull != null) { - await Clipboard.setData( - ClipboardData( - text: await Future.microtask( - () => markdownToText(chatAsync.valueOrNull!), - ), - ), - ); - } - }, - leadingIcon: const Icon(MdiIcons.textLong), - child: const Text('Copy as plain text'), - ), - MenuItemButton( - onPressed: () async { - if (chatAsync.valueOrNull != null) { - await Clipboard.setData( - ClipboardData( - text: chatAsync.valueOrNull!, - ), - ); - } - }, - // ignore: deprecated_member_use - leadingIcon: const Icon(MdiIcons.languageMarkdown), - child: const Text('Copy as markdown'), - ), - const Divider(), - MenuItemButton( - onPressed: () async { - await ref - .read(chatArchiveFileServiceProvider.notifier) - .delete(fileName); - - if (context.mounted) { - context.pop(); - } - }, - leadingIcon: const Icon(Icons.delete), - child: const Text('Delete'), - ), - ], - ), - ], - ), - body: Skeletonizer( - enabled: chatAsync.isLoading, - justifyMultiLineText: false, - child: chatAsync.when( - data: (data) => Markdown( - data: data, - selectable: true, - onTapLink: (text, href, title) async { - if (href != null) { - if (Uri.parse(href) case final Uri url) { - final launchExternal = ref.read( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .launchUrlExternal, - ), - ); - - if (launchExternal) { - await ui_helper.launchUrlFeedback(context, Uri.parse(href)); - } else { - await ref - .read(switchNewTabControllerProvider.notifier) - .add(url); - - if (context.mounted) { - context.go(KagiRoute().location); - } - } - } - } - }, - ), - error: (error, stackTrace) { - return FailureWidget( - title: 'Could not load chat', - exception: error, - onRetry: () => ref.refresh(readArchivedChatProvider(fileName)), - ); - }, - loading: () => const Bone.multiText( - lines: 15, - ), - ), - ), - ); - } -} diff --git a/app/lib/features/chat_archive/presentation/screens/list.dart b/app/lib/features/chat_archive/presentation/screens/list.dart deleted file mode 100644 index 03488f2c..00000000 --- a/app/lib/features/chat_archive/presentation/screens/list.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:fading_scroll/fading_scroll.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/extension/date_time.dart'; -import 'package:lensai/core/routing/routes.dart'; -import 'package:lensai/features/chat_archive/domain/repositories/archive.dart'; -import 'package:lensai/presentation/widgets/failure_widget.dart'; -import 'package:skeletonizer/skeletonizer.dart'; - -class ChatArchiveListScreen extends HookConsumerWidget { - const ChatArchiveListScreen({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final chatsAsync = ref.watch(chatArchiveRepositoryProvider); - - return Scaffold( - appBar: AppBar( - title: const Text('Chat Archive'), - actions: [ - IconButton( - onPressed: () async { - await context.push(ChatArchiveSearchRoute().location); - }, - icon: const Icon(Icons.search), - ), - ], - ), - body: Skeletonizer( - enabled: chatsAsync.isLoading, - child: chatsAsync.when( - data: (chats) { - return FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView.builder( - controller: controller, - itemCount: chats.length, - itemBuilder: (context, index) { - final chat = chats[index]; - - return ListTile( - title: Text(chat.toString()), - subtitle: (chat.dateTime != null) - ? Text(chat.dateTime!.formatWithMinutePrecision()) - : null, - onTap: () async { - await context.push( - ChatArchiveDetailRoute(fileName: chat.fileName) - .location, - ); - }, - ); - }, - ); - }, - ); - }, - error: (error, stackTrace) { - return FailureWidget( - title: 'Could not load archived chats', - exception: error, - onRetry: () => ref.refresh(chatArchiveRepositoryProvider), - ); - }, - loading: () => ListView.builder( - itemCount: 3, - itemBuilder: (context, index) => const ListTile( - title: Bone.text(), - subtitle: Bone.text(), - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/features/chat_archive/presentation/screens/search.dart b/app/lib/features/chat_archive/presentation/screens/search.dart deleted file mode 100644 index af914209..00000000 --- a/app/lib/features/chat_archive/presentation/screens/search.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'dart:async'; - -import 'package:fading_scroll/fading_scroll.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/extension/date_time.dart'; -import 'package:lensai/core/routing/routes.dart'; -import 'package:lensai/features/chat_archive/domain/entities/chat_entity.dart'; -import 'package:lensai/features/chat_archive/domain/repositories/search.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/presentation/hooks/listenable_callback.dart'; -import 'package:lensai/presentation/widgets/failure_widget.dart'; - -class ChatArchiveSearchScreen extends HookConsumerWidget { - const ChatArchiveSearchScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final resultsAsync = ref.watch(chatArchiveSearchRepositoryProvider); - final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), - ); - - final textEditingController = useTextEditingController(); - - useListenableCallback(textEditingController, () async { - unawaited( - ref - .read(chatArchiveSearchRepositoryProvider.notifier) - .search(textEditingController.text), - ); - }); - - return Scaffold( - appBar: AppBar( - title: TextField( - enableIMEPersonalizedLearning: !incognitoEnabled, - controller: textEditingController, - autofocus: true, - autocorrect: false, - decoration: const InputDecoration.collapsed(hintText: 'Search'), - ), - actions: [ - IconButton( - onPressed: () { - if (textEditingController.text.isEmpty) { - context.pop(); - } else { - textEditingController.clear(); - } - }, - icon: const Icon(Icons.clear), - ), - ], - ), - body: resultsAsync.when( - skipLoadingOnReload: true, - data: (chats) => FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView.builder( - controller: controller, - itemCount: chats.length, - itemBuilder: (context, index) { - final chat = chats[index]; - final chatEntity = ChatEntity.fromFileName(chat.fileName); - - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () async { - await context.push( - ChatArchiveDetailRoute(fileName: chat.fileName) - .location, - ); - }, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Markdown( - shrinkWrap: true, - padding: EdgeInsets.zero, - data: '## ${chat.title}', - ), - if (chatEntity.dateTime != null) - Text( - chatEntity.dateTime!.formatWithMinutePrecision(), - ), - const SizedBox(height: 8.0), - Markdown( - shrinkWrap: true, - padding: EdgeInsets.zero, - data: chat.contentSnippet, - ), - ], - ), - ), - ), - ); - }, - ); - }, - ), - error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Chat Search failed', - exception: error, - ), - ), - loading: () => const SizedBox.shrink(), - ), - ); - } -} diff --git a/app/lib/features/chat_archive/utils/markdown_to_text.dart b/app/lib/features/chat_archive/utils/markdown_to_text.dart deleted file mode 100644 index 9a2bed31..00000000 --- a/app/lib/features/chat_archive/utils/markdown_to_text.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:html/parser.dart' as html_parser; -import 'package:markdown/markdown.dart' as md; - -String markdownToText(String markdown) { - final html = md.markdownToHtml(markdown); - final document = html_parser.parse(html); - - return document.body?.text ?? ''; -} diff --git a/app/lib/features/content_block/data/database/daos/host.dart b/app/lib/features/content_block/data/database/daos/host.dart deleted file mode 100644 index dcb25c2a..00000000 --- a/app/lib/features/content_block/data/database/daos/host.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:lensai/features/content_block/data/database/database.dart'; -import 'package:lensai/features/content_block/data/models/host.dart'; - -part 'host.g.dart'; - -@DriftAccessor() -class HostDao extends DatabaseAccessor with _$HostDaoMixin { - HostDao(super.db); - - Selectable getHostList({Iterable? sources}) { - final selectable = select(db.host); - if (sources != null) { - selectable.where((t) => t.source.isInValues(sources)); - } - - return selectable; - } - - SingleSelectable getHostCount({Iterable? sources}) { - return db.host.count( - where: (sources != null) ? (t) => t.source.isInValues(sources) : null, - ); - } -} diff --git a/app/lib/features/content_block/data/database/daos/host.g.dart b/app/lib/features/content_block/data/database/daos/host.g.dart deleted file mode 100644 index ebaa6250..00000000 --- a/app/lib/features/content_block/data/database/daos/host.g.dart +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'host.dart'; - -// ignore_for_file: type=lint -mixin _$HostDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/content_block/data/database/daos/sync.dart b/app/lib/features/content_block/data/database/daos/sync.dart deleted file mode 100644 index 273653d1..00000000 --- a/app/lib/features/content_block/data/database/daos/sync.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:lensai/features/content_block/data/database/database.dart'; -import 'package:lensai/features/content_block/data/models/host.dart'; - -part 'sync.g.dart'; - -@DriftAccessor() -class SyncDao extends DatabaseAccessor with _$SyncDaoMixin { - SyncDao(super.db); - - SingleOrNullSelectable lastSyncOfSource(HostSource source) { - final query = selectOnly(db.hostSync) - ..addColumns([db.hostSync.lastSync]) - ..where(db.hostSync.source.equalsValue(source)); - - return query.map((row) => row.read(db.hostSync.lastSync)); - } - - Future upsertLastSyncOfSource(HostSource source, DateTime lastSync) { - return db.hostSync.insertOne( - HostSyncCompanion.insert( - source: Value(source), - lastSync: lastSync, - ), - onConflict: DoUpdate( - (old) => HostSyncCompanion.custom(lastSync: Variable(lastSync)), - ), - ); - } - - Future insertHosts(HostSource source, Iterable hosts) { - return db.host.insertAll( - hosts.map((host) => HostCompanion.insert(hostname: host, source: source)), - // It is possible that we get conflicts, in case the host is added in - // multiple lists. In this case we just gonna igore it, and make sure, - // the unified list is inserted first. - mode: InsertMode.insertOrIgnore, - ); - } - - Future deleteHosts(Iterable hosts) { - return db.host.deleteWhere((t) => t.hostname.isIn(hosts)); - } - - Future syncHosts({ - required HostSource source, - required Set remoteHosts, - required DateTime syncTime, - }) async { - final localHosts = await db.hostDao - .getHostList(sources: [source]) - .get() - .then((hosts) => hosts.map((host) => host.hostname).toSet()); - - final removedHosts = localHosts.difference(remoteHosts); - final addedHosts = remoteHosts.difference(localHosts); - - await db.transaction( - () async { - await deleteHosts(removedHosts); - await insertHosts(source, addedHosts); - await upsertLastSyncOfSource(source, syncTime); - }, - ); - } -} diff --git a/app/lib/features/content_block/data/database/daos/sync.g.dart b/app/lib/features/content_block/data/database/daos/sync.g.dart deleted file mode 100644 index ccd14eda..00000000 --- a/app/lib/features/content_block/data/database/daos/sync.g.dart +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'sync.dart'; - -// ignore_for_file: type=lint -mixin _$SyncDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/content_block/data/database/database.dart b/app/lib/features/content_block/data/database/database.dart deleted file mode 100644 index 04b27763..00000000 --- a/app/lib/features/content_block/data/database/database.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:lensai/features/content_block/data/database/daos/host.dart'; -import 'package:lensai/features/content_block/data/database/daos/sync.dart'; -import 'package:lensai/features/content_block/data/models/host.dart'; - -part 'database.g.dart'; - -@DriftDatabase( - include: {'database.drift'}, - daos: [HostDao, SyncDao], -) -class HostDatabase extends _$HostDatabase { - @override - final int schemaVersion = 1; - - HostDatabase(super.e); -} diff --git a/app/lib/features/content_block/data/database/database.drift b/app/lib/features/content_block/data/database/database.drift deleted file mode 100644 index 2aa62591..00000000 --- a/app/lib/features/content_block/data/database/database.drift +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:lensai/features/content_block/data/models/host.dart'; - -CREATE TABLE host ( - hostname TEXT PRIMARY KEY NOT NULL, - source ENUM(HostSource) NOT NULL -); - -CREATE TABLE host_sync ( - source ENUM(HostSource) PRIMARY KEY NOT NULL, - last_sync DATETIME NOT NULL -); \ No newline at end of file diff --git a/app/lib/features/content_block/data/database/database.g.dart b/app/lib/features/content_block/data/database/database.g.dart deleted file mode 100644 index b4bb4353..00000000 --- a/app/lib/features/content_block/data/database/database.g.dart +++ /dev/null @@ -1,544 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'database.dart'; - -// ignore_for_file: type=lint -class Host extends Table with TableInfo { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - Host(this.attachedDatabase, [this._alias]); - late final GeneratedColumn hostname = GeneratedColumn( - 'hostname', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'PRIMARY KEY NOT NULL'); - late final GeneratedColumnWithTypeConverter source = - GeneratedColumn('source', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL') - .withConverter(Host.$convertersource); - @override - List get $columns => [hostname, source]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'host'; - @override - Set get $primaryKey => {hostname}; - @override - HostData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return HostData( - hostname: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}hostname'])!, - source: Host.$convertersource.fromSql(attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}source'])!), - ); - } - - @override - Host createAlias(String alias) { - return Host(attachedDatabase, alias); - } - - static JsonTypeConverter2 $convertersource = - const EnumIndexConverter(HostSource.values); - @override - bool get dontWriteConstraints => true; -} - -class HostData extends DataClass implements Insertable { - final String hostname; - final HostSource source; - const HostData({required this.hostname, required this.source}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['hostname'] = Variable(hostname); - { - map['source'] = Variable(Host.$convertersource.toSql(source)); - } - return map; - } - - factory HostData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return HostData( - hostname: serializer.fromJson(json['hostname']), - source: Host.$convertersource - .fromJson(serializer.fromJson(json['source'])), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'hostname': serializer.toJson(hostname), - 'source': serializer.toJson(Host.$convertersource.toJson(source)), - }; - } - - HostData copyWith({String? hostname, HostSource? source}) => HostData( - hostname: hostname ?? this.hostname, - source: source ?? this.source, - ); - HostData copyWithCompanion(HostCompanion data) { - return HostData( - hostname: data.hostname.present ? data.hostname.value : this.hostname, - source: data.source.present ? data.source.value : this.source, - ); - } - - @override - String toString() { - return (StringBuffer('HostData(') - ..write('hostname: $hostname, ') - ..write('source: $source') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(hostname, source); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is HostData && - other.hostname == this.hostname && - other.source == this.source); -} - -class HostCompanion extends UpdateCompanion { - final Value hostname; - final Value source; - final Value rowid; - const HostCompanion({ - this.hostname = const Value.absent(), - this.source = const Value.absent(), - this.rowid = const Value.absent(), - }); - HostCompanion.insert({ - required String hostname, - required HostSource source, - this.rowid = const Value.absent(), - }) : hostname = Value(hostname), - source = Value(source); - static Insertable custom({ - Expression? hostname, - Expression? source, - Expression? rowid, - }) { - return RawValuesInsertable({ - if (hostname != null) 'hostname': hostname, - if (source != null) 'source': source, - if (rowid != null) 'rowid': rowid, - }); - } - - HostCompanion copyWith( - {Value? hostname, Value? source, Value? rowid}) { - return HostCompanion( - hostname: hostname ?? this.hostname, - source: source ?? this.source, - rowid: rowid ?? this.rowid, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (hostname.present) { - map['hostname'] = Variable(hostname.value); - } - if (source.present) { - map['source'] = Variable(Host.$convertersource.toSql(source.value)); - } - if (rowid.present) { - map['rowid'] = Variable(rowid.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('HostCompanion(') - ..write('hostname: $hostname, ') - ..write('source: $source, ') - ..write('rowid: $rowid') - ..write(')')) - .toString(); - } -} - -class HostSync extends Table with TableInfo { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - HostSync(this.attachedDatabase, [this._alias]); - late final GeneratedColumnWithTypeConverter source = - GeneratedColumn('source', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - $customConstraints: 'PRIMARY KEY NOT NULL') - .withConverter(HostSync.$convertersource); - late final GeneratedColumn lastSync = GeneratedColumn( - 'last_sync', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); - @override - List get $columns => [source, lastSync]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'host_sync'; - @override - Set get $primaryKey => {source}; - @override - HostSyncData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return HostSyncData( - source: HostSync.$convertersource.fromSql(attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}source'])!), - lastSync: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}last_sync'])!, - ); - } - - @override - HostSync createAlias(String alias) { - return HostSync(attachedDatabase, alias); - } - - static JsonTypeConverter2 $convertersource = - const EnumIndexConverter(HostSource.values); - @override - bool get dontWriteConstraints => true; -} - -class HostSyncData extends DataClass implements Insertable { - final HostSource source; - final DateTime lastSync; - const HostSyncData({required this.source, required this.lastSync}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - { - map['source'] = Variable(HostSync.$convertersource.toSql(source)); - } - map['last_sync'] = Variable(lastSync); - return map; - } - - factory HostSyncData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return HostSyncData( - source: HostSync.$convertersource - .fromJson(serializer.fromJson(json['source'])), - lastSync: serializer.fromJson(json['last_sync']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'source': - serializer.toJson(HostSync.$convertersource.toJson(source)), - 'last_sync': serializer.toJson(lastSync), - }; - } - - HostSyncData copyWith({HostSource? source, DateTime? lastSync}) => - HostSyncData( - source: source ?? this.source, - lastSync: lastSync ?? this.lastSync, - ); - HostSyncData copyWithCompanion(HostSyncCompanion data) { - return HostSyncData( - source: data.source.present ? data.source.value : this.source, - lastSync: data.lastSync.present ? data.lastSync.value : this.lastSync, - ); - } - - @override - String toString() { - return (StringBuffer('HostSyncData(') - ..write('source: $source, ') - ..write('lastSync: $lastSync') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(source, lastSync); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is HostSyncData && - other.source == this.source && - other.lastSync == this.lastSync); -} - -class HostSyncCompanion extends UpdateCompanion { - final Value source; - final Value lastSync; - const HostSyncCompanion({ - this.source = const Value.absent(), - this.lastSync = const Value.absent(), - }); - HostSyncCompanion.insert({ - this.source = const Value.absent(), - required DateTime lastSync, - }) : lastSync = Value(lastSync); - static Insertable custom({ - Expression? source, - Expression? lastSync, - }) { - return RawValuesInsertable({ - if (source != null) 'source': source, - if (lastSync != null) 'last_sync': lastSync, - }); - } - - HostSyncCompanion copyWith( - {Value? source, Value? lastSync}) { - return HostSyncCompanion( - source: source ?? this.source, - lastSync: lastSync ?? this.lastSync, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (source.present) { - map['source'] = - Variable(HostSync.$convertersource.toSql(source.value)); - } - if (lastSync.present) { - map['last_sync'] = Variable(lastSync.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('HostSyncCompanion(') - ..write('source: $source, ') - ..write('lastSync: $lastSync') - ..write(')')) - .toString(); - } -} - -abstract class _$HostDatabase extends GeneratedDatabase { - _$HostDatabase(QueryExecutor e) : super(e); - $HostDatabaseManager get managers => $HostDatabaseManager(this); - late final Host host = Host(this); - late final HostSync hostSync = HostSync(this); - late final HostDao hostDao = HostDao(this as HostDatabase); - late final SyncDao syncDao = SyncDao(this as HostDatabase); - @override - Iterable> get allTables => - allSchemaEntities.whereType>(); - @override - List get allSchemaEntities => [host, hostSync]; -} - -typedef $HostCreateCompanionBuilder = HostCompanion Function({ - required String hostname, - required HostSource source, - Value rowid, -}); -typedef $HostUpdateCompanionBuilder = HostCompanion Function({ - Value hostname, - Value source, - Value rowid, -}); - -class $HostFilterComposer extends FilterComposer<_$HostDatabase, Host> { - $HostFilterComposer(super.$state); - ColumnFilters get hostname => $state.composableBuilder( - column: $state.table.hostname, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnWithTypeConverterFilters get source => - $state.composableBuilder( - column: $state.table.source, - builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( - column, - joinBuilders: joinBuilders)); -} - -class $HostOrderingComposer extends OrderingComposer<_$HostDatabase, Host> { - $HostOrderingComposer(super.$state); - ColumnOrderings get hostname => $state.composableBuilder( - column: $state.table.hostname, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get source => $state.composableBuilder( - column: $state.table.source, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); -} - -class $HostTableManager extends RootTableManager< - _$HostDatabase, - Host, - HostData, - $HostFilterComposer, - $HostOrderingComposer, - $HostCreateCompanionBuilder, - $HostUpdateCompanionBuilder, - (HostData, BaseReferences<_$HostDatabase, Host, HostData>), - HostData, - PrefetchHooks Function()> { - $HostTableManager(_$HostDatabase db, Host table) - : super(TableManagerState( - db: db, - table: table, - filteringComposer: $HostFilterComposer(ComposerState(db, table)), - orderingComposer: $HostOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value hostname = const Value.absent(), - Value source = const Value.absent(), - Value rowid = const Value.absent(), - }) => - HostCompanion( - hostname: hostname, - source: source, - rowid: rowid, - ), - createCompanionCallback: ({ - required String hostname, - required HostSource source, - Value rowid = const Value.absent(), - }) => - HostCompanion.insert( - hostname: hostname, - source: source, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $HostProcessedTableManager = ProcessedTableManager< - _$HostDatabase, - Host, - HostData, - $HostFilterComposer, - $HostOrderingComposer, - $HostCreateCompanionBuilder, - $HostUpdateCompanionBuilder, - (HostData, BaseReferences<_$HostDatabase, Host, HostData>), - HostData, - PrefetchHooks Function()>; -typedef $HostSyncCreateCompanionBuilder = HostSyncCompanion Function({ - Value source, - required DateTime lastSync, -}); -typedef $HostSyncUpdateCompanionBuilder = HostSyncCompanion Function({ - Value source, - Value lastSync, -}); - -class $HostSyncFilterComposer extends FilterComposer<_$HostDatabase, HostSync> { - $HostSyncFilterComposer(super.$state); - ColumnWithTypeConverterFilters get source => - $state.composableBuilder( - column: $state.table.source, - builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( - column, - joinBuilders: joinBuilders)); - - ColumnFilters get lastSync => $state.composableBuilder( - column: $state.table.lastSync, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); -} - -class $HostSyncOrderingComposer - extends OrderingComposer<_$HostDatabase, HostSync> { - $HostSyncOrderingComposer(super.$state); - ColumnOrderings get source => $state.composableBuilder( - column: $state.table.source, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get lastSync => $state.composableBuilder( - column: $state.table.lastSync, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); -} - -class $HostSyncTableManager extends RootTableManager< - _$HostDatabase, - HostSync, - HostSyncData, - $HostSyncFilterComposer, - $HostSyncOrderingComposer, - $HostSyncCreateCompanionBuilder, - $HostSyncUpdateCompanionBuilder, - (HostSyncData, BaseReferences<_$HostDatabase, HostSync, HostSyncData>), - HostSyncData, - PrefetchHooks Function()> { - $HostSyncTableManager(_$HostDatabase db, HostSync table) - : super(TableManagerState( - db: db, - table: table, - filteringComposer: $HostSyncFilterComposer(ComposerState(db, table)), - orderingComposer: $HostSyncOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value source = const Value.absent(), - Value lastSync = const Value.absent(), - }) => - HostSyncCompanion( - source: source, - lastSync: lastSync, - ), - createCompanionCallback: ({ - Value source = const Value.absent(), - required DateTime lastSync, - }) => - HostSyncCompanion.insert( - source: source, - lastSync: lastSync, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $HostSyncProcessedTableManager = ProcessedTableManager< - _$HostDatabase, - HostSync, - HostSyncData, - $HostSyncFilterComposer, - $HostSyncOrderingComposer, - $HostSyncCreateCompanionBuilder, - $HostSyncUpdateCompanionBuilder, - (HostSyncData, BaseReferences<_$HostDatabase, HostSync, HostSyncData>), - HostSyncData, - PrefetchHooks Function()>; - -class $HostDatabaseManager { - final _$HostDatabase _db; - $HostDatabaseManager(this._db); - $HostTableManager get host => $HostTableManager(_db, _db.host); - $HostSyncTableManager get hostSync => - $HostSyncTableManager(_db, _db.hostSync); -} diff --git a/app/lib/features/content_block/data/models/host.dart b/app/lib/features/content_block/data/models/host.dart deleted file mode 100644 index 38d3b5ce..00000000 --- a/app/lib/features/content_block/data/models/host.dart +++ /dev/null @@ -1,21 +0,0 @@ -enum HostSource { - stevenBlackUnified( - 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', - ), - stevenBlackFakeNews( - 'https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews-only/hosts', - ), - stevenBlackSocial( - 'https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/social-only/hosts', - ), - stevenBlackGambling( - 'https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/gambling-only/hosts', - ), - stevenBlackPorn( - 'https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn-only/hosts', - ); - - final String url; - - const HostSource(this.url); -} diff --git a/app/lib/features/content_block/data/providers.g.dart b/app/lib/features/content_block/data/providers.g.dart deleted file mode 100644 index 757e965e..00000000 --- a/app/lib/features/content_block/data/providers.g.dart +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'providers.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$hostDatabaseHash() => r'6fb4a1b259c9a049d8ab9b688ea5fb1cc227c117'; - -/// See also [hostDatabase]. -@ProviderFor(hostDatabase) -final hostDatabaseProvider = Provider.internal( - hostDatabase, - name: r'hostDatabaseProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$hostDatabaseHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef HostDatabaseRef = ProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/content_block/data/services/source.dart b/app/lib/features/content_block/data/services/source.dart deleted file mode 100644 index 74cb48e3..00000000 --- a/app/lib/features/content_block/data/services/source.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:convert'; - -import 'package:collection/collection.dart'; -import 'package:exceptions/exceptions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:lensai/core/http_error_handler.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'source.g.dart'; - -@Riverpod() -class HostSourceService extends _$HostSourceService { - late http.Client _client; - - @override - void build() { - _client = http.Client(); - } - - Future>> getHosts(Uri url) async { - return Result.fromAsync( - () async { - final response = await _client.get(url); - return await compute( - (args) { - final hostRegex = RegExp( - r'^\s*([0-9a-fA-F:.]+)\s+(\S+)', - multiLine: true, - ); - - final matches = hostRegex.allMatches(utf8.decode(args[0])); - return matches - .map((match) => match.group(2)) - .whereNotNull() - .toSet(); - }, - [response.bodyBytes], - ); - }, - exceptionHandler: handleHttpError, - ); - } -} diff --git a/app/lib/features/content_block/data/services/source.g.dart b/app/lib/features/content_block/data/services/source.g.dart deleted file mode 100644 index 78ecceeb..00000000 --- a/app/lib/features/content_block/data/services/source.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'source.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$hostSourceServiceHash() => r'26ac09fcd34cc37440eb643075984707e0e9bba3'; - -/// See also [HostSourceService]. -@ProviderFor(HostSourceService) -final hostSourceServiceProvider = - AutoDisposeNotifierProvider.internal( - HostSourceService.new, - name: r'hostSourceServiceProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$hostSourceServiceHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$HostSourceService = AutoDisposeNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/content_block/domain/providers.dart b/app/lib/features/content_block/domain/providers.dart deleted file mode 100644 index e2d9a11d..00000000 --- a/app/lib/features/content_block/domain/providers.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:lensai/features/content_block/data/models/host.dart'; -import 'package:lensai/features/content_block/domain/repositories/host.dart'; -import 'package:lensai/features/content_block/domain/repositories/sync.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'providers.g.dart'; - -@Riverpod() -Stream lastSyncOfSource( - LastSyncOfSourceRef ref, - HostSource source, -) { - final repository = ref.watch(hostSyncRepositoryProvider.notifier); - return repository.watchLastSyncOfSource(source); -} - -@Riverpod() -Stream hostCountOfSource( - HostCountOfSourceRef ref, - HostSource source, -) { - final repository = ref.watch(hostRepositoryProvider.notifier); - return repository.watchHostCount(source); -} diff --git a/app/lib/features/content_block/domain/providers.g.dart b/app/lib/features/content_block/domain/providers.g.dart deleted file mode 100644 index 2a7a3816..00000000 --- a/app/lib/features/content_block/domain/providers.g.dart +++ /dev/null @@ -1,287 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'providers.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$lastSyncOfSourceHash() => r'0f96dc6c5ffc44654734bd4be187fd0c4d34a310'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [lastSyncOfSource]. -@ProviderFor(lastSyncOfSource) -const lastSyncOfSourceProvider = LastSyncOfSourceFamily(); - -/// See also [lastSyncOfSource]. -class LastSyncOfSourceFamily extends Family> { - /// See also [lastSyncOfSource]. - const LastSyncOfSourceFamily(); - - /// See also [lastSyncOfSource]. - LastSyncOfSourceProvider call( - HostSource source, - ) { - return LastSyncOfSourceProvider( - source, - ); - } - - @override - LastSyncOfSourceProvider getProviderOverride( - covariant LastSyncOfSourceProvider provider, - ) { - return call( - provider.source, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'lastSyncOfSourceProvider'; -} - -/// See also [lastSyncOfSource]. -class LastSyncOfSourceProvider extends AutoDisposeStreamProvider { - /// See also [lastSyncOfSource]. - LastSyncOfSourceProvider( - HostSource source, - ) : this._internal( - (ref) => lastSyncOfSource( - ref as LastSyncOfSourceRef, - source, - ), - from: lastSyncOfSourceProvider, - name: r'lastSyncOfSourceProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$lastSyncOfSourceHash, - dependencies: LastSyncOfSourceFamily._dependencies, - allTransitiveDependencies: - LastSyncOfSourceFamily._allTransitiveDependencies, - source: source, - ); - - LastSyncOfSourceProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.source, - }) : super.internal(); - - final HostSource source; - - @override - Override overrideWith( - Stream Function(LastSyncOfSourceRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: LastSyncOfSourceProvider._internal( - (ref) => create(ref as LastSyncOfSourceRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - source: source, - ), - ); - } - - @override - AutoDisposeStreamProviderElement createElement() { - return _LastSyncOfSourceProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is LastSyncOfSourceProvider && other.source == source; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, source.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin LastSyncOfSourceRef on AutoDisposeStreamProviderRef { - /// The parameter `source` of this provider. - HostSource get source; -} - -class _LastSyncOfSourceProviderElement - extends AutoDisposeStreamProviderElement - with LastSyncOfSourceRef { - _LastSyncOfSourceProviderElement(super.provider); - - @override - HostSource get source => (origin as LastSyncOfSourceProvider).source; -} - -String _$hostCountOfSourceHash() => r'3bd118037e5762fd5134838ac12ed6d0d2283e76'; - -/// See also [hostCountOfSource]. -@ProviderFor(hostCountOfSource) -const hostCountOfSourceProvider = HostCountOfSourceFamily(); - -/// See also [hostCountOfSource]. -class HostCountOfSourceFamily extends Family> { - /// See also [hostCountOfSource]. - const HostCountOfSourceFamily(); - - /// See also [hostCountOfSource]. - HostCountOfSourceProvider call( - HostSource source, - ) { - return HostCountOfSourceProvider( - source, - ); - } - - @override - HostCountOfSourceProvider getProviderOverride( - covariant HostCountOfSourceProvider provider, - ) { - return call( - provider.source, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'hostCountOfSourceProvider'; -} - -/// See also [hostCountOfSource]. -class HostCountOfSourceProvider extends AutoDisposeStreamProvider { - /// See also [hostCountOfSource]. - HostCountOfSourceProvider( - HostSource source, - ) : this._internal( - (ref) => hostCountOfSource( - ref as HostCountOfSourceRef, - source, - ), - from: hostCountOfSourceProvider, - name: r'hostCountOfSourceProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$hostCountOfSourceHash, - dependencies: HostCountOfSourceFamily._dependencies, - allTransitiveDependencies: - HostCountOfSourceFamily._allTransitiveDependencies, - source: source, - ); - - HostCountOfSourceProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.source, - }) : super.internal(); - - final HostSource source; - - @override - Override overrideWith( - Stream Function(HostCountOfSourceRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: HostCountOfSourceProvider._internal( - (ref) => create(ref as HostCountOfSourceRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - source: source, - ), - ); - } - - @override - AutoDisposeStreamProviderElement createElement() { - return _HostCountOfSourceProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is HostCountOfSourceProvider && other.source == source; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, source.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin HostCountOfSourceRef on AutoDisposeStreamProviderRef { - /// The parameter `source` of this provider. - HostSource get source; -} - -class _HostCountOfSourceProviderElement - extends AutoDisposeStreamProviderElement with HostCountOfSourceRef { - _HostCountOfSourceProviderElement(super.provider); - - @override - HostSource get source => (origin as HostCountOfSourceProvider).source; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/content_block/domain/repositories/host.dart b/app/lib/features/content_block/domain/repositories/host.dart deleted file mode 100644 index 1da89329..00000000 --- a/app/lib/features/content_block/domain/repositories/host.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:lensai/features/content_block/data/database/database.dart'; -import 'package:lensai/features/content_block/data/models/host.dart'; -import 'package:lensai/features/content_block/data/providers.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'host.g.dart'; - -@Riverpod(keepAlive: true) -class HostRepository extends _$HostRepository { - late HostDatabase _db; - - @override - void build() { - _db = ref.watch(hostDatabaseProvider); - } - - Stream> watchHosts({Iterable? sources}) { - return _db.hostDao - .getHostList(sources: sources) - .map( - (hostData) => hostData.hostname, - ) - .watch(); - } - - Stream watchHostCount(HostSource source) { - return _db.hostDao.getHostCount(sources: [source]).watchSingle(); - } -} diff --git a/app/lib/features/content_block/domain/repositories/sync.dart b/app/lib/features/content_block/domain/repositories/sync.dart deleted file mode 100644 index 0eaf792f..00000000 --- a/app/lib/features/content_block/domain/repositories/sync.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:drift/isolate.dart'; -import 'package:exceptions/exceptions.dart'; -import 'package:lensai/features/content_block/data/database/database.dart'; -import 'package:lensai/features/content_block/data/models/host.dart'; -import 'package:lensai/features/content_block/data/providers.dart'; -import 'package:lensai/features/content_block/data/services/source.dart'; -import 'package:riverpod/riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'sync.g.dart'; - -@Riverpod(keepAlive: true) -class HostSyncRepository extends _$HostSyncRepository { - late HostDatabase _db; - - HostSyncRepository(); - - static Future> _fetchAndSync({ - required HostSourceService sourceService, - required HostDatabase db, - required Uri url, - required HostSource source, - required Duration? syncInterval, - }) async { - if (syncInterval != null) { - final lastSync = - await db.syncDao.lastSyncOfSource(source).getSingleOrNull(); - - if (lastSync != null && - DateTime.now().difference(lastSync) < syncInterval) { - return Result.success(null); - } - } - - final result = await sourceService.getHosts(url); - return result.flatMapAsync( - (remoteHosts) async { - await db.syncDao.syncHosts( - source: source, - remoteHosts: remoteHosts, - syncTime: DateTime.now(), - ); - }, - ); - } - - Future> syncHostSource( - HostSource source, - Duration? syncInterval, - ) async { - try { - return Result.success( - await _db.computeWithDatabase( - connect: HostDatabase.new, - computation: (db) async { - final ref = ProviderContainer(); - final result = await _fetchAndSync( - sourceService: ref.read(hostSourceServiceProvider.notifier), - db: db, - url: Uri.parse(source.url), - source: source, - syncInterval: syncInterval, - ); - - //Throw if necessary - return result.value; - }, - ), - ); - } catch (e) { - return Result.failure( - ErrorMessage( - message: "Failed to sync Hosts (${source.name})", - source: 'HostSync', - details: e, - ), - ); - } - } - - Stream watchLastSyncOfSource(HostSource source) { - return _db.syncDao.lastSyncOfSource(source).watchSingleOrNull(); - } - - Future>> syncHostSources({ - Set? sources, - Duration? syncInterval, - }) async { - //Default to all sources - sources ??= HostSource.values.toSet(); - - //Run isolated operations - final futures = sources.map( - (source) => syncHostSource(source, syncInterval) - .then((result) => MapEntry(source, result)), - ); - - return Map.fromEntries(await Future.wait(futures)); - } - - @override - void build() { - _db = ref.watch(hostDatabaseProvider); - } -} diff --git a/app/lib/features/content_block/domain/repositories/sync.g.dart b/app/lib/features/content_block/domain/repositories/sync.g.dart deleted file mode 100644 index 57e0c97d..00000000 --- a/app/lib/features/content_block/domain/repositories/sync.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'sync.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$hostSyncRepositoryHash() => - r'5eed0c51e1b1e4dd1241634e297aff283eef01c5'; - -/// See also [HostSyncRepository]. -@ProviderFor(HostSyncRepository) -final hostSyncRepositoryProvider = - NotifierProvider.internal( - HostSyncRepository.new, - name: r'hostSyncRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$hostSyncRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$HostSyncRepository = Notifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/geckoview/domain/controllers/bottom_sheet.dart b/app/lib/features/geckoview/domain/controllers/bottom_sheet.dart new file mode 100644 index 00000000..47e20f75 --- /dev/null +++ b/app/lib/features/geckoview/domain/controllers/bottom_sheet.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; + +part 'bottom_sheet.g.dart'; + +@Riverpod() +class BottomSheetController extends _$BottomSheetController { + @override + Sheet? build() { + return null; + } + + // ignore: use_setters_to_change_properties api decision + void show(Sheet sheet) { + state = sheet; + } + + void dismiss() { + state = null; + } +} + +@Riverpod(keepAlive: true) +class BottomSheetExtend extends _$BottomSheetExtend { + late StreamController _extentStreamController; + + void add(double extent) { + if (!_extentStreamController.isClosed) { + _extentStreamController.add(extent); + } + } + + @override + Stream build() { + _extentStreamController = StreamController(); + ref.onDispose(() async { + await _extentStreamController.close(); + }); + + return _extentStreamController.stream.sampleTime( + const Duration(milliseconds: 50), + ); + } +} diff --git a/app/lib/features/geckoview/domain/controllers/bottom_sheet.g.dart b/app/lib/features/geckoview/domain/controllers/bottom_sheet.g.dart new file mode 100644 index 00000000..42f5e36a --- /dev/null +++ b/app/lib/features/geckoview/domain/controllers/bottom_sheet.g.dart @@ -0,0 +1,43 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bottom_sheet.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$bottomSheetControllerHash() => + r'0b0ea53a96b80b8c1cba79b1d5913274b7127d5b'; + +/// See also [BottomSheetController]. +@ProviderFor(BottomSheetController) +final bottomSheetControllerProvider = + AutoDisposeNotifierProvider.internal( + BottomSheetController.new, + name: r'bottomSheetControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bottomSheetControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$BottomSheetController = AutoDisposeNotifier; +String _$bottomSheetExtendHash() => r'7e11b9047c15bdeb4dfcb488a8573daadb64e25c'; + +/// See also [BottomSheetExtend]. +@ProviderFor(BottomSheetExtend) +final bottomSheetExtendProvider = + StreamNotifierProvider.internal( + BottomSheetExtend.new, + name: r'bottomSheetExtendProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bottomSheetExtendHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$BottomSheetExtend = StreamNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/domain/controllers/overlay.dart b/app/lib/features/geckoview/domain/controllers/overlay.dart new file mode 100644 index 00000000..f93be97c --- /dev/null +++ b/app/lib/features/geckoview/domain/controllers/overlay.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'overlay.g.dart'; + +@Riverpod() +class OverlayController extends _$OverlayController { + @override + Widget? build() { + return null; + } + + // ignore: use_setters_to_change_properties api decision + void show(Widget dialog) { + state = dialog; + } + + void dismiss() { + state = null; + } +} diff --git a/app/lib/features/geckoview/domain/controllers/overlay.g.dart b/app/lib/features/geckoview/domain/controllers/overlay.g.dart new file mode 100644 index 00000000..05814e73 --- /dev/null +++ b/app/lib/features/geckoview/domain/controllers/overlay.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'overlay.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$overlayControllerHash() => r'b6dc3ca17611c4efadc7e8248d493cd3c7061e01'; + +/// See also [OverlayController]. +@ProviderFor(OverlayController) +final overlayControllerProvider = + AutoDisposeNotifierProvider.internal( + OverlayController.new, + name: r'overlayControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$overlayControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$OverlayController = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/domain/entities/browser_icon.dart b/app/lib/features/geckoview/domain/entities/browser_icon.dart new file mode 100644 index 00000000..ad637ecc --- /dev/null +++ b/app/lib/features/geckoview/domain/entities/browser_icon.dart @@ -0,0 +1,37 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:weblibre/domain/entities/equatable_image.dart'; +import 'package:weblibre/features/geckoview/utils/image_helper.dart'; + +class BrowserIcon with FastEquatable { + final EquatableImage image; + + final Color? dominantColor; + final IconSource source; + + static Future fromBytes( + Uint8List bytes, { + required Color? dominantColor, + required IconSource source, + }) async { + final image = await tryDecodeImage(bytes); + + return BrowserIcon( + image: image!, + dominantColor: dominantColor, + source: source, + ); + } + + BrowserIcon({ + required this.image, + required this.dominantColor, + required this.source, + }); + + @override + List get hashParameters => [image, dominantColor, source]; +} diff --git a/app/lib/features/geckoview/domain/entities/states/find_result.dart b/app/lib/features/geckoview/domain/entities/states/find_result.dart new file mode 100644 index 00000000..8cb52a28 --- /dev/null +++ b/app/lib/features/geckoview/domain/entities/states/find_result.dart @@ -0,0 +1,33 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +class FindResultState with FastEquatable { + final String? lastSearchText; + + final int activeMatchOrdinal; + final int numberOfMatches; + final bool isDoneCounting; + + bool get hasMatches => numberOfMatches > 0; + + FindResultState({ + required this.lastSearchText, + required this.activeMatchOrdinal, + required this.numberOfMatches, + required this.isDoneCounting, + }); + + factory FindResultState.$default() => FindResultState( + lastSearchText: null, + activeMatchOrdinal: -1, + numberOfMatches: 0, + isDoneCounting: false, + ); + + @override + List get hashParameters => [ + lastSearchText, + activeMatchOrdinal, + numberOfMatches, + isDoneCounting, + ]; +} diff --git a/app/lib/features/geckoview/domain/entities/states/history.dart b/app/lib/features/geckoview/domain/entities/states/history.dart new file mode 100644 index 00000000..84a24d86 --- /dev/null +++ b/app/lib/features/geckoview/domain/entities/states/history.dart @@ -0,0 +1,41 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +class HistoryItem with FastEquatable { + final Uri url; + final String title; + + HistoryItem({required this.url, required this.title}); + + @override + List get hashParameters => [url, title]; +} + +class HistoryState with FastEquatable { + final List items; + final int currentIndex; + + final bool canGoBack; + final bool canGoForward; + + HistoryState({ + required this.items, + required this.currentIndex, + required this.canGoBack, + required this.canGoForward, + }); + + factory HistoryState.$default() => HistoryState( + items: const [], + currentIndex: 0, + canGoBack: false, + canGoForward: false, + ); + + @override + List get hashParameters => [ + items, + currentIndex, + canGoBack, + canGoForward, + ]; +} diff --git a/app/lib/features/geckoview/domain/entities/states/readerable.dart b/app/lib/features/geckoview/domain/entities/states/readerable.dart new file mode 100644 index 00000000..eda18866 --- /dev/null +++ b/app/lib/features/geckoview/domain/entities/states/readerable.dart @@ -0,0 +1,15 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +class ReaderableState with FastEquatable { + final bool readerable; + + final bool active; + + ReaderableState({required this.readerable, required this.active}); + + factory ReaderableState.$default() => + ReaderableState(readerable: false, active: false); + + @override + List get hashParameters => [readerable, active]; +} diff --git a/app/lib/features/geckoview/domain/entities/states/security.dart b/app/lib/features/geckoview/domain/entities/states/security.dart new file mode 100644 index 00000000..b5b50eaf --- /dev/null +++ b/app/lib/features/geckoview/domain/entities/states/security.dart @@ -0,0 +1,19 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +class SecurityState with FastEquatable { + final bool secure; + final String host; + final String issuer; + + SecurityState({ + required this.secure, + required this.host, + required this.issuer, + }); + + factory SecurityState.$default() => + SecurityState(secure: false, host: "", issuer: ""); + + @override + List get hashParameters => [secure, host, issuer]; +} diff --git a/app/lib/features/geckoview/domain/entities/states/tab.dart b/app/lib/features/geckoview/domain/entities/states/tab.dart new file mode 100644 index 00000000..69735519 --- /dev/null +++ b/app/lib/features/geckoview/domain/entities/states/tab.dart @@ -0,0 +1,103 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/data/models/web_page_info.dart'; +import 'package:weblibre/domain/entities/equatable_image.dart'; +import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/find_result.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/history.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/security.dart'; + +part 'tab.g.dart'; + +@CopyWith() +class TabState extends WebPageInfo { + @CopyWithField(immutable: true) + final String id; + + final String? parentId; + + final String? contextId; + + @override + String get title => super.title!; + + final EquatableImage? icon; + + @override + BrowserIcon? get favicon => icon.mapNotNull( + (icon) => BrowserIcon( + image: icon, + dominantColor: null, + source: IconSource.memory, + ), + ); + + final EquatableImage? thumbnail; + + final int progress; + + final bool isPrivate; + final bool isFullScreen; + final bool isLoading; + + final SecurityState securityInfoState; + final HistoryState historyState; + final ReaderableState readerableState; + final FindResultState findResultState; + + TabState({ + required this.id, + required this.parentId, + required this.contextId, + required super.url, + required String title, + required this.icon, + required this.thumbnail, + required this.progress, + required this.isPrivate, + required this.isFullScreen, + required this.isLoading, + required this.securityInfoState, + required this.historyState, + required this.readerableState, + required this.findResultState, + }) : super(title: title.trim()); + + factory TabState.$default(String tabId) => TabState( + id: tabId, + parentId: null, + contextId: null, + url: Uri.parse('about:blank'), + title: "", + icon: null, + thumbnail: null, + progress: 0, + isPrivate: false, + isFullScreen: false, + isLoading: false, + securityInfoState: SecurityState.$default(), + historyState: HistoryState.$default(), + readerableState: ReaderableState.$default(), + findResultState: FindResultState.$default(), + ); + + @override + List get hashParameters => [ + ...super.hashParameters, + id, + parentId, + contextId, + icon, + thumbnail, + progress, + isPrivate, + isFullScreen, + isLoading, + securityInfoState, + historyState, + readerableState, + findResultState, + ]; +} diff --git a/app/lib/features/geckoview/domain/entities/states/tab.g.dart b/app/lib/features/geckoview/domain/entities/states/tab.g.dart new file mode 100644 index 00000000..817bab07 --- /dev/null +++ b/app/lib/features/geckoview/domain/entities/states/tab.g.dart @@ -0,0 +1,203 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$TabStateCWProxy { + TabState parentId(String? parentId); + + TabState contextId(String? contextId); + + TabState url(Uri url); + + TabState title(String title); + + TabState icon(EquatableImage? icon); + + TabState thumbnail(EquatableImage? thumbnail); + + TabState progress(int progress); + + TabState isPrivate(bool isPrivate); + + TabState isFullScreen(bool isFullScreen); + + TabState isLoading(bool isLoading); + + TabState securityInfoState(SecurityState securityInfoState); + + TabState historyState(HistoryState historyState); + + TabState readerableState(ReaderableState readerableState); + + TabState findResultState(FindResultState findResultState); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `TabState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// TabState(...).copyWith(id: 12, name: "My name") + /// ```` + TabState call({ + String? parentId, + String? contextId, + Uri url, + String title, + EquatableImage? icon, + EquatableImage? thumbnail, + int progress, + bool isPrivate, + bool isFullScreen, + bool isLoading, + SecurityState securityInfoState, + HistoryState historyState, + ReaderableState readerableState, + FindResultState findResultState, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfTabState.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfTabState.copyWith.fieldName(...)` +class _$TabStateCWProxyImpl implements _$TabStateCWProxy { + const _$TabStateCWProxyImpl(this._value); + + final TabState _value; + + @override + TabState parentId(String? parentId) => this(parentId: parentId); + + @override + TabState contextId(String? contextId) => this(contextId: contextId); + + @override + TabState url(Uri url) => this(url: url); + + @override + TabState title(String title) => this(title: title); + + @override + TabState icon(EquatableImage? icon) => this(icon: icon); + + @override + TabState thumbnail(EquatableImage? thumbnail) => this(thumbnail: thumbnail); + + @override + TabState progress(int progress) => this(progress: progress); + + @override + TabState isPrivate(bool isPrivate) => this(isPrivate: isPrivate); + + @override + TabState isFullScreen(bool isFullScreen) => this(isFullScreen: isFullScreen); + + @override + TabState isLoading(bool isLoading) => this(isLoading: isLoading); + + @override + TabState securityInfoState(SecurityState securityInfoState) => + this(securityInfoState: securityInfoState); + + @override + TabState historyState(HistoryState historyState) => + this(historyState: historyState); + + @override + TabState readerableState(ReaderableState readerableState) => + this(readerableState: readerableState); + + @override + TabState findResultState(FindResultState findResultState) => + this(findResultState: findResultState); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `TabState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// TabState(...).copyWith(id: 12, name: "My name") + /// ```` + TabState call({ + Object? parentId = const $CopyWithPlaceholder(), + Object? contextId = const $CopyWithPlaceholder(), + Object? url = const $CopyWithPlaceholder(), + Object? title = const $CopyWithPlaceholder(), + Object? icon = const $CopyWithPlaceholder(), + Object? thumbnail = const $CopyWithPlaceholder(), + Object? progress = const $CopyWithPlaceholder(), + Object? isPrivate = const $CopyWithPlaceholder(), + Object? isFullScreen = const $CopyWithPlaceholder(), + Object? isLoading = const $CopyWithPlaceholder(), + Object? securityInfoState = const $CopyWithPlaceholder(), + Object? historyState = const $CopyWithPlaceholder(), + Object? readerableState = const $CopyWithPlaceholder(), + Object? findResultState = const $CopyWithPlaceholder(), + }) { + return TabState( + id: _value.id, + parentId: parentId == const $CopyWithPlaceholder() + ? _value.parentId + // ignore: cast_nullable_to_non_nullable + : parentId as String?, + contextId: contextId == const $CopyWithPlaceholder() + ? _value.contextId + // ignore: cast_nullable_to_non_nullable + : contextId as String?, + url: url == const $CopyWithPlaceholder() + ? _value.url + // ignore: cast_nullable_to_non_nullable + : url as Uri, + title: title == const $CopyWithPlaceholder() + ? _value.title + // ignore: cast_nullable_to_non_nullable + : title as String, + icon: icon == const $CopyWithPlaceholder() + ? _value.icon + // ignore: cast_nullable_to_non_nullable + : icon as EquatableImage?, + thumbnail: thumbnail == const $CopyWithPlaceholder() + ? _value.thumbnail + // ignore: cast_nullable_to_non_nullable + : thumbnail as EquatableImage?, + progress: progress == const $CopyWithPlaceholder() + ? _value.progress + // ignore: cast_nullable_to_non_nullable + : progress as int, + isPrivate: isPrivate == const $CopyWithPlaceholder() + ? _value.isPrivate + // ignore: cast_nullable_to_non_nullable + : isPrivate as bool, + isFullScreen: isFullScreen == const $CopyWithPlaceholder() + ? _value.isFullScreen + // ignore: cast_nullable_to_non_nullable + : isFullScreen as bool, + isLoading: isLoading == const $CopyWithPlaceholder() + ? _value.isLoading + // ignore: cast_nullable_to_non_nullable + : isLoading as bool, + securityInfoState: securityInfoState == const $CopyWithPlaceholder() + ? _value.securityInfoState + // ignore: cast_nullable_to_non_nullable + : securityInfoState as SecurityState, + historyState: historyState == const $CopyWithPlaceholder() + ? _value.historyState + // ignore: cast_nullable_to_non_nullable + : historyState as HistoryState, + readerableState: readerableState == const $CopyWithPlaceholder() + ? _value.readerableState + // ignore: cast_nullable_to_non_nullable + : readerableState as ReaderableState, + findResultState: findResultState == const $CopyWithPlaceholder() + ? _value.findResultState + // ignore: cast_nullable_to_non_nullable + : findResultState as FindResultState, + ); + } +} + +extension $TabStateCopyWith on TabState { + /// Returns a callable class that can be used as follows: `instanceOfTabState.copyWith(...)` or like so:`instanceOfTabState.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$TabStateCWProxy get copyWith => _$TabStateCWProxyImpl(this); +} diff --git a/app/lib/features/geckoview/domain/entities/states/web_extension.dart b/app/lib/features/geckoview/domain/entities/states/web_extension.dart new file mode 100644 index 00000000..041e27ce --- /dev/null +++ b/app/lib/features/geckoview/domain/entities/states/web_extension.dart @@ -0,0 +1,41 @@ +import 'dart:ui'; + +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/domain/entities/equatable_image.dart'; + +part 'web_extension.g.dart'; + +@CopyWith() +class WebExtensionState with FastEquatable { + String extensionId; + + String? title; + bool enabled; + String? badgeText; + Color? badgeTextColor; + Color? badgeBackgroundColor; + + final EquatableImage? icon; + + WebExtensionState({ + required this.extensionId, + required this.enabled, + this.title, + this.badgeText, + this.badgeTextColor, + this.badgeBackgroundColor, + this.icon, + }); + + @override + List get hashParameters => [ + extensionId, + title, + enabled, + badgeText, + badgeTextColor, + badgeBackgroundColor, + icon, + ]; +} diff --git a/app/lib/features/geckoview/domain/entities/states/web_extension.g.dart b/app/lib/features/geckoview/domain/entities/states/web_extension.g.dart new file mode 100644 index 00000000..add69f90 --- /dev/null +++ b/app/lib/features/geckoview/domain/entities/states/web_extension.g.dart @@ -0,0 +1,125 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'web_extension.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$WebExtensionStateCWProxy { + WebExtensionState extensionId(String extensionId); + + WebExtensionState enabled(bool enabled); + + WebExtensionState title(String? title); + + WebExtensionState badgeText(String? badgeText); + + WebExtensionState badgeTextColor(Color? badgeTextColor); + + WebExtensionState badgeBackgroundColor(Color? badgeBackgroundColor); + + WebExtensionState icon(EquatableImage? icon); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebExtensionState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// WebExtensionState(...).copyWith(id: 12, name: "My name") + /// ```` + WebExtensionState call({ + String extensionId, + bool enabled, + String? title, + String? badgeText, + Color? badgeTextColor, + Color? badgeBackgroundColor, + EquatableImage? icon, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfWebExtensionState.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfWebExtensionState.copyWith.fieldName(...)` +class _$WebExtensionStateCWProxyImpl implements _$WebExtensionStateCWProxy { + const _$WebExtensionStateCWProxyImpl(this._value); + + final WebExtensionState _value; + + @override + WebExtensionState extensionId(String extensionId) => + this(extensionId: extensionId); + + @override + WebExtensionState enabled(bool enabled) => this(enabled: enabled); + + @override + WebExtensionState title(String? title) => this(title: title); + + @override + WebExtensionState badgeText(String? badgeText) => this(badgeText: badgeText); + + @override + WebExtensionState badgeTextColor(Color? badgeTextColor) => + this(badgeTextColor: badgeTextColor); + + @override + WebExtensionState badgeBackgroundColor(Color? badgeBackgroundColor) => + this(badgeBackgroundColor: badgeBackgroundColor); + + @override + WebExtensionState icon(EquatableImage? icon) => this(icon: icon); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebExtensionState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// WebExtensionState(...).copyWith(id: 12, name: "My name") + /// ```` + WebExtensionState call({ + Object? extensionId = const $CopyWithPlaceholder(), + Object? enabled = const $CopyWithPlaceholder(), + Object? title = const $CopyWithPlaceholder(), + Object? badgeText = const $CopyWithPlaceholder(), + Object? badgeTextColor = const $CopyWithPlaceholder(), + Object? badgeBackgroundColor = const $CopyWithPlaceholder(), + Object? icon = const $CopyWithPlaceholder(), + }) { + return WebExtensionState( + extensionId: extensionId == const $CopyWithPlaceholder() + ? _value.extensionId + // ignore: cast_nullable_to_non_nullable + : extensionId as String, + enabled: enabled == const $CopyWithPlaceholder() + ? _value.enabled + // ignore: cast_nullable_to_non_nullable + : enabled as bool, + title: title == const $CopyWithPlaceholder() + ? _value.title + // ignore: cast_nullable_to_non_nullable + : title as String?, + badgeText: badgeText == const $CopyWithPlaceholder() + ? _value.badgeText + // ignore: cast_nullable_to_non_nullable + : badgeText as String?, + badgeTextColor: badgeTextColor == const $CopyWithPlaceholder() + ? _value.badgeTextColor + // ignore: cast_nullable_to_non_nullable + : badgeTextColor as Color?, + badgeBackgroundColor: badgeBackgroundColor == const $CopyWithPlaceholder() + ? _value.badgeBackgroundColor + // ignore: cast_nullable_to_non_nullable + : badgeBackgroundColor as Color?, + icon: icon == const $CopyWithPlaceholder() + ? _value.icon + // ignore: cast_nullable_to_non_nullable + : icon as EquatableImage?, + ); + } +} + +extension $WebExtensionStateCopyWith on WebExtensionState { + /// Returns a callable class that can be used as follows: `instanceOfWebExtensionState.copyWith(...)` or like so:`instanceOfWebExtensionState.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$WebExtensionStateCWProxy get copyWith => + _$WebExtensionStateCWProxyImpl(this); +} diff --git a/app/lib/features/geckoview/domain/providers.dart b/app/lib/features/geckoview/domain/providers.dart new file mode 100644 index 00000000..ce3c40fd --- /dev/null +++ b/app/lib/features/geckoview/domain/providers.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/core/providers/router.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +GeckoSelectionActionService selectionActionService(Ref ref) { + final service = GeckoSelectionActionService.setUp(); + + unawaited( + service.setActions([ + SearchAction((text) async { + final router = await ref.read(routerProvider.future); + final route = SearchRoute( + tabType: ref.read(selectedTabTypeProvider) ?? TabType.regular, + searchText: text, + ); + + await router.push(route.location); + }), + DefaultSearchAction((text) async { + final defaultSearchBang = await ref.read( + defaultSearchBangDataProvider.future, + ); + + if (defaultSearchBang != null) { + final currentTab = ref.read(selectedTabStateProvider); + + await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: defaultSearchBang.getTemplateUrl(text), + parentId: currentTab?.id, + private: currentTab?.isPrivate ?? false, + ); + } else { + logger.e('No default search bang found'); + } + }), + FindInPageAction((text) async { + await ref + .read(findInPageControllerProvider.notifier) + .findAll(text: text); + }), + ShareAction((text) async { + await SharePlus.instance.share(ShareParams(text: text)); + }), + CallAction((text) async { + final uri = Uri.tryParse('tel:${text.replaceAll(' ', '')}'); + + if (uri != null) { + final canLaunch = await canLaunchUrl(uri); + if (canLaunch) { + await launchUrl(uri); + } + } + }), + EmailAction((text) async { + final uri = Uri.tryParse('mailto:$text'); + + if (uri != null) { + final canLaunch = await canLaunchUrl(uri); + if (canLaunch) { + await launchUrl(uri); + } + } + }), + ]), + ); + + return service; +} + +@Riverpod(keepAlive: true) +GeckoEventService eventService(Ref ref) { + final service = GeckoEventService.setUp(); + + ref.onDispose(() { + service.dispose(); + }); + + return service; +} + +@Riverpod(keepAlive: true) +GeckoAddonService addonService(Ref ref) { + final service = GeckoAddonService.setUp(); + + ref.onDispose(() { + service.dispose(); + }); + + return service; +} + +@Riverpod(keepAlive: true) +GeckoTabContentService tabContentService(Ref ref) { + final service = GeckoTabContentService.setUp(); + + ref.onDispose(() { + service.dispose(); + }); + + return service; +} + +@Riverpod(keepAlive: true) +GeckoSuggestionsService engineSuggestionsService(Ref ref) { + final service = GeckoSuggestionsService.setUp(); + + ref.onDispose(() { + service.dispose(); + }); + + return service; +} + +@Riverpod(keepAlive: true) +class EngineReadyState extends _$EngineReadyState { + @override + bool build() { + final eventService = ref.watch(eventServiceProvider); + + final currentState = + eventService.engineReadyStateEvents.valueOrNull ?? false; + + if (!currentState) { + unawaited( + eventService.engineReadyStateEvents + .firstWhere((value) => value == true) + .timeout( + const Duration(seconds: 3), + onTimeout: () { + logger.w('Waiting for engine ready state timed out'); + return true; + }, + ) + .whenComplete(() => state = true), + ); + } + + final sub = eventService.engineReadyStateEvents.listen((value) { + state = value; + }); + + ref.onDispose(() async { + await sub.cancel(); + }); + + return currentState; + } +} diff --git a/app/lib/features/geckoview/domain/providers.g.dart b/app/lib/features/geckoview/domain/providers.g.dart new file mode 100644 index 00000000..d924c5e6 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers.g.dart @@ -0,0 +1,115 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$selectionActionServiceHash() => + r'a3682b3f97d9798aadbca3be882d5be74085b762'; + +/// See also [selectionActionService]. +@ProviderFor(selectionActionService) +final selectionActionServiceProvider = + Provider.internal( + selectionActionService, + name: r'selectionActionServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectionActionServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SelectionActionServiceRef = ProviderRef; +String _$eventServiceHash() => r'166b01f636fbdd4355dbc55a18ca4f83e0006de8'; + +/// See also [eventService]. +@ProviderFor(eventService) +final eventServiceProvider = Provider.internal( + eventService, + name: r'eventServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$eventServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef EventServiceRef = ProviderRef; +String _$addonServiceHash() => r'c7aca09b99c3810908176f3464f5f90a185aa5e7'; + +/// See also [addonService]. +@ProviderFor(addonService) +final addonServiceProvider = Provider.internal( + addonService, + name: r'addonServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$addonServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AddonServiceRef = ProviderRef; +String _$tabContentServiceHash() => r'd9a991add907ecc138c62790883e59d8e9aa9266'; + +/// See also [tabContentService]. +@ProviderFor(tabContentService) +final tabContentServiceProvider = Provider.internal( + tabContentService, + name: r'tabContentServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabContentServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TabContentServiceRef = ProviderRef; +String _$engineSuggestionsServiceHash() => + r'f7414b335564578b2c7f6a86baf8bf13f2d5ba2d'; + +/// See also [engineSuggestionsService]. +@ProviderFor(engineSuggestionsService) +final engineSuggestionsServiceProvider = + Provider.internal( + engineSuggestionsService, + name: r'engineSuggestionsServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$engineSuggestionsServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef EngineSuggestionsServiceRef = ProviderRef; +String _$engineReadyStateHash() => r'a892c48ffc29e3414de12e66d9bfec522e4f40a8'; + +/// See also [EngineReadyState]. +@ProviderFor(EngineReadyState) +final engineReadyStateProvider = + NotifierProvider.internal( + EngineReadyState.new, + name: r'engineReadyStateProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$engineReadyStateHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$EngineReadyState = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/domain/providers/browser_extension.dart b/app/lib/features/geckoview/domain/providers/browser_extension.dart new file mode 100644 index 00000000..962aee70 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/browser_extension.dart @@ -0,0 +1,22 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'browser_extension.g.dart'; + +@Riverpod(keepAlive: true) +GeckoBrowserExtensionService browserExtensionService(Ref ref) { + final service = GeckoBrowserExtensionService.setUp(); + + ref.onDispose(() { + service.dispose(); + }); + + return service; +} + +@Riverpod() +Stream feedRequested(Ref ref) { + final service = ref.watch(browserExtensionServiceProvider); + return service.feedRequested; +} diff --git a/app/lib/features/geckoview/domain/providers/browser_extension.g.dart b/app/lib/features/geckoview/domain/providers/browser_extension.g.dart new file mode 100644 index 00000000..c0384913 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/browser_extension.g.dart @@ -0,0 +1,46 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'browser_extension.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$browserExtensionServiceHash() => + r'c3f67763e0abb10039b407fced6e175e5ec7c6c3'; + +/// See also [browserExtensionService]. +@ProviderFor(browserExtensionService) +final browserExtensionServiceProvider = + Provider.internal( + browserExtensionService, + name: r'browserExtensionServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$browserExtensionServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef BrowserExtensionServiceRef = ProviderRef; +String _$feedRequestedHash() => r'4f179d0878072a77a87422ff6afdac53a3d57c04'; + +/// See also [feedRequested]. +@ProviderFor(feedRequested) +final feedRequestedProvider = AutoDisposeStreamProvider.internal( + feedRequested, + name: r'feedRequestedProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedRequestedHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FeedRequestedRef = AutoDisposeStreamProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/domain/providers/selected_tab.dart b/app/lib/features/geckoview/domain/providers/selected_tab.dart new file mode 100644 index 00000000..a9e8b019 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/selected_tab.dart @@ -0,0 +1,41 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; + +part 'selected_tab.g.dart'; + +@Riverpod(keepAlive: true) +class SelectedTab extends _$SelectedTab { + @override + String? build() { + final eventSerivce = ref.watch(eventServiceProvider); + + ref.listen( + fireImmediately: true, + engineReadyStateProvider, + (previous, next) async { + if (next) { + await GeckoTabService().syncEvents(onSelectedTabChange: true); + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to engineReadyStateProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + final selectedTabSub = eventSerivce.selectedTabEvents.listen((tabId) { + state = tabId; + }); + + ref.onDispose(() async { + await selectedTabSub.cancel(); + }); + + return null; + } +} diff --git a/app/lib/features/search_browser/domain/services/session.g.dart b/app/lib/features/geckoview/domain/providers/selected_tab.g.dart similarity index 50% rename from app/lib/features/search_browser/domain/services/session.g.dart rename to app/lib/features/geckoview/domain/providers/selected_tab.g.dart index 3c81fd49..10b87276 100644 --- a/app/lib/features/search_browser/domain/services/session.g.dart +++ b/app/lib/features/geckoview/domain/providers/selected_tab.g.dart @@ -1,26 +1,25 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'session.dart'; +part of 'selected_tab.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** -String _$sessionServiceHash() => r'fd2cd03bbf2dd254bb19e541c71a6fb249f5a7bb'; +String _$selectedTabHash() => r'26d1bb90c777d7011f4f27f7b962584a4a62a0a7'; -/// See also [SessionService]. -@ProviderFor(SessionService) -final sessionServiceProvider = - AutoDisposeNotifierProvider.internal( - SessionService.new, - name: r'sessionServiceProvider', +/// See also [SelectedTab]. +@ProviderFor(SelectedTab) +final selectedTabProvider = NotifierProvider.internal( + SelectedTab.new, + name: r'selectedTabProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$sessionServiceHash, + : _$selectedTabHash, dependencies: null, allTransitiveDependencies: null, ); -typedef _$SessionService = AutoDisposeNotifier; +typedef _$SelectedTab = Notifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/domain/providers/tab_list.dart b/app/lib/features/geckoview/domain/providers/tab_list.dart new file mode 100644 index 00000000..0ce998b4 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/tab_list.dart @@ -0,0 +1,46 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; + +part 'tab_list.g.dart'; + +@Riverpod(keepAlive: true) +class TabList extends _$TabList { + @override + EquatableValue> build() { + final eventService = ref.watch(eventServiceProvider); + + ref.listen( + fireImmediately: true, + engineReadyStateProvider, + (previous, next) async { + if (next) { + await GeckoTabService().syncEvents(onTabListChange: true); + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to eventServiceProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + final tabListSub = eventService.tabListEvents.listen((tabs) { + final equatableTabs = EquatableValue(tabs); + + if (equatableTabs != state) { + state = equatableTabs; + } + }); + + ref.onDispose(() async { + await tabListSub.cancel(); + }); + + return EquatableValue([]); + } +} diff --git a/app/lib/features/geckoview/domain/providers/tab_list.g.dart b/app/lib/features/geckoview/domain/providers/tab_list.g.dart new file mode 100644 index 00000000..a187dbac --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/tab_list.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab_list.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tabListHash() => r'0b9ca1e089a59895df4cbf28691c2849a7004f08'; + +/// See also [TabList]. +@ProviderFor(TabList) +final tabListProvider = + NotifierProvider>>.internal( + TabList.new, + name: r'tabListProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabListHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$TabList = Notifier>>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/domain/providers/tab_session.dart b/app/lib/features/geckoview/domain/providers/tab_session.dart new file mode 100644 index 00000000..ed3cb421 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/tab_session.dart @@ -0,0 +1,60 @@ +import 'dart:typed_data'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'tab_session.g.dart'; + +@Riverpod() +class TabSession extends _$TabSession { + late GeckoSessionService _sessionService; + + Future loadUrl({ + required Uri url, + LoadUrlFlags flags = LoadUrlFlags.NONE, + Map? additionalHeaders, + }) { + return _sessionService.loadUrl( + url: url, + flags: flags, + additionalHeaders: additionalHeaders, + ); + } + + Future stopLoading() { + return _sessionService.stopLoading(); + } + + Future reload() { + return _sessionService.reload(); + } + + Future goBack() { + return _sessionService.goBack(); + } + + Future goForward() { + return _sessionService.goForward(); + } + + Future exitFullscreen() { + return _sessionService.exitFullscreen(); + } + + Future requestScreenshot({bool requireImageResult = true}) { + return _sessionService.requestScreenshot(requireImageResult); + } + + @override + void build({required String? tabId}) { + _sessionService = (tabId != null) + ? GeckoSessionService(tabId: tabId) + : GeckoSessionService.forActiveTab(); + } +} + +@Riverpod() +Raw selectedTabSessionNotifier(Ref ref) { + return ref.watch(tabSessionProvider(tabId: null).notifier); +} diff --git a/app/lib/features/geckoview/domain/providers/tab_session.g.dart b/app/lib/features/geckoview/domain/providers/tab_session.g.dart new file mode 100644 index 00000000..1a3d51f3 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/tab_session.g.dart @@ -0,0 +1,179 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab_session.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$selectedTabSessionNotifierHash() => + r'aef19991d5b05a2bb28e8a6ccb57dc75c2f3148f'; + +/// See also [selectedTabSessionNotifier]. +@ProviderFor(selectedTabSessionNotifier) +final selectedTabSessionNotifierProvider = + AutoDisposeProvider>.internal( + selectedTabSessionNotifier, + name: r'selectedTabSessionNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedTabSessionNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SelectedTabSessionNotifierRef = AutoDisposeProviderRef>; +String _$tabSessionHash() => r'33d68f3860b3bc9a92db386535c7482bf4416008'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$TabSession extends BuildlessAutoDisposeNotifier { + late final String? tabId; + + void build({required String? tabId}); +} + +/// See also [TabSession]. +@ProviderFor(TabSession) +const tabSessionProvider = TabSessionFamily(); + +/// See also [TabSession]. +class TabSessionFamily extends Family { + /// See also [TabSession]. + const TabSessionFamily(); + + /// See also [TabSession]. + TabSessionProvider call({required String? tabId}) { + return TabSessionProvider(tabId: tabId); + } + + @override + TabSessionProvider getProviderOverride( + covariant TabSessionProvider provider, + ) { + return call(tabId: provider.tabId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'tabSessionProvider'; +} + +/// See also [TabSession]. +class TabSessionProvider + extends AutoDisposeNotifierProviderImpl { + /// See also [TabSession]. + TabSessionProvider({required String? tabId}) + : this._internal( + () => TabSession()..tabId = tabId, + from: tabSessionProvider, + name: r'tabSessionProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabSessionHash, + dependencies: TabSessionFamily._dependencies, + allTransitiveDependencies: TabSessionFamily._allTransitiveDependencies, + tabId: tabId, + ); + + TabSessionProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.tabId, + }) : super.internal(); + + final String? tabId; + + @override + void runNotifierBuild(covariant TabSession notifier) { + return notifier.build(tabId: tabId); + } + + @override + Override overrideWith(TabSession Function() create) { + return ProviderOverride( + origin: this, + override: TabSessionProvider._internal( + () => create()..tabId = tabId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + tabId: tabId, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement createElement() { + return _TabSessionProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TabSessionProvider && other.tabId == tabId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, tabId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin TabSessionRef on AutoDisposeNotifierProviderRef { + /// The parameter `tabId` of this provider. + String? get tabId; +} + +class _TabSessionProviderElement + extends AutoDisposeNotifierProviderElement + with TabSessionRef { + _TabSessionProviderElement(super.provider); + + @override + String? get tabId => (origin as TabSessionProvider).tabId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/domain/providers/tab_state.dart b/app/lib/features/geckoview/domain/providers/tab_state.dart new file mode 100644 index 00000000..82aa38d2 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/tab_state.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/find_result.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/history.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/security.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; +import 'package:weblibre/features/geckoview/utils/image_helper.dart'; + +part 'tab_state.g.dart'; + +@Riverpod(keepAlive: true) +class TabStates extends _$TabStates { + void _onTabContentStateChange(TabContentState contentState) { + final current = + state[contentState.id] ?? TabState.$default(contentState.id); + state = {...state} + ..[contentState.id] = current.copyWith( + parentId: contentState.parentId, + contextId: contentState.contextId, + url: Uri.parse(contentState.url), + title: (contentState.title.isNotEmpty) + ? contentState.title + : current.title, + progress: contentState.progress, + isPrivate: contentState.isPrivate, + isFullScreen: contentState.isFullScreen, + isLoading: contentState.isLoading, + ); + } + + Future _onIconChange(IconChangeEvent event) async { + final IconChangeEvent(:tabId, :bytes) = event; + + final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); + + final current = state[tabId] ?? TabState.$default(tabId); + state = {...state}..[tabId] = current.copyWith.icon(image); + } + + Future _onThumbnailChange(ThumbnailEvent event) async { + final ThumbnailEvent(:tabId, :bytes) = event; + + final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); + + final current = state[tabId] ?? TabState.$default(tabId); + state = {...state}..[tabId] = current.copyWith.thumbnail(image); + } + + void _onSecurityInfoStateChange(SecurityInfoEvent event) { + final SecurityInfoEvent(:tabId, :securityInfo) = event; + + final current = state[tabId] ?? TabState.$default(tabId); + state = {...state} + ..[tabId] = current.copyWith.securityInfoState( + SecurityState( + secure: securityInfo.secure, + host: securityInfo.host, + issuer: securityInfo.issuer, + ), + ); + } + + void _onHistoryStateChange(HistoryEvent event) { + final HistoryEvent(:tabId, :history) = event; + + final current = state[tabId] ?? TabState.$default(tabId); + state = {...state} + ..[tabId] = current.copyWith.historyState( + HistoryState( + items: history.items.nonNulls + .map( + (item) => + HistoryItem(url: Uri.parse(item.url), title: item.title), + ) + .toList(), + currentIndex: history.currentIndex, + canGoBack: history.canGoBack, + canGoForward: history.canGoForward, + ), + ); + } + + void _onReaderableStateChange(ReaderableEvent event) { + final ReaderableEvent(:tabId, :readerable) = event; + + final current = state[tabId] ?? TabState.$default(tabId); + state = {...state} + ..[tabId] = current.copyWith.readerableState( + ReaderableState( + readerable: readerable.readerable, + active: readerable.active, + ), + ); + } + + void _onFindResultsChange(FindResultsEvent event) { + final FindResultsEvent(:tabId, :results) = event; + final current = state[tabId] ?? TabState.$default(tabId); + + if (results.isNotEmpty) { + final result = results.last; + + state = {...state} + ..[tabId] = current.copyWith.findResultState( + FindResultState( + lastSearchText: ref.read(findInPageRepositoryProvider(tabId)), + activeMatchOrdinal: result.activeMatchOrdinal, + numberOfMatches: result.numberOfMatches, + isDoneCounting: result.isDoneCounting, + ), + ); + } else if (current.findResultState.hasMatches) { + state = { + ...state, + }..[tabId] = current.copyWith.findResultState(FindResultState.$default()); + } + } + + @override + Map build() { + final eventService = ref.watch(eventServiceProvider); + + final subscriptions = [ + eventService.tabContentEvents.listen((event) { + _onTabContentStateChange(event); + }), + eventService.iconChangeEvents.listen((event) async { + await _onIconChange(event); + }), + eventService.thumbnailEvents.listen((event) async { + await _onThumbnailChange(event); + }), + eventService.securityInfoEvents.listen((event) { + _onSecurityInfoStateChange(event); + }), + eventService.historyEvents.listen((event) { + _onHistoryStateChange(event); + }), + eventService.readerableEvents.listen((event) { + _onReaderableStateChange(event); + }), + eventService.findResultsEvent + .debounceTime(const Duration(milliseconds: 25)) + .listen((event) { + _onFindResultsChange(event); + }), + ]; + + ref.listen( + fireImmediately: true, + engineReadyStateProvider, + (previous, next) async { + if (next) { + await GeckoTabService().syncEvents( + onTabContentStateChange: true, + onIconChange: true, + onThumbnailChange: true, + onSecurityInfoStateChange: true, + onHistoryStateChange: true, + onFindResults: true, + ); + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to engineReadyStateProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.onDispose(() async { + for (final sub in subscriptions) { + await sub.cancel(); + } + }); + + return {}; + } +} + +@Riverpod() +TabState? tabState(Ref ref, String? tabId) { + if (tabId == null) { + return null; + } + + return ref.watch(tabStatesProvider.select((tabs) => tabs[tabId])); +} + +@Riverpod() +TabState? selectedTabState(Ref ref) { + final tabId = ref.watch(selectedTabProvider); + return ref.watch(tabStateProvider(tabId)); +} + +@Riverpod() +TabType? selectedTabType(Ref ref) { + final isPrivate = ref.watch( + selectedTabStateProvider.select((value) => value?.isPrivate), + ); + + return isPrivate.mapNotNull( + (isCurrentPrivate) => isCurrentPrivate ? TabType.private : TabType.regular, + ); +} diff --git a/app/lib/features/geckoview/domain/providers/tab_state.g.dart b/app/lib/features/geckoview/domain/providers/tab_state.g.dart new file mode 100644 index 00000000..866ac503 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -0,0 +1,195 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab_state.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tabStateHash() => r'f57b2353acc99ca73670d504b45ea5ff19df38c7'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [tabState]. +@ProviderFor(tabState) +const tabStateProvider = TabStateFamily(); + +/// See also [tabState]. +class TabStateFamily extends Family { + /// See also [tabState]. + const TabStateFamily(); + + /// See also [tabState]. + TabStateProvider call(String? tabId) { + return TabStateProvider(tabId); + } + + @override + TabStateProvider getProviderOverride(covariant TabStateProvider provider) { + return call(provider.tabId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'tabStateProvider'; +} + +/// See also [tabState]. +class TabStateProvider extends AutoDisposeProvider { + /// See also [tabState]. + TabStateProvider(String? tabId) + : this._internal( + (ref) => tabState(ref as TabStateRef, tabId), + from: tabStateProvider, + name: r'tabStateProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabStateHash, + dependencies: TabStateFamily._dependencies, + allTransitiveDependencies: TabStateFamily._allTransitiveDependencies, + tabId: tabId, + ); + + TabStateProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.tabId, + }) : super.internal(); + + final String? tabId; + + @override + Override overrideWith(TabState? Function(TabStateRef provider) create) { + return ProviderOverride( + origin: this, + override: TabStateProvider._internal( + (ref) => create(ref as TabStateRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + tabId: tabId, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _TabStateProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TabStateProvider && other.tabId == tabId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, tabId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin TabStateRef on AutoDisposeProviderRef { + /// The parameter `tabId` of this provider. + String? get tabId; +} + +class _TabStateProviderElement extends AutoDisposeProviderElement + with TabStateRef { + _TabStateProviderElement(super.provider); + + @override + String? get tabId => (origin as TabStateProvider).tabId; +} + +String _$selectedTabStateHash() => r'dbd36af7af286bd9d079f30e8e11bbda23bf7728'; + +/// See also [selectedTabState]. +@ProviderFor(selectedTabState) +final selectedTabStateProvider = AutoDisposeProvider.internal( + selectedTabState, + name: r'selectedTabStateProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedTabStateHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SelectedTabStateRef = AutoDisposeProviderRef; +String _$selectedTabTypeHash() => r'53093fc6db8becde7500163661e087fb0a2eb955'; + +/// See also [selectedTabType]. +@ProviderFor(selectedTabType) +final selectedTabTypeProvider = AutoDisposeProvider.internal( + selectedTabType, + name: r'selectedTabTypeProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedTabTypeHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SelectedTabTypeRef = AutoDisposeProviderRef; +String _$tabStatesHash() => r'e4d2f2b4ddd65beac8a1fd0d22bad1e0a26b41bd'; + +/// See also [TabStates]. +@ProviderFor(TabStates) +final tabStatesProvider = + NotifierProvider>.internal( + TabStates.new, + name: r'tabStatesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabStatesHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$TabStates = Notifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/domain/providers/web_extensions_state.dart b/app/lib/features/geckoview/domain/providers/web_extensions_state.dart new file mode 100644 index 00000000..d8687549 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/web_extensions_state.dart @@ -0,0 +1,97 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/domain/entities/equatable_image.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/web_extension.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/utils/image_helper.dart'; + +part 'web_extensions_state.g.dart'; + +@Riverpod(keepAlive: true) +class WebExtensionsState extends _$WebExtensionsState { + final _imageCache = {}; + + void _onExtensionUpdate(ExtensionDataEvent event) { + final ExtensionDataEvent(:extensionId, :data) = event; + + if (data != null) { + final current = + state[extensionId] ?? + WebExtensionState( + extensionId: extensionId, + icon: _imageCache[extensionId], + enabled: false, + ); + + state = {...state} + ..[extensionId] = current.copyWith( + title: data.title, + enabled: data.enabled ?? current.enabled, + badgeText: data.badgeText, + badgeTextColor: data.badgeTextColor.mapNotNull( + (color) => Color(color), + ), + badgeBackgroundColor: data.badgeBackgroundColor.mapNotNull( + (color) => Color(color), + ), + ); + } else { + if (state.containsKey(extensionId)) { + state = {...state}..remove(extensionId); + } + } + } + + Future _onIconChange(ExtensionIconEvent event) async { + final ExtensionIconEvent(:extensionId, :bytes) = event; + + final image = await tryDecodeImage(bytes); + + if (image != null) { + if (_imageCache[extensionId] != image) { + _imageCache[extensionId] = image; + + if (state.containsKey(extensionId)) { + state = {...state} + ..[extensionId] = state[extensionId]!.copyWith.icon(image); + } + } + } + } + + @override + Map build(WebExtensionActionType actionType) { + final addonService = ref.watch(addonServiceProvider); + + final subscriptions = switch (actionType) { + WebExtensionActionType.browser => [ + addonService.browserExtensionStream.listen((event) { + _onExtensionUpdate(event); + }), + addonService.browserIconStream.listen((event) async { + await _onIconChange(event); + }), + ], + WebExtensionActionType.page => [ + addonService.pageExtensionStream.listen((event) { + _onExtensionUpdate(event); + }), + addonService.pageIconStream.listen((event) async { + await _onIconChange(event); + }), + ], + }; + + ref.onDispose(() async { + for (final sub in subscriptions) { + await sub.cancel(); + } + }); + + return {}; + } +} diff --git a/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart b/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart new file mode 100644 index 00000000..a7a3e276 --- /dev/null +++ b/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart @@ -0,0 +1,177 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'web_extensions_state.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$webExtensionsStateHash() => + r'6fb44ab543250f2b2554e410ee749fdd035cd4e7'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$WebExtensionsState + extends BuildlessNotifier> { + late final WebExtensionActionType actionType; + + Map build(WebExtensionActionType actionType); +} + +/// See also [WebExtensionsState]. +@ProviderFor(WebExtensionsState) +const webExtensionsStateProvider = WebExtensionsStateFamily(); + +/// See also [WebExtensionsState]. +class WebExtensionsStateFamily extends Family> { + /// See also [WebExtensionsState]. + const WebExtensionsStateFamily(); + + /// See also [WebExtensionsState]. + WebExtensionsStateProvider call(WebExtensionActionType actionType) { + return WebExtensionsStateProvider(actionType); + } + + @override + WebExtensionsStateProvider getProviderOverride( + covariant WebExtensionsStateProvider provider, + ) { + return call(provider.actionType); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'webExtensionsStateProvider'; +} + +/// See also [WebExtensionsState]. +class WebExtensionsStateProvider + extends + NotifierProviderImpl< + WebExtensionsState, + Map + > { + /// See also [WebExtensionsState]. + WebExtensionsStateProvider(WebExtensionActionType actionType) + : this._internal( + () => WebExtensionsState()..actionType = actionType, + from: webExtensionsStateProvider, + name: r'webExtensionsStateProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$webExtensionsStateHash, + dependencies: WebExtensionsStateFamily._dependencies, + allTransitiveDependencies: + WebExtensionsStateFamily._allTransitiveDependencies, + actionType: actionType, + ); + + WebExtensionsStateProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.actionType, + }) : super.internal(); + + final WebExtensionActionType actionType; + + @override + Map runNotifierBuild( + covariant WebExtensionsState notifier, + ) { + return notifier.build(actionType); + } + + @override + Override overrideWith(WebExtensionsState Function() create) { + return ProviderOverride( + origin: this, + override: WebExtensionsStateProvider._internal( + () => create()..actionType = actionType, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + actionType: actionType, + ), + ); + } + + @override + NotifierProviderElement> + createElement() { + return _WebExtensionsStateProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is WebExtensionsStateProvider && + other.actionType == actionType; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, actionType.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin WebExtensionsStateRef + on NotifierProviderRef> { + /// The parameter `actionType` of this provider. + WebExtensionActionType get actionType; +} + +class _WebExtensionsStateProviderElement + extends + NotifierProviderElement< + WebExtensionsState, + Map + > + with WebExtensionsStateRef { + _WebExtensionsStateProviderElement(super.provider); + + @override + WebExtensionActionType get actionType => + (origin as WebExtensionsStateProvider).actionType; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/domain/repositories/tab.dart b/app/lib/features/geckoview/domain/repositories/tab.dart new file mode 100644 index 00000000..b706c205 --- /dev/null +++ b/app/lib/features/geckoview/domain/repositories/tab.dart @@ -0,0 +1,360 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers/intent.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart'; +import 'package:weblibre/utils/debouncer.dart'; + +part 'tab.g.dart'; + +@Riverpod(keepAlive: true) +class TabRepository extends _$TabRepository { + final _tabsService = GeckoTabService(); + + String? _previousTabId; + final _tabFromIntent = {}; + + bool hasLaunchedFromIntent(String? tabId) { + if (tabId == null) { + return false; + } + + return _tabFromIntent.contains(tabId); + } + + Future addTab({ + Uri? url, + bool selectTab = true, + bool startLoading = true, + String? parentId, + LoadUrlFlags flags = LoadUrlFlags.NONE, + Source source = Internal.newTab, + bool private = false, + HistoryMetadataKey? historyMetadata, + Map? additionalHeaders, + }) async { + final selectedContainer = await ref + .read(selectedContainerProvider.notifier) + .fetchData(); + + return ref + .read(tabDatabaseProvider) + .tabDao + .upsertContainerTabTransactional( + () { + return _tabsService.addTab( + url: url, + selectTab: selectTab, + startLoading: startLoading, + parentId: parentId, + flags: flags, + contextId: selectedContainer?.metadata.contextualIdentity, + source: source, + private: private, + historyMetadata: historyMetadata, + additionalHeaders: additionalHeaders, + ); + }, + parentId: Value(parentId), + containerId: Value(selectedContainer?.id), + ); + } + + Future duplicateTab({ + required String? selectTabId, + String? containerId, + bool selectTab = true, + }) async { + final containerData = await containerId.mapNotNull( + (containerId) => ref + .read(containerRepositoryProvider.notifier) + .getContainerData(containerId), + ); + + return ref + .read(tabDatabaseProvider) + .tabDao + .upsertContainerTabTransactional( + () { + return _tabsService.duplicateTab( + selectTabId: selectTabId, + newContextId: containerData?.metadata.contextualIdentity, + selectNewTab: selectTab, + ); + }, + parentId: const Value.absent(), + containerId: Value(containerData?.id), + ); + } + + Future selectPreviousTab() async { + if (_previousTabId != null) { + return selectTab(_previousTabId!); + } + + return false; + } + + Future selectTab(String tabId) async { + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .containerTabId(tabId); + + final containerData = await containerId.mapNotNull( + (containerId) => ref + .read(containerRepositoryProvider.notifier) + .getContainerData(containerId), + ); + + if (containerData != null) { + if (containerData.metadata.authSettings.authenticationRequired) { + if (containerId != ref.read(selectedContainerProvider)) { + logger.w( + 'Tried to open authenticated tab $tabId but container not selected', + ); + return false; + } + } + + if (containerData.metadata.useProxy) { + final proxyPluginHealthy = await GeckoContainerProxyService() + .healthcheck(); + + if (!proxyPluginHealthy) { + logger.w( + 'Tried to open proxied tab $tabId but proxy plugin not responding', + ); + return false; + } + } + } + + await _tabsService.selectTab(tabId: tabId); + return true; + } + + Future _selectNextTab(String tabId) async { + if (ref.read(tabListProvider).value.length == 1) { + return; + } + + final currentContainerId = await ref + .read(tabDataRepositoryProvider.notifier) + .containerTabId(tabId); + + final sameContainerTabs = await ref + .read(containerRepositoryProvider.notifier) + .getContainerTabIds(currentContainerId); + + final nextAvailabeInContainer = sameContainerTabs.firstWhereOrNull( + (tab) => tab != tabId, + ); + + if (nextAvailabeInContainer != null) { + return _tabsService.selectTab(tabId: sameContainerTabs.first); + } + + final unassignedTabs = await ref + .read(containerRepositoryProvider.notifier) + .getContainerTabIds(null); + + if (unassignedTabs.isNotEmpty) { + return _tabsService.selectTab(tabId: unassignedTabs.first); + } + + //We only take containers without authentication! + final availableContainers = await ref + .read(containerRepositoryProvider.notifier) + .getAllContainersWithCount(); + + final nextAvailableContainerUnauthenticated = availableContainers + .firstWhereOrNull( + (container) => + container.metadata.authSettings.authenticationRequired == false, + ); + + final nextContainerTabs = await nextAvailableContainerUnauthenticated + .mapNotNull( + (container) => ref + .read(containerRepositoryProvider.notifier) + .getContainerTabIds(container.id), + ); + + if (nextContainerTabs.isNotEmpty) { + return _tabsService.selectTab(tabId: nextContainerTabs!.first); + } + + if (availableContainers.isNotEmpty) { + //Last resort push new tab to avoid any authenticated tab is selected + // ignore: avoid_redundant_argument_values + await addTab(selectTab: true); + } + } + + Future closeTab(String tabId) async { + if (ref.read(selectedTabProvider) == tabId) { + await _selectNextTab(tabId); + } + + return _tabsService.removeTab(tabId: tabId); + } + + Future closeTabs(List tabIds) async { + final selectedTab = ref.read(selectedTabProvider); + if (selectedTab.mapNotNull(tabIds.contains) ?? false) { + await _selectNextTab(selectedTab!); + } + + return _tabsService.removeTabs(ids: tabIds); + } + + @override + void build() { + final eventSerivce = ref.watch(eventServiceProvider); + final tabContentService = ref.watch(tabContentServiceProvider); + + final db = ref.watch(tabDatabaseProvider); + + final tabAddedSub = eventSerivce.tabAddedStream.listen((tabId) async { + final containerId = ref.read(selectedContainerProvider); + await db.tabDao.upsertUnassignedTab( + tabId, + parentId: const Value.absent(), + containerId: Value(containerId), + ); + }); + + final tabContentSub = tabContentService.tabContentStream.listen(( + content, + ) async { + await db.tabDao.updateTabContent( + content.tabId, + isProbablyReaderable: content.isProbablyReaderable, + extractedContentMarkdown: content.extractedContentMarkdown, + extractedContentPlain: content.extractedContentPlain, + fullContentMarkdown: content.fullContentMarkdown, + fullContentPlain: content.fullContentPlain, + ); + }); + + ref.listen( + fireImmediately: true, + selectedTabProvider, + (previous, tabId) async { + if (tabId != null) { + _previousTabId = previous; + await db.tabDao.touchTab(tabId, timestamp: DateTime.now()); + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to selectedTabProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listen( + tabListProvider, + (previous, next) async { + //Only sync tabs if there has been a previous value or is not empty + final syncTabs = + next.value.isNotEmpty || (previous?.value.isNotEmpty ?? false); + + if (_previousTabId != null && !next.value.contains(_previousTabId)) { + _previousTabId = null; + } + + if (syncTabs) { + await db.tabDao.syncTabs(retainTabIds: next.value); + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to tabListProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + final tabStateDebouncer = Debouncer(const Duration(seconds: 3)); + Map? debounceStartValue; + + ref.listen( + tabStatesProvider, + (previous, next) { + //Since state changes occure pretty often and our map always contains + //the latest state, we cache the value before starting debouncing and + //later diff to that, to avoid frequent database writes + if (!tabStateDebouncer.isDebouncing) { + debounceStartValue = previous; + } + + tabStateDebouncer.eventOccured(() async { + await db.tabDao.updateTabs(debounceStartValue, next); + }); + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to tabStatesProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listen( + fireImmediately: true, + engineBoundIntentStreamProvider, + (previous, next) { + next.whenData((value) async { + switch (value) { + case SharedUrl(): + _tabFromIntent.add(await addTab(url: value.url)); + case SharedText(): + final defaultSearchBang = + ref.read(selectedBangDataProvider()) ?? + await ref.read(defaultSearchBangDataProvider.future); + + _tabFromIntent.add( + await addTab( + url: defaultSearchBang?.getTemplateUrl(value.text), + ), + ); + } + }); + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to engineBoundIntentStreamProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.onDispose(() async { + tabStateDebouncer.dispose(); + await tabAddedSub.cancel(); + await tabContentSub.cancel(); + }); + } +} diff --git a/app/lib/features/geckoview/domain/repositories/tab.g.dart b/app/lib/features/geckoview/domain/repositories/tab.g.dart new file mode 100644 index 00000000..7bbe174b --- /dev/null +++ b/app/lib/features/geckoview/domain/repositories/tab.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tabRepositoryHash() => r'94e67307d67c8873b2ebd8cfadcd71258782987a'; + +/// See also [TabRepository]. +@ProviderFor(TabRepository) +final tabRepositoryProvider = NotifierProvider.internal( + TabRepository.new, + name: r'tabRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$TabRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/browser/domain/entities/sheet.dart b/app/lib/features/geckoview/features/browser/domain/entities/sheet.dart new file mode 100644 index 00000000..8f734cb6 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/entities/sheet.dart @@ -0,0 +1,17 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +sealed class Sheet with FastEquatable {} + +final class ViewTabsSheet extends Sheet { + @override + List get hashParameters => [null]; +} + +final class TabQaChatSheet extends Sheet { + final String chatId; + + TabQaChatSheet({required this.chatId}); + + @override + List get hashParameters => [chatId]; +} diff --git a/app/lib/features/geckoview/features/browser/domain/providers.dart b/app/lib/features/geckoview/features/browser/domain/providers.dart new file mode 100644 index 00000000..a7297c90 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/providers.dart @@ -0,0 +1,206 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/domain/repositories/data.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_preview.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +class SelectedBangTrigger extends _$SelectedBangTrigger { + // ignore: document_ignores api decision + // ignore: use_setters_to_change_properties + void setTrigger(String trigger) { + state = trigger; + } + + void clearTrigger() { + state = null; + } + + @override + String? build({String? domain}) { + return null; + } +} + +@Riverpod() +class SelectedBangData extends _$SelectedBangData { + @override + BangData? build({String? domain}) { + final repository = ref.watch(bangDataRepositoryProvider.notifier); + final selectedBangTrigger = ref.watch( + selectedBangTriggerProvider(domain: domain), + ); + + final subscription = repository.watchBang(selectedBangTrigger).listen(( + value, + ) { + state = value; + }); + + ref.onDispose(() async { + await subscription.cancel(); + }); + + return null; + } +} + +@Riverpod() +EquatableValue> availableTabIds( + Ref ref, + ContainerFilter containerFilter, +) { + final containerTabs = ref.watch( + containerTabIdsProvider( + containerFilter, + ).select((value) => value.valueOrNull), + ); + final tabList = ref.watch(tabListProvider); + + return EquatableValue( + containerTabs?.where((tabId) => tabList.value.contains(tabId)).toList() ?? + [], + ); +} + +@Riverpod() +EquatableValue> availableTabStates( + Ref ref, + ContainerFilter containerFilter, +) { + final availableTabs = ref.watch(availableTabIdsProvider(containerFilter)); + final tabStates = ref.watch(tabStatesProvider); + + return EquatableValue({ + for (final tabId in availableTabs.value) + if (tabStates.containsKey(tabId)) tabId: tabStates[tabId]!, + }); +} + +@Riverpod() +EquatableValue> seamlessFilteredTabEntities( + Ref ref, { + required TabSearchPartition searchPartition, + required ContainerFilter containerFilter, + required bool groupTrees, +}) { + final tabSearchResults = ref + .watch( + tabSearchRepositoryProvider(searchPartition).select( + (value) => EquatableValue( + value.valueOrNull + ?.map((tab) => SingleTabEntity(tabId: tab.id)) + .toList(), + ), + ), + ) + .value; + + final availableTabs = ref.watch( + availableTabIdsProvider(containerFilter).select( + (value) => EquatableValue( + value.value.map((tab) => SingleTabEntity(tabId: tab)).toList(), + ), + ), + ); + + if (tabSearchResults == null) { + if (groupTrees) { + final trees = ref.watch( + tabTreesProvider.select( + (value) => EquatableValue( + value.valueOrNull + ?.map( + (tree) => TabTreeEntity( + tabId: tree.latestTabId, + rootId: tree.rootTabId, + totalTabs: tree.totalTabs, + ), + ) + .toList() ?? + [], + ), + ), + ); + + return EquatableValue( + trees.value + .where( + (tree) => availableTabs.value.any( + (available) => available.tabId == tree.tabId, + ), + ) + .toList(), + ); + } + + return availableTabs; + } + + return EquatableValue( + tabSearchResults.where((tab) => availableTabs.value.contains(tab)).toList(), + ); +} + +@Riverpod() +EquatableValue> seamlessFilteredTabPreviews( + Ref ref, + TabSearchPartition searchPartition, + ContainerFilter containerFilter, +) { + final tabSearchResults = ref + .watch( + tabSearchRepositoryProvider( + searchPartition, + ).select((value) => EquatableValue(value.valueOrNull)), + ) + .value; + + final availableTabStates = ref.watch( + availableTabStatesProvider(containerFilter), + ); + + if (tabSearchResults == null) { + return EquatableValue( + availableTabStates.value.values + .map( + (state) => TabPreview( + id: state.id, + title: state.title, + icon: state.icon, + url: state.url, + highlightedUrl: null, + content: null, + ), + ) + .toList(), + ); + } + + return EquatableValue( + tabSearchResults + .where((tab) => availableTabStates.value.containsKey(tab.id)) + .map((tab) { + return TabPreview( + id: tab.id, + title: tab.title ?? availableTabStates.value[tab.id]!.title, + icon: null, + url: tab.cleanUrl ?? availableTabStates.value[tab.id]!.url, + highlightedUrl: tab.url, + content: tab.extractedContent ?? tab.fullContent, + ); + }) + .whereType() + .toList(), + ); +} diff --git a/app/lib/features/geckoview/features/browser/domain/providers.g.dart b/app/lib/features/geckoview/features/browser/domain/providers.g.dart new file mode 100644 index 00000000..29b1ae79 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -0,0 +1,875 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$availableTabIdsHash() => r'ec3691063293e1c19c997f7b60178938ae631c7f'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [availableTabIds]. +@ProviderFor(availableTabIds) +const availableTabIdsProvider = AvailableTabIdsFamily(); + +/// See also [availableTabIds]. +class AvailableTabIdsFamily extends Family>> { + /// See also [availableTabIds]. + const AvailableTabIdsFamily(); + + /// See also [availableTabIds]. + AvailableTabIdsProvider call(ContainerFilter containerFilter) { + return AvailableTabIdsProvider(containerFilter); + } + + @override + AvailableTabIdsProvider getProviderOverride( + covariant AvailableTabIdsProvider provider, + ) { + return call(provider.containerFilter); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'availableTabIdsProvider'; +} + +/// See also [availableTabIds]. +class AvailableTabIdsProvider + extends AutoDisposeProvider>> { + /// See also [availableTabIds]. + AvailableTabIdsProvider(ContainerFilter containerFilter) + : this._internal( + (ref) => availableTabIds(ref as AvailableTabIdsRef, containerFilter), + from: availableTabIdsProvider, + name: r'availableTabIdsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$availableTabIdsHash, + dependencies: AvailableTabIdsFamily._dependencies, + allTransitiveDependencies: + AvailableTabIdsFamily._allTransitiveDependencies, + containerFilter: containerFilter, + ); + + AvailableTabIdsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.containerFilter, + }) : super.internal(); + + final ContainerFilter containerFilter; + + @override + Override overrideWith( + EquatableValue> Function(AvailableTabIdsRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: AvailableTabIdsProvider._internal( + (ref) => create(ref as AvailableTabIdsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + containerFilter: containerFilter, + ), + ); + } + + @override + AutoDisposeProviderElement>> createElement() { + return _AvailableTabIdsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is AvailableTabIdsProvider && + other.containerFilter == containerFilter; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, containerFilter.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin AvailableTabIdsRef + on AutoDisposeProviderRef>> { + /// The parameter `containerFilter` of this provider. + ContainerFilter get containerFilter; +} + +class _AvailableTabIdsProviderElement + extends AutoDisposeProviderElement>> + with AvailableTabIdsRef { + _AvailableTabIdsProviderElement(super.provider); + + @override + ContainerFilter get containerFilter => + (origin as AvailableTabIdsProvider).containerFilter; +} + +String _$availableTabStatesHash() => + r'f506b050e1dc4f50fbd2699d97af5a16a037bc4b'; + +/// See also [availableTabStates]. +@ProviderFor(availableTabStates) +const availableTabStatesProvider = AvailableTabStatesFamily(); + +/// See also [availableTabStates]. +class AvailableTabStatesFamily + extends Family>> { + /// See also [availableTabStates]. + const AvailableTabStatesFamily(); + + /// See also [availableTabStates]. + AvailableTabStatesProvider call(ContainerFilter containerFilter) { + return AvailableTabStatesProvider(containerFilter); + } + + @override + AvailableTabStatesProvider getProviderOverride( + covariant AvailableTabStatesProvider provider, + ) { + return call(provider.containerFilter); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'availableTabStatesProvider'; +} + +/// See also [availableTabStates]. +class AvailableTabStatesProvider + extends AutoDisposeProvider>> { + /// See also [availableTabStates]. + AvailableTabStatesProvider(ContainerFilter containerFilter) + : this._internal( + (ref) => + availableTabStates(ref as AvailableTabStatesRef, containerFilter), + from: availableTabStatesProvider, + name: r'availableTabStatesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$availableTabStatesHash, + dependencies: AvailableTabStatesFamily._dependencies, + allTransitiveDependencies: + AvailableTabStatesFamily._allTransitiveDependencies, + containerFilter: containerFilter, + ); + + AvailableTabStatesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.containerFilter, + }) : super.internal(); + + final ContainerFilter containerFilter; + + @override + Override overrideWith( + EquatableValue> Function( + AvailableTabStatesRef provider, + ) + create, + ) { + return ProviderOverride( + origin: this, + override: AvailableTabStatesProvider._internal( + (ref) => create(ref as AvailableTabStatesRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + containerFilter: containerFilter, + ), + ); + } + + @override + AutoDisposeProviderElement>> + createElement() { + return _AvailableTabStatesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is AvailableTabStatesProvider && + other.containerFilter == containerFilter; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, containerFilter.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin AvailableTabStatesRef + on AutoDisposeProviderRef>> { + /// The parameter `containerFilter` of this provider. + ContainerFilter get containerFilter; +} + +class _AvailableTabStatesProviderElement + extends AutoDisposeProviderElement>> + with AvailableTabStatesRef { + _AvailableTabStatesProviderElement(super.provider); + + @override + ContainerFilter get containerFilter => + (origin as AvailableTabStatesProvider).containerFilter; +} + +String _$seamlessFilteredTabEntitiesHash() => + r'0ef5c14f3fa018e3417e097759817bc871be3e0d'; + +/// See also [seamlessFilteredTabEntities]. +@ProviderFor(seamlessFilteredTabEntities) +const seamlessFilteredTabEntitiesProvider = SeamlessFilteredTabEntitiesFamily(); + +/// See also [seamlessFilteredTabEntities]. +class SeamlessFilteredTabEntitiesFamily + extends Family>> { + /// See also [seamlessFilteredTabEntities]. + const SeamlessFilteredTabEntitiesFamily(); + + /// See also [seamlessFilteredTabEntities]. + SeamlessFilteredTabEntitiesProvider call({ + required TabSearchPartition searchPartition, + required ContainerFilter containerFilter, + required bool groupTrees, + }) { + return SeamlessFilteredTabEntitiesProvider( + searchPartition: searchPartition, + containerFilter: containerFilter, + groupTrees: groupTrees, + ); + } + + @override + SeamlessFilteredTabEntitiesProvider getProviderOverride( + covariant SeamlessFilteredTabEntitiesProvider provider, + ) { + return call( + searchPartition: provider.searchPartition, + containerFilter: provider.containerFilter, + groupTrees: provider.groupTrees, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'seamlessFilteredTabEntitiesProvider'; +} + +/// See also [seamlessFilteredTabEntities]. +class SeamlessFilteredTabEntitiesProvider + extends AutoDisposeProvider>> { + /// See also [seamlessFilteredTabEntities]. + SeamlessFilteredTabEntitiesProvider({ + required TabSearchPartition searchPartition, + required ContainerFilter containerFilter, + required bool groupTrees, + }) : this._internal( + (ref) => seamlessFilteredTabEntities( + ref as SeamlessFilteredTabEntitiesRef, + searchPartition: searchPartition, + containerFilter: containerFilter, + groupTrees: groupTrees, + ), + from: seamlessFilteredTabEntitiesProvider, + name: r'seamlessFilteredTabEntitiesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$seamlessFilteredTabEntitiesHash, + dependencies: SeamlessFilteredTabEntitiesFamily._dependencies, + allTransitiveDependencies: + SeamlessFilteredTabEntitiesFamily._allTransitiveDependencies, + searchPartition: searchPartition, + containerFilter: containerFilter, + groupTrees: groupTrees, + ); + + SeamlessFilteredTabEntitiesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.searchPartition, + required this.containerFilter, + required this.groupTrees, + }) : super.internal(); + + final TabSearchPartition searchPartition; + final ContainerFilter containerFilter; + final bool groupTrees; + + @override + Override overrideWith( + EquatableValue> Function( + SeamlessFilteredTabEntitiesRef provider, + ) + create, + ) { + return ProviderOverride( + origin: this, + override: SeamlessFilteredTabEntitiesProvider._internal( + (ref) => create(ref as SeamlessFilteredTabEntitiesRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + searchPartition: searchPartition, + containerFilter: containerFilter, + groupTrees: groupTrees, + ), + ); + } + + @override + AutoDisposeProviderElement>> createElement() { + return _SeamlessFilteredTabEntitiesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SeamlessFilteredTabEntitiesProvider && + other.searchPartition == searchPartition && + other.containerFilter == containerFilter && + other.groupTrees == groupTrees; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, searchPartition.hashCode); + hash = _SystemHash.combine(hash, containerFilter.hashCode); + hash = _SystemHash.combine(hash, groupTrees.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SeamlessFilteredTabEntitiesRef + on AutoDisposeProviderRef>> { + /// The parameter `searchPartition` of this provider. + TabSearchPartition get searchPartition; + + /// The parameter `containerFilter` of this provider. + ContainerFilter get containerFilter; + + /// The parameter `groupTrees` of this provider. + bool get groupTrees; +} + +class _SeamlessFilteredTabEntitiesProviderElement + extends AutoDisposeProviderElement>> + with SeamlessFilteredTabEntitiesRef { + _SeamlessFilteredTabEntitiesProviderElement(super.provider); + + @override + TabSearchPartition get searchPartition => + (origin as SeamlessFilteredTabEntitiesProvider).searchPartition; + @override + ContainerFilter get containerFilter => + (origin as SeamlessFilteredTabEntitiesProvider).containerFilter; + @override + bool get groupTrees => + (origin as SeamlessFilteredTabEntitiesProvider).groupTrees; +} + +String _$seamlessFilteredTabPreviewsHash() => + r'c59c2819eda4f132e1a0f5f7784ddb3b5ceec817'; + +/// See also [seamlessFilteredTabPreviews]. +@ProviderFor(seamlessFilteredTabPreviews) +const seamlessFilteredTabPreviewsProvider = SeamlessFilteredTabPreviewsFamily(); + +/// See also [seamlessFilteredTabPreviews]. +class SeamlessFilteredTabPreviewsFamily + extends Family>> { + /// See also [seamlessFilteredTabPreviews]. + const SeamlessFilteredTabPreviewsFamily(); + + /// See also [seamlessFilteredTabPreviews]. + SeamlessFilteredTabPreviewsProvider call( + TabSearchPartition searchPartition, + ContainerFilter containerFilter, + ) { + return SeamlessFilteredTabPreviewsProvider( + searchPartition, + containerFilter, + ); + } + + @override + SeamlessFilteredTabPreviewsProvider getProviderOverride( + covariant SeamlessFilteredTabPreviewsProvider provider, + ) { + return call(provider.searchPartition, provider.containerFilter); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'seamlessFilteredTabPreviewsProvider'; +} + +/// See also [seamlessFilteredTabPreviews]. +class SeamlessFilteredTabPreviewsProvider + extends AutoDisposeProvider>> { + /// See also [seamlessFilteredTabPreviews]. + SeamlessFilteredTabPreviewsProvider( + TabSearchPartition searchPartition, + ContainerFilter containerFilter, + ) : this._internal( + (ref) => seamlessFilteredTabPreviews( + ref as SeamlessFilteredTabPreviewsRef, + searchPartition, + containerFilter, + ), + from: seamlessFilteredTabPreviewsProvider, + name: r'seamlessFilteredTabPreviewsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$seamlessFilteredTabPreviewsHash, + dependencies: SeamlessFilteredTabPreviewsFamily._dependencies, + allTransitiveDependencies: + SeamlessFilteredTabPreviewsFamily._allTransitiveDependencies, + searchPartition: searchPartition, + containerFilter: containerFilter, + ); + + SeamlessFilteredTabPreviewsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.searchPartition, + required this.containerFilter, + }) : super.internal(); + + final TabSearchPartition searchPartition; + final ContainerFilter containerFilter; + + @override + Override overrideWith( + EquatableValue> Function( + SeamlessFilteredTabPreviewsRef provider, + ) + create, + ) { + return ProviderOverride( + origin: this, + override: SeamlessFilteredTabPreviewsProvider._internal( + (ref) => create(ref as SeamlessFilteredTabPreviewsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + searchPartition: searchPartition, + containerFilter: containerFilter, + ), + ); + } + + @override + AutoDisposeProviderElement>> createElement() { + return _SeamlessFilteredTabPreviewsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SeamlessFilteredTabPreviewsProvider && + other.searchPartition == searchPartition && + other.containerFilter == containerFilter; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, searchPartition.hashCode); + hash = _SystemHash.combine(hash, containerFilter.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SeamlessFilteredTabPreviewsRef + on AutoDisposeProviderRef>> { + /// The parameter `searchPartition` of this provider. + TabSearchPartition get searchPartition; + + /// The parameter `containerFilter` of this provider. + ContainerFilter get containerFilter; +} + +class _SeamlessFilteredTabPreviewsProviderElement + extends AutoDisposeProviderElement>> + with SeamlessFilteredTabPreviewsRef { + _SeamlessFilteredTabPreviewsProviderElement(super.provider); + + @override + TabSearchPartition get searchPartition => + (origin as SeamlessFilteredTabPreviewsProvider).searchPartition; + @override + ContainerFilter get containerFilter => + (origin as SeamlessFilteredTabPreviewsProvider).containerFilter; +} + +String _$selectedBangTriggerHash() => + r'b0e12bc95e93d50d04f1c658230cea9f15ff0385'; + +abstract class _$SelectedBangTrigger extends BuildlessNotifier { + late final String? domain; + + String? build({String? domain}); +} + +/// See also [SelectedBangTrigger]. +@ProviderFor(SelectedBangTrigger) +const selectedBangTriggerProvider = SelectedBangTriggerFamily(); + +/// See also [SelectedBangTrigger]. +class SelectedBangTriggerFamily extends Family { + /// See also [SelectedBangTrigger]. + const SelectedBangTriggerFamily(); + + /// See also [SelectedBangTrigger]. + SelectedBangTriggerProvider call({String? domain}) { + return SelectedBangTriggerProvider(domain: domain); + } + + @override + SelectedBangTriggerProvider getProviderOverride( + covariant SelectedBangTriggerProvider provider, + ) { + return call(domain: provider.domain); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'selectedBangTriggerProvider'; +} + +/// See also [SelectedBangTrigger]. +class SelectedBangTriggerProvider + extends NotifierProviderImpl { + /// See also [SelectedBangTrigger]. + SelectedBangTriggerProvider({String? domain}) + : this._internal( + () => SelectedBangTrigger()..domain = domain, + from: selectedBangTriggerProvider, + name: r'selectedBangTriggerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedBangTriggerHash, + dependencies: SelectedBangTriggerFamily._dependencies, + allTransitiveDependencies: + SelectedBangTriggerFamily._allTransitiveDependencies, + domain: domain, + ); + + SelectedBangTriggerProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.domain, + }) : super.internal(); + + final String? domain; + + @override + String? runNotifierBuild(covariant SelectedBangTrigger notifier) { + return notifier.build(domain: domain); + } + + @override + Override overrideWith(SelectedBangTrigger Function() create) { + return ProviderOverride( + origin: this, + override: SelectedBangTriggerProvider._internal( + () => create()..domain = domain, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + domain: domain, + ), + ); + } + + @override + NotifierProviderElement createElement() { + return _SelectedBangTriggerProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SelectedBangTriggerProvider && other.domain == domain; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, domain.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SelectedBangTriggerRef on NotifierProviderRef { + /// The parameter `domain` of this provider. + String? get domain; +} + +class _SelectedBangTriggerProviderElement + extends NotifierProviderElement + with SelectedBangTriggerRef { + _SelectedBangTriggerProviderElement(super.provider); + + @override + String? get domain => (origin as SelectedBangTriggerProvider).domain; +} + +String _$selectedBangDataHash() => r'666858556efe59d2de38a04fda30e8ec227c6675'; + +abstract class _$SelectedBangData + extends BuildlessAutoDisposeNotifier { + late final String? domain; + + BangData? build({String? domain}); +} + +/// See also [SelectedBangData]. +@ProviderFor(SelectedBangData) +const selectedBangDataProvider = SelectedBangDataFamily(); + +/// See also [SelectedBangData]. +class SelectedBangDataFamily extends Family { + /// See also [SelectedBangData]. + const SelectedBangDataFamily(); + + /// See also [SelectedBangData]. + SelectedBangDataProvider call({String? domain}) { + return SelectedBangDataProvider(domain: domain); + } + + @override + SelectedBangDataProvider getProviderOverride( + covariant SelectedBangDataProvider provider, + ) { + return call(domain: provider.domain); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'selectedBangDataProvider'; +} + +/// See also [SelectedBangData]. +class SelectedBangDataProvider + extends AutoDisposeNotifierProviderImpl { + /// See also [SelectedBangData]. + SelectedBangDataProvider({String? domain}) + : this._internal( + () => SelectedBangData()..domain = domain, + from: selectedBangDataProvider, + name: r'selectedBangDataProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedBangDataHash, + dependencies: SelectedBangDataFamily._dependencies, + allTransitiveDependencies: + SelectedBangDataFamily._allTransitiveDependencies, + domain: domain, + ); + + SelectedBangDataProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.domain, + }) : super.internal(); + + final String? domain; + + @override + BangData? runNotifierBuild(covariant SelectedBangData notifier) { + return notifier.build(domain: domain); + } + + @override + Override overrideWith(SelectedBangData Function() create) { + return ProviderOverride( + origin: this, + override: SelectedBangDataProvider._internal( + () => create()..domain = domain, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + domain: domain, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement + createElement() { + return _SelectedBangDataProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SelectedBangDataProvider && other.domain == domain; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, domain.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SelectedBangDataRef on AutoDisposeNotifierProviderRef { + /// The parameter `domain` of this provider. + String? get domain; +} + +class _SelectedBangDataProviderElement + extends AutoDisposeNotifierProviderElement + with SelectedBangDataRef { + _SelectedBangDataProviderElement(super.provider); + + @override + String? get domain => (origin as SelectedBangDataProvider).domain; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/browser/domain/providers/intent.dart b/app/lib/features/geckoview/features/browser/domain/providers/intent.dart new file mode 100644 index 00000000..8b4de065 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/providers/intent.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:nullability/nullability.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:weblibre/data/models/received_intent_parameter.dart'; +import 'package:weblibre/features/app_widget/domain/services/home_widget.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart'; +import 'package:weblibre/features/share_intent/domain/services/sharing_intent.dart'; + +part 'intent.g.dart'; + +final _contentParserTransformer = + StreamTransformer.fromHandlers( + handleData: (parameter, sink) { + final parsed = parameter.content.mapNotNull( + (content) => SharedContent.parse(content), + ); + + if (parsed != null) { + sink.add(parsed); + } + }, + ); + +@Riverpod() +class EngineBoundIntentStream extends _$EngineBoundIntentStream { + late StreamController _streamController; + + @override + Stream build() { + _streamController = StreamController(); + ref.onDispose(() async { + await _streamController.close(); + }); + + final engineReady = ref.watch(engineReadyStateProvider); + if (!engineReady) { + return const Stream.empty(); + } + + final sharingItentStream = ref.watch(sharingIntentStreamProvider); + final appWidgetLaunchStream = ref.watch(appWidgetLaunchStreamProvider); + + return MergeStream([ + sharingItentStream.transform(_contentParserTransformer), + appWidgetLaunchStream.transform(_contentParserTransformer), + _streamController.stream, + ]); + } +} diff --git a/app/lib/features/geckoview/features/browser/domain/providers/intent.g.dart b/app/lib/features/geckoview/features/browser/domain/providers/intent.g.dart new file mode 100644 index 00000000..e27e8ba2 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/providers/intent.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'intent.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$engineBoundIntentStreamHash() => + r'860e39a7b608d579f7383e2199719067ad26f268'; + +/// See also [EngineBoundIntentStream]. +@ProviderFor(EngineBoundIntentStream) +final engineBoundIntentStreamProvider = + AutoDisposeStreamNotifierProvider< + EngineBoundIntentStream, + SharedContent + >.internal( + EngineBoundIntentStream.new, + name: r'engineBoundIntentStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$engineBoundIntentStreamHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$EngineBoundIntentStream = AutoDisposeStreamNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/browser/domain/providers/lifecycle.dart b/app/lib/features/geckoview/features/browser/domain/providers/lifecycle.dart new file mode 100644 index 00000000..b25e2890 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/providers/lifecycle.dart @@ -0,0 +1,18 @@ +import 'dart:ui'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'lifecycle.g.dart'; + +@Riverpod(keepAlive: true) +class BrowserViewLifecycle extends _$BrowserViewLifecycle { + // ignore: use_setters_to_change_properties + void update(AppLifecycleState? newState) { + state = newState; + } + + @override + AppLifecycleState? build() { + return null; + } +} diff --git a/app/lib/features/geckoview/features/browser/domain/providers/lifecycle.g.dart b/app/lib/features/geckoview/features/browser/domain/providers/lifecycle.g.dart new file mode 100644 index 00000000..7ae4f55d --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/providers/lifecycle.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'lifecycle.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$browserViewLifecycleHash() => + r'9082d058611aaaae268b60b8ce130150f73d453d'; + +/// See also [BrowserViewLifecycle]. +@ProviderFor(BrowserViewLifecycle) +final browserViewLifecycleProvider = + NotifierProvider.internal( + BrowserViewLifecycle.new, + name: r'browserViewLifecycleProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$browserViewLifecycleHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$BrowserViewLifecycle = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart new file mode 100644 index 00000000..de84f75b --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; + +part 'browser_addon.g.dart'; + +@Riverpod() +class BrowserAddonService extends _$BrowserAddonService { + Future getAddonXpiUrl(String guid) async { + final url = 'https://addons.mozilla.org/api/v5/addons/addon/$guid/'; + + try { + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + + // ignore: avoid_dynamic_calls + final xpiUrl = data['current_version']['file']['url'] as String; + + return Uri.parse(xpiUrl); + } else { + throw Exception('Failed to load addon data: ${response.statusCode}'); + } + } catch (e) { + throw Exception('Error fetching addon data: $e'); + } + } + + Future install(String addonGuid) async { + try { + final xpiUrl = await getAddonXpiUrl(addonGuid); + await ref.read(addonServiceProvider).installAddon(xpiUrl); + + return true; + } catch (e, s) { + logger.e('Failed installing $addonGuid', error: e, stackTrace: s); + + return false; + } + } + + @override + void build() { + return; + } +} diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart new file mode 100644 index 00000000..b26a23cb --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'browser_addon.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$browserAddonServiceHash() => + r'1dd73c322e712e1492d74f1af844d5b5cc8f81bb'; + +/// See also [BrowserAddonService]. +@ProviderFor(BrowserAddonService) +final browserAddonServiceProvider = + AutoDisposeNotifierProvider.internal( + BrowserAddonService.new, + name: r'browserAddonServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$browserAddonServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$BrowserAddonService = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_data.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_data.dart new file mode 100644 index 00000000..f9873cae --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/services/browser_data.dart @@ -0,0 +1,45 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; + +part 'browser_data.g.dart'; + +@Riverpod(keepAlive: true) +class BrowserDataService extends _$BrowserDataService { + final _service = GeckoDeleteBrowserDataService(); + + var _onStartDeleted = false; + + Future deleteDataOnEngineStart( + Set? types, + ) async { + if (!_onStartDeleted) { + _onStartDeleted = true; + return deleteData(types); + } + } + + Future deleteData(Set? types) async { + if (types != null) { + for (final type in types) { + switch (type) { + case DeleteBrowsingDataType.tabs: + await _service.deleteTabs(); + case DeleteBrowsingDataType.history: + await _service.deleteBrowsingHistory(); + case DeleteBrowsingDataType.cookies: + await _service.deleteCookiesAndSiteData(); + case DeleteBrowsingDataType.cache: + await _service.deleteCachedFiles(); + case DeleteBrowsingDataType.permissions: + await _service.deleteSitePermissions(); + case DeleteBrowsingDataType.downloads: + await _service.deleteDownloads(); + } + } + } + } + + @override + void build() {} +} diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart new file mode 100644 index 00000000..c71537d6 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'browser_data.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$browserDataServiceHash() => + r'0a00db5b143d3851f2c171f4f939940f959b67bb'; + +/// See also [BrowserDataService]. +@ProviderFor(BrowserDataService) +final browserDataServiceProvider = + NotifierProvider.internal( + BrowserDataService.new, + name: r'browserDataServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$browserDataServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$BrowserDataService = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart new file mode 100644 index 00000000..c39e6a4d --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart' show ThemeMode; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +part 'engine_settings_replication.g.dart'; + +@Riverpod(keepAlive: true) +class EngineSettingsReplicationService + extends _$EngineSettingsReplicationService { + final _service = GeckoEngineSettingsService(); + + @override + void build() { + var initialSettingsSent = false; + + ref.listen( + fireImmediately: true, + generalSettingsRepositoryProvider.select( + (settings) => settings.themeMode, + ), + (previous, next) async { + final theme = switch (next) { + ThemeMode.system => ColorScheme.system, + ThemeMode.light => ColorScheme.light, + ThemeMode.dark => ColorScheme.dark, + }; + + await _service.preferredColorScheme(theme); + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to generalSettingsRepositoryProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listen( + fireImmediately: true, + engineSettingsRepositoryProvider, + (previous, next) async { + if (initialSettingsSent && previous != null) { + if (previous.javascriptEnabled != next.javascriptEnabled) { + await _service.javascriptEnabled(next.javascriptEnabled); + } + if (previous.trackingProtectionPolicy != + next.trackingProtectionPolicy) { + await _service.trackingProtectionPolicy( + next.trackingProtectionPolicy, + ); + } + if (previous.httpsOnlyMode != next.httpsOnlyMode) { + await _service.httpsOnlyMode(next.httpsOnlyMode); + } + if (previous.globalPrivacyControlEnabled != + next.globalPrivacyControlEnabled) { + await _service.globalPrivacyControlEnabled( + next.globalPrivacyControlEnabled, + ); + } + if (previous.preferredColorScheme != next.preferredColorScheme) { + await _service.preferredColorScheme(next.preferredColorScheme); + } + if (previous.cookieBannerHandlingMode != + next.cookieBannerHandlingMode) { + await _service.cookieBannerHandlingMode( + next.cookieBannerHandlingMode, + ); + } + if (previous.cookieBannerHandlingModePrivateBrowsing != + next.cookieBannerHandlingModePrivateBrowsing) { + await _service.cookieBannerHandlingModePrivateBrowsing( + next.cookieBannerHandlingModePrivateBrowsing, + ); + } + if (previous.cookieBannerHandlingGlobalRules != + next.cookieBannerHandlingGlobalRules) { + await _service.cookieBannerHandlingGlobalRules( + next.cookieBannerHandlingGlobalRules, + ); + } + if (previous.cookieBannerHandlingGlobalRulesSubFrames != + next.cookieBannerHandlingGlobalRulesSubFrames) { + await _service.cookieBannerHandlingGlobalRulesSubFrames( + next.cookieBannerHandlingGlobalRulesSubFrames, + ); + } + if (previous.webContentIsolationStrategy != + next.webContentIsolationStrategy) { + await _service.webContentIsolationStrategy( + next.webContentIsolationStrategy, + ); + } + } else { + await _service.setDefaultSettings(next); + initialSettingsSent = true; + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to engineSettingsRepositoryProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart new file mode 100644 index 00000000..c2ba1006 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'engine_settings_replication.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$engineSettingsReplicationServiceHash() => + r'31b5b79e429bfedb9c6b57050cd2d99407914228'; + +/// See also [EngineSettingsReplicationService]. +@ProviderFor(EngineSettingsReplicationService) +final engineSettingsReplicationServiceProvider = + NotifierProvider.internal( + EngineSettingsReplicationService.new, + name: r'engineSettingsReplicationServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$engineSettingsReplicationServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$EngineSettingsReplicationService = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart b/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart new file mode 100644 index 00000000..7d4a4465 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart @@ -0,0 +1,87 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +part 'proxy_settings_replication.g.dart'; + +@Riverpod(keepAlive: true) +class ProxySettingsReplication extends _$ProxySettingsReplication { + final _service = GeckoContainerProxyService(); + + @override + void build() { + ref.listen( + fireImmediately: true, + torProxyServiceProvider.select((data) => data.valueOrNull), + (previous, next) async { + if (next != null) { + await _service.setProxyPort(next); + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to torProxyServiceProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listen( + fireImmediately: true, + containersWithCountProvider.select( + (value) => EquatableValue(value.valueOrNull), + ), + (previous, next) async { + if (next.value != null) { + for (final container in next.value!) { + if (container.metadata.contextualIdentity.isNotEmpty) { + if (container.metadata.useProxy) { + await _service.addContainerProxy( + container.metadata.contextualIdentity!, + ); + } else { + await _service.removeContainerProxy( + container.metadata.contextualIdentity!, + ); + } + } + } + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to containersWithCountProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listen( + fireImmediately: true, + generalSettingsRepositoryProvider.select( + (value) => value.proxyPrivateTabsTor, + ), + (previous, next) async { + if (next) { + await _service.addContainerProxy('private'); + } else { + await _service.removeContainerProxy('private'); + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to generalSettingsRepositoryProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart b/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart new file mode 100644 index 00000000..d8956c16 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'proxy_settings_replication.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$proxySettingsReplicationHash() => + r'4476bab659f09de0866d0b3ae10d54ccb996335d'; + +/// See also [ProxySettingsReplication]. +@ProviderFor(ProxySettingsReplication) +final proxySettingsReplicationProvider = + NotifierProvider.internal( + ProxySettingsReplication.new, + name: r'proxySettingsReplicationProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$proxySettingsReplicationHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ProxySettingsReplication = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.dart new file mode 100644 index 00000000..cc1ed6ea --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.dart @@ -0,0 +1,15 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'tree_view.g.dart'; + +@Riverpod(keepAlive: true) +class TreeViewController extends _$TreeViewController { + void toggle() { + state = !state; + } + + @override + bool build() { + return false; + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.g.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.g.dart new file mode 100644 index 00000000..b5de5332 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tree_view.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$treeViewControllerHash() => + r'53da9cc3cc89eab2fee4a3aaf4feccc5ecbfeb20'; + +/// See also [TreeViewController]. +@ProviderFor(TreeViewController) +final treeViewControllerProvider = + NotifierProvider.internal( + TreeViewController.new, + name: r'treeViewControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$treeViewControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$TreeViewController = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/qr_code.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/qr_code.dart new file mode 100644 index 00000000..5c588e82 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/qr_code.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:pretty_qr_code/pretty_qr_code.dart'; + +Future showQrCode(BuildContext context, String data) { + return showDialog( + context: context, + builder: (context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Share QR Code', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: PrettyQrView.data( + data: data, + decoration: const PrettyQrDecoration( + image: PrettyQrDecorationImage( + image: AssetImage('assets/icon/icon.png'), + ), + ), + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ], + ), + ), + ); + }, + ); +} diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart new file mode 100644 index 00000000..6a976ac7 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart @@ -0,0 +1,185 @@ +import 'package:collection/collection.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:go_router/go_router.dart'; +import 'package:graphview/GraphView.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:vector_math/vector_math_64.dart' show Vector3; +import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +class BuchheimWalkerAlgorithmWithFocus extends BuchheimWalkerAlgorithm { + bool _hasScrolled = false; + + final Size childSize; + final TransformationController transforamtionController; + final dynamic initialNodeId; + + BuchheimWalkerAlgorithmWithFocus( + super.configuration, + super.renderer, { + required this.childSize, + required this.transforamtionController, + this.initialNodeId, + }); + + @override + void setFocusedNode(Node node) { + final scale = transforamtionController.value.getMaxScaleOnAxis(); + final newPosition = + (node.position - Offset(childSize.width / 2, childSize.height / 2)) * + scale; + + transforamtionController.value = transforamtionController.value.clone() + ..setTranslation(Vector3(-newPosition.dx, -newPosition.dy, 0)); + } + + @override + Size run(Graph? graph, double shiftX, double shiftY) { + final size = super.run(graph, shiftX, shiftY); + + if (initialNodeId != null && !_hasScrolled) { + final node = graph?.nodes.firstWhereOrNull( + (element) => element.key?.value == initialNodeId, + ); + + if (node != null && node.position != Offset.zero) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setFocusedNode(node); + }); + + _hasScrolled = true; + } + } + + return size; + } +} + +class TabTreeDialog extends HookConsumerWidget { + final String tabId; + final Size childSize; + + const TabTreeDialog( + this.tabId, { + super.key, + this.childSize = const Size(200, 300), + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final transforamtionController = useTransformationController(); + + final selectedTabId = ref.watch(selectedTabProvider); + final tabs = ref.watch(tabDescendantsProvider(tabId)); + + final graph = useMemoized(() { + final graph = Graph()..isTree = true; + + if (tabs.hasValue) { + for (final MapEntry(:key, :value) in tabs.valueOrNull!.entries) { + if (value != null) { + final current = Node.Id(key); + final parent = Node.Id(value); + + graph.addEdge(parent, current); + } + } + } + + return graph; + }, [EquatableValue(tabs.valueOrNull), selectedTabId]); + + final graphAlgo = useMemoized(() { + final config = BuchheimWalkerConfiguration() + ..siblingSeparation = 100 + ..levelSeparation = 150 + ..subtreeSeparation = 150 + ..orientation = BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM; + + return BuchheimWalkerAlgorithmWithFocus( + config, + TreeEdgeRenderer(config), + childSize: childSize, + transforamtionController: transforamtionController, + initialNodeId: selectedTabId, + ); + }, [selectedTabId]); + + return Dialog.fullscreen( + child: Scaffold( + appBar: AppBar( + backgroundColor: const Color(0x44000000), + elevation: 0, + leading: IconButton( + onPressed: () { + context.pop(); + }, + icon: const Icon(Icons.close), + ), + ), + floatingActionButton: FloatingActionButton( + child: const Icon(MdiIcons.target), + onPressed: () { + final node = graph.nodes.firstWhereOrNull( + (element) => element.key?.value == selectedTabId, + ); + + if (node != null) { + graphAlgo.setFocusedNode(node); + } else { + showErrorMessage( + context, + 'The current tab is not part of this tree', + ); + } + }, + ), + extendBodyBehindAppBar: true, + body: InteractiveViewer( + transformationController: transforamtionController, + constrained: false, + boundaryMargin: const EdgeInsets.all(250), + minScale: 0.1, + maxScale: 5.0, + child: Skeletonizer( + enabled: !tabs.hasValue || tabs.valueOrNull?.isEmpty == true, + child: Skeleton.replace( + replacement: const Bone.square(), + child: GraphView( + graph: graph, + algorithm: graphAlgo, + paint: Paint() + ..color = Theme.of(context).colorScheme.outline + ..strokeWidth = 1 + ..style = PaintingStyle.stroke, + builder: (Node node) { + final id = node.key!.value as String; + return SizedBox.fromSize( + size: childSize, + child: SingleTabPreview( + tabId: id, + activeTabId: selectedTabId, + onClose: () { + context.pop(); + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + }, + ), + ); + }, + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart new file mode 100644 index 00000000..47263fad --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart @@ -0,0 +1,257 @@ +import 'dart:ui' as ui; + +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:weblibre/data/models/web_page_info.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/presentation/widgets/site_search.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/address_with_suggestions_field.dart'; +import 'package:weblibre/features/user/domain/providers.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/presentation/widgets/share_tile.dart'; +import 'package:weblibre/presentation/widgets/website_feed_tile.dart'; +import 'package:weblibre/presentation/widgets/website_title_tile.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; + +class WebPageDialog extends HookConsumerWidget { + final Uri url; + final WebPageInfo? precachedInfo; + + const WebPageDialog({required this.url, this.precachedInfo, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final incognitoEnabled = ref.watch(incognitoModeEnabledProvider); + + final availableBangsAsync = ref.watch( + bangListProvider(domain: url.host, orderMostFrequentFirst: true), + ); + + final availableBangCount = availableBangsAsync.valueOrNull?.length; + + return MediaQuery.removeViewInsets( + context: context, + removeBottom: true, + child: Dialog( + insetPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 64.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0), + child: WebsiteTitleTile(url, precachedInfo: precachedInfo), + ), + SizedBox( + //We need this to stretch the dialog, then padding from dialog is applied + width: double.maxFinite, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: AddressWithSuggestionsField( + url: url, + incognitoEnabled: incognitoEnabled, + ), + ), + ), + const SizedBox(height: 4), + const Divider(), + Flexible( + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return SingleChildScrollView( + controller: controller, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: availableBangsAsync.when( + skipLoadingOnReload: true, + data: (availableBangs) { + if (availableBangs.isEmpty) { + return const SizedBox.shrink(); + } + + return SiteSearch( + domain: url.host, + availableBangs: availableBangs, + searchInNewTab: false, + ); + }, + error: (error, stackTrace) => FailureWidget( + title: 'Could not load bangs', + exception: error, + ), + loading: () => SiteSearch( + domain: url.host, + availableBangs: const [ + // BangData( + // websiteName: 'websiteName', + // domain: 'domain', + // trigger: 'trigger', + // urlTemplate: 'urlTemplate', + // ), + ], + ), + ), + ), + if (availableBangsAsync.isLoading || + availableBangCount == null || + availableBangCount > 0) + const Divider(), + WebsiteFeedTile(url, precachedInfo: precachedInfo), + ListTile( + leading: const Icon(MdiIcons.contentCopy), + title: const Text('Copy address'), + onTap: () async { + await Clipboard.setData( + ClipboardData(text: url.toString()), + ); + if (context.mounted) { + context.pop(); + } + }, + ), + ListTile( + onTap: () async { + await ui_helper.launchUrlFeedback(context, url); + }, + leading: const Icon(Icons.open_in_browser), + title: const Text('Launch External'), + ), + ListTile( + leading: const Icon(MdiIcons.tabPlus), + title: const Text('Clone tab'), + onTap: () async { + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: url); + + if (context.mounted) { + //save reference before pop `ref` gets disposed + final repo = ref.read( + tabRepositoryProvider.notifier, + ); + + ui_helper.showTabSwitchMessage( + context, + onSwitch: () async { + await repo.selectTab(tabId); + }, + ); + + context.pop(); + } + }, + ), + ListTile( + leading: const Icon(MdiIcons.tabUnselected), + title: const Text('Clone as private tab'), + onTap: () async { + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: url, private: true); + + if (context.mounted) { + //save reference before pop `ref` gets disposed + final repo = ref.read( + tabRepositoryProvider.notifier, + ); + + ui_helper.showTabSwitchMessage( + context, + onSwitch: () async { + await repo.selectTab(tabId); + }, + ); + + context.pop(); + } + }, + ), + ShareTile( + onTap: () async { + await SharePlus.instance.share( + ShareParams(uri: url), + ); + + if (context.mounted) { + context.pop(); + } + }, + onTapQr: () async { + await showQrCode(context, url.toString()); + }, + ), + ListTile( + leading: const Icon(Icons.mobile_screen_share), + title: const Text('Share screenshot'), + onTap: () async { + final screenshot = await ref + .read(selectedTabSessionNotifierProvider) + .requestScreenshot(); + + if (screenshot != null) { + ui.decodeImageFromList(screenshot, ( + result, + ) async { + final png = await result.toByteData( + format: ui.ImageByteFormat.png, + ); + + if (png != null) { + final file = XFile.fromData( + png.buffer.asUint8List(), + mimeType: 'image/png', + ); + + await SharePlus.instance.share( + ShareParams( + files: [file], + subject: precachedInfo?.title, + ), + ); + } + }); + } + + if (context.mounted) { + context.pop(); + } + }, + ), + ], + ), + ); + }, + ), + ), + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(right: 16.0, bottom: 16.0), + child: TextButton( + onPressed: () { + context.pop(); + }, + child: const Text('Close'), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart new file mode 100644 index 00000000..3c1dce89 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -0,0 +1,420 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/core/providers/global_drop.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/data/models/drag_data.dart'; +import 'package:weblibre/extensions/media_query.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tree_view.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart'; +import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; + +class BrowserScreen extends HookConsumerWidget { + Duration get _backButtonPressTimeout => const Duration(seconds: 2); + + const BrowserScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final eventService = ref.watch(eventServiceProvider); + + final displayedSheet = ref.watch(bottomSheetControllerProvider); + final displayedOverlay = ref.watch(overlayControllerProvider); + + final lastBackButtonPress = useRef(null); + + final overlayController = useOverlayPortalController(); + + ref.listen(overlayControllerProvider, (previous, next) { + if (next != null) { + overlayController.show(); + } else { + overlayController.hide(); + } + }); + + useOnStreamChange( + eventService.longPressEvent, + onData: (event) async { + await ContextMenuRoute(event.hitResult.toJson()).push(context); + }, + ); + + return PopScope( + //We need this for BackButtonListener to work downstream + //No direct pop result will be handled here + canPop: false, + child: Theme( + data: Theme.of(context).copyWith( + bottomSheetTheme: BottomSheetThemeData( + //Remove M3 default of 640p + constraints: BoxConstraints( + maxWidth: + MediaQuery.of(context).size.width - + math.max( + MediaQuery.of(context).padding.left * 2, + MediaQuery.of(context).padding.right * 2, + ), + ), + ), + ), + child: Scaffold( + bottomNavigationBar: Consumer( + builder: (context, ref, child) { + final tabInFullScreen = ref.watch( + selectedTabStateProvider.select( + (value) => value?.isFullScreen ?? false, + ), + ); + + return Visibility( + visible: !tabInFullScreen, + child: BrowserBottomAppBar(displayedSheet: displayedSheet), + ); + }, + ), + body: DragTarget( + onMove: (details) { + ref + .read(willAcceptDropProvider.notifier) + .setData(DeleteDropData(details.data.tabId)); + }, + onLeave: (data) { + ref.read(willAcceptDropProvider.notifier).clear(); + }, + onAcceptWithDetails: (details) async { + await ref + .read(tabRepositoryProvider.notifier) + .closeTab(details.data.tabId); + }, + builder: (context, _, _) { + return OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return displayedOverlay!; + }, + child: Listener( + onPointerDown: (displayedSheet != null) + ? (_) { + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + } + : null, + child: BackButtonListener( + onBackButtonPressed: () async { + final tabState = ref.read(selectedTabStateProvider); + + final tabCount = ref.read( + tabListProvider.select((tabs) => tabs.value.length), + ); + + //Don't do anything if a child route is active + if (GoRouterState.of(context).topRoute?.name != + BrowserRoute.name) { + return false; + } + + if (displayedSheet != null) { + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + return true; + } + + if (displayedOverlay != null) { + ref.read(overlayControllerProvider.notifier).dismiss(); + return true; + } + + if (tabState?.isFullScreen == true) { + await ref + .read(selectedTabSessionNotifierProvider) + .exitFullscreen(); + return true; + } + + if (tabState?.isLoading == true) { + lastBackButtonPress.value = null; + + final controller = ref.read( + selectedTabSessionNotifierProvider, + ); + + await controller.stopLoading(); + return true; + } else if (tabState?.historyState.canGoBack == true) { + lastBackButtonPress.value = null; + + final controller = ref.read( + selectedTabSessionNotifierProvider, + ); + + await controller.goBack(); + return true; + } + + //Go router has routes to go back to + if (context.canPop()) { + return true; + } + + if (ref + .read(tabRepositoryProvider.notifier) + .hasLaunchedFromIntent(tabState?.id)) { + //Mark back as unhandled and navigator will pop + await SystemNavigator.pop(); + return false; + } + + if (lastBackButtonPress.value != null && + DateTime.now().difference( + lastBackButtonPress.value!, + ) < + _backButtonPressTimeout) { + lastBackButtonPress.value = null; + + if (tabState != null && tabCount > 1) { + await ref + .read(tabRepositoryProvider.notifier) + .closeTab(tabState.id); + return true; + } else { + //Mark back as unhandled and navigator will pop + await SystemNavigator.pop(); + return false; + } + } else { + lastBackButtonPress.value = DateTime.now(); + ui_helper.showTabBackButtonMessage( + context, + tabCount, + _backButtonPressTimeout, + ); + + return true; + } + }, + child: _BrowserView(displayedSheet: displayedSheet), + ), + ), + ); + }, + ), + floatingActionButton: ReaderAppearanceButton(), + bottomSheet: (displayedSheet != null) + ? NotificationListener( + onNotification: (notification) { + if (notification.extent <= 0.1) { + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + return true; + } else { + ref + .read(bottomSheetExtendProvider.notifier) + .add(notification.extent); + } + + return false; + }, + child: switch (displayedSheet) { + ViewTabsSheet() => _ViewTabsSheet( + key: ValueKey(displayedSheet), + maxChildSize: MediaQuery.of(context).relativeSafeArea(), + ), + final TabQaChatSheet parameter => _QaSheet( + key: ValueKey(displayedSheet), + maxChildSize: MediaQuery.of(context).relativeSafeArea(), + chatId: parameter.chatId, + ), + }, + ) + : null, + ), + ), + ); + } +} + +class _BrowserView extends StatelessWidget { + const _BrowserView({required this.displayedSheet}); + + final Sheet? displayedSheet; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + SafeArea( + child: Stack( + children: [ + const BrowserView(), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Consumer( + builder: (context, ref, child) { + final value = ref.watch( + selectedTabStateProvider.select((state) { + if (state?.isLoading == true) { + return state?.progress ?? 100; + } + + //When not loading we assumed finished + return 100; + }), + ); + + return Visibility( + visible: value < 100, + child: LinearProgressIndicator(value: value / 100), + ); + }, + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Consumer( + builder: (context, ref, child) { + final value = ref.watch( + selectedTabStateProvider.select( + (state) => EdgeInsets.only( + bottom: + (state?.isLoading == true && + state?.progress != null && + state!.progress < 100) + ? 4.0 + : 0.0, + ), + ), + ); + + return FindInPageWidget(padding: value); + }, + ), + ), + ], + ), + ), + if (displayedSheet != null) + ModalBarrier( + color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54, + ), + ], + ); + } +} + +class _QaSheet extends HookConsumerWidget { + final String chatId; + final double maxChildSize; + + const _QaSheet({super.key, required this.chatId, this.maxChildSize = 1.0}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final draggableScrollableController = useDraggableScrollableController(); + + return DraggableScrollableSheet( + controller: draggableScrollableController, + expand: false, + minChildSize: 0.1, + maxChildSize: maxChildSize, + builder: (context, scrollController) { + return ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + child: Column( + children: [ + DraggableScrollableHeader( + controller: draggableScrollableController, + child: Material( + child: SizedBox( + width: double.infinity, + child: Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 16.0), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _ViewTabsSheet extends HookConsumerWidget { + final double maxChildSize; + + const _ViewTabsSheet({super.key, this.maxChildSize = 1.0}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final draggableScrollableController = useDraggableScrollableController(); + + final treeModeEnabled = ref.watch(treeViewControllerProvider); + + return DraggableScrollableSheet( + controller: draggableScrollableController, + expand: false, + minChildSize: 0.1, + maxChildSize: maxChildSize, + builder: (context, scrollController) { + return ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + child: treeModeEnabled + ? ViewTabTreesSheetWidget( + sheetScrollController: scrollController, + onClose: () { + ref.read(bottomSheetControllerProvider.notifier).dismiss(); + }, + ) + : ViewTabsSheetWidget( + sheetScrollController: scrollController, + draggableScrollableController: draggableScrollableController, + onClose: () { + ref.read(bottomSheetControllerProvider.notifier).dismiss(); + }, + ), + ); + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/address_with_suggestions_field.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/address_with_suggestions_field.dart new file mode 100644 index 00000000..ecbf8f7e --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/address_with_suggestions_field.dart @@ -0,0 +1,97 @@ +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:nullability/nullability.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/auto_suggest_text_field.dart'; +import 'package:weblibre/utils/uri_parser.dart' as uri_parser; + +class AddressWithSuggestionsField extends HookConsumerWidget { + const AddressWithSuggestionsField({ + super.key, + required this.url, + required this.incognitoEnabled, + }); + + final Uri url; + final bool incognitoEnabled; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final addressTextController = useTextEditingController( + text: url.toString(), + ); + final addressTextFocusNode = useFocusNode(); + + final suggestion = useState(null); + final lastText = useRef(addressTextController.text); + + useListenableCallback(addressTextController, () async { + if (addressTextController.text.isNotEmpty) { + if (suggestion.value.isNotEmpty && + lastText.value.length > 1 && + addressTextController.text == + lastText.value.substring(0, lastText.value.length - 1)) { + suggestion.value = null; + addressTextController.text = lastText.value; + } else if (addressTextController.text != lastText.value) { + final result = await ref + .read(engineSuggestionsProvider.notifier) + .getAutocompleteSuggestion(addressTextController.text); + + suggestion.value = result; + } + } + + lastText.value = addressTextController.text; + }); + + return AutoSuggestTextField( + controller: addressTextController, + focusNode: addressTextFocusNode, + suggestion: suggestion.value, + enableIMEPersonalizedLearning: !incognitoEnabled, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + label: Text('Address'), + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + onTap: () { + if (!addressTextFocusNode.hasFocus) { + // Select all text when the field is tapped + addressTextController.selection = TextSelection( + baseOffset: 0, + extentOffset: addressTextController.text.length, + ); + } + }, + onSubmitted: (value) async { + if (value.isNotEmpty) { + var newUrl = uri_parser.tryParseUrl(value, eagerParsing: true); + + if (newUrl == null) { + final defaultSearchBang = await ref.read( + defaultSearchBangDataProvider.future, + ); + + newUrl = defaultSearchBang?.getTemplateUrl(value); + } + + if (newUrl != null) { + await ref + .read(tabSessionProvider(tabId: null).notifier) + .loadUrl(url: newUrl); + + if (context.mounted) { + context.pop(); + } + } + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart new file mode 100644 index 00000000..891b08da --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:text_scroll/text_scroll.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart'; + +class AppBarTitle extends HookConsumerWidget { + const AppBarTitle({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final tabState = ref.watch(selectedTabStateProvider); + + if (tabState == null) { + return const SizedBox.shrink(); + } + + final icon = useMemoized(() { + if (tabState.url.isScheme('http')) { + return Icon( + MdiIcons.lockOff, + color: Theme.of(context).colorScheme.error, + size: 14, + ); + } else if (!tabState.securityInfoState.secure) { + return Icon( + MdiIcons.lockAlert, + color: Theme.of(context).colorScheme.errorContainer, + size: 14, + ); + } else if (!tabState.isLoading) { + return const Icon(MdiIcons.lock, size: 14); + } else { + return const Icon(MdiIcons.timerSand, size: 14); + } + }, [tabState]); + + return Row( + children: [ + TabIcon(state: tabState), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Skeletonizer( + enabled: tabState.title.isEmpty, + child: Skeleton.replace( + replacement: const Padding( + padding: EdgeInsets.only(right: 4, top: 1, bottom: 1), + child: Bone.text(), + ), + child: TextScroll( + key: ValueKey(tabState.title), + tabState.title, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + // mode: TextScrollMode.bouncing, + velocity: const Velocity(pixelsPerSecond: Offset(75, 0)), + delayBefore: const Duration(milliseconds: 500), + pauseBetween: const Duration(milliseconds: 5000), + fadedBorder: true, + fadeBorderSide: FadeBorderSide.right, + fadedBorderWidth: 0.05, + intervalSpaces: 4, + numberOfReps: 2, + ), + ), + ), + Row( + children: [ + icon, + const SizedBox(width: 4), + Expanded( + child: Text( + tabState.url.authority, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart new file mode 100644 index 00000000..a034a134 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -0,0 +1,385 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:weblibre/core/providers/defaults.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart'; +import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; +import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; +import 'package:weblibre/presentation/hooks/menu_controller.dart'; +import 'package:weblibre/presentation/icons/tor_icons.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; + +class BrowserBottomAppBar extends HookConsumerWidget { + const BrowserBottomAppBar({required this.displayedSheet}); + + final Sheet? displayedSheet; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final addonService = ref.watch(addonServiceProvider); + + final tabMenuController = useMenuController(); + final trippleDotMenuController = useMenuController(); + + final selectedTabId = ref.watch(selectedTabProvider); + final isPrivateTab = ref.watch( + selectedTabStateProvider.select((state) => state?.isPrivate ?? false), + ); + + final dragStartPosition = useRef(Offset.zero); + + return BottomAppBar( + height: AppBar().preferredSize.height, + padding: EdgeInsets.zero, + child: GestureDetector( + onTap: () async { + final tabState = ref.read(selectedTabStateProvider); + + if (tabState != null) { + await WebPageRoute( + url: tabState.url.toString(), + $extra: tabState, + ).push(context); + } + }, + onLongPress: () async { + final tabState = ref.read(selectedTabStateProvider); + + if (tabState != null) { + final newUrl = await showDialog( + context: context, + builder: (context) => EditUrlDialog(initialUrl: tabState.url), + ); + + if (newUrl != null) { + await ref + .read(tabSessionProvider(tabId: null).notifier) + .loadUrl(url: newUrl); + } + } + }, + onHorizontalDragStart: (details) { + dragStartPosition.value = details.globalPosition; + }, + onHorizontalDragEnd: (details) async { + final distance = dragStartPosition.value - details.globalPosition; + + if (distance.dx.abs() > 50) { + await ref.read(tabRepositoryProvider.notifier).selectPreviousTab(); + } + }, + child: AppBar( + automaticallyImplyLeading: false, + titleSpacing: 8.0, + backgroundColor: (isPrivateTab && displayedSheet is! ViewTabsSheet) + ? const Color(0x648000D7) + : null, + title: (selectedTabId != null && displayedSheet is! ViewTabsSheet) + ? const AppBarTitle() + : null, + actions: [ + if (selectedTabId != null && displayedSheet is! ViewTabsSheet) + ReaderButton(), + TabCreationMenu( + controller: tabMenuController, + selectedTabId: selectedTabId, + child: TabsActionButton( + isActive: displayedSheet is ViewTabsSheet, + onTap: () { + if (displayedSheet case ViewTabsSheet()) { + ref.read(bottomSheetControllerProvider.notifier).dismiss(); + } else { + ref + .read(bottomSheetControllerProvider.notifier) + .show(ViewTabsSheet()); + } + }, + onLongPress: () { + if (tabMenuController.isOpen) { + tabMenuController.close(); + } else { + tabMenuController.open(); + } + }, + ), + ), + MenuAnchor( + controller: trippleDotMenuController, + builder: (context, controller, child) { + return Padding( + padding: const EdgeInsets.only(right: 4.0), + child: InkWell( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Padding( + padding: EdgeInsets.symmetric( + vertical: 15.0, + horizontal: 8.0, + ), + child: Icon(Icons.more_vert), + ), + ), + ); + }, + menuChildren: [ + Consumer( + builder: (context, childRef, child) { + final pageExtensions = childRef.watch( + webExtensionsStateProvider( + WebExtensionActionType.page, + ).select((value) => value.values.toList()), + ); + + return Wrap( + alignment: WrapAlignment.center, + children: [ + ...pageExtensions.map( + (extension) => IconButton( + onPressed: () async { + //Use parents .ref because after onPressed this consumer gets disposed already + await addonService.invokeAddonAction( + extension.extensionId, + WebExtensionActionType.page, + ); + }, + icon: ExtensionBadgeIcon(extension), + ), + ), + ], + ); + }, + ), + MenuItemButton( + onPressed: () async { + await AboutRoute().push(context); + }, + leadingIcon: const Icon(Icons.info), + child: const Text('About'), + ), + MenuItemButton( + onPressed: () async { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: ref.read(docsUriProvider)); + }, + leadingIcon: const Icon(Icons.help), + child: const Text('Help and feedback'), + ), + const Divider(), + MenuItemButton( + onPressed: () async { + await SettingsRoute().push(context); + }, + leadingIcon: const Icon(Icons.settings), + child: const Text('Settings'), + ), + Consumer( + builder: (context, ref, child) { + final browserExtensions = ref.watch( + webExtensionsStateProvider( + WebExtensionActionType.browser, + ).select((value) => value.values.toList()), + ); + + return SubmenuButton( + menuChildren: [ + ...browserExtensions.map( + (extension) => MenuItemButton( + onPressed: () async { + //Use parents .ref because after onPressed this consumer gets disposed already + await addonService.invokeAddonAction( + extension.extensionId, + WebExtensionActionType.browser, + ); + }, + leadingIcon: ExtensionBadgeIcon(extension), + child: Text(extension.title ?? ''), + ), + ), + MenuItemButton( + onPressed: () async { + await addonService.startAddonManagerActivity(); + }, + leadingIcon: const Icon(MdiIcons.puzzleEdit), + child: const Text('Addon Manager'), + ), + ], + leadingIcon: const Icon(MdiIcons.puzzle), + child: const Text('Addons'), + ); + }, + ), + MenuItemButton( + onPressed: () async { + await TorProxyRoute().push(context); + }, + leadingIcon: const Icon(TorIcons.onionAlt), + child: const Text('Tor'), + ), + const Divider(), + MenuItemButton( + onPressed: () async { + await BangCategoriesRoute().push(context); + }, + leadingIcon: const Icon(MdiIcons.exclamationThick), + child: const Text('Bangs'), + ), + MenuItemButton( + onPressed: () async { + await context.push(FeedListRoute().location); + }, + leadingIcon: const Icon(Icons.rss_feed), + child: const Text('Feeds'), + ), + const Divider(), + if (selectedTabId != null) + MenuItemButton( + onPressed: () async { + final tabState = ref.read( + tabStateProvider(selectedTabId), + ); + + if (tabState?.url case final Uri url) { + await ui_helper.launchUrlFeedback(context, url); + } + }, + leadingIcon: const Icon(Icons.open_in_browser), + child: const Text('Launch External'), + ), + if (selectedTabId != null) + MenuItemButton( + onPressed: () async { + final tabState = ref.read( + tabStateProvider(selectedTabId), + ); + + if (tabState?.url case final Uri url) { + await SharePlus.instance.share(ShareParams(uri: url)); + } + }, + leadingIcon: const Icon(Icons.share), + child: const Text('Share'), + ), + if (selectedTabId != null) const Divider(), + if (selectedTabId != null) + MenuItemButton( + onPressed: () { + ref.read(findInPageControllerProvider.notifier).show(); + }, + leadingIcon: const Icon(Icons.search), + child: const Text('Find in page'), + ), + if (selectedTabId != null) const Divider(), + if (selectedTabId != null) + MenuItemButton( + onPressed: () async { + final controller = ref.read( + tabSessionProvider(tabId: selectedTabId).notifier, + ); + + await controller.reload(); + trippleDotMenuController.close(); + }, + leadingIcon: const Icon(Icons.refresh), + child: const Text('Reload'), + ), + if (selectedTabId != null) const Divider(), + if (selectedTabId != null) + Consumer( + builder: (context, ref, child) { + final history = ref.watch( + tabStateProvider( + selectedTabId, + ).select((value) => value?.historyState), + ); + + final isLoading = ref.watch( + selectedTabStateProvider.select( + (state) => state?.isLoading ?? false, + ), + ); + + return Row( + children: [ + Expanded( + child: (history?.canGoBack == true || isLoading) + ? IconButton( + onPressed: () async { + final controller = ref.read( + tabSessionProvider( + tabId: selectedTabId, + ).notifier, + ); + + if (isLoading) { + await controller.stopLoading(); + } else { + await controller.goBack(); + } + + trippleDotMenuController.close(); + }, + icon: const Icon(Icons.arrow_back), + ) + : IconButton( + onPressed: () async { + await ref + .read(tabRepositoryProvider.notifier) + .closeTab(selectedTabId); + trippleDotMenuController.close(); + }, + icon: const Icon(Icons.close), + ), + ), + const SizedBox(height: 48, child: VerticalDivider()), + Expanded( + child: IconButton( + onPressed: (history?.canGoForward == true) + ? () async { + final controller = ref.read( + tabSessionProvider( + tabId: selectedTabId, + ).notifier, + ); + + await controller.goForward(); + trippleDotMenuController.close(); + } + : null, + icon: const Icon(Icons.arrow_forward), + ), + ), + ], + ); + }, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart new file mode 100644 index 00000000..1dde4b52 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -0,0 +1,324 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers/lifecycle.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/services/engine_settings_replication.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart'; +import 'package:weblibre/features/user/domain/repositories/cache.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/user/domain/services/local_authentication.dart'; +import 'package:weblibre/features/web_feed/domain/providers/add_dialog_blocking.dart'; +import 'package:weblibre/features/web_feed/domain/services/article_content_processor.dart'; +import 'package:weblibre/presentation/hooks/on_initialization.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +class BrowserView extends StatefulHookConsumerWidget { + final Duration screenshotPeriod; + final Duration suggestionTimeout; + final Future Function()? postInitializationStep; + + const BrowserView({ + this.screenshotPeriod = const Duration(seconds: 10), + this.suggestionTimeout = const Duration(seconds: 30), + this.postInitializationStep, + }); + + @override + ConsumerState createState() => _BrowserViewState(); +} + +class _BrowserViewState extends ConsumerState + with WidgetsBindingObserver { + Timer? _periodicScreenshotUpdate; + + //This is managed by widget state changes to resume a timer + bool _timerPaused = false; + + DateTime? _suggestionCountTime; + + Future _timerTick(Timer timer) async { + await ref + .read(selectedTabSessionNotifierProvider) + .requestScreenshot(requireImageResult: false) + .onError((error, stackTrace) { + logger.e(error, stackTrace: stackTrace); + timer.cancel(); + + return null; + }); + } + + @override + Widget build(BuildContext context) { + useOnInitialization(() async { + await ref.read(generalSettingsRepositoryProvider.notifier).fetch().then(( + settings, + ) async { + await ref + .read(browserDataServiceProvider.notifier) + .deleteDataOnEngineStart(settings.deleteBrowsingDataOnQuit); + }); + }); + + final hasTab = ref.watch( + selectedTabProvider.select((value) => value != null), + ); + + ref.listen( + selectedTabStateProvider.select( + (state) => (tabId: state?.id, isLoading: state?.isLoading), + ), + (previous, next) { + if (previous?.tabId != next.tabId || next.isLoading == true) { + _periodicScreenshotUpdate?.cancel(); + _periodicScreenshotUpdate = null; + _timerPaused = false; + + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + } + + if (next.isLoading == false && + (_periodicScreenshotUpdate?.isActive ?? false) == false) { + _timerPaused = false; + _periodicScreenshotUpdate = Timer.periodic( + widget.screenshotPeriod, + _timerTick, + ); + } + }, + ); + + ref.listen(feedRequestedProvider, (previous, next) async { + if (next.valueOrNull.mapNotNull(Uri.tryParse) case final Uri url) { + if (GoRouterState.of(context).topRoute?.name != FeedAddRoute.name) { + if (ref.read(addFeedDialogBlockingProvider.notifier).canPush(url)) { + await FeedAddRoute($extra: url).push(context); + } + } + } + }); + + return Visibility( + visible: hasTab, + replacement: SizedBox.expand(child: Container(color: Colors.grey[800])), + child: GeckoView( + preInitializationStep: () async { + await ref + .read(eventServiceProvider) + .viewReadyStateEvents + .firstWhere((state) => state == true) + .timeout( + const Duration(seconds: 3), + onTimeout: () { + logger.e( + 'Browser fragement not reported ready, trying to intitialize anyways', + ); + return true; + }, + ); + }, + postInitializationStep: () async { + await widget.postInitializationStep?.call(); + }, + ), + ); + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref + .read(browserViewLifecycleProvider.notifier) + .update(SchedulerBinding.instance.lifecycleState); + }); + + WidgetsBinding.instance.addObserver(this); + + //Initialize and register dependencies + ref.listenManual( + fireImmediately: true, + tabRepositoryProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to tabRepositoryProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listenManual( + fireImmediately: true, + selectionActionServiceProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to selectionActionServiceProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listenManual( + fireImmediately: true, + webExtensionsStateProvider(WebExtensionActionType.browser), + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to webExtensionsStateProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listenManual( + fireImmediately: true, + webExtensionsStateProvider(WebExtensionActionType.page), + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to webExtensionsStateProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listenManual( + fireImmediately: true, + cacheRepositoryProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to cacheRepositoryProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listenManual( + fireImmediately: true, + engineSettingsReplicationServiceProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to engineSettingsReplicationServiceProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listenManual( + fireImmediately: true, + proxySettingsReplicationProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to proxySettingsReplicationProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listenManual( + fireImmediately: true, + articleContentProcessorServiceProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to articleContentProcessorServiceProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + ref.read(browserViewLifecycleProvider.notifier).update(state); + + switch (state) { + case AppLifecycleState.detached: + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + if (_periodicScreenshotUpdate?.isActive == true) { + _periodicScreenshotUpdate?.cancel(); + _timerPaused = true; + } + + ref + .read(localAuthenticationServiceProvider.notifier) + .evictCacheOnBackground(); + + if (state == AppLifecycleState.paused) { + _suggestionCountTime = DateTime.now(); + } + case AppLifecycleState.resumed: + if (_timerPaused) { + _periodicScreenshotUpdate = Timer.periodic( + widget.screenshotPeriod, + _timerTick, + ); + _timerPaused = false; + } + + if (_suggestionCountTime != null && + DateTime.now().difference(_suggestionCountTime!) > + widget.suggestionTimeout) { + //Don't do anything if a child route is active + if (GoRouterState.of(context).topRoute?.name == BrowserRoute.name) { + unawaited( + showSuggestNewTabMessage( + context, + onAdd: (searchText) async { + await SearchRoute( + tabType: + ref.read(selectedTabTypeProvider) ?? TabType.regular, + searchText: searchText ?? SearchRoute.emptySearchText, + ).push(context); + }, + ), + ); + } + } + + _suggestionCountTime = null; + } + } + + @override + Future dispose() async { + WidgetsBinding.instance.removeObserver(this); + + _periodicScreenshotUpdate?.cancel(); + + super.dispose(); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart new file mode 100644 index 00000000..a233f2a8 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart @@ -0,0 +1,27 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class DraggableScrollableHeader extends StatelessWidget { + final DraggableScrollableController controller; + final Widget child; + + const DraggableScrollableHeader({ + super.key, + required this.controller, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onVerticalDragUpdate: (details) { + // Use the DraggableScrollableSheet's controller + controller.jumpTo( + min(1, controller.pixelsToSize(controller.pixels - details.delta.dy)), + ); + }, + child: child, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart new file mode 100644 index 00000000..c8ad0528 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/auto_suggest_text_field.dart'; +import 'package:weblibre/utils/form_validators.dart'; +import 'package:weblibre/utils/uri_parser.dart' as uri_parser; + +class EditUrlDialog extends HookConsumerWidget { + final Uri initialUrl; + + const EditUrlDialog({required this.initialUrl}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + final addressTextController = useTextEditingController( + text: initialUrl.toString(), + ); + + return AlertDialog( + title: const Text('Edit Address'), + content: HookBuilder( + builder: (context) { + final addressTextFocusNode = useFocusNode(); + + final suggestion = useState(null); + final lastText = useRef(addressTextController.text); + + useListenableCallback(addressTextController, () async { + if (addressTextController.text.isNotEmpty) { + if (suggestion.value.isNotEmpty && + lastText.value.length > 1 && + addressTextController.text == + lastText.value.substring(0, lastText.value.length - 1)) { + suggestion.value = null; + addressTextController.text = lastText.value; + } else if (addressTextController.text != lastText.value) { + final result = await ref + .read(engineSuggestionsProvider.notifier) + .getAutocompleteSuggestion(addressTextController.text); + + suggestion.value = result; + } + } + + lastText.value = addressTextController.text; + }); + + return Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AutoSuggestTextField( + controller: addressTextController, + focusNode: addressTextFocusNode, + suggestion: suggestion.value, + // enableIMEPersonalizedLearning: !incognitoEnabled, + maxLines: null, + keyboardType: TextInputType.url, + decoration: const InputDecoration(hintText: 'Enter URL'), + onTap: () { + if (!addressTextFocusNode.hasFocus) { + // Select all text when the field is tapped + addressTextController.selection = TextSelection( + baseOffset: 0, + extentOffset: addressTextController.text.length, + ); + } + }, + validator: (value) { + return validateUrl(value, requireAuthority: false); + }, + ), + ClipboardFillLink(controller: addressTextController), + ], + ), + ); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + if (formKey.currentState?.validate() ?? false) { + Navigator.of(context).pop( + uri_parser.tryParseUrl( + addressTextController.text, + eagerParsing: true, + ), + ); + } + }, + child: const Text('Edit'), + ), + ], + ); + } +} diff --git a/app/lib/features/search_browser/presentation/widgets/error_container.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/error_container.dart similarity index 92% rename from app/lib/features/search_browser/presentation/widgets/error_container.dart rename to app/lib/features/geckoview/features/browser/presentation/widgets/error_container.dart index a32a9c69..d4dc68fa 100644 --- a/app/lib/features/search_browser/presentation/widgets/error_container.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/error_container.dart @@ -25,9 +25,7 @@ class ErrorContainer extends StatelessWidget { color: theme.colorScheme.onErrorContainer, ), ), - Expanded( - child: content, - ), + Expanded(child: content), ], ), ); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart new file mode 100644 index 00000000..3716fb50 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/web_extension.dart'; + +class ExtensionBadgeIcon extends StatelessWidget { + final WebExtensionState state; + + const ExtensionBadgeIcon(this.state); + + @override + Widget build(BuildContext context) { + final hasBadge = state.badgeText.isNotEmpty; + + return Badge( + isLabelVisible: hasBadge, + label: hasBadge ? Text(state.badgeText!) : null, + textColor: state.badgeTextColor, + backgroundColor: state.badgeBackgroundColor, + child: RawImage(image: state.icon?.value, width: 24, height: 24), + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart new file mode 100644 index 00000000..e284fcdb --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart @@ -0,0 +1,631 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_reorderable_grid_view/widgets/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/providers/global_drop.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/data/models/drag_data.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tree_view.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; +import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/speech_to_text_button.dart'; + +class _TabSheetHeader extends HookConsumerWidget { + static const headerSize = 124.0; + + final bool treeViewEnabled; + final VoidCallback onClose; + + const _TabSheetHeader({required this.onClose, required this.treeViewEnabled}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchMode = useState(false); + final searchTextFocus = useFocusNode(); + final searchTextController = useTextEditingController(); + + final hasSearchText = useListenableSelector( + searchTextController, + () => searchTextController.text.isNotEmpty, + ); + + useListenableCallback(searchTextController, () async { + await ref + .read( + tabSearchRepositoryProvider(TabSearchPartition.preview).notifier, + ) + .addQuery(searchTextController.text); + }); + + return Material( + //Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555 + child: Align( + child: Padding( + padding: const EdgeInsets.only(right: 8.0, left: 8.0, top: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!searchMode.value) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(MdiIcons.tabSearch), + iconSize: 18, + padding: EdgeInsets.zero, + onPressed: () { + searchMode.value = true; + searchTextFocus.requestFocus(); + }, + ), + IconButton( + icon: const Icon(MdiIcons.familyTree), + selectedIcon: const Icon(MdiIcons.table), + isSelected: treeViewEnabled, + iconSize: 18, + padding: EdgeInsets.zero, + onPressed: () { + ref + .read(treeViewControllerProvider.notifier) + .toggle(); + }, + ), + ], + ), + TextButton.icon( + onPressed: () async { + final container = ref.read(selectedContainerProvider); + await ref + .read(tabDataRepositoryProvider.notifier) + .closeAllTabsByContainer(container); + + onClose(); + }, + icon: const Icon(MdiIcons.closeBoxMultiple), + label: const Text('Close All'), + ), + ], + ) + else + TextField( + controller: searchTextController, + focusNode: searchTextFocus, + // enableIMEPersonalizedLearning: !incognitoEnabled, + decoration: InputDecoration( + // border: InputBorder.none, + prefixIcon: const Icon(MdiIcons.tabSearch, size: 18), + hintText: 'Search inside tabs...', + floatingLabelBehavior: FloatingLabelBehavior.always, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!hasSearchText) + SpeechToTextButton( + onTextReceived: (data) { + searchTextController.text = data.toString(); + }, + ), + IconButton( + onPressed: () { + searchTextController.clear(); + searchTextFocus.requestFocus(); + searchMode.value = false; + }, + icon: const Icon(Icons.clear), + ), + ], + ), + ), + ), + if (!treeViewEnabled) + Padding( + padding: const EdgeInsets.only(left: 8.0, top: 12), + child: Consumer( + builder: (context, ref, child) { + final selectedContainer = ref.watch( + selectedContainerDataProvider.select( + (value) => value.valueOrNull, + ), + ); + + return ContainerChips( + selectedContainer: selectedContainer, + onSelected: (container) async { + final result = await ref + .read(selectedContainerProvider.notifier) + .setContainerId(container.id); + + if (context.mounted && + result == SetContainerResult.successHasProxy) { + await ref + .read(startProxyControllerProvider.notifier) + .maybeStartProxy(context); + } + }, + onDeleted: (container) { + ref + .read(selectedContainerProvider.notifier) + .clearContainer(); + }, + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } +} + +class ViewTabsSheetWidget extends HookConsumerWidget { + final ScrollController sheetScrollController; + final DraggableScrollableController draggableScrollableController; + final VoidCallback onClose; + + const ViewTabsSheetWidget({ + required this.onClose, + required this.sheetScrollController, + required this.draggableScrollableController, + super.key, + }); + + int _calculateCrossAxisItemCount({ + required double screenWidth, + required double horizontalPadding, + required double crossAxisSpacing, + }) { + final totalHorizontalPadding = horizontalPadding * 2; + final availableWidth = + screenWidth - totalHorizontalPadding - crossAxisSpacing; + + final crossAxisCount = availableWidth ~/ 180.0; + + return crossAxisCount; + } + + double _calculateItemHeight({ + required double screenWidth, + required double childAspectRatio, + required double horizontalPadding, + required double mainAxisSpacing, + required double crossAxisSpacing, + required int crossAxisCount, + }) { + final totalHorizontalPadding = horizontalPadding * 2; + final totalCrossAxisSpacing = crossAxisSpacing * (crossAxisCount - 1); + final availableWidth = + screenWidth - totalHorizontalPadding - totalCrossAxisSpacing; + final itemWidth = availableWidth / crossAxisCount; + final itemHeight = itemWidth / childAspectRatio; + final totalItemHeight = itemHeight + mainAxisSpacing; + + return totalItemHeight; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Stack( + alignment: Alignment.bottomRight, + children: [ + Column( + children: [ + DraggableScrollableHeader( + controller: draggableScrollableController, + child: _TabSheetHeader(onClose: onClose, treeViewEnabled: false), + ), + Expanded( + child: HookConsumer( + builder: (context, ref, child) { + final container = ref.watch(selectedContainerProvider); + + final filteredTabIds = ref.watch( + seamlessFilteredTabEntitiesProvider( + searchPartition: TabSearchPartition.preview, + // ignore: document_ignores using fast equatable + // ignore: provider_parameters + containerFilter: ContainerFilterById( + containerId: container, + ), + groupTrees: false, + ), + ); + + final activeTab = ref.watch(selectedTabProvider); + + final crossAxisCount = useMemoized( + () { + final calculatedCount = _calculateCrossAxisItemCount( + screenWidth: MediaQuery.of(context).size.width, + horizontalPadding: 4.0, + crossAxisSpacing: 8.0, + ); + + return math.max( + math.min(calculatedCount, filteredTabIds.value.length), + 2, + ); + }, + [ + MediaQuery.of(context).size.width, + filteredTabIds.value.length, + ], + ); + + final itemHeight = useMemoized( + () => _calculateItemHeight( + screenWidth: MediaQuery.of(context).size.width, + childAspectRatio: 0.75, + horizontalPadding: 4.0, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + ), + [MediaQuery.of(context).size.width, crossAxisCount], + ); + + useEffect(() { + final index = filteredTabIds.value.indexWhere( + (entity) => entity.tabId == activeTab, + ); + + if (index > -1) { + final offset = (index ~/ 2) * itemHeight; + + if (offset != sheetScrollController.offset) { + unawaited( + sheetScrollController.animateTo( + offset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + ); + } + } + + return null; + }, [filteredTabIds, activeTab]); + + final tabs = useMemoized(() { + return filteredTabIds.value + .whereType() + .mapIndexed((index, entity) { + final child = Consumer( + child: SingleTabPreview( + tabId: entity.tabId, + activeTabId: activeTab, + onClose: onClose, + ), + builder: (context, ref, child) { + final dragData = ref.watch( + willAcceptDropProvider.select((value) { + final dragTabId = switch (value) { + ContainerDropData() => value.tabId, + DeleteDropData() => value.tabId, + null => null, + }; + + return (dragTabId == entity.tabId) + ? value + : null; + }), + ); + + return switch (dragData) { + ContainerDropData() => Opacity( + opacity: 0.3, + child: Transform.scale( + scale: 0.9, + child: child, + ), + ), + DeleteDropData() => Opacity( + opacity: 0.3, + child: ColorFiltered( + colorFilter: const ColorFilter.mode( + Colors.red, + BlendMode.modulate, + ), + child: child, + ), + ), + null => child!, + }; + }, + ); + + return CustomDraggable( + key: Key(entity.tabId), + data: TabDragData(entity.tabId), + child: child, + ); + }) + .toList(); + }, [filteredTabIds, activeTab]); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: ReorderableBuilder( + //Rebuild when cross axis count changes + key: ValueKey(crossAxisCount), + scrollController: sheetScrollController, + onDragStarted: (index) { + ref.read(willAcceptDropProvider.notifier).clear(); + }, + onReorderPositions: (positions) async { + assert( + positions.length == 1, + 'Not ready for multiple reorders', + ); + + final oldIndex = positions.first.oldIndex; + final newIndex = positions.first.newIndex; + + final containerRepository = ref.read( + containerRepositoryProvider.notifier, + ); + + final tabId = filteredTabIds.value[oldIndex].tabId; + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .containerTabId(tabId); + + final String key; + if (newIndex <= 0) { + key = await containerRepository.getLeadingOrderKey( + containerId, + ); + } else if (newIndex >= + filteredTabIds.value.length - 1) { + key = await containerRepository.getTrailingOrderKey( + containerId, + ); + } else { + final orderAfterIndex = newIndex; + key = await containerRepository.getOrderKeyAfterTab( + filteredTabIds.value[orderAfterIndex].tabId, + containerId, + ); + } + + await ref + .read(tabDataRepositoryProvider.notifier) + .assignOrderKey(tabId, key); + }, + builder: (children) { + return GridView.builder( + controller: sheetScrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + //Sync values for itemHeight calculation _calculateItemHeight + childAspectRatio: 0.75, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + ), + itemCount: children.length, + itemBuilder: (context, index) => children[index], + ); + }, + children: tabs, + ), + ); + }, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only( + top: _TabSheetHeader.headerSize + 4, + right: 4, + ), + child: FloatingActionButton.small( + onPressed: () async { + await SearchRoute( + tabType: ref.read(selectedTabTypeProvider) ?? TabType.regular, + ).push(context); + + onClose(); + }, + child: const Icon(Icons.add), + ), + ), + ], + ); + } +} + +class ViewTabTreesSheetWidget extends HookConsumerWidget { + final ScrollController sheetScrollController; + final VoidCallback onClose; + + const ViewTabTreesSheetWidget({ + required this.onClose, + required this.sheetScrollController, + super.key, + }); + + int _calculateCrossAxisItemCount({ + required double screenWidth, + required double horizontalPadding, + required double crossAxisSpacing, + }) { + final totalHorizontalPadding = horizontalPadding * 2; + final availableWidth = + screenWidth - totalHorizontalPadding - crossAxisSpacing; + + final crossAxisCount = availableWidth ~/ 180.0; + + return crossAxisCount; + } + + Size _calculateItemSize({ + required double screenWidth, + required double childAspectRatio, + required double horizontalPadding, + required double crossAxisSpacing, + required int crossAxisCount, + }) { + final totalHorizontalPadding = horizontalPadding * 2; + final totalCrossAxisSpacing = crossAxisSpacing * (crossAxisCount - 1); + final availableWidth = + screenWidth - totalHorizontalPadding - totalCrossAxisSpacing; + final itemWidth = availableWidth / crossAxisCount; + final itemHeight = itemWidth / childAspectRatio; + + return Size(itemWidth, itemHeight); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Stack( + alignment: Alignment.bottomRight, + children: [ + Column( + children: [ + _TabSheetHeader(onClose: onClose, treeViewEnabled: true), + Expanded( + child: HookConsumer( + builder: (context, ref, child) { + final filteredTabIds = ref.watch( + seamlessFilteredTabEntitiesProvider( + searchPartition: TabSearchPartition.preview, + // ignore: document_ignores using fast equatable + // ignore: provider_parameters + containerFilter: ContainerFilterDisabled(), + groupTrees: true, + ), + ); + + final activeTab = ref.watch(selectedTabProvider); + + final crossAxisCount = useMemoized( + () { + final calculatedCount = _calculateCrossAxisItemCount( + screenWidth: MediaQuery.of(context).size.width, + horizontalPadding: 4.0, + crossAxisSpacing: 8.0, + ); + + return math.max( + math.min(calculatedCount, filteredTabIds.value.length), + 2, + ); + }, + [ + MediaQuery.of(context).size.width, + filteredTabIds.value.length, + ], + ); + + final itemSize = useMemoized( + () => + _calculateItemSize( + screenWidth: MediaQuery.of(context).size.width, + childAspectRatio: 0.75, + horizontalPadding: 4.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + ) + + //mainAxisSpacing + const Offset(0, 8.0), + [MediaQuery.of(context).size.width, crossAxisCount], + ); + + useEffect(() { + final index = filteredTabIds.value.indexWhere( + (entity) => entity.tabId == activeTab, + ); + + if (index > -1) { + final offset = (index ~/ 2) * itemSize.height; + + if (offset != sheetScrollController.offset) { + unawaited( + sheetScrollController.animateTo( + offset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + ); + } + } + + return null; + }, [filteredTabIds, activeTab]); + + final tabs = useMemoized(() { + return filteredTabIds.value.whereType().map(( + entity, + ) { + return TabTreePreview( + entity: entity, + activeTabId: activeTab, + onClose: onClose, + stackPadding: const Offset(8, 8), + ); + }).toList(); + }, [filteredTabIds, activeTab]); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: GridView.builder( + controller: sheetScrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + //Sync values for itemHeight calculation _calculateItemHeight + childAspectRatio: 0.75, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + ), + itemCount: tabs.length, + itemBuilder: (context, index) => tabs[index], + ), + ); + }, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only( + top: _TabSheetHeader.headerSize + 4, + right: 4, + ), + child: FloatingActionButton.small( + onPressed: () async { + await SearchRoute( + tabType: ref.read(selectedTabTypeProvider) ?? TabType.regular, + ).push(context); + + onClose(); + }, + child: const Icon(Icons.add), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart new file mode 100644 index 00000000..bc90755f --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +class TabCreationMenu extends HookConsumerWidget { + final Widget child; + final MenuController controller; + final String? selectedTabId; + + const TabCreationMenu({ + super.key, + required this.child, + required this.controller, + required this.selectedTabId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final createChildTabsOption = ref.watch( + generalSettingsRepositoryProvider.select( + (value) => value.createChildTabsOption, + ), + ); + + return MenuAnchor( + controller: controller, + builder: (context, controller, child) { + return child!; + }, + menuChildren: [ + if (selectedTabId != null) ...[ + MenuItemButton( + onPressed: () async { + await ref + .read(tabRepositoryProvider.notifier) + .closeTab(selectedTabId!); + }, + leadingIcon: const Icon(Icons.close), + child: const Text('Close Tab'), + ), + const Divider(), + ], + MenuItemButton( + onPressed: () async { + await const SearchRoute(tabType: TabType.regular).push(context); + }, + leadingIcon: const Icon(MdiIcons.tabPlus), + child: const Text('Add Regular Tab'), + ), + MenuItemButton( + onPressed: () async { + await const SearchRoute(tabType: TabType.private).push(context); + }, + leadingIcon: const Icon(MdiIcons.tabUnselected), + child: const Text('Add Private Tab'), + ), + if (createChildTabsOption) + MenuItemButton( + onPressed: () async { + await const SearchRoute(tabType: TabType.child).push(context); + }, + leadingIcon: const Icon(MdiIcons.fileTree), + child: const Text('Add Child Tab'), + ), + ], + child: child, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart new file mode 100644 index 00000000..4e659f3a --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/domain/services/generic_website.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/presentation/hooks/cached_future.dart'; + +class TabIcon extends HookConsumerWidget { + final TabState state; + + final double iconSize; + + const TabIcon({super.key, required this.state, this.iconSize = 16}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final icon = useCachedFuture(() async { + if (state.icon != null) { + return state.icon!.value; + } + + final icon = await ref + .read(genericWebsiteServiceProvider.notifier) + .getCachedIcon(state.url); + + return icon?.image.value; + }, [state.icon, state.url]); + + return Skeletonizer( + enabled: + icon.connectionState != ConnectionState.done && icon.data == null, + child: Skeleton.replace( + replacement: Bone.icon(size: iconSize), + child: RepaintBoundary( + child: RawImage(image: icon.data, height: iconSize, width: iconSize), + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart new file mode 100644 index 00000000..babd9486 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart @@ -0,0 +1,391 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/presentation/hooks/menu_controller.dart'; + +class _TabBox extends StatelessWidget { + final bool isActive; + final bool isPrivate; + final Widget? child; + + const _TabBox({required this.isActive, required this.isPrivate, this.child}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + decoration: BoxDecoration( + border: Border.all( + color: isActive ? colorScheme.primary : colorScheme.outline, + width: isActive ? 2.0 : 1.0, + ), + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + ), + child: Material( + color: isPrivate + ? const Color(0xFF25003E) + : colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(14.0)), + child: child, + ), + ); + } +} + +class TabPreview extends HookWidget { + final TabState tab; + final bool isActive; + + final VoidCallback? onTap; + final VoidCallback? onDoubleTap; + final VoidCallback? onLongPress; + final VoidCallback? onDelete; + final void Function(String host)? onDeleteAll; + + const TabPreview({ + required this.tab, + required this.isActive, + this.onTap, + this.onDoubleTap, + this.onLongPress, + this.onDelete, + this.onDeleteAll, + super.key, + }); + @override + Widget build(BuildContext context) { + final extendedDeleteMenuController = useMenuController(); + + return _TabBox( + isActive: isActive, + isPrivate: tab.isPrivate, + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(14.0)), + onTap: onTap, + onDoubleTap: onDoubleTap, + onLongPress: onLongPress, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 6.0, top: 2.0), + child: Text( + overflow: TextOverflow.ellipsis, + tab.title, + maxLines: 2, + style: tab.isPrivate + ? const TextStyle(color: Colors.white) + : null, + ), + ), + ), + if (onDelete != null || onDeleteAll != null) + MenuAnchor( + controller: extendedDeleteMenuController, + builder: (context, controller, child) { + return child!; + }, + menuChildren: [ + ?onDeleteAll.mapNotNull( + (p0) => MenuItemButton( + onPressed: () { + p0(tab.url.host); + }, + + leadingIcon: const Icon(MdiIcons.closeBoxMultiple), + child: Text('Close all from ${tab.url.host}'), + ), + ), + ], + child: IconButton( + visualDensity: const VisualDensity( + horizontal: -4.0, + vertical: -4.0, + ), + onPressed: onDelete, + onLongPress: () { + if (extendedDeleteMenuController.isOpen) { + extendedDeleteMenuController.close(); + } else { + extendedDeleteMenuController.open(); + } + }, + icon: const Icon(Icons.close), + ), + ), + ], + ), + Row( + children: [ + const SizedBox(width: 6.0), + TabIcon(state: tab), + const SizedBox(width: 6.0), + Expanded( + child: Text( + tab.url.authority, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tab.isPrivate ? Colors.white : null, + ), + ), + ), + if (tab.isPrivate) ...[ + const SizedBox(width: 6.0), + const SizedBox( + height: 16, + width: 24, + child: Stack( + fit: StackFit.expand, + children: [ + Positioned( + top: -4, + child: Icon( + MdiIcons.dominoMask, + color: Color(0xFF8000D7), + ), + ), + ], + ), + ), + ], + const SizedBox(width: 8.0), + ], + ), + const SizedBox(height: 6), + if (tab.thumbnail != null) + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(14.0), + bottomRight: Radius.circular(14.0), + ), + child: SizedBox( + width: double.infinity, + child: RepaintBoundary( + child: RawImage( + image: tab.thumbnail!.value, + fit: BoxFit.fitWidth, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class SingleTabPreview extends HookConsumerWidget { + final String tabId; + final String? activeTabId; + + final void Function() onClose; + + SingleTabPreview({ + required this.tabId, + required this.activeTabId, + required this.onClose, + }) : super(key: ValueKey(tabId)); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tab = ref.watch(tabStateProvider(tabId)); + + if (tab == null) { + return const SizedBox.shrink(); + } + + return TabPreview( + tab: tab, + isActive: tabId == activeTabId, + onTap: () async { + if (tabId != activeTabId) { + //Close first to avoid rebuilds + onClose(); + await ref.read(tabRepositoryProvider.notifier).selectTab(tab.id); + } else { + onClose(); + } + }, + onDeleteAll: (host) async { + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .containerTabId(tab.id); + + await ref + .read(tabDataRepositoryProvider.notifier) + .closeAllTabsByHost(containerId, host); + }, + // onDoubleTap: () { + // ref.read(overlayDialogControllerProvider.notifier).show( + // TabActionDialog( + // initialTab: tab, + // onDismiss: + // ref.read(overlayDialogControllerProvider.notifier).dismiss, + // ), + // ); + // }, + onDelete: () async { + await ref.read(tabRepositoryProvider.notifier).closeTab(tab.id); + }, + ); + } +} + +class TabTreePreview extends HookConsumerWidget { + final TabTreeEntity entity; + final String? activeTabId; + + final Offset stackPadding; + + final void Function() onClose; + + TabTreePreview({ + required this.entity, + required this.activeTabId, + required this.onClose, + required this.stackPadding, + }) : super(key: ValueKey(entity)); + + Widget _addPadding(int stackCount, int totalCount, Widget child) { + final topPadding = stackPadding * stackCount.toDouble(); + final bottomPadding = stackPadding * (totalCount - stackCount).toDouble(); + + return Positioned( + top: topPadding.dy, + left: topPadding.dx, + right: bottomPadding.dx, + bottom: bottomPadding.dy, + child: child, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tab = ref.watch(tabStateProvider(entity.tabId)); + + if (tab == null) { + return const SizedBox.shrink(); + } + + final stackCount = math.min(entity.totalTabs, 3) - 1; + + return Stack( + children: [ + for (var index = 0; index < stackCount; index++) + _addPadding( + index, + stackCount, + _TabBox( + isActive: entity.tabId == activeTabId, + isPrivate: tab.isPrivate, + ), + ), + _addPadding( + stackCount, + stackCount, + Badge.count( + isLabelVisible: entity.totalTabs > 1, + count: entity.totalTabs, + alignment: AlignmentDirectional.bottomEnd, + offset: const Offset(-8, -24), + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + textColor: Theme.of(context).colorScheme.onPrimaryContainer, + child: TabPreview( + tab: tab, + isActive: entity.tabId == activeTabId, + onLongPress: () async { + if (entity.tabId != activeTabId) { + //Close first to avoid rebuilds + onClose(); + await ref + .read(tabRepositoryProvider.notifier) + .selectTab(tab.id); + } else { + onClose(); + } + }, + onTap: () async { + if (entity.totalTabs > 1) { + await TabTreeRoute(entity.rootId).push(context); + } + }, + // onDeleteAll: (host) async { + // final containerId = await ref + // .read(tabDataRepositoryProvider.notifier) + // .containerTabId(tab.id); + + // await ref + // .read(tabDataRepositoryProvider.notifier) + // .closeAllTabsByHost(containerId, host); + // }, + // onDoubleTap: () { + // ref.read(overlayDialogControllerProvider.notifier).show( + // TabActionDialog( + // initialTab: tab, + // onDismiss: + // ref.read(overlayDialogControllerProvider.notifier).dismiss, + // ), + // ); + // }, + onDelete: () async { + final result = (entity.totalTabs > 1) + ? await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Close Tab Tree'), + content: Text( + 'Are you sure you want to close the entire tree containing ${entity.totalTabs} tabs?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Close'), + ), + ], + ); + }, + ) + : true; + + if (result == true) { + final tabs = await ref.read( + tabDescendantsProvider(entity.rootId).future, + ); + await ref + .read(tabRepositoryProvider.notifier) + .closeTabs(tabs.keys.toList()); + } + }, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart similarity index 70% rename from app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart rename to app/lib/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart index 71e8629a..36eec540 100644 --- a/app/lib/features/search_browser/presentation/widgets/tabs_action_button.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/web_view/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; class TabsActionButton extends HookConsumerWidget { final bool isActive; - final VoidCallback onTap; + final VoidCallback? onTap; + final VoidCallback? onDoubleTap; + final VoidCallback? onLongPress; const TabsActionButton({ - required this.onTap, + this.onTap, + this.onDoubleTap, + this.onLongPress, this.isActive = false, super.key, }); @@ -16,16 +20,16 @@ class TabsActionButton extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final tabCount = - ref.watch(activeTabsProvider.select((tabs) => tabs.length)); + final tabCount = ref.watch( + tabListProvider.select((tabs) => tabs.value.length), + ); return InkWell( onTap: onTap, + onDoubleTap: onDoubleTap, + onLongPress: onLongPress, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 15.0, - ), + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 15.0), child: Container( decoration: BoxDecoration( border: Border.all( diff --git a/app/lib/features/geckoview/features/contextmenu/domain/converters/hit_result_converter.dart b/app/lib/features/geckoview/features/contextmenu/domain/converters/hit_result_converter.dart new file mode 100644 index 00000000..48c15885 --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/domain/converters/hit_result_converter.dart @@ -0,0 +1,45 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; + +class HitResultConverter { + const HitResultConverter(); + + Map encode(HitResult input) { + final (type, data) = switch (input) { + final UnknownHitResult result => (result.runtimeType, result.encode()), + final ImageHitResult result => (result.runtimeType, result.encode()), + final VideoHitResult result => (result.runtimeType, result.encode()), + final AudioHitResult result => (result.runtimeType, result.encode()), + final ImageSrcHitResult result => (result.runtimeType, result.encode()), + final PhoneHitResult result => (result.runtimeType, result.encode()), + final EmailHitResult result => (result.runtimeType, result.encode()), + final GeoHitResult result => (result.runtimeType, result.encode()), + }; + + return {'type': type.toString(), 'data': data}; + } + + HitResult decode(Map input) { + final {'type': String type, 'data': List data} = input; + + switch (type) { + case 'UnknownHitResult': + return UnknownHitResult.decode(data); + case 'ImageHitResult': + return ImageHitResult.decode(data); + case 'VideoHitResult': + return VideoHitResult.decode(data); + case 'AudioHitResult': + return AudioHitResult.decode(data); + case 'ImageSrcHitResult': + return ImageSrcHitResult.decode(data); + case 'PhoneHitResult': + return PhoneHitResult.decode(data); + case 'EmailHitResult': + return EmailHitResult.decode(data); + case 'GeoHitResult': + return GeoHitResult.decode(data); + default: + throw FormatException('Unknown HitResult type: $type'); + } + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart b/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart new file mode 100644 index 00000000..f1034cfd --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/domain/converters/hit_result_converter.dart'; + +extension HitResultJson on HitResult { + String toJson() { + const codec = HitResultConverter(); + return jsonEncode(codec.encode(this)); + } + + static HitResult fromJson(String json) { + const codec = HitResultConverter(); + return codec.decode(jsonDecode(json) as Map); + } +} + +extension HitResultX on HitResult { + Uri? tryGetLink() { + final uri = switch (this) { + UnknownHitResult(src: final src) => src, + ImageHitResult(src: final src) => src, + VideoHitResult(src: final src) => src, + AudioHitResult(src: final src) => src, + ImageSrcHitResult(src: final src) => src, + PhoneHitResult(src: final src) => src, + EmailHitResult(src: final src) => src, + GeoHitResult(src: final src) => src, + }; + + return Uri.tryParse(uri); + } + + String getTitle() { + const maxTitleLength = 2500; + + return switch (this) { + UnknownHitResult(src: final src) => src, + ImageSrcHitResult(uri: final uri) => uri, + ImageHitResult(src: final src, title: final title) => + title.isEmpty ? (src.length > maxTitleLength ? 'image' : src) : title!, + VideoHitResult(src: final src, title: final title) => + (title.isEmpty) ? src : title!, + AudioHitResult(src: final src, title: final title) => + (title.isEmpty) ? src : title!, + _ => 'about:blank', + }; + } + + bool get hasSrc => tryGetLink() != null; + + bool isImage() { + return (this is ImageHitResult || this is ImageSrcHitResult) && hasSrc; + } + + bool isVideoAudio() { + return (this is VideoHitResult || this is AudioHitResult) && hasSrc; + } + + bool isFile() { + return this is UnknownHitResult && hasSrc; + } + + bool isUri() { + return (this is UnknownHitResult && hasSrc) || this is ImageSrcHitResult; + } + + bool isHttpLink() { + return isUri() && + switch (tryGetLink()?.scheme) { + 'http' => true, + 'https' => true, + _ => false, + }; + } + + bool isIntent() { + return this is UnknownHitResult && + hasSrc && + tryGetLink()?.scheme == 'intent'; + } + + bool isMailto() { + return this is UnknownHitResult && + hasSrc && + tryGetLink()?.scheme == 'mailto'; + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_email.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_email.dart new file mode 100644 index 00000000..13ec88c4 --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_email.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; + +class CopyEmail extends HookConsumerWidget { + final HitResult hitResult; + + const CopyEmail({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isMailto(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.emailMultiple), + title: const Text('Share email address'), + onTap: () async { + final email = hitResult.tryGetLink().mapNotNull((url) => url.host); + if (email != null) { + await Clipboard.setData(ClipboardData(text: email)); + + if (context.mounted) { + context.pop(); + } + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart new file mode 100644 index 00000000..29703d58 --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; + +class CopyImage extends HookConsumerWidget { + final HitResult hitResult; + + const CopyImage({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isImage(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.imageMultiple), + title: const Text('Copy image'), + onTap: () async { + final currentTab = ref.read(selectedTabStateProvider); + final url = hitResult.tryGetLink(); + + if (currentTab != null && url != null) { + await GeckoDownloadsService().copyInternetResource( + currentTab.id, + url: url, + isPrivate: currentTab.isPrivate, + referrerUrl: currentTab.url, + ); + + if (context.mounted) { + context.pop(); + } + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_image_location.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_image_location.dart new file mode 100644 index 00000000..0bbbbb0f --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_image_location.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; + +class CopyImageLocation extends HookConsumerWidget { + final HitResult hitResult; + + const CopyImageLocation({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isImage(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.imageMarker), + title: const Text('Copy image location'), + onTap: () async { + await hitResult.tryGetLink().mapNotNull((link) async { + await Clipboard.setData(ClipboardData(text: link.toString())); + + if (context.mounted) { + context.pop(); + } + }); + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_link.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_link.dart new file mode 100644 index 00000000..1a681b3c --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_link.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; + +class CopyLink extends HookConsumerWidget { + final HitResult hitResult; + + const CopyLink({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isUri() || hitResult.isImage() || hitResult.isVideoAudio(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.contentCopy), + title: const Text('Copy link'), + onTap: () async { + await hitResult.tryGetLink().mapNotNull((link) async { + await Clipboard.setData(ClipboardData(text: link.toString())); + + if (context.mounted) { + context.pop(); + } + }); + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart new file mode 100644 index 00000000..56bed872 --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +class LaunchExternal extends HookConsumerWidget { + final HitResult hitResult; + + const LaunchExternal({super.key, required this.hitResult}); + + static Future isSupported(HitResult hitResult) async { + return hitResult.tryGetLink().mapNotNull((url) => canLaunchUrl(url)) ?? + false; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(Icons.open_in_browser), + title: const Text('Launch External'), + onTap: () async { + await hitResult.tryGetLink().mapNotNull((url) async { + await launchUrlFeedback(context, url); + + if (context.mounted) { + context.pop(); + } + }); + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart new file mode 100644 index 00000000..cc5214b3 --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +class OpenImageInNewTab extends HookConsumerWidget { + final HitResult hitResult; + + const OpenImageInNewTab({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isImage(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.tabPlus), + title: const Text('Open image in new tab'), + onTap: () async { + final currentTab = ref.read(selectedTabStateProvider); + + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: hitResult.tryGetLink(), + parentId: currentTab?.id, + selectTab: false, + private: currentTab?.isPrivate ?? false, + ); + + if (context.mounted) { + //save reference before pop `ref` gets disposed + final repo = ref.read(tabRepositoryProvider.notifier); + + showTabSwitchMessage( + context, + onSwitch: () async { + await repo.selectTab(tabId); + }, + ); + + context.pop(); + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart new file mode 100644 index 00000000..c422e58e --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +class OpenInNewTab extends HookConsumerWidget { + final HitResult hitResult; + + const OpenInNewTab({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isHttpLink(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.tabPlus), + title: const Text('Open in new tab'), + onTap: () async { + final currentTab = ref.read(selectedTabStateProvider); + + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: hitResult.tryGetLink(), + parentId: currentTab?.id, + selectTab: false, + private: currentTab?.isPrivate ?? false, + ); + + if (context.mounted) { + //save reference before pop `ref` gets disposed + final repo = ref.read(tabRepositoryProvider.notifier); + + showTabSwitchMessage( + context, + onSwitch: () async { + await repo.selectTab(tabId); + }, + ); + + context.pop(); + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart new file mode 100644 index 00000000..ed7106fe --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; + +class SaveFile extends HookConsumerWidget { + final HitResult hitResult; + + const SaveFile({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isVideoAudio() || hitResult.isFile(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.fileDownload), + title: const Text('Save file'), + onTap: () async { + final currentTab = ref.read(selectedTabStateProvider); + final url = hitResult.tryGetLink(); + + if (currentTab != null && url != null) { + await GeckoDownloadsService().requestDownload( + currentTab.id, + url: url, + skipConfirmation: true, + isPrivate: currentTab.isPrivate, + referrerUrl: currentTab.url, + ); + + if (context.mounted) { + context.pop(); + } + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart new file mode 100644 index 00000000..9b2065e8 --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; + +class SaveImage extends HookConsumerWidget { + final HitResult hitResult; + + const SaveImage({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isImage(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.imageMove), + title: const Text('Save image'), + onTap: () async { + final currentTab = ref.read(selectedTabStateProvider); + final url = hitResult.tryGetLink(); + + if (currentTab != null && url != null) { + await GeckoDownloadsService().requestDownload( + currentTab.id, + url: url, + skipConfirmation: true, + isPrivate: currentTab.isPrivate, + referrerUrl: currentTab.url, + ); + + if (context.mounted) { + context.pop(); + } + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_email.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_email.dart new file mode 100644 index 00000000..863bfbe6 --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_email.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; + +class ShareEmail extends HookConsumerWidget { + final HitResult hitResult; + + const ShareEmail({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isMailto(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.emailArrowRight), + title: const Text('Share email address'), + onTap: () async { + final email = hitResult.tryGetLink().mapNotNull((url) => url.path); + if (email != null) { + await SharePlus.instance.share(ShareParams(text: email)); + + if (context.mounted) { + context.pop(); + } + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_image.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_image.dart new file mode 100644 index 00000000..6ecb6898 --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_image.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; + +class ShareImage extends HookConsumerWidget { + final HitResult hitResult; + + const ShareImage({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isImage(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.imagePlus), + title: const Text('Share image'), + onTap: () async { + final currentTab = ref.read(selectedTabStateProvider); + final url = hitResult.tryGetLink(); + + if (currentTab != null && url != null) { + await GeckoDownloadsService().shareInternetResource( + currentTab.id, + url: url, + isPrivate: currentTab.isPrivate, + referrerUrl: currentTab.url, + ); + + if (context.mounted) { + context.pop(); + } + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_link.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_link.dart new file mode 100644 index 00000000..605b6c23 --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_link.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/presentation/widgets/share_tile.dart'; + +class ShareLink extends HookConsumerWidget { + final HitResult hitResult; + + const ShareLink({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isUri() || hitResult.isImage() || hitResult.isVideoAudio(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final link = hitResult.tryGetLink(); + + return ShareTile( + onTap: () async { + await link.mapNotNull( + (url) => SharePlus.instance.share(ShareParams(uri: url)), + ); + + if (context.mounted) { + context.pop(); + } + }, + onTapQr: link.mapNotNull( + (url) => () async { + await showQrCode(context, url.toString()); + }, + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart new file mode 100644 index 00000000..43b853cb --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_auto_size_text/flutter_auto_size_text.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_email.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_image_location.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_link.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/share_email.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/share_image.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/share_link.dart'; +import 'package:weblibre/presentation/hooks/cached_future.dart'; + +class ContextMenuDialog extends HookConsumerWidget { + final HitResult hitResult; + + const ContextMenuDialog({super.key, required this.hitResult}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SimpleDialog( + title: AutoSizeText( + hitResult.getTitle(), + minFontSize: 18, + maxFontSize: DefaultTextStyle.of(context).style.fontSize, + maxLines: 10, + overflow: TextOverflow.ellipsis, + softWrap: true, + ), + children: [ + if (OpenInNewTab.isSupported(hitResult)) + OpenInNewTab(hitResult: hitResult), + if (CopyLink.isSupported(hitResult)) CopyLink(hitResult: hitResult), + if (SaveFile.isSupported(hitResult)) SaveFile(hitResult: hitResult), + if (ShareLink.isSupported(hitResult)) ShareLink(hitResult: hitResult), + if (ShareImage.isSupported(hitResult)) ShareImage(hitResult: hitResult), + if (OpenImageInNewTab.isSupported(hitResult)) + OpenImageInNewTab(hitResult: hitResult), + if (CopyImage.isSupported(hitResult)) CopyImage(hitResult: hitResult), + if (SaveImage.isSupported(hitResult)) SaveImage(hitResult: hitResult), + if (CopyImageLocation.isSupported(hitResult)) + CopyImageLocation(hitResult: hitResult), + if (ShareEmail.isSupported(hitResult)) ShareEmail(hitResult: hitResult), + if (CopyEmail.isSupported(hitResult)) CopyEmail(hitResult: hitResult), + HookBuilder( + builder: (context) { + final isSupported = useCachedFuture( + // ignore: discarded_futures useFuture + () => LaunchExternal.isSupported(hitResult), + ); + + if (isSupported.data == false) { + return const SizedBox.shrink(); + } + + return Skeletonizer( + enabled: isSupported.connectionState != ConnectionState.done, + child: LaunchExternal(hitResult: hitResult), + ); + }, + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart b/app/lib/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart new file mode 100644 index 00000000..983c8da9 --- /dev/null +++ b/app/lib/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart @@ -0,0 +1,17 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; + +part 'find_in_page_state.g.dart'; + +@CopyWith() +class FindInPageState with FastEquatable { + final bool visible; + final String? lastSearchText; + + FindInPageState({required this.visible, required this.lastSearchText}); + + FindInPageState.hidden() : visible = false, lastSearchText = null; + + @override + List get hashParameters => [visible, lastSearchText]; +} diff --git a/app/lib/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.g.dart b/app/lib/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.g.dart new file mode 100644 index 00000000..1106dc8f --- /dev/null +++ b/app/lib/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'find_in_page_state.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$FindInPageStateCWProxy { + FindInPageState visible(bool visible); + + FindInPageState lastSearchText(String? lastSearchText); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FindInPageState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// FindInPageState(...).copyWith(id: 12, name: "My name") + /// ```` + FindInPageState call({bool visible, String? lastSearchText}); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfFindInPageState.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfFindInPageState.copyWith.fieldName(...)` +class _$FindInPageStateCWProxyImpl implements _$FindInPageStateCWProxy { + const _$FindInPageStateCWProxyImpl(this._value); + + final FindInPageState _value; + + @override + FindInPageState visible(bool visible) => this(visible: visible); + + @override + FindInPageState lastSearchText(String? lastSearchText) => + this(lastSearchText: lastSearchText); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FindInPageState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// FindInPageState(...).copyWith(id: 12, name: "My name") + /// ```` + FindInPageState call({ + Object? visible = const $CopyWithPlaceholder(), + Object? lastSearchText = const $CopyWithPlaceholder(), + }) { + return FindInPageState( + visible: visible == const $CopyWithPlaceholder() + ? _value.visible + // ignore: cast_nullable_to_non_nullable + : visible as bool, + lastSearchText: lastSearchText == const $CopyWithPlaceholder() + ? _value.lastSearchText + // ignore: cast_nullable_to_non_nullable + : lastSearchText as String?, + ); + } +} + +extension $FindInPageStateCopyWith on FindInPageState { + /// Returns a callable class that can be used as follows: `instanceOfFindInPageState.copyWith(...)` or like so:`instanceOfFindInPageState.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$FindInPageStateCWProxy get copyWith => _$FindInPageStateCWProxyImpl(this); +} diff --git a/app/lib/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart b/app/lib/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart new file mode 100644 index 00000000..15eef5d8 --- /dev/null +++ b/app/lib/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart @@ -0,0 +1,37 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'find_in_page.g.dart'; + +@Riverpod(keepAlive: true) +class FindInPageRepository extends _$FindInPageRepository + implements GeckoFindInPageService { + @override + String? build(String? tabId) { + return null; + } + + @override + Future clearMatches() { + final service = GeckoFindInPageService(tabId: tabId); + + state = null; + + return service.clearMatches(); + } + + @override + Future findAll(String text) { + final service = GeckoFindInPageService(tabId: tabId); + + state = text; + + return service.findAll(text); + } + + @override + Future findNext(bool forward) { + final service = GeckoFindInPageService(tabId: tabId); + return service.findNext(forward); + } +} diff --git a/app/lib/features/geckoview/features/find_in_page/domain/repositories/find_in_page.g.dart b/app/lib/features/geckoview/features/find_in_page/domain/repositories/find_in_page.g.dart new file mode 100644 index 00000000..7be05a8d --- /dev/null +++ b/app/lib/features/geckoview/features/find_in_page/domain/repositories/find_in_page.g.dart @@ -0,0 +1,162 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'find_in_page.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$findInPageRepositoryHash() => + r'c289f136c27a22978f15668a13d7044eb39c3355'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$FindInPageRepository extends BuildlessNotifier { + late final String? tabId; + + String? build(String? tabId); +} + +/// See also [FindInPageRepository]. +@ProviderFor(FindInPageRepository) +const findInPageRepositoryProvider = FindInPageRepositoryFamily(); + +/// See also [FindInPageRepository]. +class FindInPageRepositoryFamily extends Family { + /// See also [FindInPageRepository]. + const FindInPageRepositoryFamily(); + + /// See also [FindInPageRepository]. + FindInPageRepositoryProvider call(String? tabId) { + return FindInPageRepositoryProvider(tabId); + } + + @override + FindInPageRepositoryProvider getProviderOverride( + covariant FindInPageRepositoryProvider provider, + ) { + return call(provider.tabId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'findInPageRepositoryProvider'; +} + +/// See also [FindInPageRepository]. +class FindInPageRepositoryProvider + extends NotifierProviderImpl { + /// See also [FindInPageRepository]. + FindInPageRepositoryProvider(String? tabId) + : this._internal( + () => FindInPageRepository()..tabId = tabId, + from: findInPageRepositoryProvider, + name: r'findInPageRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$findInPageRepositoryHash, + dependencies: FindInPageRepositoryFamily._dependencies, + allTransitiveDependencies: + FindInPageRepositoryFamily._allTransitiveDependencies, + tabId: tabId, + ); + + FindInPageRepositoryProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.tabId, + }) : super.internal(); + + final String? tabId; + + @override + String? runNotifierBuild(covariant FindInPageRepository notifier) { + return notifier.build(tabId); + } + + @override + Override overrideWith(FindInPageRepository Function() create) { + return ProviderOverride( + origin: this, + override: FindInPageRepositoryProvider._internal( + () => create()..tabId = tabId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + tabId: tabId, + ), + ); + } + + @override + NotifierProviderElement createElement() { + return _FindInPageRepositoryProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FindInPageRepositoryProvider && other.tabId == tabId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, tabId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FindInPageRepositoryRef on NotifierProviderRef { + /// The parameter `tabId` of this provider. + String? get tabId; +} + +class _FindInPageRepositoryProviderElement + extends NotifierProviderElement + with FindInPageRepositoryRef { + _FindInPageRepositoryProviderElement(super.provider); + + @override + String? get tabId => (origin as FindInPageRepositoryProvider).tabId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart b/app/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart new file mode 100644 index 00000000..f11a81df --- /dev/null +++ b/app/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart @@ -0,0 +1,87 @@ +import 'package:nullability/nullability.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart'; +import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; + +part 'find_in_page.g.dart'; + +@Riverpod() +class FindInPageController extends _$FindInPageController { + void show() { + state = state.copyWith.visible(true); + } + + Future hide() async { + await clearMatches(); + state = FindInPageState.hidden(); + } + + Future findAll({required String text}) { + final tabId = ref.read(selectedTabProvider); + final service = ref.read(findInPageRepositoryProvider(tabId).notifier); + + state = FindInPageState(visible: true, lastSearchText: text); + + return service.findAll(text); + } + + Future findNext({required String fallbackText, bool forward = true}) { + final tabId = ref.read(selectedTabProvider); + final service = ref.read(findInPageRepositoryProvider(tabId).notifier); + + final hasMatches = + ref.read(selectedTabStateProvider)?.findResultState.hasMatches == true; + + state = state.copyWith.visible(true); + + if (hasMatches) { + return service.findNext(forward); + } else { + return service.findAll(fallbackText); + } + } + + Future clearMatches() { + final tabId = ref.read(selectedTabProvider); + final service = ref.read(findInPageRepositoryProvider(tabId).notifier); + + return service.clearMatches(); + } + + @override + FindInPageState build() { + ref.listen( + fireImmediately: true, + selectedTabStateProvider, + (previous, next) async { + //Ensure state is already initialized + if (stateOrNull != null) { + if (state.visible && state.lastSearchText.isNotEmpty) { + if (previous != null && next != null) { + final loadingOrReloading = + previous.isLoading == true && next.isLoading == false; + final tabSwitchWithoutResults = + previous.id != next.id && !next.findResultState.hasMatches; + + if (loadingOrReloading || tabSwitchWithoutResults) { + await findAll(text: state.lastSearchText!); + } + } + } + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to selectedTabStateProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + return FindInPageState.hidden(); + } +} diff --git a/app/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart b/app/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart new file mode 100644 index 00000000..5a6ff99c --- /dev/null +++ b/app/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'find_in_page.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$findInPageControllerHash() => + r'fd335be9345b405856cf2aa68312d4d1535d8c5e'; + +/// See also [FindInPageController]. +@ProviderFor(FindInPageController) +final findInPageControllerProvider = + AutoDisposeNotifierProvider.internal( + FindInPageController.new, + name: r'findInPageControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$findInPageControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$FindInPageController = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart b/app/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart new file mode 100644 index 00000000..739f9ac0 --- /dev/null +++ b/app/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; + +class FindInPageWidget extends HookConsumerWidget { + final EdgeInsetsGeometry padding; + + const FindInPageWidget({this.padding = EdgeInsets.zero}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final findInPageState = ref.watch(findInPageControllerProvider); + final searchResult = ref.watch( + selectedTabStateProvider.select((state) => state?.findResultState), + ); + + final focusNode = useFocusNode(); + final textController = useTextEditingController( + text: searchResult?.lastSearchText ?? findInPageState.lastSearchText, + keys: [searchResult?.lastSearchText, findInPageState.lastSearchText], + ); + + return Visibility( + visible: findInPageState.visible || searchResult?.hasMatches == true, + child: Padding( + padding: padding, + child: Material( + child: Row( + children: [ + const SizedBox(width: 8), + Expanded( + child: TextField( + focusNode: focusNode, + controller: textController, + autofocus: true, + autocorrect: false, + decoration: const InputDecoration.collapsed( + hintText: 'Find in page', + ), + keyboardType: TextInputType.text, + onSubmitted: (value) async { + if (value == '') { + await ref + .read(findInPageControllerProvider.notifier) + .clearMatches(); + } else { + await ref + .read(findInPageControllerProvider.notifier) + .findAll(text: value); + } + }, + ), + ), + Text( + (searchResult != null && searchResult.hasMatches) + ? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}' + : 'Not found', + ), + IconButton( + icon: const Icon(Icons.arrow_upward), + onPressed: () async { + await ref + .read(findInPageControllerProvider.notifier) + .findNext( + forward: false, + fallbackText: textController.text, + ); + }, + ), + IconButton( + icon: const Icon(Icons.arrow_downward), + onPressed: () async { + await ref + .read(findInPageControllerProvider.notifier) + .findNext(fallbackText: textController.text); + }, + ), + IconButton( + icon: const Icon(Icons.clear), + onPressed: () async { + await ref.read(findInPageControllerProvider.notifier).hide(); + textController.clear(); + focusNode.requestFocus(); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/preferences/data/models/preference_setting.dart b/app/lib/features/geckoview/features/preferences/data/models/preference_setting.dart new file mode 100644 index 00000000..009d532b --- /dev/null +++ b/app/lib/features/geckoview/features/preferences/data/models/preference_setting.dart @@ -0,0 +1,68 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'preference_setting.g.dart'; + +@CopyWith() +class PreferenceSettingGroup with FastEquatable { + final String? description; + + final Map settings; + + bool get isActiveOrOptional => settings.values.every( + (setting) => setting.requireUserOptIn || setting.isActive, + ); + + bool get isPartlyActive => settings.values.any((setting) => setting.isActive); + + bool get hasInactiveOptional => settings.values.any( + (setting) => !setting.isActive && setting.requireUserOptIn, + ); + + PreferenceSettingGroup({required this.description, required this.settings}); + + @override + List get hashParameters => [description, settings]; +} + +@JsonSerializable() +@CopyWith() +class PreferenceSetting with FastEquatable { + final Object value; + + @JsonKey(includeFromJson: false, includeToJson: false) + final Object? actualValue; + + bool get isActive => value == actualValue; + + final String? title; + final String? description; + + final bool requireUserOptIn; + final bool shouldBeDefault; + + PreferenceSetting({ + required this.value, + required this.title, + required this.description, + this.actualValue, + this.requireUserOptIn = false, + this.shouldBeDefault = false, + }); + + factory PreferenceSetting.fromJson(Map json) => + _$PreferenceSettingFromJson(json); + + Map toJson() => _$PreferenceSettingToJson(this); + + @override + List get hashParameters => [ + value, + actualValue, + title, + description, + requireUserOptIn, + shouldBeDefault, + ]; +} diff --git a/app/lib/features/geckoview/features/preferences/data/models/preference_setting.g.dart b/app/lib/features/geckoview/features/preferences/data/models/preference_setting.g.dart new file mode 100644 index 00000000..ef29fe49 --- /dev/null +++ b/app/lib/features/geckoview/features/preferences/data/models/preference_setting.g.dart @@ -0,0 +1,200 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'preference_setting.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$PreferenceSettingGroupCWProxy { + PreferenceSettingGroup description(String? description); + + PreferenceSettingGroup settings(Map settings); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `PreferenceSettingGroup(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// PreferenceSettingGroup(...).copyWith(id: 12, name: "My name") + /// ```` + PreferenceSettingGroup call({ + String? description, + Map settings, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfPreferenceSettingGroup.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfPreferenceSettingGroup.copyWith.fieldName(...)` +class _$PreferenceSettingGroupCWProxyImpl + implements _$PreferenceSettingGroupCWProxy { + const _$PreferenceSettingGroupCWProxyImpl(this._value); + + final PreferenceSettingGroup _value; + + @override + PreferenceSettingGroup description(String? description) => + this(description: description); + + @override + PreferenceSettingGroup settings(Map settings) => + this(settings: settings); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `PreferenceSettingGroup(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// PreferenceSettingGroup(...).copyWith(id: 12, name: "My name") + /// ```` + PreferenceSettingGroup call({ + Object? description = const $CopyWithPlaceholder(), + Object? settings = const $CopyWithPlaceholder(), + }) { + return PreferenceSettingGroup( + description: description == const $CopyWithPlaceholder() + ? _value.description + // ignore: cast_nullable_to_non_nullable + : description as String?, + settings: settings == const $CopyWithPlaceholder() + ? _value.settings + // ignore: cast_nullable_to_non_nullable + : settings as Map, + ); + } +} + +extension $PreferenceSettingGroupCopyWith on PreferenceSettingGroup { + /// Returns a callable class that can be used as follows: `instanceOfPreferenceSettingGroup.copyWith(...)` or like so:`instanceOfPreferenceSettingGroup.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$PreferenceSettingGroupCWProxy get copyWith => + _$PreferenceSettingGroupCWProxyImpl(this); +} + +abstract class _$PreferenceSettingCWProxy { + PreferenceSetting value(Object value); + + PreferenceSetting title(String? title); + + PreferenceSetting description(String? description); + + PreferenceSetting actualValue(Object? actualValue); + + PreferenceSetting requireUserOptIn(bool requireUserOptIn); + + PreferenceSetting shouldBeDefault(bool shouldBeDefault); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `PreferenceSetting(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// PreferenceSetting(...).copyWith(id: 12, name: "My name") + /// ```` + PreferenceSetting call({ + Object value, + String? title, + String? description, + Object? actualValue, + bool requireUserOptIn, + bool shouldBeDefault, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfPreferenceSetting.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfPreferenceSetting.copyWith.fieldName(...)` +class _$PreferenceSettingCWProxyImpl implements _$PreferenceSettingCWProxy { + const _$PreferenceSettingCWProxyImpl(this._value); + + final PreferenceSetting _value; + + @override + PreferenceSetting value(Object value) => this(value: value); + + @override + PreferenceSetting title(String? title) => this(title: title); + + @override + PreferenceSetting description(String? description) => + this(description: description); + + @override + PreferenceSetting actualValue(Object? actualValue) => + this(actualValue: actualValue); + + @override + PreferenceSetting requireUserOptIn(bool requireUserOptIn) => + this(requireUserOptIn: requireUserOptIn); + + @override + PreferenceSetting shouldBeDefault(bool shouldBeDefault) => + this(shouldBeDefault: shouldBeDefault); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `PreferenceSetting(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// PreferenceSetting(...).copyWith(id: 12, name: "My name") + /// ```` + PreferenceSetting call({ + Object? value = const $CopyWithPlaceholder(), + Object? title = const $CopyWithPlaceholder(), + Object? description = const $CopyWithPlaceholder(), + Object? actualValue = const $CopyWithPlaceholder(), + Object? requireUserOptIn = const $CopyWithPlaceholder(), + Object? shouldBeDefault = const $CopyWithPlaceholder(), + }) { + return PreferenceSetting( + value: value == const $CopyWithPlaceholder() + ? _value.value + // ignore: cast_nullable_to_non_nullable + : value as Object, + title: title == const $CopyWithPlaceholder() + ? _value.title + // ignore: cast_nullable_to_non_nullable + : title as String?, + description: description == const $CopyWithPlaceholder() + ? _value.description + // ignore: cast_nullable_to_non_nullable + : description as String?, + actualValue: actualValue == const $CopyWithPlaceholder() + ? _value.actualValue + // ignore: cast_nullable_to_non_nullable + : actualValue as Object?, + requireUserOptIn: requireUserOptIn == const $CopyWithPlaceholder() + ? _value.requireUserOptIn + // ignore: cast_nullable_to_non_nullable + : requireUserOptIn as bool, + shouldBeDefault: shouldBeDefault == const $CopyWithPlaceholder() + ? _value.shouldBeDefault + // ignore: cast_nullable_to_non_nullable + : shouldBeDefault as bool, + ); + } +} + +extension $PreferenceSettingCopyWith on PreferenceSetting { + /// Returns a callable class that can be used as follows: `instanceOfPreferenceSetting.copyWith(...)` or like so:`instanceOfPreferenceSetting.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$PreferenceSettingCWProxy get copyWith => + _$PreferenceSettingCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PreferenceSetting _$PreferenceSettingFromJson(Map json) => + PreferenceSetting( + value: json['value'] as Object, + title: json['title'] as String?, + description: json['description'] as String?, + requireUserOptIn: json['requireUserOptIn'] as bool? ?? false, + shouldBeDefault: json['shouldBeDefault'] as bool? ?? false, + ); + +Map _$PreferenceSettingToJson(PreferenceSetting instance) => + { + 'value': instance.value, + 'title': instance.title, + 'description': instance.description, + 'requireUserOptIn': instance.requireUserOptIn, + 'shouldBeDefault': instance.shouldBeDefault, + }; diff --git a/app/lib/features/geckoview/features/preferences/data/repositories/preference_settings.dart b/app/lib/features/geckoview/features/preferences/data/repositories/preference_settings.dart new file mode 100644 index 00000000..e3a1a414 --- /dev/null +++ b/app/lib/features/geckoview/features/preferences/data/repositories/preference_settings.dart @@ -0,0 +1,208 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:weblibre/features/geckoview/features/preferences/data/models/preference_setting.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/setting_groups_serializer.dart'; + +part 'preference_settings.g.dart'; + +@Riverpod(keepAlive: true) +Future> _preferenceSettingContent(Ref ref) async { + return await rootBundle + .loadString('assets/preferences/settings.json') + .then(jsonDecode) + as Map; +} + +@Riverpod(keepAlive: true) +Future> _preferenceSettingGroups( + Ref ref, + PreferencePartition partition, +) async { + final content = await ref.read(_preferenceSettingContentProvider.future); + return deserializePreferenceSettingGroups(partition, content); +} + +@Riverpod(keepAlive: true) +Future _preferenceSettingGroup( + Ref ref, + PreferencePartition partition, + String groupName, +) { + return ref.watch( + _preferenceSettingGroupsProvider(partition).selectAsync( + (groups) => + groups[groupName] ?? (throw Exception('Unknown setting group')), + ), + ); +} + +@Riverpod() +class _PreferenceRepository extends _$PreferenceRepository { + final _prefManager = GeckoPrefService(); + // Use BehaviorSubject instead of StreamController + late BehaviorSubject> _prefSubject; + + Future _updatePrefs() async { + final prefs = await _prefManager.getAllPrefs(); + _prefSubject.add(prefs); + } + + Future applyPrefs(Map prefs) async { + await _prefManager.applyPrefs(prefs); + await _updatePrefs(); + } + + Future resetPrefs(List prefNames) async { + await _prefManager.resetPrefs(prefNames); + await _updatePrefs(); + } + + @override + Raw>> build() { + _prefSubject = BehaviorSubject>(); + + ref.onDispose(() async { + await _prefSubject.close(); + }); + + ref.onAddListener(() async { + await _updatePrefs(); + }); + + return _prefSubject.stream; + } +} + +@Riverpod() +class UnifiedPreferenceSettingsRepository + extends _$UnifiedPreferenceSettingsRepository { + Map? _statelessGroups; + + Future apply() async { + _statelessGroups = await ref.read( + _preferenceSettingGroupsProvider(partition).future, + ); + + final prefs = { + for (final group in _statelessGroups!.values) + ...Map.fromEntries( + group.settings.entries + .where((e) => !e.value.requireUserOptIn) + .map((e) => MapEntry(e.key, e.value.value)), + ), + }; + + await ref.read(_preferenceRepositoryProvider.notifier).applyPrefs(prefs); + } + + Future reset() async { + _statelessGroups = await ref.read( + _preferenceSettingGroupsProvider(partition).future, + ); + + final prefs = _statelessGroups!.values + .map((group) => group.settings.keys) + .flattened + .toList(); + + await ref.read(_preferenceRepositoryProvider.notifier).resetPrefs(prefs); + } + + @override + Stream> build( + PreferencePartition partition, + ) async* { + _statelessGroups = await ref.watch( + _preferenceSettingGroupsProvider(partition).future, + ); + + final prefStream = ref.watch(_preferenceRepositoryProvider); + + yield* prefStream.map( + (prefs) => _statelessGroups!.map( + (groupName, group) => MapEntry( + groupName, + group.copyWith.settings( + group.settings.map( + (prefName, setting) => MapEntry( + prefName, + setting.copyWith.actualValue(prefs[prefName]), + ), + ), + ), + ), + ), + ); + } +} + +@Riverpod() +class PreferenceSettingsGroupRepository + extends _$PreferenceSettingsGroupRepository { + PreferenceSettingGroup? _statelessSettingGroup; + + Future apply({List? filter}) async { + _statelessSettingGroup ??= await ref.read( + _preferenceSettingGroupProvider(partition, groupName).future, + ); + + final prefs = Map.fromEntries( + filter?.map( + (e) => MapEntry( + e, + _statelessSettingGroup!.settings[e]?.value ?? + (throw Exception('Preference not part of group')), + ), + ) ?? + _statelessSettingGroup!.settings.entries + .where((e) => !e.value.requireUserOptIn) + .map((e) => MapEntry(e.key, e.value.value)), + ); + + await ref.read(_preferenceRepositoryProvider.notifier).applyPrefs(prefs); + } + + Future reset({List? filter}) async { + _statelessSettingGroup ??= await ref.read( + _preferenceSettingGroupProvider(partition, groupName).future, + ); + + if (filter != null && + filter.any( + (value) => !_statelessSettingGroup!.settings.containsKey(value), + )) { + throw Exception('Preference not part of group'); + } + + await ref + .read(_preferenceRepositoryProvider.notifier) + .resetPrefs(filter ?? _statelessSettingGroup!.settings.keys.toList()); + } + + @override + Stream build( + PreferencePartition partition, + String groupName, + ) async* { + _statelessSettingGroup = await ref.watch( + _preferenceSettingGroupProvider(partition, groupName).future, + ); + + final prefStream = ref.watch(_preferenceRepositoryProvider); + + yield* prefStream.map( + (prefs) => _statelessSettingGroup!.copyWith.settings( + _statelessSettingGroup!.settings.map( + (key, value) => MapEntry(key, value.copyWith.actualValue(prefs[key])), + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart b/app/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart new file mode 100644 index 00000000..6ceef00b --- /dev/null +++ b/app/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart @@ -0,0 +1,688 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'preference_settings.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$preferenceSettingContentHash() => + r'a8a61fdf8800cb7adb365c100409ddce09c574b1'; + +/// See also [_preferenceSettingContent]. +@ProviderFor(_preferenceSettingContent) +final _preferenceSettingContentProvider = + FutureProvider>.internal( + _preferenceSettingContent, + name: r'_preferenceSettingContentProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$preferenceSettingContentHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef _PreferenceSettingContentRef = FutureProviderRef>; +String _$preferenceSettingGroupsHash() => + r'd267dacf82a11568c9cf0cc864f434db35f93394'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [_preferenceSettingGroups]. +@ProviderFor(_preferenceSettingGroups) +const _preferenceSettingGroupsProvider = _PreferenceSettingGroupsFamily(); + +/// See also [_preferenceSettingGroups]. +class _PreferenceSettingGroupsFamily + extends Family>> { + /// See also [_preferenceSettingGroups]. + const _PreferenceSettingGroupsFamily(); + + /// See also [_preferenceSettingGroups]. + _PreferenceSettingGroupsProvider call(PreferencePartition partition) { + return _PreferenceSettingGroupsProvider(partition); + } + + @override + _PreferenceSettingGroupsProvider getProviderOverride( + covariant _PreferenceSettingGroupsProvider provider, + ) { + return call(provider.partition); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'_preferenceSettingGroupsProvider'; +} + +/// See also [_preferenceSettingGroups]. +class _PreferenceSettingGroupsProvider + extends FutureProvider> { + /// See also [_preferenceSettingGroups]. + _PreferenceSettingGroupsProvider(PreferencePartition partition) + : this._internal( + (ref) => _preferenceSettingGroups( + ref as _PreferenceSettingGroupsRef, + partition, + ), + from: _preferenceSettingGroupsProvider, + name: r'_preferenceSettingGroupsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$preferenceSettingGroupsHash, + dependencies: _PreferenceSettingGroupsFamily._dependencies, + allTransitiveDependencies: + _PreferenceSettingGroupsFamily._allTransitiveDependencies, + partition: partition, + ); + + _PreferenceSettingGroupsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.partition, + }) : super.internal(); + + final PreferencePartition partition; + + @override + Override overrideWith( + FutureOr> Function( + _PreferenceSettingGroupsRef provider, + ) + create, + ) { + return ProviderOverride( + origin: this, + override: _PreferenceSettingGroupsProvider._internal( + (ref) => create(ref as _PreferenceSettingGroupsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + partition: partition, + ), + ); + } + + @override + FutureProviderElement> createElement() { + return _PreferenceSettingGroupsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is _PreferenceSettingGroupsProvider && + other.partition == partition; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, partition.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin _PreferenceSettingGroupsRef + on FutureProviderRef> { + /// The parameter `partition` of this provider. + PreferencePartition get partition; +} + +class _PreferenceSettingGroupsProviderElement + extends FutureProviderElement> + with _PreferenceSettingGroupsRef { + _PreferenceSettingGroupsProviderElement(super.provider); + + @override + PreferencePartition get partition => + (origin as _PreferenceSettingGroupsProvider).partition; +} + +String _$preferenceSettingGroupHash() => + r'64d80a37928d7e5aea7d3d567dde8ade7292cd5f'; + +/// See also [_preferenceSettingGroup]. +@ProviderFor(_preferenceSettingGroup) +const _preferenceSettingGroupProvider = _PreferenceSettingGroupFamily(); + +/// See also [_preferenceSettingGroup]. +class _PreferenceSettingGroupFamily + extends Family> { + /// See also [_preferenceSettingGroup]. + const _PreferenceSettingGroupFamily(); + + /// See also [_preferenceSettingGroup]. + _PreferenceSettingGroupProvider call( + PreferencePartition partition, + String groupName, + ) { + return _PreferenceSettingGroupProvider(partition, groupName); + } + + @override + _PreferenceSettingGroupProvider getProviderOverride( + covariant _PreferenceSettingGroupProvider provider, + ) { + return call(provider.partition, provider.groupName); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'_preferenceSettingGroupProvider'; +} + +/// See also [_preferenceSettingGroup]. +class _PreferenceSettingGroupProvider + extends FutureProvider { + /// See also [_preferenceSettingGroup]. + _PreferenceSettingGroupProvider( + PreferencePartition partition, + String groupName, + ) : this._internal( + (ref) => _preferenceSettingGroup( + ref as _PreferenceSettingGroupRef, + partition, + groupName, + ), + from: _preferenceSettingGroupProvider, + name: r'_preferenceSettingGroupProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$preferenceSettingGroupHash, + dependencies: _PreferenceSettingGroupFamily._dependencies, + allTransitiveDependencies: + _PreferenceSettingGroupFamily._allTransitiveDependencies, + partition: partition, + groupName: groupName, + ); + + _PreferenceSettingGroupProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.partition, + required this.groupName, + }) : super.internal(); + + final PreferencePartition partition; + final String groupName; + + @override + Override overrideWith( + FutureOr Function( + _PreferenceSettingGroupRef provider, + ) + create, + ) { + return ProviderOverride( + origin: this, + override: _PreferenceSettingGroupProvider._internal( + (ref) => create(ref as _PreferenceSettingGroupRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + partition: partition, + groupName: groupName, + ), + ); + } + + @override + FutureProviderElement createElement() { + return _PreferenceSettingGroupProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is _PreferenceSettingGroupProvider && + other.partition == partition && + other.groupName == groupName; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, partition.hashCode); + hash = _SystemHash.combine(hash, groupName.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin _PreferenceSettingGroupRef on FutureProviderRef { + /// The parameter `partition` of this provider. + PreferencePartition get partition; + + /// The parameter `groupName` of this provider. + String get groupName; +} + +class _PreferenceSettingGroupProviderElement + extends FutureProviderElement + with _PreferenceSettingGroupRef { + _PreferenceSettingGroupProviderElement(super.provider); + + @override + PreferencePartition get partition => + (origin as _PreferenceSettingGroupProvider).partition; + @override + String get groupName => (origin as _PreferenceSettingGroupProvider).groupName; +} + +String _$preferenceRepositoryHash() => + r'b2b7f75b3d30842b3006bb18af89f7ff90ba4ce7'; + +/// See also [_PreferenceRepository]. +@ProviderFor(_PreferenceRepository) +final _preferenceRepositoryProvider = + AutoDisposeNotifierProvider< + _PreferenceRepository, + Raw>> + >.internal( + _PreferenceRepository.new, + name: r'_preferenceRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$preferenceRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$PreferenceRepository = + AutoDisposeNotifier>>>; +String _$unifiedPreferenceSettingsRepositoryHash() => + r'35aa16ffa7aa1453c8331ed7470ef85a68d27178'; + +abstract class _$UnifiedPreferenceSettingsRepository + extends + BuildlessAutoDisposeStreamNotifier< + Map + > { + late final PreferencePartition partition; + + Stream> build( + PreferencePartition partition, + ); +} + +/// See also [UnifiedPreferenceSettingsRepository]. +@ProviderFor(UnifiedPreferenceSettingsRepository) +const unifiedPreferenceSettingsRepositoryProvider = + UnifiedPreferenceSettingsRepositoryFamily(); + +/// See also [UnifiedPreferenceSettingsRepository]. +class UnifiedPreferenceSettingsRepositoryFamily + extends Family>> { + /// See also [UnifiedPreferenceSettingsRepository]. + const UnifiedPreferenceSettingsRepositoryFamily(); + + /// See also [UnifiedPreferenceSettingsRepository]. + UnifiedPreferenceSettingsRepositoryProvider call( + PreferencePartition partition, + ) { + return UnifiedPreferenceSettingsRepositoryProvider(partition); + } + + @override + UnifiedPreferenceSettingsRepositoryProvider getProviderOverride( + covariant UnifiedPreferenceSettingsRepositoryProvider provider, + ) { + return call(provider.partition); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'unifiedPreferenceSettingsRepositoryProvider'; +} + +/// See also [UnifiedPreferenceSettingsRepository]. +class UnifiedPreferenceSettingsRepositoryProvider + extends + AutoDisposeStreamNotifierProviderImpl< + UnifiedPreferenceSettingsRepository, + Map + > { + /// See also [UnifiedPreferenceSettingsRepository]. + UnifiedPreferenceSettingsRepositoryProvider(PreferencePartition partition) + : this._internal( + () => UnifiedPreferenceSettingsRepository()..partition = partition, + from: unifiedPreferenceSettingsRepositoryProvider, + name: r'unifiedPreferenceSettingsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$unifiedPreferenceSettingsRepositoryHash, + dependencies: UnifiedPreferenceSettingsRepositoryFamily._dependencies, + allTransitiveDependencies: UnifiedPreferenceSettingsRepositoryFamily + ._allTransitiveDependencies, + partition: partition, + ); + + UnifiedPreferenceSettingsRepositoryProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.partition, + }) : super.internal(); + + final PreferencePartition partition; + + @override + Stream> runNotifierBuild( + covariant UnifiedPreferenceSettingsRepository notifier, + ) { + return notifier.build(partition); + } + + @override + Override overrideWith(UnifiedPreferenceSettingsRepository Function() create) { + return ProviderOverride( + origin: this, + override: UnifiedPreferenceSettingsRepositoryProvider._internal( + () => create()..partition = partition, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + partition: partition, + ), + ); + } + + @override + AutoDisposeStreamNotifierProviderElement< + UnifiedPreferenceSettingsRepository, + Map + > + createElement() { + return _UnifiedPreferenceSettingsRepositoryProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is UnifiedPreferenceSettingsRepositoryProvider && + other.partition == partition; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, partition.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin UnifiedPreferenceSettingsRepositoryRef + on + AutoDisposeStreamNotifierProviderRef< + Map + > { + /// The parameter `partition` of this provider. + PreferencePartition get partition; +} + +class _UnifiedPreferenceSettingsRepositoryProviderElement + extends + AutoDisposeStreamNotifierProviderElement< + UnifiedPreferenceSettingsRepository, + Map + > + with UnifiedPreferenceSettingsRepositoryRef { + _UnifiedPreferenceSettingsRepositoryProviderElement(super.provider); + + @override + PreferencePartition get partition => + (origin as UnifiedPreferenceSettingsRepositoryProvider).partition; +} + +String _$preferenceSettingsGroupRepositoryHash() => + r'60f416ffe1cde4d535fb5b643a80aead20836bae'; + +abstract class _$PreferenceSettingsGroupRepository + extends BuildlessAutoDisposeStreamNotifier { + late final PreferencePartition partition; + late final String groupName; + + Stream build( + PreferencePartition partition, + String groupName, + ); +} + +/// See also [PreferenceSettingsGroupRepository]. +@ProviderFor(PreferenceSettingsGroupRepository) +const preferenceSettingsGroupRepositoryProvider = + PreferenceSettingsGroupRepositoryFamily(); + +/// See also [PreferenceSettingsGroupRepository]. +class PreferenceSettingsGroupRepositoryFamily + extends Family> { + /// See also [PreferenceSettingsGroupRepository]. + const PreferenceSettingsGroupRepositoryFamily(); + + /// See also [PreferenceSettingsGroupRepository]. + PreferenceSettingsGroupRepositoryProvider call( + PreferencePartition partition, + String groupName, + ) { + return PreferenceSettingsGroupRepositoryProvider(partition, groupName); + } + + @override + PreferenceSettingsGroupRepositoryProvider getProviderOverride( + covariant PreferenceSettingsGroupRepositoryProvider provider, + ) { + return call(provider.partition, provider.groupName); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'preferenceSettingsGroupRepositoryProvider'; +} + +/// See also [PreferenceSettingsGroupRepository]. +class PreferenceSettingsGroupRepositoryProvider + extends + AutoDisposeStreamNotifierProviderImpl< + PreferenceSettingsGroupRepository, + PreferenceSettingGroup + > { + /// See also [PreferenceSettingsGroupRepository]. + PreferenceSettingsGroupRepositoryProvider( + PreferencePartition partition, + String groupName, + ) : this._internal( + () => PreferenceSettingsGroupRepository() + ..partition = partition + ..groupName = groupName, + from: preferenceSettingsGroupRepositoryProvider, + name: r'preferenceSettingsGroupRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$preferenceSettingsGroupRepositoryHash, + dependencies: PreferenceSettingsGroupRepositoryFamily._dependencies, + allTransitiveDependencies: + PreferenceSettingsGroupRepositoryFamily._allTransitiveDependencies, + partition: partition, + groupName: groupName, + ); + + PreferenceSettingsGroupRepositoryProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.partition, + required this.groupName, + }) : super.internal(); + + final PreferencePartition partition; + final String groupName; + + @override + Stream runNotifierBuild( + covariant PreferenceSettingsGroupRepository notifier, + ) { + return notifier.build(partition, groupName); + } + + @override + Override overrideWith(PreferenceSettingsGroupRepository Function() create) { + return ProviderOverride( + origin: this, + override: PreferenceSettingsGroupRepositoryProvider._internal( + () => create() + ..partition = partition + ..groupName = groupName, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + partition: partition, + groupName: groupName, + ), + ); + } + + @override + AutoDisposeStreamNotifierProviderElement< + PreferenceSettingsGroupRepository, + PreferenceSettingGroup + > + createElement() { + return _PreferenceSettingsGroupRepositoryProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is PreferenceSettingsGroupRepositoryProvider && + other.partition == partition && + other.groupName == groupName; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, partition.hashCode); + hash = _SystemHash.combine(hash, groupName.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin PreferenceSettingsGroupRepositoryRef + on AutoDisposeStreamNotifierProviderRef { + /// The parameter `partition` of this provider. + PreferencePartition get partition; + + /// The parameter `groupName` of this provider. + String get groupName; +} + +class _PreferenceSettingsGroupRepositoryProviderElement + extends + AutoDisposeStreamNotifierProviderElement< + PreferenceSettingsGroupRepository, + PreferenceSettingGroup + > + with PreferenceSettingsGroupRepositoryRef { + _PreferenceSettingsGroupRepositoryProviderElement(super.provider); + + @override + PreferencePartition get partition => + (origin as PreferenceSettingsGroupRepositoryProvider).partition; + @override + String get groupName => + (origin as PreferenceSettingsGroupRepositoryProvider).groupName; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/readerview/domain/providers/readerable.dart b/app/lib/features/geckoview/features/readerview/domain/providers/readerable.dart new file mode 100644 index 00000000..79e24202 --- /dev/null +++ b/app/lib/features/geckoview/features/readerview/domain/providers/readerable.dart @@ -0,0 +1,22 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'readerable.g.dart'; + +@Riverpod(keepAlive: true) +GeckoReaderableService readerableService(Ref ref) { + final service = GeckoReaderableService.setUp(); + + ref.onDispose(() { + service.dispose(); + }); + + return service; +} + +@Riverpod() +Stream appearanceButtonVisibility(Ref ref) { + final service = ref.watch(readerableServiceProvider); + return service.appearanceVisibility; +} diff --git a/app/lib/features/geckoview/features/readerview/domain/providers/readerable.g.dart b/app/lib/features/geckoview/features/readerview/domain/providers/readerable.g.dart new file mode 100644 index 00000000..3bc02107 --- /dev/null +++ b/app/lib/features/geckoview/features/readerview/domain/providers/readerable.g.dart @@ -0,0 +1,46 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'readerable.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$readerableServiceHash() => r'03182c8afc41f5184322a3206473cbfa96df5819'; + +/// See also [readerableService]. +@ProviderFor(readerableService) +final readerableServiceProvider = Provider.internal( + readerableService, + name: r'readerableServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$readerableServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ReaderableServiceRef = ProviderRef; +String _$appearanceButtonVisibilityHash() => + r'e7102b63113937dd5d17028a5ec8dac428c38bbb'; + +/// See also [appearanceButtonVisibility]. +@ProviderFor(appearanceButtonVisibility) +final appearanceButtonVisibilityProvider = + AutoDisposeStreamProvider.internal( + appearanceButtonVisibility, + name: r'appearanceButtonVisibilityProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$appearanceButtonVisibilityHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AppearanceButtonVisibilityRef = AutoDisposeStreamProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/readerview/presentation/controllers/readerable.dart b/app/lib/features/geckoview/features/readerview/presentation/controllers/readerable.dart new file mode 100644 index 00000000..dd68d59c --- /dev/null +++ b/app/lib/features/geckoview/features/readerview/presentation/controllers/readerable.dart @@ -0,0 +1,27 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/readerview/domain/providers/readerable.dart'; + +part 'readerable.g.dart'; + +@Riverpod(keepAlive: true) +class ReaderableScreenController extends _$ReaderableScreenController { + late GeckoReaderableService _service; + + Future toggleReaderView(bool enable) async { + state = const AsyncValue.loading(); + + final eventChange = ref.read(eventServiceProvider).readerableEvents.first; + final toggle = _service.toggleReaderView(enable); + + state = await AsyncValue.guard(() => Future.wait([toggle, eventChange])); + } + + @override + Future build() { + _service = ref.watch(readerableServiceProvider); + + return Future.value(); + } +} diff --git a/app/lib/features/geckoview/features/readerview/presentation/controllers/readerable.g.dart b/app/lib/features/geckoview/features/readerview/presentation/controllers/readerable.g.dart new file mode 100644 index 00000000..f7cf926f --- /dev/null +++ b/app/lib/features/geckoview/features/readerview/presentation/controllers/readerable.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'readerable.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$readerableScreenControllerHash() => + r'62b2167ff8f9fe21c3cf3339e135a50aa684160c'; + +/// See also [ReaderableScreenController]. +@ProviderFor(ReaderableScreenController) +final readerableScreenControllerProvider = + AsyncNotifierProvider.internal( + ReaderableScreenController.new, + name: r'readerableScreenControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$readerableScreenControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ReaderableScreenController = AsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart b/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart new file mode 100644 index 00000000..429f61d1 --- /dev/null +++ b/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/readerview/domain/providers/readerable.dart'; + +class ReaderAppearanceButton extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final buttonVisible = ref.watch( + appearanceButtonVisibilityProvider.select( + (value) => value.valueOrNull ?? false, + ), + ); + + final readerabilityState = ref.watch( + selectedTabStateProvider.select( + (state) => state?.readerableState ?? ReaderableState.$default(), + ), + ); + + return Visibility( + visible: readerabilityState.active && buttonVisible, + child: FloatingActionButton( + onPressed: () async { + await ref.read(readerableServiceProvider).onAppearanceButtonTap(); + }, + child: const Icon(MdiIcons.formatFont), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_button.dart b/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_button.dart new file mode 100644 index 00000000..758971d2 --- /dev/null +++ b/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_button.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/widgets/animate_gradient_shader.dart'; + +class ReaderButton extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + final readerChanging = ref.watch(readerableScreenControllerProvider); + + final enableReadability = ref.watch( + generalSettingsRepositoryProvider.select( + (value) => value.enableReadability, + ), + ); + + final enforceReadability = ref.watch( + generalSettingsRepositoryProvider.select( + (value) => value.enforceReadability, + ), + ); + + final readerabilityState = ref.watch( + selectedTabStateProvider.select( + (state) => state?.readerableState ?? ReaderableState.$default(), + ), + ); + + final icon = readerabilityState.active + ? Icon(MdiIcons.bookOpen, color: Theme.of(context).colorScheme.primary) + : Icon( + MdiIcons.bookOpenOutline, + color: Theme.of(context).colorScheme.onSurface, + ); + + return Visibility( + visible: + (readerabilityState.readerable && + (enableReadability || readerabilityState.active)) || + (enforceReadability && enableReadability), + child: readerChanging.when( + skipLoadingOnReload: true, + data: (_) => InkWell( + onTap: readerChanging.isLoading + ? null + : () async { + await ref + .read(readerableScreenControllerProvider.notifier) + .toggleReaderView(!readerabilityState.active); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 15.0, + horizontal: 8.0, + ), + child: icon, + ), + ), + error: (error, stackTrace) => SizedBox.shrink(), + loading: () => AnimateGradientShader( + duration: const Duration(milliseconds: 500), + primaryEnd: Alignment.bottomLeft, + secondaryEnd: Alignment.topRight, + primaryColors: [colorScheme.primary, colorScheme.primaryContainer], + secondaryColors: [ + colorScheme.secondary, + colorScheme.secondaryContainer, + ], + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 15.0, + horizontal: 8.0, + ), + child: icon, + ), + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/search/domain/entities/tab_preview.dart b/app/lib/features/geckoview/features/search/domain/entities/tab_preview.dart new file mode 100644 index 00000000..8878d0f8 --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/entities/tab_preview.dart @@ -0,0 +1,33 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/domain/entities/equatable_image.dart'; + +class TabPreview with FastEquatable { + final String id; + + final String title; + final EquatableImage? icon; + + final Uri url; + final String? highlightedUrl; + + final String? content; + + TabPreview({ + required this.id, + required this.title, + required this.icon, + required this.url, + required this.highlightedUrl, + required this.content, + }); + + @override + List get hashParameters => [ + id, + title, + icon, + url, + highlightedUrl, + content, + ]; +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.dart b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.dart new file mode 100644 index 00000000..d8cb7f4b --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.dart @@ -0,0 +1,54 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; + +part 'engine_suggestions.g.dart'; + +@Riverpod() +class EngineSuggestions extends _$EngineSuggestions { + Future getAutocompleteSuggestion(String query) { + return ref + .read(engineSuggestionsServiceProvider) + .getAutocompleteSuggestion(query) + .then((result) => result?.text); + } + + Future addQuery( + String query, { + List providers = const [GeckoSuggestionType.history], + }) { + return ref + .read(engineSuggestionsServiceProvider) + .querySuggestions(query, providers: providers); + } + + @override + Stream> build() { + final service = ref.watch(engineSuggestionsServiceProvider); + return ConcatStream([Stream.value([]), service.suggestionsStream]); + } +} + +@Riverpod() +AsyncValue> engineHistorySuggestions(Ref ref) { + return ref.watch( + engineSuggestionsProvider.select( + (suggestions) => suggestions.whenData( + (suggestions) => suggestions + .where( + (suggestion) => + suggestion.type == GeckoSuggestionType.history && + (suggestion.title.isNotEmpty) && + (suggestion.description.mapNotNull( + (url) => Uri.tryParse(url), + ) != + null), + ) + .toList(), + ), + ), + ); +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart new file mode 100644 index 00000000..7fdc5ff0 --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'engine_suggestions.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$engineHistorySuggestionsHash() => + r'7e6c17e6a2df98098ca1a683a438d1c261ccabdf'; + +/// See also [engineHistorySuggestions]. +@ProviderFor(engineHistorySuggestions) +final engineHistorySuggestionsProvider = + AutoDisposeProvider>>.internal( + engineHistorySuggestions, + name: r'engineHistorySuggestionsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$engineHistorySuggestionsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef EngineHistorySuggestionsRef = + AutoDisposeProviderRef>>; +String _$engineSuggestionsHash() => r'ba2c2d7f0a5e99fd9e639acd020b8cf3016c6126'; + +/// See also [EngineSuggestions]. +@ProviderFor(EngineSuggestions) +final engineSuggestionsProvider = + AutoDisposeStreamNotifierProvider< + EngineSuggestions, + List + >.internal( + EngineSuggestions.new, + name: r'engineSuggestionsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$engineSuggestionsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$EngineSuggestions = AutoDisposeStreamNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_suggestions.dart b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions.dart new file mode 100644 index 00000000..8b84b01c --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions.dart @@ -0,0 +1,58 @@ +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/repositories/search_suggestions.dart'; +import 'package:weblibre/features/search/domain/autosuggest/brave.dart'; +import 'package:weblibre/features/search/domain/autosuggest/duckduckgo.dart'; +import 'package:weblibre/features/search/domain/autosuggest/empty.dart'; +import 'package:weblibre/features/search/domain/autosuggest/kagi.dart'; +import 'package:weblibre/features/search/domain/autosuggest/qwant.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +part 'search_suggestions.g.dart'; + +@Riverpod(keepAlive: true) +ISearchSuggestionProvider defaultSearchSuggestions(Ref ref) { + final provider = ref.watch( + generalSettingsRepositoryProvider.select( + (value) => value.defaultSearchSuggestionsProvider, + ), + ); + + return switch (provider) { + SearchSuggestionProviders.none => ref.watch( + emptyAutosuggestServiceProvider.notifier, + ), + SearchSuggestionProviders.brave => ref.watch( + braveAutosuggestServiceProvider.notifier, + ), + SearchSuggestionProviders.ddg => ref.watch( + duckDuckGoAutosuggestServiceProvider.notifier, + ), + SearchSuggestionProviders.kagi => ref.watch( + kagiAutosuggestServiceProvider.notifier, + ), + SearchSuggestionProviders.qwant => ref.watch( + qwantAutosuggestServiceProvider.notifier, + ), + }; +} + +@Riverpod() +class SearchSuggestions extends _$SearchSuggestions { + late void Function(String query) _addQueryBinding; + + void addQuery(String query) => _addQueryBinding(query); + + @override + Stream> build({ISearchSuggestionProvider? suggestionsProvider}) { + final defaultProvider = ref.watch(defaultSearchSuggestionsProvider); + final resolvedProvider = suggestionsProvider ?? defaultProvider; + + _addQueryBinding = ref + .watch(searchSuggestionsRepositoryProvider(resolvedProvider).notifier) + .addQuery; + + return ref.watch(searchSuggestionsRepositoryProvider(resolvedProvider)); + } +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_suggestions.g.dart b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions.g.dart new file mode 100644 index 00000000..0eb555c3 --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions.g.dart @@ -0,0 +1,192 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_suggestions.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$defaultSearchSuggestionsHash() => + r'eb96a5db465422558a26e22abb3f843a82c17452'; + +/// See also [defaultSearchSuggestions]. +@ProviderFor(defaultSearchSuggestions) +final defaultSearchSuggestionsProvider = + Provider.internal( + defaultSearchSuggestions, + name: r'defaultSearchSuggestionsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$defaultSearchSuggestionsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef DefaultSearchSuggestionsRef = ProviderRef; +String _$searchSuggestionsHash() => r'8d1fb72cab374491a53f7a814f87af6a26356f42'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$SearchSuggestions + extends BuildlessAutoDisposeStreamNotifier> { + late final ISearchSuggestionProvider? suggestionsProvider; + + Stream> build({ISearchSuggestionProvider? suggestionsProvider}); +} + +/// See also [SearchSuggestions]. +@ProviderFor(SearchSuggestions) +const searchSuggestionsProvider = SearchSuggestionsFamily(); + +/// See also [SearchSuggestions]. +class SearchSuggestionsFamily extends Family>> { + /// See also [SearchSuggestions]. + const SearchSuggestionsFamily(); + + /// See also [SearchSuggestions]. + SearchSuggestionsProvider call({ + ISearchSuggestionProvider? suggestionsProvider, + }) { + return SearchSuggestionsProvider(suggestionsProvider: suggestionsProvider); + } + + @override + SearchSuggestionsProvider getProviderOverride( + covariant SearchSuggestionsProvider provider, + ) { + return call(suggestionsProvider: provider.suggestionsProvider); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'searchSuggestionsProvider'; +} + +/// See also [SearchSuggestions]. +class SearchSuggestionsProvider + extends + AutoDisposeStreamNotifierProviderImpl> { + /// See also [SearchSuggestions]. + SearchSuggestionsProvider({ISearchSuggestionProvider? suggestionsProvider}) + : this._internal( + () => SearchSuggestions()..suggestionsProvider = suggestionsProvider, + from: searchSuggestionsProvider, + name: r'searchSuggestionsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$searchSuggestionsHash, + dependencies: SearchSuggestionsFamily._dependencies, + allTransitiveDependencies: + SearchSuggestionsFamily._allTransitiveDependencies, + suggestionsProvider: suggestionsProvider, + ); + + SearchSuggestionsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.suggestionsProvider, + }) : super.internal(); + + final ISearchSuggestionProvider? suggestionsProvider; + + @override + Stream> runNotifierBuild(covariant SearchSuggestions notifier) { + return notifier.build(suggestionsProvider: suggestionsProvider); + } + + @override + Override overrideWith(SearchSuggestions Function() create) { + return ProviderOverride( + origin: this, + override: SearchSuggestionsProvider._internal( + () => create()..suggestionsProvider = suggestionsProvider, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + suggestionsProvider: suggestionsProvider, + ), + ); + } + + @override + AutoDisposeStreamNotifierProviderElement> + createElement() { + return _SearchSuggestionsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SearchSuggestionsProvider && + other.suggestionsProvider == suggestionsProvider; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, suggestionsProvider.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SearchSuggestionsRef + on AutoDisposeStreamNotifierProviderRef> { + /// The parameter `suggestionsProvider` of this provider. + ISearchSuggestionProvider? get suggestionsProvider; +} + +class _SearchSuggestionsProviderElement + extends + AutoDisposeStreamNotifierProviderElement< + SearchSuggestions, + List + > + with SearchSuggestionsRef { + _SearchSuggestionsProviderElement(super.provider); + + @override + ISearchSuggestionProvider? get suggestionsProvider => + (origin as SearchSuggestionsProvider).suggestionsProvider; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/search/domain/repositories/search_suggestions.dart b/app/lib/features/geckoview/features/search/domain/repositories/search_suggestions.dart new file mode 100644 index 00000000..b6a05261 --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/repositories/search_suggestions.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; +import 'package:weblibre/utils/lru_cache.dart'; + +part 'search_suggestions.g.dart'; + +@Riverpod(keepAlive: true) +class SearchSuggestionsRepository extends _$SearchSuggestionsRepository { + final LRUCache> _cache; + + late StreamController _queryStreamController; + + SearchSuggestionsRepository() : _cache = LRUCache(100); + + void addQuery(String query) { + if (!_queryStreamController.isClosed) { + _queryStreamController.add(query); + } + } + + @override + Raw>> build( + ISearchSuggestionProvider suggestionsProvider, + ) { + _queryStreamController = StreamController(); + ref.onDispose(() async { + await _queryStreamController.close(); + }); + + return _queryStreamController.stream + .sampleTime(const Duration(milliseconds: 100)) + .switchMap>((query) { + if (query.isEmpty) { + return Stream.value([]); + } + + final cached = _cache.get(query); + if (cached != null) { + return Stream.value(cached); + } + + return suggestionsProvider + // ignore: discarded_futures is used as stream + .getSuggestions(query) + // ignore: discarded_futures is used as stream + .then((result) { + result.onSuccess((result) { + _cache.set(query, result); + }); + + return result.value; + }) + .asStream(); + }) + .asBroadcastStream(); + } +} diff --git a/app/lib/features/geckoview/features/search/domain/repositories/search_suggestions.g.dart b/app/lib/features/geckoview/features/search/domain/repositories/search_suggestions.g.dart new file mode 100644 index 00000000..e8c9c0e4 --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/repositories/search_suggestions.g.dart @@ -0,0 +1,188 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_suggestions.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$searchSuggestionsRepositoryHash() => + r'be75d075c3479c3d5e462211dec8bbd21b106820'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$SearchSuggestionsRepository + extends BuildlessNotifier>>> { + late final ISearchSuggestionProvider suggestionsProvider; + + Raw>> build( + ISearchSuggestionProvider suggestionsProvider, + ); +} + +/// See also [SearchSuggestionsRepository]. +@ProviderFor(SearchSuggestionsRepository) +const searchSuggestionsRepositoryProvider = SearchSuggestionsRepositoryFamily(); + +/// See also [SearchSuggestionsRepository]. +class SearchSuggestionsRepositoryFamily + extends Family>>> { + /// See also [SearchSuggestionsRepository]. + const SearchSuggestionsRepositoryFamily(); + + /// See also [SearchSuggestionsRepository]. + SearchSuggestionsRepositoryProvider call( + ISearchSuggestionProvider suggestionsProvider, + ) { + return SearchSuggestionsRepositoryProvider(suggestionsProvider); + } + + @override + SearchSuggestionsRepositoryProvider getProviderOverride( + covariant SearchSuggestionsRepositoryProvider provider, + ) { + return call(provider.suggestionsProvider); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'searchSuggestionsRepositoryProvider'; +} + +/// See also [SearchSuggestionsRepository]. +class SearchSuggestionsRepositoryProvider + extends + NotifierProviderImpl< + SearchSuggestionsRepository, + Raw>> + > { + /// See also [SearchSuggestionsRepository]. + SearchSuggestionsRepositoryProvider( + ISearchSuggestionProvider suggestionsProvider, + ) : this._internal( + () => + SearchSuggestionsRepository() + ..suggestionsProvider = suggestionsProvider, + from: searchSuggestionsRepositoryProvider, + name: r'searchSuggestionsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$searchSuggestionsRepositoryHash, + dependencies: SearchSuggestionsRepositoryFamily._dependencies, + allTransitiveDependencies: + SearchSuggestionsRepositoryFamily._allTransitiveDependencies, + suggestionsProvider: suggestionsProvider, + ); + + SearchSuggestionsRepositoryProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.suggestionsProvider, + }) : super.internal(); + + final ISearchSuggestionProvider suggestionsProvider; + + @override + Raw>> runNotifierBuild( + covariant SearchSuggestionsRepository notifier, + ) { + return notifier.build(suggestionsProvider); + } + + @override + Override overrideWith(SearchSuggestionsRepository Function() create) { + return ProviderOverride( + origin: this, + override: SearchSuggestionsRepositoryProvider._internal( + () => create()..suggestionsProvider = suggestionsProvider, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + suggestionsProvider: suggestionsProvider, + ), + ); + } + + @override + NotifierProviderElement< + SearchSuggestionsRepository, + Raw>> + > + createElement() { + return _SearchSuggestionsRepositoryProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SearchSuggestionsRepositoryProvider && + other.suggestionsProvider == suggestionsProvider; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, suggestionsProvider.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SearchSuggestionsRepositoryRef + on NotifierProviderRef>>> { + /// The parameter `suggestionsProvider` of this provider. + ISearchSuggestionProvider get suggestionsProvider; +} + +class _SearchSuggestionsRepositoryProviderElement + extends + NotifierProviderElement< + SearchSuggestionsRepository, + Raw>> + > + with SearchSuggestionsRepositoryRef { + _SearchSuggestionsRepositoryProviderElement(super.provider); + + @override + ISearchSuggestionProvider get suggestionsProvider => + (origin as SearchSuggestionsRepositoryProvider).suggestionsProvider; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart new file mode 100644 index 00000000..74e55d70 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/domain/providers/search.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart'; +import 'package:weblibre/utils/uri_parser.dart' as uri_parser; + +class SearchScreen extends HookConsumerWidget { + final String? initialSearchText; + final TabType tabType; + + const SearchScreen({required this.initialSearchText, required this.tabType}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + + final createChildTabsOption = ref.watch( + generalSettingsRepositoryProvider.select( + (value) => value.createChildTabsOption, + ), + ); + + final selectedTabType = useState(tabType); + final currentTabTabType = ref.watch(selectedTabTypeProvider); + + final privateTabMode = switch (selectedTabType.value) { + TabType.regular => false, + TabType.private => true, + TabType.child => currentTabTabType == TabType.private, + }; + + final searchTextController = useTextEditingController( + text: initialSearchText, + ); + final sampledSearchText = useSampledValueNotifier( + source: searchTextController, + sampleDuration: const Duration(milliseconds: 150), + ); + final searchFocusNode = useFocusNode(); + + final defaultSearchBang = ref.watch( + defaultSearchBangDataProvider.select((value) => value.valueOrNull), + ); + + final selectedBang = useState(null); + final activeBang = selectedBang.value ?? defaultSearchBang; + final showBangIcon = useState(false); + + ref.listen(selectedBangDataProvider(), (previous, next) { + if (previous != next) { + showBangIcon.value = true; + } + + selectedBang.value = next; + }); + + Future submitSearch(String query) async { + if (activeBang != null && (formKey.currentState?.validate() == true)) { + final searchUri = activeBang.getTemplateUrl(query); + + if (!privateTabMode) { + await ref + .read(bangSearchProvider.notifier) + .triggerBangSearch(activeBang, query); + } + + await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: searchUri, + private: privateTabMode, + parentId: (selectedTabType.value == TabType.child) + ? ref.read(selectedTabProvider) + : null, + ); + + if (context.mounted) { + ref.read(bottomSheetControllerProvider.notifier).dismiss(); + + context.pop(); + } + } + } + + return Scaffold( + body: Form( + key: formKey, + child: CustomScrollView( + slivers: [ + SliverAppBar( + floating: true, + pinned: true, + automaticallyImplyLeading: false, + title: Align( + child: Focus( + canRequestFocus: false, + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + const ButtonSegment( + value: TabType.regular, + label: Text('Regular'), + icon: Icon(MdiIcons.tab), + ), + const ButtonSegment( + value: TabType.private, + label: Text('Private'), + icon: Icon(MdiIcons.tabUnselected), + ), + if (createChildTabsOption) + const ButtonSegment( + value: TabType.child, + label: Text('Child'), + icon: Icon(MdiIcons.fileTree), + ), + ], + selected: {selectedTabType.value}, + onSelectionChanged: (value) { + selectedTabType.value = value.first; + // Restore focus to search field after segment change + WidgetsBinding.instance.addPostFrameCallback((_) { + searchFocusNode.requestFocus(); + }); + }, + style: switch (selectedTabType.value) { + TabType.regular => null, + TabType.private => SegmentedButton.styleFrom( + selectedBackgroundColor: const Color(0x648000D7), + ), + TabType.child => + (currentTabTabType == TabType.private) + ? SegmentedButton.styleFrom( + selectedBackgroundColor: const Color( + 0x648000D7, + ), + ) + : null, + }, + ), + ), + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: SearchField( + showBangIcon: showBangIcon.value, + textEditingController: searchTextController, + focusNode: searchFocusNode, + autofocus: true, + onSubmitted: (value) async { + if (value.isNotEmpty) { + var newUrl = uri_parser.tryParseUrl( + value, + eagerParsing: true, + ); + + if (newUrl == null) { + final defaultSearchBang = + ref.read(selectedBangDataProvider()) ?? + await ref.read( + defaultSearchBangDataProvider.future, + ); + + newUrl = defaultSearchBang?.getTemplateUrl(value); + } + + if (newUrl != null) { + await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: newUrl, + private: privateTabMode, + parentId: + (selectedTabType.value == TabType.child) + ? ref.read(selectedTabProvider) + : null, + ); + + if (context.mounted) { + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + + context.pop(); + } + } + } + }, + activeBang: activeBang, + showSuggestions: true, + ), + ), + ), + ), + SliverToBoxAdapter( + child: ClipboardFillLink(controller: searchTextController), + ), + const SliverToBoxAdapter(child: Divider()), + SearchTermSuggestions( + searchTextController: searchTextController, + activeBang: activeBang, + submitSearch: submitSearch, + ), + TabSearch(searchTextListenable: sampledSearchText), + FeedSearch(searchTextNotifier: sampledSearchText), + HistorySuggestions( + isPrivate: privateTabMode, + searchTextListenable: sampledSearchText, + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/bang_chips.dart b/app/lib/features/geckoview/features/search/presentation/widgets/bang_chips.dart new file mode 100644 index 00000000..f532620b --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/bang_chips.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/domain/providers/search.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/selectable_chips.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class BangChips extends HookConsumerWidget { + final bool displayMenu; + + final BangData? activeBang; + final void Function(BangData)? onSelected; + final void Function(BangData)? onDeleted; + + final TextEditingController? searchTextController; + + const BangChips({ + required this.activeBang, + required this.onSelected, + required this.onDeleted, + this.searchTextController, + this.displayMenu = true, + }); + + static Future resetBangDialog( + BuildContext context, + String triggerName, + ) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Reset usage frequency of $triggerName?'), + content: const Text('This will remove the Bang from quick select.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Reset'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final availableBangs = ref.watch(seamlessBangProvider); + + useListenableCallback(searchTextController, () { + ref + .read(seamlessBangProvider.notifier) + .search(searchTextController!.text); + }); + + return availableBangs.when( + skipLoadingOnReload: true, + data: (availableBangs) { + return SizedBox( + height: 48, + child: Row( + children: [ + if (activeBang != null || availableBangs.isNotEmpty) + Expanded( + child: SelectableChips( + itemId: (bang) => bang.trigger, + itemAvatar: (bang) => + UrlIcon([bang.getTemplateUrl('')], iconSize: 20), + itemLabel: (bang) => Text(bang.websiteName), + itemTooltip: (bang) => bang.trigger, + availableItems: availableBangs, + selectedItem: activeBang, + onSelected: onSelected, + onDeleted: onDeleted, + ), + ) + else + Visibility( + visible: displayMenu, + child: Expanded( + child: Text( + "Press '>' to search Bangs.", + style: TextStyle( + color: Theme.of(context).hintColor, + fontStyle: FontStyle.italic, + ), + ), + ), + ), + if (displayMenu) + IconButton( + onPressed: () async { + final searchText = searchTextController?.text.trim(); + + final trigger = await BangSearchRoute( + searchText: (searchText.isEmpty) + ? BangSearchRoute.emptySearchText + : searchText!, + ).push(context); + + if (trigger != null) { + ref + .read(selectedBangTriggerProvider().notifier) + .setTrigger(trigger); + } + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ); + }, + error: (error, stackTrace) => const SizedBox.shrink(), + loading: () => const SizedBox(height: 48, width: double.infinity), + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart b/app/lib/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart new file mode 100644 index 00000000..c4ba36b3 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:weblibre/presentation/hooks/cached_future.dart'; +import 'package:weblibre/utils/clipboard.dart'; + +class ClipboardFillLink extends HookWidget { + final TextEditingController controller; + + const ClipboardFillLink({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + final clipboardUrl = useCachedFuture(() => tryGetUriFromClipboard()); + final currentText = useValueListenable(controller); + + return Visibility( + visible: + clipboardUrl.hasData && + clipboardUrl.data.toString() != currentText.text, + child: ListTile( + leading: const Icon(MdiIcons.linkPlus), + title: const Text('Fill link from clipboard'), + onTap: () { + controller.text = clipboardUrl.data.toString(); + }, + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart new file mode 100644 index 00000000..4e806027 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart'; +import 'package:weblibre/features/user/domain/providers.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/auto_suggest_text_field.dart'; +import 'package:weblibre/presentation/widgets/speech_to_text_button.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class SearchField extends HookConsumerWidget { + final TextEditingController textEditingController; + final FocusNode? focusNode; + final Widget? label; + final bool autofocus; + final void Function(String)? onSubmitted; + final bool showSuggestions; + + final BangData? activeBang; + final bool showBangIcon; + + const SearchField({ + super.key, + required this.textEditingController, + required this.onSubmitted, + required this.activeBang, + required this.showSuggestions, + this.label, + this.focusNode, + this.showBangIcon = true, + this.autofocus = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final incognitoEnabled = ref.watch(incognitoModeEnabledProvider); + + final hasText = useListenableSelector( + textEditingController, + () => textEditingController.text.isNotEmpty, + ); + + final safeFocusNode = focusNode ?? useFocusNode(); + + final suggestion = useState(null); + final lastText = useRef(textEditingController.text); + + if (showSuggestions) { + useListenableCallback(textEditingController, () async { + if (textEditingController.text.isNotEmpty) { + if (suggestion.value.isNotEmpty && + lastText.value.length > 1 && + textEditingController.text == + lastText.value.substring(0, lastText.value.length - 1)) { + suggestion.value = null; + textEditingController.text = lastText.value; + } else if (textEditingController.text != lastText.value) { + final result = await ref + .read(engineSuggestionsProvider.notifier) + .getAutocompleteSuggestion(textEditingController.text); + + suggestion.value = result; + } + } + + lastText.value = textEditingController.text; + }); + } + + return AutoSuggestTextField( + controller: textEditingController, + suggestion: suggestion.value, + enableIMEPersonalizedLearning: !incognitoEnabled, + focusNode: safeFocusNode, + autofocus: autofocus, + decoration: InputDecoration( + border: InputBorder.none, + prefixIcon: (showBangIcon && activeBang != null) + ? Padding( + padding: const EdgeInsetsDirectional.all(12.0), + child: UrlIcon([ + activeBang!.getTemplateUrl(''), + ], iconSize: 24.0), + ) + : null, + label: label ?? const Text('Search'), + // hintText: 'Ask anything...', + floatingLabelBehavior: FloatingLabelBehavior.always, + suffixIcon: hasText + ? IconButton( + onPressed: () { + textEditingController.clear(); + }, + icon: const Icon(Icons.clear), + ) + : SpeechToTextButton( + onTextReceived: (data) { + textEditingController.text = data.toString(); + }, + ), + ), + onTapOutside: (event) { + safeFocusNode.unfocus(); + }, + onSubmitted: onSubmitted, + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart new file mode 100644 index 00000000..7532a8ad --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart @@ -0,0 +1,184 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:weblibre/core/providers/format.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/extensions/uri.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article_query_result.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; +import 'package:weblibre/features/web_feed/domain/providers.dart'; +import 'package:weblibre/features/web_feed/extensions/atom.dart'; +import 'package:weblibre/features/web_feed/extensions/feed_article.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class FeedSearch extends HookConsumerWidget { + static const _matchPrefix = '***'; + static const _matchSuffix = '***'; + + final ValueListenable searchTextNotifier; + + const FeedSearch({required this.searchTextNotifier}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final articlesAsync = ref.watch(articleSearchProvider(null)); + + useListenableCallback(searchTextNotifier, () async { + await ref + .read(articleSearchProvider(null).notifier) + .search( + searchTextNotifier.value.text, + // ignore: avoid_redundant_argument_values dont break things + matchPrefix: _matchPrefix, + // ignore: avoid_redundant_argument_values dont break things + matchSuffix: _matchSuffix, + ); + }); + + if (articlesAsync.hasValue && (articlesAsync.valueOrNull.isEmpty)) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + return MultiSliver( + children: [ + const SliverToBoxAdapter(child: Divider()), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Articles', style: Theme.of(context).textTheme.labelSmall), + ], + ), + ), + ), + SliverSkeletonizer( + enabled: articlesAsync.isLoading, + child: articlesAsync.when( + data: (articles) => SliverList.builder( + itemCount: articles.length, + itemBuilder: (context, index) { + final article = articles[index]; + + final titleHighlight = switch (article) { + final FeedArticleQueryResult result => + result.titleHighlight.whenNotEmpty, + _ => null, + }; + + final searchSnippet = switch (article) { + final FeedArticleQueryResult result => + result.summarySnippet.whenNotEmpty ?? + result.contentSnippet.whenNotEmpty, + _ => null, + }; + + final articleDate = article.updated ?? article.created; + + return ListTile( + leading: RepaintBoundary( + child: UrlIcon([ + article.icon ?? + article.links + ?.getRelation(FeedLinkRelation.alternate) + ?.uri ?? + article.siteLink ?? + article.feedId.base, + ], iconSize: 24.0), + ), + title: (titleHighlight.isNotEmpty) + ? MarkdownBody( + data: titleHighlight!, + styleSheet: MarkdownStyleSheet( + p: Theme.of(context).textTheme.titleMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + ), + ) + : Text( + article.displayTitle, + style: theme.textTheme.titleMedium, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (searchSnippet.isNotEmpty) + MarkdownBody( + data: searchSnippet!, + styleSheet: MarkdownStyleSheet( + p: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + a: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + decoration: TextDecoration.none, + ), + ), + ) + else + (article.summaryPlain != null) + ? Text( + article.summaryPlain!, + style: theme.textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : const SizedBox.shrink(), + if (articleDate != null) + Align( + alignment: Alignment.topRight, + child: Text( + ref + .read(formatProvider.notifier) + .fullDateTimeWithTimezone(articleDate), + style: theme.textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + onTap: () { + FeedArticleRoute( + articleId: article.id, + ).pushReplacement(context); + }, + ); + }, + ), + error: (error, stackTrace) { + return SliverToBoxAdapter( + child: FailureWidget( + title: 'Failed searching Articles', + exception: error, + ), + ); + }, + loading: () => SliverList.builder( + itemCount: articlesAsync.valueOrNull?.length ?? 3, + itemBuilder: (context, index) { + return const ListTile(title: Bone.text()); + }, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart new file mode 100644 index 00000000..a13e74d1 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart @@ -0,0 +1,136 @@ +import 'package:flutter/foundation.dart'; +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:nullability/nullability.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart'; +import 'package:weblibre/features/geckoview/utils/image_helper.dart'; +import 'package:weblibre/presentation/hooks/cached_future.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; + +class HistorySuggestions extends HookConsumerWidget { + final bool isPrivate; + final ValueListenable searchTextListenable; + + const HistorySuggestions({ + super.key, + required this.isPrivate, + required this.searchTextListenable, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historySuggestionsAsync = ref.watch(engineHistorySuggestionsProvider); + + useListenableCallback(searchTextListenable, () async { + await ref + .watch(engineSuggestionsProvider.notifier) + .addQuery(searchTextListenable.value.text); + }); + + if (historySuggestionsAsync.hasValue && + (historySuggestionsAsync.valueOrNull.isEmpty)) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + return MultiSliver( + children: [ + const SliverToBoxAdapter(child: Divider()), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + 'History', + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ), + SliverSkeletonizer( + enabled: historySuggestionsAsync.isLoading, + child: historySuggestionsAsync.when( + skipLoadingOnReload: true, + data: (historySuggestions) { + return SliverList.builder( + itemCount: historySuggestions.length, + itemBuilder: (context, index) { + final suggestion = historySuggestions[index]; + + return HookBuilder( + key: ValueKey(suggestion.id), + builder: (context) { + final icon = useCachedFuture( + () async => suggestion.icon.mapNotNull(tryDecodeImage), + //URL as key + [suggestion.description], + ); + + return ListTile( + leading: RepaintBoundary( + child: RawImage( + image: icon.data?.value, + height: 24, + width: 24, + ), + ), + title: suggestion.title.mapNotNull( + (title) => Text(title), + ), + subtitle: suggestion.description.mapNotNull( + (description) => Text( + description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + onTap: () async { + if (suggestion.description != null) { + if (Uri.tryParse(suggestion.description!) + case final Uri url) { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: url, private: isPrivate); + + if (context.mounted) { + ref + .read( + bottomSheetControllerProvider.notifier, + ) + .dismiss(); + + context.pop(); + } + } + } + }, + ); + }, + ); + }, + ); + }, + error: (error, stackTrace) { + return SliverToBoxAdapter( + child: FailureWidget( + title: 'Could not load history', + exception: error, + ), + ); + }, + loading: () => SliverList.builder( + itemCount: historySuggestionsAsync.valueOrNull?.length ?? 3, + itemBuilder: (context, index) { + return const ListTile(title: Bone.text()); + }, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart new file mode 100644 index 00000000..5d188290 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart @@ -0,0 +1,175 @@ +import 'package:collection/collection.dart'; +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/domain/repositories/data.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chips.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; + +class SearchTermSuggestions extends HookConsumerWidget { + final TextEditingController searchTextController; + final Future Function(String query) submitSearch; + final BangData? activeBang; + + final bool showHistory; + final bool showChips; + + const SearchTermSuggestions({ + required this.searchTextController, + required this.submitSearch, + required this.activeBang, + this.showHistory = true, + this.showChips = true, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchTextIsNotEmpty = useListenableSelector( + searchTextController, + () => searchTextController.text.isNotEmpty, + ); + + final searchSuggestions = ref.watch(searchSuggestionsProvider()); + + final searchHistory = ref.watch(searchHistoryProvider); + + useListenableCallback(searchTextController, () { + ref + .read(searchSuggestionsProvider().notifier) + .addQuery(searchTextController.text); + }); + + final Widget listSliver; + + if (showHistory && + !searchTextIsNotEmpty && + (searchHistory.value.isNotEmpty)) { + final entries = searchHistory.value!; + + listSliver = SliverList.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final query = entries[index].searchQuery; + + return ListTile( + leading: const Icon(Icons.history), + title: Text(query), + onLongPress: () { + searchTextController.text = query; + }, + onTap: () async { + await submitSearch(query); + }, + trailing: IconButton( + onPressed: () async { + await ref + .read(bangDataRepositoryProvider.notifier) + .removeSearchEntry(query); + }, + icon: const Icon(Icons.close), + ), + ); + }, + ); + } else { + final prioritizedSuggestions = [ + if (searchTextIsNotEmpty) searchTextController.text, + if (searchSuggestions.valueOrNull != null) + ...searchSuggestions.valueOrNull!.whereNot( + (suggestion) => suggestion == searchTextController.text, + ), + ]; + + listSliver = SliverList.builder( + itemCount: prioritizedSuggestions.length, + itemBuilder: (context, index) { + final suggestion = prioritizedSuggestions[index]; + + return ListTile( + leading: const Icon(Icons.search), + title: Text(suggestion), + onLongPress: () { + searchTextController.text = suggestion; + }, + onTap: () async { + await submitSearch(suggestion); + }, + ); + }, + ); + } + + return MultiSliver( + children: [ + if (showChips) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Search Provider', + style: Theme.of(context).textTheme.labelSmall, + ), + BangChips( + activeBang: activeBang, + onSelected: (bang) { + searchTextController.clear(); + + ref + .read(selectedBangTriggerProvider().notifier) + .setTrigger(bang.trigger); + }, + onDeleted: (bang) async { + if (ref.read(selectedBangTriggerProvider()) == + bang.trigger) { + ref + .read(selectedBangTriggerProvider().notifier) + .clearTrigger(); + } else { + final dialogResult = await BangChips.resetBangDialog( + context, + bang.trigger, + ); + + if (dialogResult == true) { + await ref + .read(bangDataRepositoryProvider.notifier) + .resetFrequency(bang.trigger); + } + } + }, + searchTextController: searchTextController, + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 150), + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return CustomScrollView( + shrinkWrap: true, + controller: controller, + slivers: [listSliver], + ); + }, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart new file mode 100644 index 00000000..97953086 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -0,0 +1,155 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class TabSearch extends HookConsumerWidget { + static const _matchPrefix = '***'; + static const _matchSuffix = '***'; + + final ValueListenable searchTextListenable; + + const TabSearch({required this.searchTextListenable}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedContainer = useState( + ref.read( + selectedContainerDataProvider.select((value) => value.valueOrNull), + ), + ); + + final tabs = ref + .watch( + seamlessFilteredTabPreviewsProvider( + TabSearchPartition.search, + (selectedContainer.value != null) + ? ContainerFilterById(containerId: selectedContainer.value!.id) + : ContainerFilterDisabled(), + ), + ) + .value; + + useListenableCallback(searchTextListenable, () async { + await ref + .read(tabSearchRepositoryProvider(TabSearchPartition.search).notifier) + .addQuery( + searchTextListenable.value.text, + // ignore: avoid_redundant_argument_values dont break things + matchPrefix: _matchPrefix, + // ignore: avoid_redundant_argument_values dont break things + matchSuffix: _matchSuffix, + ); + }); + + if (tabs.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + return MultiSliver( + children: [ + const SliverToBoxAdapter(child: Divider()), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tabs', style: Theme.of(context).textTheme.labelSmall), + ContainerChips( + displayMenu: false, + selectedContainer: selectedContainer.value, + onSelected: (container) { + selectedContainer.value = container; + }, + onDeleted: (container) { + selectedContainer.value = null; + }, + containerFilter: (container) => (container.tabCount ?? 0) > 0, + searchTextListenable: searchTextListenable, + ), + ], + ), + ), + ), + SliverList.builder( + itemCount: tabs.length, + itemBuilder: (context, index) { + final result = tabs[index]; + + final urlHasMatch = + result.highlightedUrl?.contains(_matchPrefix) ?? false; + final bodyHasMatch = + result.content?.contains(_matchPrefix) ?? false; + + return ListTile( + leading: RepaintBoundary( + child: + result.icon.mapNotNull( + (icon) => + RawImage(image: icon.value, height: 24, width: 24), + ) ?? + UrlIcon([result.url], iconSize: 24), + ), + title: result.title.mapNotNull( + (title) => MarkdownBody( + data: title, + styleSheet: MarkdownStyleSheet( + p: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ), + subtitle: (bodyHasMatch || urlHasMatch) + ? MarkdownBody( + data: bodyHasMatch + ? result.content! + : result.highlightedUrl!, + styleSheet: MarkdownStyleSheet( + p: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + a: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + decoration: TextDecoration.none, + ), + ), + ) + : Text( + result.url.toString(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onTap: () async { + await ref + .read(tabRepositoryProvider.notifier) + .selectTab(result.id); + + if (context.mounted) { + ref.read(bottomSheetControllerProvider.notifier).dismiss(); + + context.pop(); + } + }, + ); + }, + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/tabs/data/database/converters/container_metadata_converter.dart b/app/lib/features/geckoview/features/tabs/data/database/converters/container_metadata_converter.dart new file mode 100644 index 00000000..40e3bef0 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/database/converters/container_metadata_converter.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; + +class ContainerMetadataConverter + implements TypeConverter { + const ContainerMetadataConverter(); + + @override + ContainerMetadata fromSql(String fromDb) { + final json = jsonDecode(fromDb) as Map; + return ContainerMetadata.fromJson(json); + } + + @override + String toSql(ContainerMetadata value) { + return jsonEncode(value.toJson()); + } +} diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart new file mode 100644 index 00000000..725bbf12 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart @@ -0,0 +1,99 @@ +import 'dart:ui'; + +import 'package:drift/drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; + +part 'container.g.dart'; + +@DriftAccessor() +class ContainerDao extends DatabaseAccessor + with _$ContainerDaoMixin { + ContainerDao(super.db); + + Future addContainer(ContainerData container) { + return db.container.insertOne( + ContainerCompanion.insert( + id: container.id, + name: Value(container.name), + color: container.color, + metadata: Value(container.metadata), + ), + ); + } + + Future replaceContainer(ContainerData container) { + return db.container.replaceOne( + ContainerCompanion( + id: Value(container.id), + name: Value(container.name), + color: Value(container.color), + metadata: Value(container.metadata), + ), + ); + } + + Future deleteContainer(String id) { + return db.container.deleteOne(ContainerCompanion(id: Value(id))); + } + + SingleOrNullSelectable getContainerData(String id) { + return select(db.container)..where((t) => t.id.equals(id)); + } + + Selectable getDistinctColors() { + final query = db.selectOnly(db.container, distinct: true) + ..addColumns([db.container.color]) + ..where(db.container.color.isNotNull()); + + return query.map( + (row) => row.readWithConverter(db.container.color)!, + ); + } + + Selectable getContainerTabIds(String? containerId) { + final query = selectOnly(db.tab) + ..addColumns([db.tab.id]) + ..where( + (containerId != null) + ? db.tab.containerId.equals(containerId) + : db.tab.containerId.isNull(), + ) + ..orderBy([OrderingTerm.asc(db.tab.orderKey)]); + + return query.map((row) => row.read(db.tab.id)!); + } + + Selectable getContainerTabsData(String? containerId) { + final query = db.tab.select() + ..where( + (t) => (containerId != null) + ? t.containerId.equals(containerId) + : t.containerId.isNull(), + ) + ..orderBy([(t) => OrderingTerm.asc(t.orderKey)]); + + return query; + } + + SingleSelectable generateLeadingOrderKey( + String? containerId, { + int bucket = 0, + }) { + return db.leadingOrderKey(bucket: bucket, containerId: containerId); + } + + SingleSelectable generateTrailingOrderKey( + String? containerId, { + int bucket = 0, + }) { + return db.trailingOrderKey(bucket: bucket, containerId: containerId); + } + + SingleSelectable generateOrderKeyAfterTabId( + String? containerId, + String tabId, + ) { + return db.orderKeyAfterTab(containerId: containerId, tabId: tabId); + } +} diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/container.g.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/container.g.dart new file mode 100644 index 00000000..55df7c87 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/container.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'container.dart'; + +// ignore_for_file: type=lint +mixin _$ContainerDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart new file mode 100644 index 00000000..1ff63de0 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:lexo_rank/lexo_rank.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart'; + +part 'tab.g.dart'; + +@DriftAccessor() +class TabDao extends DatabaseAccessor with _$TabDaoMixin { + TabDao(super.db); + + UpdateStatement _updateByIdStatement(String id) => + db.tab.update()..where((t) => t.id.equals(id)); + + Selectable allTabData() => db.tab.select(); + + SingleOrNullSelectable getTabDataById(String id) => + db.tab.select()..where((t) => t.id.equals(id)); + + Selectable getAllTabIds() { + final query = selectOnly(db.tab) + ..addColumns([db.tab.id]) + ..orderBy([OrderingTerm.asc(db.tab.orderKey)]); + + return query.map((row) => row.read(db.tab.id)!); + } + + SingleOrNullSelectable getTabContainerId(String tabId) { + final query = selectOnly(db.tab) + ..addColumns([db.tab.containerId]) + ..where(db.tab.id.equals(tabId)); + + return query.map((row) => row.read(db.tab.containerId)); + } + + Future upsertContainerTabTransactional( + Future Function() createTab, { + required Value parentId, + Value containerId = const Value.absent(), + Value orderKey = const Value.absent(), + }) { + return db.transaction(() async { + final tabId = await createTab(); + + final currentOrderKey = + orderKey.value ?? + await db.containerDao + .generateLeadingOrderKey(containerId.value) + .getSingle(); + + await db.tab.insertOne( + TabCompanion.insert( + id: tabId, + parentId: parentId, + timestamp: DateTime.now(), + containerId: containerId, + orderKey: currentOrderKey, + ), + onConflict: DoUpdate( + (old) => TabCompanion( + parentId: parentId, + containerId: containerId, + orderKey: Value.absentIfNull(orderKey.value), + ), + ), + ); + + return tabId; + }); + } + + //Upsert an tab only if there is no container assigned yet + Future upsertUnassignedTab( + String tabId, { + required Value parentId, + Value containerId = const Value.absent(), + Value orderKey = const Value.absent(), + }) { + return db.transaction(() async { + final currentOrderKey = + orderKey.value ?? + await db.containerDao + .generateLeadingOrderKey(containerId.value) + .getSingle(); + + await db.tab.insertOne( + TabCompanion.insert( + id: tabId, + parentId: parentId, + timestamp: DateTime.now(), + containerId: containerId, + orderKey: currentOrderKey, + ), + onConflict: DoUpdate( + (old) => TabCompanion( + parentId: parentId, + containerId: containerId, + orderKey: Value.absentIfNull(orderKey.value), + ), + where: (old) => old.containerId.isNull(), + ), + ); + + return tabId; + }); + } + + Future assignContainer(String id, {required String? containerId}) { + final statement = _updateByIdStatement(id); + return statement.write(TabCompanion(containerId: Value(containerId))); + } + + Future assignOrderKey(String id, {required String orderKey}) { + final statement = _updateByIdStatement(id); + return statement.write(TabCompanion(orderKey: Value(orderKey))); + } + + Future touchTab(String id, {required DateTime timestamp}) { + final statement = _updateByIdStatement(id); + return statement.write(TabCompanion(timestamp: Value(timestamp))); + } + + Future updateTabContent( + String id, { + required bool isProbablyReaderable, + required String? extractedContentMarkdown, + required String? extractedContentPlain, + required String? fullContentMarkdown, + required String? fullContentPlain, + }) async { + final statement = _updateByIdStatement(id); + + await statement.write( + TabCompanion( + isProbablyReaderable: Value(isProbablyReaderable), + extractedContentMarkdown: Value(extractedContentMarkdown), + extractedContentPlain: Value(extractedContentPlain), + fullContentMarkdown: Value(fullContentMarkdown), + fullContentPlain: Value(fullContentPlain), + ), + ); + } + + Future updateTabs( + Map? previous, + Map next, + ) async { + await batch((batch) { + for (final state in next.values) { + final previousState = previous?[state.id]; + + if (previousState == null || + previousState.url != state.url || + previousState.title != state.title) { + batch.update( + db.tab, + TabCompanion( + parentId: (previousState?.parentId != state.parentId) + ? Value(state.parentId) + : const Value.absent(), + url: (previousState?.url != state.url) + ? Value(state.url) + : const Value.absent(), + title: (previousState?.title != state.title) + ? Value(state.title) + : const Value.absent(), + ), + where: (t) => t.id.equals(state.id), + ); + } + } + }); + } + + Future syncTabs({required List retainTabIds}) { + return db.transaction(() async { + await (db.tab.delete()..where((t) => t.id.isNotIn(retainTabIds))).go(); + + var currentOrderKey = await db.containerDao + .generateLeadingOrderKey(null) + .getSingle(); + + await db.tab.insertAll( + retainTabIds.map((id) { + final insertable = TabCompanion.insert( + id: id, + orderKey: currentOrderKey, + timestamp: DateTime.now(), + ); + + currentOrderKey = LexoRank.parse(currentOrderKey).genPrev().value; + + return insertable; + }), + mode: InsertMode.insertOrIgnore, + ); + }); + } + + Selectable queryTabs({ + required String matchPrefix, + required String matchSuffix, + required String ellipsis, + required int snippetLength, + required String searchString, + }) { + final ftsQuery = db.buildFtsQuery(searchString); + + if (ftsQuery.isNotEmpty) { + return db.queryTabsFullContent( + query: ftsQuery, + snippetLength: snippetLength, + beforeMatch: matchPrefix, + afterMatch: matchSuffix, + ellipsis: ellipsis, + ); + } else { + return db.queryTabsBasic(query: db.buildLikeQuery(searchString)); + } + } +} diff --git a/app/lib/features/topics/data/database/daos/tab.g.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.g.dart similarity index 100% rename from app/lib/features/topics/data/database/daos/tab.g.dart rename to app/lib/features/geckoview/features/tabs/data/database/daos/tab.g.dart diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.dart b/app/lib/features/geckoview/features/tabs/data/database/database.dart new file mode 100644 index 00000000..5d261e67 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/database/database.dart @@ -0,0 +1,33 @@ +import 'package:drift/drift.dart'; +import 'package:flutter/widgets.dart' show Color; +import 'package:weblibre/data/database/converters/color.dart'; +import 'package:weblibre/data/database/converters/uri.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/converters/container_metadata_converter.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart'; +import 'package:weblibre/features/search/domain/fts_tokenizer.dart'; + +part 'database.g.dart'; + +@DriftDatabase(include: {'database.drift'}, daos: [ContainerDao, TabDao]) +class TabDatabase extends _$TabDatabase with TrigramQueryBuilderMixin { + @override + final int schemaVersion = 2; + + @override + final int ftsTokenLimit = 10; + @override + final int ftsMinTokenLength = 3; + + @override + MigrationStrategy get migration => MigrationStrategy( + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON;'); + await optimizeFtsIndex(); + }, + ); + + TabDatabase(super.e); +} diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.drift b/app/lib/features/geckoview/features/tabs/data/database/database.drift new file mode 100644 index 00000000..634228ba --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/database/database.drift @@ -0,0 +1,231 @@ +import 'package:weblibre/data/database/converters/color.dart'; +import 'package:weblibre/data/database/converters/uri.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/converters/container_metadata_converter.dart'; + +CREATE TABLE container ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT, + color INTEGER NOT NULL MAPPED BY `const ColorConverter()`, + metadata TEXT MAPPED BY `const ContainerMetadataConverter()` +) WITH ContainerData; + +CREATE TABLE tab( + id TEXT PRIMARY KEY 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 MAPPED BY `const UriConverterNullable()`, + title TEXT, + is_probably_readerable BOOL, + extracted_content_markdown TEXT, + extracted_content_plain TEXT, + full_content_markdown TEXT, + full_content_plain TEXT, + timestamp DATETIME NOT NULL +); + +CREATE VIRTUAL TABLE tab_fts + USING fts5( + title, + url, + extracted_content_plain, + full_content_plain, + content=tab, + tokenize="trigram" + ); + +-- Create trigger to handle parent deletion +CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN + -- Update all children of the deleted row to point to its parent + UPDATE tab + SET parent_id = OLD.parent_id + WHERE parent_id = OLD.id; +END; + +-- Triggers to keep the FTS index up to date. +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; +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; +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; + +optimizeFtsIndex: + INSERT INTO tab_fts(tab_fts) VALUES ('optimize'); + +containersWithCount WITH ContainerDataWithCount: + SELECT + container.*, + tab_agg.tab_count + FROM container + LEFT JOIN ( + SELECT + container_id, + COUNT(*) AS tab_count, + MAX(timestamp) AS last_updated + FROM tab + GROUP BY container_id + ) AS tab_agg ON container.id = tab_agg.container_id + ORDER BY tab_agg.last_updated DESC NULLS LAST; + +leadingOrderKey(:container_id AS TEXT OR NULL, :bucket AS INTEGER): + SELECT lexo_rank_previous( + :bucket, + ( + SELECT order_key + FROM tab + WHERE container_id IS :container_id + ORDER BY order_key + LIMIT 1 + ) + ); + +trailingOrderKey(:container_id AS TEXT OR NULL, :bucket AS INTEGER): + SELECT lexo_rank_next( + :bucket, + ( + SELECT order_key + FROM tab + WHERE container_id IS :container_id + ORDER BY order_key DESC + LIMIT 1 + ) + ); + +orderKeyAfterTab(:tab_id AS TEXT, :container_id AS TEXT OR NULL): + WITH ordered_table AS ( + SELECT id, + order_key, + LEAD(order_key) OVER (ORDER BY order_key) AS next_order_key + FROM tab + WHERE container_id IS :container_id + ) + SELECT lexo_rank_reorder_after(order_key, next_order_key) + FROM ordered_table + WHERE id = :tab_id; + +queryTabsBasic WITH TabQueryResult: + WITH weights AS ( + SELECT + -- Customize these weights (higher = more important) + 10.0 as title_weight, -- Title matches are most important + 5.0 as url_weight -- URL matches are quite important + ) + SELECT + t.id, + t.title, + CAST(t.url AS TEXT) AS url, + t.url AS clean_url, + bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank + FROM tab_fts fts + INNER JOIN + tab t ON t.rowid = fts.rowid + CROSS JOIN weights + WHERE + fts.title LIKE :query OR + fts.url LIKE :query + ORDER BY + weighted_rank ASC, + t.timestamp DESC; + +queryTabsFullContent WITH TabQueryResult: + WITH weights AS ( + SELECT + -- Customize these weights (higher = more important) + 10.0 as title_weight, -- Title matches are most important + 5.0 as url_weight, -- URL matches are quite important + 3.0 as extracted_weight, -- Extracted content matches + 1.0 as full_weight -- Full content matches less important + ) + SELECT + t.id, + highlight(tab_fts, 0, :beforeMatch, :afterMatch) AS title, + highlight(tab_fts, 1, :beforeMatch, :afterMatch) AS url, + snippet(tab_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS extracted_content, + snippet(tab_fts, 3, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS full_content, + t.url AS clean_url, + ( + bm25(tab_fts, weights.title_weight, weights.url_weight, + weights.extracted_weight, weights.full_weight) + ) AS weighted_rank + FROM tab_fts(:query) fts + INNER JOIN + tab t ON t.rowid = fts.rowid + CROSS JOIN weights + ORDER BY + weighted_rank ASC, + t.timestamp DESC; + +tabTrees: + WITH RECURSIVE descendants AS ( + -- Base case: all root tabs (no parent) + SELECT + id, + parent_id, + timestamp, + id AS root_id + FROM tab + WHERE parent_id IS NULL + + UNION ALL + + -- Recursive case: find all descendants + SELECT + t.id, + t.parent_id, + t.timestamp, + d.root_id + FROM tab t + JOIN descendants d ON t.parent_id = d.id +), +root_stats AS ( + -- Calculate stats for each root + SELECT + root_id, + MAX(timestamp) AS max_timestamp, + COUNT(*) AS total_children + FROM descendants + GROUP BY root_id +) +-- Get the actual tab records with latest timestamp per root +SELECT + d.root_id AS root_tab_id, + d.id AS latest_tab_id, + d.timestamp AS latest_timestamp, + rs.total_children AS total_tabs +FROM descendants d +JOIN root_stats rs +ON + d.root_id = rs.root_id AND + d.timestamp = rs.max_timestamp +ORDER BY d.timestamp DESC; + +unorderedTabDescendants: + WITH RECURSIVE descendants AS ( + SELECT id, parent_id + FROM tab + WHERE id = :tab_id + + UNION ALL + + -- Recursive case: find all descendants + SELECT t.id, t.parent_id + FROM tab t + JOIN descendants d ON t.parent_id = d.id + ) + SELECT id, parent_id + FROM descendants; \ No newline at end of file diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.g.dart b/app/lib/features/geckoview/features/tabs/data/database/database.g.dart new file mode 100644 index 00000000..192eaf91 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/database/database.g.dart @@ -0,0 +1,2230 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class Container extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Container(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumnWithTypeConverter color = + GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(Container.$convertercolor); + late final GeneratedColumnWithTypeConverter + metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(Container.$convertermetadatan); + @override + List get $columns => [id, name, color, metadata]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'container'; + @override + Set get $primaryKey => {id}; + @override + ContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ContainerData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + color: Container.$convertercolor.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color'], + )!, + ), + metadata: Container.$convertermetadatan.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + ), + ); + } + + @override + Container createAlias(String alias) { + return Container(attachedDatabase, alias); + } + + static TypeConverter $convertercolor = const ColorConverter(); + static TypeConverter $convertermetadata = + const ContainerMetadataConverter(); + static TypeConverter $convertermetadatan = + NullAwareTypeConverter.wrap($convertermetadata); + @override + bool get dontWriteConstraints => true; +} + +class ContainerCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value color; + final Value metadata; + final Value rowid; + const ContainerCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.color = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }); + ContainerCompanion.insert({ + required String id, + this.name = const Value.absent(), + required Color color, + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + color = Value(color); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? color, + Expression? metadata, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (color != null) 'color': color, + if (metadata != null) 'metadata': metadata, + if (rowid != null) 'rowid': rowid, + }); + } + + ContainerCompanion copyWith({ + Value? id, + Value? name, + Value? color, + Value? metadata, + Value? rowid, + }) { + return ContainerCompanion( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + metadata: metadata ?? this.metadata, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (color.present) { + map['color'] = Variable( + Container.$convertercolor.toSql(color.value), + ); + } + if (metadata.present) { + map['metadata'] = Variable( + Container.$convertermetadatan.toSql(metadata.value), + ); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ContainerCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Tab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Tab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES tab(id)ON DELETE SET NULL', + ); + late final GeneratedColumn containerId = GeneratedColumn( + 'container_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES container(id)ON DELETE CASCADE', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumnWithTypeConverter url = + GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(Tab.$converterurl); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + id, + parentId, + containerId, + orderKey, + url, + title, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab'; + @override + Set get $primaryKey => {id}; + @override + TabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + containerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}container_id'], + ), + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + url: Tab.$converterurl.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}timestamp'], + )!, + ); + } + + @override + Tab createAlias(String alias) { + return Tab(attachedDatabase, alias); + } + + static TypeConverter $converterurl = + const UriConverterNullable(); + @override + bool get dontWriteConstraints => true; +} + +class TabData extends DataClass implements Insertable { + final String id; + final String? parentId; + final String? containerId; + final String orderKey; + final Uri? url; + final String? title; + final bool? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final DateTime timestamp; + const TabData({ + required this.id, + this.parentId, + this.containerId, + required this.orderKey, + this.url, + this.title, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + required this.timestamp, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + if (!nullToAbsent || containerId != null) { + map['container_id'] = Variable(containerId); + } + map['order_key'] = Variable(orderKey); + if (!nullToAbsent || url != null) { + map['url'] = Variable(Tab.$converterurl.toSql(url)); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + map['timestamp'] = Variable(timestamp); + return map; + } + + factory TabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabData( + id: serializer.fromJson(json['id']), + parentId: serializer.fromJson(json['parent_id']), + containerId: serializer.fromJson(json['container_id']), + orderKey: serializer.fromJson(json['order_key']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + isProbablyReaderable: serializer.fromJson( + json['is_probably_readerable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extracted_content_markdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extracted_content_plain'], + ), + fullContentMarkdown: serializer.fromJson( + json['full_content_markdown'], + ), + fullContentPlain: serializer.fromJson( + json['full_content_plain'], + ), + timestamp: serializer.fromJson(json['timestamp']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'parent_id': serializer.toJson(parentId), + 'container_id': serializer.toJson(containerId), + 'order_key': serializer.toJson(orderKey), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'is_probably_readerable': serializer.toJson(isProbablyReaderable), + 'extracted_content_markdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extracted_content_plain': serializer.toJson( + extractedContentPlain, + ), + 'full_content_markdown': serializer.toJson(fullContentMarkdown), + 'full_content_plain': serializer.toJson(fullContentPlain), + 'timestamp': serializer.toJson(timestamp), + }; + } + + TabData copyWith({ + String? id, + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + String? orderKey, + Value url = const Value.absent(), + Value title = const Value.absent(), + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + DateTime? timestamp, + }) => TabData( + id: id ?? this.id, + parentId: parentId.present ? parentId.value : this.parentId, + containerId: containerId.present ? containerId.value : this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url.present ? url.value : this.url, + title: title.present ? title.value : this.title, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + ); + TabData copyWithCompanion(TabCompanion data) { + return TabData( + id: data.id.present ? data.id.value : this.id, + parentId: data.parentId.present ? data.parentId.value : this.parentId, + containerId: data.containerId.present + ? data.containerId.value + : this.containerId, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + isProbablyReaderable: data.isProbablyReaderable.present + ? data.isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: data.extractedContentMarkdown.present + ? data.extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: data.fullContentMarkdown.present + ? data.fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + ); + } + + @override + String toString() { + return (StringBuffer('TabData(') + ..write('id: $id, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + parentId, + containerId, + orderKey, + url, + title, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabData && + other.id == this.id && + other.parentId == this.parentId && + other.containerId == this.containerId && + other.orderKey == this.orderKey && + other.url == this.url && + other.title == this.title && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.timestamp == this.timestamp); +} + +class TabCompanion extends UpdateCompanion { + final Value id; + final Value parentId; + final Value containerId; + final Value orderKey; + final Value url; + final Value title; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value timestamp; + final Value rowid; + const TabCompanion({ + this.id = const Value.absent(), + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + this.orderKey = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.timestamp = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabCompanion.insert({ + required String id, + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + required String orderKey, + this.url = const Value.absent(), + this.title = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + required DateTime timestamp, + this.rowid = const Value.absent(), + }) : id = Value(id), + orderKey = Value(orderKey), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? parentId, + Expression? containerId, + Expression? orderKey, + Expression? url, + Expression? title, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? timestamp, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (parentId != null) 'parent_id': parentId, + if (containerId != null) 'container_id': containerId, + if (orderKey != null) 'order_key': orderKey, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (timestamp != null) 'timestamp': timestamp, + if (rowid != null) 'rowid': rowid, + }); + } + + TabCompanion copyWith({ + Value? id, + Value? parentId, + Value? containerId, + Value? orderKey, + Value? url, + Value? title, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? timestamp, + Value? rowid, + }) { + return TabCompanion( + id: id ?? this.id, + parentId: parentId ?? this.parentId, + containerId: containerId ?? this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url ?? this.url, + title: title ?? this.title, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (containerId.present) { + map['container_id'] = Variable(containerId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (url.present) { + map['url'] = Variable(Tab.$converterurl.toSql(url.value)); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable( + isProbablyReaderable.value, + ); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabCompanion(') + ..write('id: $id, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class TabFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TabFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + url, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_fts'; + @override + Set get $primaryKey => const {}; + @override + TabFt map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabFt( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + )!, + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + )!, + ); + } + + @override + TabFts createAlias(String alias) { + return TabFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")'; +} + +class TabFt extends DataClass implements Insertable { + final String title; + final String url; + final String extractedContentPlain; + final String fullContentPlain; + const TabFt({ + required this.title, + required this.url, + required this.extractedContentPlain, + required this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['title'] = Variable(title); + map['url'] = Variable(url); + map['extracted_content_plain'] = Variable(extractedContentPlain); + map['full_content_plain'] = Variable(fullContentPlain); + return map; + } + + factory TabFt.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabFt( + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + extractedContentPlain: serializer.fromJson( + json['extracted_content_plain'], + ), + fullContentPlain: serializer.fromJson(json['full_content_plain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'extracted_content_plain': serializer.toJson( + extractedContentPlain, + ), + 'full_content_plain': serializer.toJson(fullContentPlain), + }; + } + + TabFt copyWith({ + String? title, + String? url, + String? extractedContentPlain, + String? fullContentPlain, + }) => TabFt( + title: title ?? this.title, + url: url ?? this.url, + extractedContentPlain: extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + ); + TabFt copyWithCompanion(TabFtsCompanion data) { + return TabFt( + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('TabFt(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(title, url, extractedContentPlain, fullContentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabFt && + other.title == this.title && + other.url == this.url && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class TabFtsCompanion extends UpdateCompanion { + final Value title; + final Value url; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const TabFtsCompanion({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabFtsCompanion.insert({ + required String title, + required String url, + required String extractedContentPlain, + required String fullContentPlain, + this.rowid = const Value.absent(), + }) : title = Value(title), + url = Value(url), + extractedContentPlain = Value(extractedContentPlain), + fullContentPlain = Value(fullContentPlain); + static Insertable custom({ + Expression? title, + Expression? url, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (url != null) 'url': url, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + TabFtsCompanion copyWith({ + Value? title, + Value? url, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return TabFtsCompanion( + title: title ?? this.title, + url: url ?? this.url, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabFtsCompanion(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$TabDatabase extends GeneratedDatabase { + _$TabDatabase(QueryExecutor e) : super(e); + $TabDatabaseManager get managers => $TabDatabaseManager(this); + late final Container container = Container(this); + late final Tab tab = Tab(this); + late final TabFts tabFts = TabFts(this); + late final Trigger tabMaintainParentChainOnDelete = Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = OLD.parent_id WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + late final Trigger tabAfterInsert = Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + late final Trigger tabAfterDelete = Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + late final Trigger tabAfterUpdate = Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + late final ContainerDao containerDao = ContainerDao(this as TabDatabase); + late final TabDao tabDao = TabDao(this as TabDatabase); + Future optimizeFtsIndex() { + return customInsert( + 'INSERT INTO tab_fts (tab_fts) VALUES (\'optimize\')', + variables: [], + updates: {tabFts}, + ); + } + + Selectable containersWithCount() { + return customSelect( + 'SELECT container.*, tab_agg.tab_count FROM container LEFT JOIN (SELECT container_id, COUNT(*) AS tab_count, MAX(timestamp) AS last_updated FROM tab GROUP BY container_id) AS tab_agg ON container.id = tab_agg.container_id ORDER BY tab_agg.last_updated DESC NULLS LAST', + variables: [], + readsFrom: {container, tab}, + ).map( + (QueryRow row) => ContainerDataWithCount( + id: row.read('id'), + name: row.readNullable('name'), + color: Container.$convertercolor.fromSql(row.read('color')), + metadata: NullAwareTypeConverter.wrapFromSql( + Container.$convertermetadata, + row.readNullable('metadata'), + ), + tabCount: row.readNullable('tab_count'), + ), + ); + } + + Selectable leadingOrderKey({ + required int bucket, + String? containerId, + }) { + return customSelect( + 'SELECT lexo_rank_previous(?1, (SELECT order_key FROM tab WHERE container_id IS ?2 ORDER BY order_key LIMIT 1)) AS _c0', + variables: [Variable(bucket), Variable(containerId)], + readsFrom: {tab}, + ).map((QueryRow row) => row.read('_c0')); + } + + Selectable trailingOrderKey({ + required int bucket, + String? containerId, + }) { + return customSelect( + 'SELECT lexo_rank_next(?1, (SELECT order_key FROM tab WHERE container_id IS ?2 ORDER BY order_key DESC LIMIT 1)) AS _c0', + variables: [Variable(bucket), Variable(containerId)], + readsFrom: {tab}, + ).map((QueryRow row) => row.read('_c0')); + } + + Selectable orderKeyAfterTab({ + String? containerId, + required String tabId, + }) { + return customSelect( + 'WITH ordered_table AS (SELECT id, order_key, LEAD(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS next_order_key FROM tab WHERE container_id IS ?1) SELECT lexo_rank_reorder_after(order_key, next_order_key) AS _c0 FROM ordered_table WHERE id = ?2', + variables: [Variable(containerId), Variable(tabId)], + readsFrom: {tab}, + ).map((QueryRow row) => row.read('_c0')); + } + + Selectable queryTabsBasic({required String query}) { + return customSelect( + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, t.title, CAST(t.url AS TEXT) AS url, t.url AS clean_url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?1 OR fts.url LIKE ?1 ORDER BY weighted_rank ASC, t.timestamp DESC', + variables: [Variable(query)], + readsFrom: {tab, tabFts}, + ).map( + (QueryRow row) => TabQueryResult( + id: row.read('id'), + title: row.readNullable('title'), + url: row.readNullable('url'), + cleanUrl: Tab.$converterurl.fromSql( + row.readNullable('clean_url'), + ), + weightedRank: row.read('weighted_rank'), + ), + ); + } + + Selectable queryTabsFullContent({ + required String beforeMatch, + required String afterMatch, + required String ellipsis, + required int snippetLength, + required String query, + }) { + return customSelect( + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content, t.url AS clean_url,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC', + variables: [ + Variable(beforeMatch), + Variable(afterMatch), + Variable(ellipsis), + Variable(snippetLength), + Variable(query), + ], + readsFrom: {tab, tabFts}, + ).map( + (QueryRow row) => TabQueryResult( + id: row.read('id'), + title: row.readNullable('title'), + url: row.readNullable('url'), + cleanUrl: Tab.$converterurl.fromSql( + row.readNullable('clean_url'), + ), + extractedContent: row.readNullable('extracted_content'), + fullContent: row.readNullable('full_content'), + weightedRank: row.read('weighted_rank'), + ), + ); + } + + Selectable tabTrees() { + return customSelect( + 'WITH RECURSIVE descendants AS (SELECT id, parent_id, timestamp, id AS root_id FROM tab WHERE parent_id IS NULL UNION ALL SELECT t.id, t.parent_id, t.timestamp, d.root_id FROM tab AS t JOIN descendants AS d ON t.parent_id = d.id), root_stats AS (SELECT root_id, MAX(timestamp) AS max_timestamp, COUNT(*) AS total_children FROM descendants GROUP BY root_id) SELECT d.root_id AS root_tab_id, d.id AS latest_tab_id, d.timestamp AS latest_timestamp, rs.total_children AS total_tabs FROM descendants AS d JOIN root_stats AS rs ON d.root_id = rs.root_id AND d.timestamp = rs.max_timestamp ORDER BY d.timestamp DESC', + variables: [], + readsFrom: {tab}, + ).map( + (QueryRow row) => TabTreesResult( + rootTabId: row.read('root_tab_id'), + latestTabId: row.read('latest_tab_id'), + latestTimestamp: row.read('latest_timestamp'), + totalTabs: row.read('total_tabs'), + ), + ); + } + + Selectable unorderedTabDescendants({ + required String tabId, + }) { + return customSelect( + 'WITH RECURSIVE descendants AS (SELECT id, parent_id FROM tab WHERE id = ?1 UNION ALL SELECT t.id, t.parent_id FROM tab AS t JOIN descendants AS d ON t.parent_id = d.id) SELECT id, parent_id FROM descendants', + variables: [Variable(tabId)], + readsFrom: {tab}, + ).map( + (QueryRow row) => UnorderedTabDescendantsResult( + id: row.read('id'), + parentId: row.readNullable('parent_id'), + ), + ); + } + + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + container, + tab, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + ]); +} + +typedef $ContainerCreateCompanionBuilder = + ContainerCompanion Function({ + required String id, + Value name, + required Color color, + Value metadata, + Value rowid, + }); +typedef $ContainerUpdateCompanionBuilder = + ContainerCompanion Function({ + Value id, + Value name, + Value color, + Value metadata, + Value rowid, + }); + +final class $ContainerReferences + extends BaseReferences<_$TabDatabase, Container, ContainerData> { + $ContainerReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey> _tabRefsTable( + _$TabDatabase db, + ) => MultiTypedResultKey.fromTable( + db.tab, + aliasName: $_aliasNameGenerator(db.container.id, db.tab.containerId), + ); + + $TabProcessedTableManager get tabRefs { + final manager = $TabTableManager( + $_db, + $_db.tab, + ).filter((f) => f.containerId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_tabRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $ContainerFilterComposer extends Composer<_$TabDatabase, Container> { + $ContainerFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get color => + $composableBuilder( + column: $table.color, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters + get metadata => $composableBuilder( + column: $table.metadata, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + Expression tabRefs(Expression Function($TabFilterComposer f) f) { + final $TabFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tab, + getReferencedColumn: (t) => t.containerId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $TabFilterComposer( + $db: $db, + $table: $db.tab, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $ContainerOrderingComposer extends Composer<_$TabDatabase, Container> { + $ContainerOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get color => $composableBuilder( + column: $table.color, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get metadata => $composableBuilder( + column: $table.metadata, + builder: (column) => ColumnOrderings(column), + ); +} + +class $ContainerAnnotationComposer extends Composer<_$TabDatabase, Container> { + $ContainerAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumnWithTypeConverter get color => + $composableBuilder(column: $table.color, builder: (column) => column); + + GeneratedColumnWithTypeConverter get metadata => + $composableBuilder(column: $table.metadata, builder: (column) => column); + + Expression tabRefs( + Expression Function($TabAnnotationComposer a) f, + ) { + final $TabAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tab, + getReferencedColumn: (t) => t.containerId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $TabAnnotationComposer( + $db: $db, + $table: $db.tab, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $ContainerTableManager + extends + RootTableManager< + _$TabDatabase, + Container, + ContainerData, + $ContainerFilterComposer, + $ContainerOrderingComposer, + $ContainerAnnotationComposer, + $ContainerCreateCompanionBuilder, + $ContainerUpdateCompanionBuilder, + (ContainerData, $ContainerReferences), + ContainerData, + PrefetchHooks Function({bool tabRefs}) + > { + $ContainerTableManager(_$TabDatabase db, Container table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $ContainerFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $ContainerOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $ContainerAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value color = const Value.absent(), + Value metadata = const Value.absent(), + Value rowid = const Value.absent(), + }) => ContainerCompanion( + id: id, + name: name, + color: color, + metadata: metadata, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value name = const Value.absent(), + required Color color, + Value metadata = const Value.absent(), + Value rowid = const Value.absent(), + }) => ContainerCompanion.insert( + id: id, + name: name, + color: color, + metadata: metadata, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => (e.readTable(table), $ContainerReferences(db, table, e)), + ) + .toList(), + prefetchHooksCallback: ({tabRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (tabRefs) db.tab], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (tabRefs) + await $_getPrefetchedData< + ContainerData, + Container, + TabData + >( + currentTable: table, + referencedTable: $ContainerReferences._tabRefsTable(db), + managerFromTypedResult: (p0) => + $ContainerReferences(db, table, p0).tabRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where( + (e) => e.containerId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $ContainerProcessedTableManager = + ProcessedTableManager< + _$TabDatabase, + Container, + ContainerData, + $ContainerFilterComposer, + $ContainerOrderingComposer, + $ContainerAnnotationComposer, + $ContainerCreateCompanionBuilder, + $ContainerUpdateCompanionBuilder, + (ContainerData, $ContainerReferences), + ContainerData, + PrefetchHooks Function({bool tabRefs}) + >; +typedef $TabCreateCompanionBuilder = + TabCompanion Function({ + required String id, + Value parentId, + Value containerId, + required String orderKey, + Value url, + Value title, + Value isProbablyReaderable, + Value extractedContentMarkdown, + Value extractedContentPlain, + Value fullContentMarkdown, + Value fullContentPlain, + required DateTime timestamp, + Value rowid, + }); +typedef $TabUpdateCompanionBuilder = + TabCompanion Function({ + Value id, + Value parentId, + Value containerId, + Value orderKey, + Value url, + Value title, + Value isProbablyReaderable, + Value extractedContentMarkdown, + Value extractedContentPlain, + Value fullContentMarkdown, + Value fullContentPlain, + Value timestamp, + Value rowid, + }); + +final class $TabReferences extends BaseReferences<_$TabDatabase, Tab, TabData> { + $TabReferences(super.$_db, super.$_table, super.$_typedResult); + + static Container _containerIdTable(_$TabDatabase db) => db.container + .createAlias($_aliasNameGenerator(db.tab.containerId, db.container.id)); + + $ContainerProcessedTableManager? get containerId { + final $_column = $_itemColumn('container_id'); + if ($_column == null) return null; + final manager = $ContainerTableManager( + $_db, + $_db.container, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_containerIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $TabFilterComposer extends Composer<_$TabDatabase, Tab> { + $TabFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get parentId => $composableBuilder( + column: $table.parentId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get orderKey => $composableBuilder( + column: $table.orderKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get url => + $composableBuilder( + column: $table.url, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isProbablyReaderable => $composableBuilder( + column: $table.isProbablyReaderable, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get extractedContentMarkdown => $composableBuilder( + column: $table.extractedContentMarkdown, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fullContentMarkdown => $composableBuilder( + column: $table.fullContentMarkdown, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnFilters(column), + ); + + $ContainerFilterComposer get containerId { + final $ContainerFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.containerId, + referencedTable: $db.container, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $ContainerFilterComposer( + $db: $db, + $table: $db.container, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $TabOrderingComposer extends Composer<_$TabDatabase, Tab> { + $TabOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get parentId => $composableBuilder( + column: $table.parentId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get orderKey => $composableBuilder( + column: $table.orderKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isProbablyReaderable => $composableBuilder( + column: $table.isProbablyReaderable, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get extractedContentMarkdown => $composableBuilder( + column: $table.extractedContentMarkdown, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fullContentMarkdown => $composableBuilder( + column: $table.fullContentMarkdown, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnOrderings(column), + ); + + $ContainerOrderingComposer get containerId { + final $ContainerOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.containerId, + referencedTable: $db.container, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $ContainerOrderingComposer( + $db: $db, + $table: $db.container, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $TabAnnotationComposer extends Composer<_$TabDatabase, Tab> { + $TabAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get parentId => + $composableBuilder(column: $table.parentId, builder: (column) => column); + + GeneratedColumn get orderKey => + $composableBuilder(column: $table.orderKey, builder: (column) => column); + + GeneratedColumnWithTypeConverter get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get isProbablyReaderable => $composableBuilder( + column: $table.isProbablyReaderable, + builder: (column) => column, + ); + + GeneratedColumn get extractedContentMarkdown => $composableBuilder( + column: $table.extractedContentMarkdown, + builder: (column) => column, + ); + + GeneratedColumn get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => column, + ); + + GeneratedColumn get fullContentMarkdown => $composableBuilder( + column: $table.fullContentMarkdown, + builder: (column) => column, + ); + + GeneratedColumn get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => column, + ); + + GeneratedColumn get timestamp => + $composableBuilder(column: $table.timestamp, builder: (column) => column); + + $ContainerAnnotationComposer get containerId { + final $ContainerAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.containerId, + referencedTable: $db.container, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $ContainerAnnotationComposer( + $db: $db, + $table: $db.container, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $TabTableManager + extends + RootTableManager< + _$TabDatabase, + Tab, + TabData, + $TabFilterComposer, + $TabOrderingComposer, + $TabAnnotationComposer, + $TabCreateCompanionBuilder, + $TabUpdateCompanionBuilder, + (TabData, $TabReferences), + TabData, + PrefetchHooks Function({bool containerId}) + > { + $TabTableManager(_$TabDatabase db, Tab table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $TabFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $TabOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $TabAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + Value orderKey = const Value.absent(), + Value url = const Value.absent(), + Value title = const Value.absent(), + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + Value timestamp = const Value.absent(), + Value rowid = const Value.absent(), + }) => TabCompanion( + id: id, + parentId: parentId, + containerId: containerId, + orderKey: orderKey, + url: url, + title: title, + isProbablyReaderable: isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown, + extractedContentPlain: extractedContentPlain, + fullContentMarkdown: fullContentMarkdown, + fullContentPlain: fullContentPlain, + timestamp: timestamp, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + required String orderKey, + Value url = const Value.absent(), + Value title = const Value.absent(), + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + required DateTime timestamp, + Value rowid = const Value.absent(), + }) => TabCompanion.insert( + id: id, + parentId: parentId, + containerId: containerId, + orderKey: orderKey, + url: url, + title: title, + isProbablyReaderable: isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown, + extractedContentPlain: extractedContentPlain, + fullContentMarkdown: fullContentMarkdown, + fullContentPlain: fullContentPlain, + timestamp: timestamp, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), $TabReferences(db, table, e))) + .toList(), + prefetchHooksCallback: ({containerId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (containerId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.containerId, + referencedTable: $TabReferences + ._containerIdTable(db), + referencedColumn: $TabReferences + ._containerIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $TabProcessedTableManager = + ProcessedTableManager< + _$TabDatabase, + Tab, + TabData, + $TabFilterComposer, + $TabOrderingComposer, + $TabAnnotationComposer, + $TabCreateCompanionBuilder, + $TabUpdateCompanionBuilder, + (TabData, $TabReferences), + TabData, + PrefetchHooks Function({bool containerId}) + >; +typedef $TabFtsCreateCompanionBuilder = + TabFtsCompanion Function({ + required String title, + required String url, + required String extractedContentPlain, + required String fullContentPlain, + Value rowid, + }); +typedef $TabFtsUpdateCompanionBuilder = + TabFtsCompanion Function({ + Value title, + Value url, + Value extractedContentPlain, + Value fullContentPlain, + Value rowid, + }); + +class $TabFtsFilterComposer extends Composer<_$TabDatabase, TabFts> { + $TabFtsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => ColumnFilters(column), + ); +} + +class $TabFtsOrderingComposer extends Composer<_$TabDatabase, TabFts> { + $TabFtsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => ColumnOrderings(column), + ); +} + +class $TabFtsAnnotationComposer extends Composer<_$TabDatabase, TabFts> { + $TabFtsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get extractedContentPlain => $composableBuilder( + column: $table.extractedContentPlain, + builder: (column) => column, + ); + + GeneratedColumn get fullContentPlain => $composableBuilder( + column: $table.fullContentPlain, + builder: (column) => column, + ); +} + +class $TabFtsTableManager + extends + RootTableManager< + _$TabDatabase, + TabFts, + TabFt, + $TabFtsFilterComposer, + $TabFtsOrderingComposer, + $TabFtsAnnotationComposer, + $TabFtsCreateCompanionBuilder, + $TabFtsUpdateCompanionBuilder, + (TabFt, BaseReferences<_$TabDatabase, TabFts, TabFt>), + TabFt, + PrefetchHooks Function() + > { + $TabFtsTableManager(_$TabDatabase db, TabFts table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $TabFtsFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $TabFtsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $TabFtsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value title = const Value.absent(), + Value url = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + Value rowid = const Value.absent(), + }) => TabFtsCompanion( + title: title, + url: url, + extractedContentPlain: extractedContentPlain, + fullContentPlain: fullContentPlain, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String title, + required String url, + required String extractedContentPlain, + required String fullContentPlain, + Value rowid = const Value.absent(), + }) => TabFtsCompanion.insert( + title: title, + url: url, + extractedContentPlain: extractedContentPlain, + fullContentPlain: fullContentPlain, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $TabFtsProcessedTableManager = + ProcessedTableManager< + _$TabDatabase, + TabFts, + TabFt, + $TabFtsFilterComposer, + $TabFtsOrderingComposer, + $TabFtsAnnotationComposer, + $TabFtsCreateCompanionBuilder, + $TabFtsUpdateCompanionBuilder, + (TabFt, BaseReferences<_$TabDatabase, TabFts, TabFt>), + TabFt, + PrefetchHooks Function() + >; + +class $TabDatabaseManager { + final _$TabDatabase _db; + $TabDatabaseManager(this._db); + $ContainerTableManager get container => + $ContainerTableManager(_db, _db.container); + $TabTableManager get tab => $TabTableManager(_db, _db.tab); + $TabFtsTableManager get tabFts => $TabFtsTableManager(_db, _db.tabFts); +} + +class TabTreesResult { + final String rootTabId; + final String latestTabId; + final DateTime latestTimestamp; + final int totalTabs; + TabTreesResult({ + required this.rootTabId, + required this.latestTabId, + required this.latestTimestamp, + required this.totalTabs, + }); +} + +class UnorderedTabDescendantsResult { + final String id; + final String? parentId; + UnorderedTabDescendantsResult({required this.id, this.parentId}); +} diff --git a/app/lib/features/geckoview/features/tabs/data/entities/container_filter.dart b/app/lib/features/geckoview/features/tabs/data/entities/container_filter.dart new file mode 100644 index 00000000..509a4a9c --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/entities/container_filter.dart @@ -0,0 +1,17 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +sealed class ContainerFilter with FastEquatable {} + +class ContainerFilterById extends ContainerFilter { + final String? containerId; + + ContainerFilterById({required this.containerId}); + + @override + List get hashParameters => [containerId]; +} + +class ContainerFilterDisabled extends ContainerFilter { + @override + List get hashParameters => [null]; +} diff --git a/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart b/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart new file mode 100644 index 00000000..94da0c43 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart @@ -0,0 +1,33 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +sealed class TabEntity with FastEquatable { + String get tabId; +} + +class SingleTabEntity extends TabEntity { + @override + final String tabId; + + SingleTabEntity({required this.tabId}); + + @override + List get hashParameters => [tabId]; +} + +class TabTreeEntity extends TabEntity { + @override + final String tabId; + + final String rootId; + + final int totalTabs; + + TabTreeEntity({ + required this.tabId, + required this.rootId, + required this.totalTabs, + }); + + @override + List get hashParameters => [tabId, rootId, totalTabs]; +} diff --git a/app/lib/features/geckoview/features/tabs/data/models/container_data.dart b/app/lib/features/geckoview/features/tabs/data/models/container_data.dart new file mode 100644 index 00000000..f70f56f7 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/models/container_data.dart @@ -0,0 +1,120 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/widgets.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/data/database/converters/icon_data.dart'; + +part 'container_data.g.dart'; + +@CopyWith() +@JsonSerializable() +class ContainerAuthSettings with FastEquatable { + final bool authenticationRequired; + final bool lockOnAppBackground; + final Duration? lockTimeout; + + ContainerAuthSettings({ + required this.authenticationRequired, + required this.lockOnAppBackground, + required this.lockTimeout, + }); + + ContainerAuthSettings.withDefaults({ + bool? authenticationRequired, + bool? lockOnAppBackground, + Duration? lockTimeout, + }) : this( + authenticationRequired: authenticationRequired ?? false, + lockOnAppBackground: lockOnAppBackground ?? false, + lockTimeout: lockTimeout, + ); + + factory ContainerAuthSettings.fromJson(Map json) => + _$ContainerAuthSettingsFromJson(json); + + Map toJson() => _$ContainerAuthSettingsToJson(this); + + @override + List get hashParameters => [ + authenticationRequired, + lockOnAppBackground, + lockTimeout, + ]; +} + +@CopyWith() +@JsonSerializable(constructor: 'withDefaults') +class ContainerMetadata with FastEquatable { + @IconDataJsonConverter() + final IconData? iconData; + final String? contextualIdentity; + final ContainerAuthSettings authSettings; + + @JsonKey(defaultValue: false) + final bool useProxy; + + ContainerMetadata({ + required this.iconData, + required this.contextualIdentity, + required this.authSettings, + required this.useProxy, + }); + + ContainerMetadata.withDefaults({ + IconData? iconData, + String? contextualIdentity, + ContainerAuthSettings? authSettings, + bool? useProxy, + }) : this( + iconData: iconData, + contextualIdentity: contextualIdentity, + authSettings: authSettings ?? ContainerAuthSettings.withDefaults(), + useProxy: useProxy ?? false, + ); + + factory ContainerMetadata.fromJson(Map json) => + _$ContainerMetadataFromJson(json); + + Map toJson() => _$ContainerMetadataToJson(this); + + @override + List get hashParameters => [ + iconData, + contextualIdentity, + authSettings, + useProxy, + ]; +} + +@CopyWith() +class ContainerData with FastEquatable { + final String id; + final String? name; + final Color color; + final ContainerMetadata metadata; + + ContainerData({ + required this.id, + this.name, + required this.color, + ContainerMetadata? metadata, + }) : metadata = metadata ?? ContainerMetadata.withDefaults(); + + @override + List get hashParameters => [id, name, color, metadata]; +} + +class ContainerDataWithCount extends ContainerData { + final int? tabCount; + + ContainerDataWithCount({ + required super.id, + super.name, + required super.color, + super.metadata, + required this.tabCount, + }); + + @override + List get hashParameters => [...super.hashParameters, tabCount]; +} diff --git a/app/lib/features/geckoview/features/tabs/data/models/container_data.g.dart b/app/lib/features/geckoview/features/tabs/data/models/container_data.g.dart new file mode 100644 index 00000000..deffca1e --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/models/container_data.g.dart @@ -0,0 +1,307 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'container_data.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$ContainerAuthSettingsCWProxy { + ContainerAuthSettings authenticationRequired(bool authenticationRequired); + + ContainerAuthSettings lockOnAppBackground(bool lockOnAppBackground); + + ContainerAuthSettings lockTimeout(Duration? lockTimeout); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `ContainerAuthSettings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// ContainerAuthSettings(...).copyWith(id: 12, name: "My name") + /// ```` + ContainerAuthSettings call({ + bool authenticationRequired, + bool lockOnAppBackground, + Duration? lockTimeout, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfContainerAuthSettings.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfContainerAuthSettings.copyWith.fieldName(...)` +class _$ContainerAuthSettingsCWProxyImpl + implements _$ContainerAuthSettingsCWProxy { + const _$ContainerAuthSettingsCWProxyImpl(this._value); + + final ContainerAuthSettings _value; + + @override + ContainerAuthSettings authenticationRequired(bool authenticationRequired) => + this(authenticationRequired: authenticationRequired); + + @override + ContainerAuthSettings lockOnAppBackground(bool lockOnAppBackground) => + this(lockOnAppBackground: lockOnAppBackground); + + @override + ContainerAuthSettings lockTimeout(Duration? lockTimeout) => + this(lockTimeout: lockTimeout); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `ContainerAuthSettings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// ContainerAuthSettings(...).copyWith(id: 12, name: "My name") + /// ```` + ContainerAuthSettings call({ + Object? authenticationRequired = const $CopyWithPlaceholder(), + Object? lockOnAppBackground = const $CopyWithPlaceholder(), + Object? lockTimeout = const $CopyWithPlaceholder(), + }) { + return ContainerAuthSettings( + authenticationRequired: + authenticationRequired == const $CopyWithPlaceholder() + ? _value.authenticationRequired + // ignore: cast_nullable_to_non_nullable + : authenticationRequired as bool, + lockOnAppBackground: lockOnAppBackground == const $CopyWithPlaceholder() + ? _value.lockOnAppBackground + // ignore: cast_nullable_to_non_nullable + : lockOnAppBackground as bool, + lockTimeout: lockTimeout == const $CopyWithPlaceholder() + ? _value.lockTimeout + // ignore: cast_nullable_to_non_nullable + : lockTimeout as Duration?, + ); + } +} + +extension $ContainerAuthSettingsCopyWith on ContainerAuthSettings { + /// Returns a callable class that can be used as follows: `instanceOfContainerAuthSettings.copyWith(...)` or like so:`instanceOfContainerAuthSettings.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$ContainerAuthSettingsCWProxy get copyWith => + _$ContainerAuthSettingsCWProxyImpl(this); +} + +abstract class _$ContainerMetadataCWProxy { + ContainerMetadata iconData(IconData? iconData); + + ContainerMetadata contextualIdentity(String? contextualIdentity); + + ContainerMetadata authSettings(ContainerAuthSettings authSettings); + + ContainerMetadata useProxy(bool useProxy); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `ContainerMetadata(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// ContainerMetadata(...).copyWith(id: 12, name: "My name") + /// ```` + ContainerMetadata call({ + IconData? iconData, + String? contextualIdentity, + ContainerAuthSettings authSettings, + bool useProxy, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfContainerMetadata.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfContainerMetadata.copyWith.fieldName(...)` +class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { + const _$ContainerMetadataCWProxyImpl(this._value); + + final ContainerMetadata _value; + + @override + ContainerMetadata iconData(IconData? iconData) => this(iconData: iconData); + + @override + ContainerMetadata contextualIdentity(String? contextualIdentity) => + this(contextualIdentity: contextualIdentity); + + @override + ContainerMetadata authSettings(ContainerAuthSettings authSettings) => + this(authSettings: authSettings); + + @override + ContainerMetadata useProxy(bool useProxy) => this(useProxy: useProxy); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `ContainerMetadata(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// ContainerMetadata(...).copyWith(id: 12, name: "My name") + /// ```` + ContainerMetadata call({ + Object? iconData = const $CopyWithPlaceholder(), + Object? contextualIdentity = const $CopyWithPlaceholder(), + Object? authSettings = const $CopyWithPlaceholder(), + Object? useProxy = const $CopyWithPlaceholder(), + }) { + return ContainerMetadata( + iconData: iconData == const $CopyWithPlaceholder() + ? _value.iconData + // ignore: cast_nullable_to_non_nullable + : iconData as IconData?, + contextualIdentity: contextualIdentity == const $CopyWithPlaceholder() + ? _value.contextualIdentity + // ignore: cast_nullable_to_non_nullable + : contextualIdentity as String?, + authSettings: authSettings == const $CopyWithPlaceholder() + ? _value.authSettings + // ignore: cast_nullable_to_non_nullable + : authSettings as ContainerAuthSettings, + useProxy: useProxy == const $CopyWithPlaceholder() + ? _value.useProxy + // ignore: cast_nullable_to_non_nullable + : useProxy as bool, + ); + } +} + +extension $ContainerMetadataCopyWith on ContainerMetadata { + /// Returns a callable class that can be used as follows: `instanceOfContainerMetadata.copyWith(...)` or like so:`instanceOfContainerMetadata.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$ContainerMetadataCWProxy get copyWith => + _$ContainerMetadataCWProxyImpl(this); +} + +abstract class _$ContainerDataCWProxy { + ContainerData id(String id); + + ContainerData name(String? name); + + ContainerData color(Color color); + + ContainerData metadata(ContainerMetadata? metadata); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `ContainerData(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// ContainerData(...).copyWith(id: 12, name: "My name") + /// ```` + ContainerData call({ + String id, + String? name, + Color color, + ContainerMetadata? metadata, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfContainerData.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfContainerData.copyWith.fieldName(...)` +class _$ContainerDataCWProxyImpl implements _$ContainerDataCWProxy { + const _$ContainerDataCWProxyImpl(this._value); + + final ContainerData _value; + + @override + ContainerData id(String id) => this(id: id); + + @override + ContainerData name(String? name) => this(name: name); + + @override + ContainerData color(Color color) => this(color: color); + + @override + ContainerData metadata(ContainerMetadata? metadata) => + this(metadata: metadata); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `ContainerData(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// ContainerData(...).copyWith(id: 12, name: "My name") + /// ```` + ContainerData call({ + Object? id = const $CopyWithPlaceholder(), + Object? name = const $CopyWithPlaceholder(), + Object? color = const $CopyWithPlaceholder(), + Object? metadata = const $CopyWithPlaceholder(), + }) { + return ContainerData( + id: id == const $CopyWithPlaceholder() + ? _value.id + // ignore: cast_nullable_to_non_nullable + : id as String, + name: name == const $CopyWithPlaceholder() + ? _value.name + // ignore: cast_nullable_to_non_nullable + : name as String?, + color: color == const $CopyWithPlaceholder() + ? _value.color + // ignore: cast_nullable_to_non_nullable + : color as Color, + metadata: metadata == const $CopyWithPlaceholder() + ? _value.metadata + // ignore: cast_nullable_to_non_nullable + : metadata as ContainerMetadata?, + ); + } +} + +extension $ContainerDataCopyWith on ContainerData { + /// Returns a callable class that can be used as follows: `instanceOfContainerData.copyWith(...)` or like so:`instanceOfContainerData.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$ContainerDataCWProxy get copyWith => _$ContainerDataCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ContainerAuthSettings _$ContainerAuthSettingsFromJson( + Map json, +) => ContainerAuthSettings( + authenticationRequired: json['authenticationRequired'] as bool, + lockOnAppBackground: json['lockOnAppBackground'] as bool, + lockTimeout: json['lockTimeout'] == null + ? null + : Duration(microseconds: (json['lockTimeout'] as num).toInt()), +); + +Map _$ContainerAuthSettingsToJson( + ContainerAuthSettings instance, +) => { + 'authenticationRequired': instance.authenticationRequired, + 'lockOnAppBackground': instance.lockOnAppBackground, + 'lockTimeout': instance.lockTimeout?.inMicroseconds, +}; + +ContainerMetadata _$ContainerMetadataFromJson(Map json) => + ContainerMetadata.withDefaults( + iconData: _$JsonConverterFromJson, IconData>( + json['iconData'], + const IconDataJsonConverter().fromJson, + ), + contextualIdentity: json['contextualIdentity'] as String?, + authSettings: json['authSettings'] == null + ? null + : ContainerAuthSettings.fromJson( + json['authSettings'] as Map, + ), + useProxy: json['useProxy'] as bool? ?? false, + ); + +Map _$ContainerMetadataToJson(ContainerMetadata instance) => + { + 'iconData': _$JsonConverterToJson, IconData>( + instance.iconData, + const IconDataJsonConverter().toJson, + ), + 'contextualIdentity': instance.contextualIdentity, + 'authSettings': instance.authSettings.toJson(), + 'useProxy': instance.useProxy, + }; + +Value? _$JsonConverterFromJson( + Object? json, + Value? Function(Json json) fromJson, +) => json == null ? null : fromJson(json as Json); + +Json? _$JsonConverterToJson( + Value? value, + Json? Function(Value value) toJson, +) => value == null ? null : toJson(value); diff --git a/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart b/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart new file mode 100644 index 00000000..62aecb0f --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart @@ -0,0 +1,35 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +class TabQueryResult with FastEquatable { + final String id; + + final String? title; + final Uri? cleanUrl; + + final String? url; + final String? extractedContent; + final String? fullContent; + + final double weightedRank; + + TabQueryResult({ + required this.id, + required this.title, + required this.url, + required this.cleanUrl, + this.extractedContent, + this.fullContent, + required this.weightedRank, + }); + + @override + List get hashParameters => [ + id, + title, + cleanUrl, + url, + extractedContent, + fullContent, + weightedRank, + ]; +} diff --git a/app/lib/features/chat_archive/data/providers.dart b/app/lib/features/geckoview/features/tabs/data/providers.dart similarity index 55% rename from app/lib/features/chat_archive/data/providers.dart rename to app/lib/features/geckoview/features/tabs/data/providers.dart index a4ec489b..eb2c8f98 100644 --- a/app/lib/features/chat_archive/data/providers.dart +++ b/app/lib/features/geckoview/features/tabs/data/providers.dart @@ -1,18 +1,26 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; -import 'package:lensai/features/chat_archive/data/database/database.dart'; +import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; import 'package:universal_io/io.dart'; +import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; part 'providers.g.dart'; -@Riverpod() -ChatSearchDatabase chatSearchDatabase(ChatSearchDatabaseRef ref) { - return ChatSearchDatabase( +@Riverpod(keepAlive: true) +TabDatabase tabDatabase(Ref ref) { + final db = TabDatabase( LazyDatabase(() async { + // put the database file, called db.sqlite here, into the documents folder + // for your app. + final dbFolder = await path_provider.getApplicationDocumentsDirectory(); + final file = File(p.join(dbFolder.path, 'tab2.db')); + // Also work around limitations on old Android versions if (Platform.isAndroid) { await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); @@ -25,7 +33,18 @@ ChatSearchDatabase chatSearchDatabase(ChatSearchDatabaseRef ref) { // Explicitly tell it about the correct temporary directory. sqlite3.tempDirectory = cachebase; - return NativeDatabase.memory(); + return NativeDatabase.createInBackground( + file, + setup: (database) { + registerLexorankFunctions(database); + }, + ); }), ); + + ref.onDispose(() async { + await db.close(); + }); + + return db; } diff --git a/app/lib/features/chat_archive/data/providers.g.dart b/app/lib/features/geckoview/features/tabs/data/providers.g.dart similarity index 50% rename from app/lib/features/chat_archive/data/providers.g.dart rename to app/lib/features/geckoview/features/tabs/data/providers.g.dart index cb0de8a9..332ce76f 100644 --- a/app/lib/features/chat_archive/data/providers.g.dart +++ b/app/lib/features/geckoview/features/tabs/data/providers.g.dart @@ -6,22 +6,22 @@ part of 'providers.dart'; // RiverpodGenerator // ************************************************************************** -String _$chatSearchDatabaseHash() => - r'd7d2ad34264201d9196b28140b9b0ce0be6b7e1c'; +String _$tabDatabaseHash() => r'422bd4789296dc271fabbd5906f2e2ab16bccaa3'; -/// See also [chatSearchDatabase]. -@ProviderFor(chatSearchDatabase) -final chatSearchDatabaseProvider = - AutoDisposeProvider.internal( - chatSearchDatabase, - name: r'chatSearchDatabaseProvider', +/// See also [tabDatabase]. +@ProviderFor(tabDatabase) +final tabDatabaseProvider = Provider.internal( + tabDatabase, + name: r'tabDatabaseProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$chatSearchDatabaseHash, + : _$tabDatabaseHash, dependencies: null, allTransitiveDependencies: null, ); -typedef ChatSearchDatabaseRef = AutoDisposeProviderRef; +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TabDatabaseRef = ProviderRef; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.dart b/app/lib/features/geckoview/features/tabs/domain/providers.dart new file mode 100644 index 00000000..795acfa9 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/providers.dart @@ -0,0 +1,66 @@ +import 'package:nullability/nullability.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; +import 'package:weblibre/features/search/util/tokenized_filter.dart'; + +part 'providers.g.dart'; + +@Riverpod() +Stream> containersWithCount(Ref ref) { + final db = ref.watch(tabDatabaseProvider); + return db.containersWithCount().watch(); +} + +@Riverpod() +AsyncValue> matchSortedContainersWithCount( + Ref ref, + String? searchText, +) { + return ref.watch( + containersWithCountProvider.select((value) { + if (searchText.isEmpty) { + return value; + } + + return value.whenData( + (cb) => TokenizedFilter.sort( + items: cb, + toString: (item) => item.name, + query: searchText!, + ).filtered, + ); + }), + ); +} + +@Riverpod() +Stream> containerTabIds(Ref ref, ContainerFilter containerFilter) { + final db = ref.watch(tabDatabaseProvider); + + switch (containerFilter) { + case ContainerFilterById(:final containerId): + return db.containerDao.getContainerTabIds(containerId).watch(); + case ContainerFilterDisabled(): + return db.tabDao.getAllTabIds().watch(); + } +} + +@Riverpod() +Stream> tabTrees(Ref ref) { + final db = ref.watch(tabDatabaseProvider); + return db.tabTrees().watch(); +} + +@Riverpod() +Stream> tabDescendants(Ref ref, String tabId) { + final db = ref.watch(tabDatabaseProvider); + return db.unorderedTabDescendants(tabId: tabId).watch().map((results) { + return Map.fromEntries( + results.map((pair) => MapEntry(pair.id, pair.parentId)), + ); + }); +} diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart new file mode 100644 index 00000000..d303244d --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -0,0 +1,446 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$containersWithCountHash() => + r'ee0734e4b71472b79636816616a158f54fa6bc23'; + +/// See also [containersWithCount]. +@ProviderFor(containersWithCount) +final containersWithCountProvider = + AutoDisposeStreamProvider>.internal( + containersWithCount, + name: r'containersWithCountProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$containersWithCountHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ContainersWithCountRef = + AutoDisposeStreamProviderRef>; +String _$matchSortedContainersWithCountHash() => + r'bc2077602c3c1d86c5b916e4ad920053310993e4'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [matchSortedContainersWithCount]. +@ProviderFor(matchSortedContainersWithCount) +const matchSortedContainersWithCountProvider = + MatchSortedContainersWithCountFamily(); + +/// See also [matchSortedContainersWithCount]. +class MatchSortedContainersWithCountFamily + extends Family>> { + /// See also [matchSortedContainersWithCount]. + const MatchSortedContainersWithCountFamily(); + + /// See also [matchSortedContainersWithCount]. + MatchSortedContainersWithCountProvider call(String? searchText) { + return MatchSortedContainersWithCountProvider(searchText); + } + + @override + MatchSortedContainersWithCountProvider getProviderOverride( + covariant MatchSortedContainersWithCountProvider provider, + ) { + return call(provider.searchText); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'matchSortedContainersWithCountProvider'; +} + +/// See also [matchSortedContainersWithCount]. +class MatchSortedContainersWithCountProvider + extends AutoDisposeProvider>> { + /// See also [matchSortedContainersWithCount]. + MatchSortedContainersWithCountProvider(String? searchText) + : this._internal( + (ref) => matchSortedContainersWithCount( + ref as MatchSortedContainersWithCountRef, + searchText, + ), + from: matchSortedContainersWithCountProvider, + name: r'matchSortedContainersWithCountProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$matchSortedContainersWithCountHash, + dependencies: MatchSortedContainersWithCountFamily._dependencies, + allTransitiveDependencies: + MatchSortedContainersWithCountFamily._allTransitiveDependencies, + searchText: searchText, + ); + + MatchSortedContainersWithCountProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.searchText, + }) : super.internal(); + + final String? searchText; + + @override + Override overrideWith( + AsyncValue> Function( + MatchSortedContainersWithCountRef provider, + ) + create, + ) { + return ProviderOverride( + origin: this, + override: MatchSortedContainersWithCountProvider._internal( + (ref) => create(ref as MatchSortedContainersWithCountRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + searchText: searchText, + ), + ); + } + + @override + AutoDisposeProviderElement>> + createElement() { + return _MatchSortedContainersWithCountProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MatchSortedContainersWithCountProvider && + other.searchText == searchText; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, searchText.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin MatchSortedContainersWithCountRef + on AutoDisposeProviderRef>> { + /// The parameter `searchText` of this provider. + String? get searchText; +} + +class _MatchSortedContainersWithCountProviderElement + extends AutoDisposeProviderElement>> + with MatchSortedContainersWithCountRef { + _MatchSortedContainersWithCountProviderElement(super.provider); + + @override + String? get searchText => + (origin as MatchSortedContainersWithCountProvider).searchText; +} + +String _$containerTabIdsHash() => r'61716a2ae74ffa590c1498d259382e69e24ad7f1'; + +/// See also [containerTabIds]. +@ProviderFor(containerTabIds) +const containerTabIdsProvider = ContainerTabIdsFamily(); + +/// See also [containerTabIds]. +class ContainerTabIdsFamily extends Family>> { + /// See also [containerTabIds]. + const ContainerTabIdsFamily(); + + /// See also [containerTabIds]. + ContainerTabIdsProvider call(ContainerFilter containerFilter) { + return ContainerTabIdsProvider(containerFilter); + } + + @override + ContainerTabIdsProvider getProviderOverride( + covariant ContainerTabIdsProvider provider, + ) { + return call(provider.containerFilter); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'containerTabIdsProvider'; +} + +/// See also [containerTabIds]. +class ContainerTabIdsProvider extends AutoDisposeStreamProvider> { + /// See also [containerTabIds]. + ContainerTabIdsProvider(ContainerFilter containerFilter) + : this._internal( + (ref) => containerTabIds(ref as ContainerTabIdsRef, containerFilter), + from: containerTabIdsProvider, + name: r'containerTabIdsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$containerTabIdsHash, + dependencies: ContainerTabIdsFamily._dependencies, + allTransitiveDependencies: + ContainerTabIdsFamily._allTransitiveDependencies, + containerFilter: containerFilter, + ); + + ContainerTabIdsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.containerFilter, + }) : super.internal(); + + final ContainerFilter containerFilter; + + @override + Override overrideWith( + Stream> Function(ContainerTabIdsRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: ContainerTabIdsProvider._internal( + (ref) => create(ref as ContainerTabIdsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + containerFilter: containerFilter, + ), + ); + } + + @override + AutoDisposeStreamProviderElement> createElement() { + return _ContainerTabIdsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ContainerTabIdsProvider && + other.containerFilter == containerFilter; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, containerFilter.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ContainerTabIdsRef on AutoDisposeStreamProviderRef> { + /// The parameter `containerFilter` of this provider. + ContainerFilter get containerFilter; +} + +class _ContainerTabIdsProviderElement + extends AutoDisposeStreamProviderElement> + with ContainerTabIdsRef { + _ContainerTabIdsProviderElement(super.provider); + + @override + ContainerFilter get containerFilter => + (origin as ContainerTabIdsProvider).containerFilter; +} + +String _$tabTreesHash() => r'b7b7f7136827207dd01a7894a915be3d17b1ae63'; + +/// See also [tabTrees]. +@ProviderFor(tabTrees) +final tabTreesProvider = + AutoDisposeStreamProvider>.internal( + tabTrees, + name: r'tabTreesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabTreesHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TabTreesRef = AutoDisposeStreamProviderRef>; +String _$tabDescendantsHash() => r'93e19df9896e4876dc911370c02bf10f1ee9a9e6'; + +/// See also [tabDescendants]. +@ProviderFor(tabDescendants) +const tabDescendantsProvider = TabDescendantsFamily(); + +/// See also [tabDescendants]. +class TabDescendantsFamily extends Family>> { + /// See also [tabDescendants]. + const TabDescendantsFamily(); + + /// See also [tabDescendants]. + TabDescendantsProvider call(String tabId) { + return TabDescendantsProvider(tabId); + } + + @override + TabDescendantsProvider getProviderOverride( + covariant TabDescendantsProvider provider, + ) { + return call(provider.tabId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'tabDescendantsProvider'; +} + +/// See also [tabDescendants]. +class TabDescendantsProvider + extends AutoDisposeStreamProvider> { + /// See also [tabDescendants]. + TabDescendantsProvider(String tabId) + : this._internal( + (ref) => tabDescendants(ref as TabDescendantsRef, tabId), + from: tabDescendantsProvider, + name: r'tabDescendantsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabDescendantsHash, + dependencies: TabDescendantsFamily._dependencies, + allTransitiveDependencies: + TabDescendantsFamily._allTransitiveDependencies, + tabId: tabId, + ); + + TabDescendantsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.tabId, + }) : super.internal(); + + final String tabId; + + @override + Override overrideWith( + Stream> Function(TabDescendantsRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: TabDescendantsProvider._internal( + (ref) => create(ref as TabDescendantsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + tabId: tabId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement> createElement() { + return _TabDescendantsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TabDescendantsProvider && other.tabId == tabId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, tabId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin TabDescendantsRef on AutoDisposeStreamProviderRef> { + /// The parameter `tabId` of this provider. + String get tabId; +} + +class _TabDescendantsProviderElement + extends AutoDisposeStreamProviderElement> + with TabDescendantsRef { + _TabDescendantsProviderElement(super.provider); + + @override + String get tabId => (origin as TabDescendantsProvider).tabId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart new file mode 100644 index 00000000..d241d570 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart @@ -0,0 +1,141 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/user/domain/services/local_authentication.dart'; + +part 'selected_container.g.dart'; + +enum SetContainerResult { failed, success, successHasProxy } + +@Riverpod(keepAlive: true) +class SelectedContainer extends _$SelectedContainer { + Future fetchData() async { + if (state != null) { + return ref + .read(containerRepositoryProvider.notifier) + .getContainerData(state!); + } + + return null; + } + + Future setContainerId(String id) async { + final container = await ref + .read(containerRepositoryProvider.notifier) + .getContainerData(id); + + if (container != null) { + var passAuth = false; + if (container.metadata.authSettings.authenticationRequired) { + final authResult = await ref + .read(localAuthenticationServiceProvider.notifier) + .authenticate( + authKey: 'container_access::${container.id}', + localizedReason: + 'Require authentication for container ${container.name ?? 'New Container'}', + settings: container.metadata.authSettings, + useAuthCache: true, + ); + + if (authResult) { + passAuth = true; + } + } else { + passAuth = true; + } + + if (passAuth) { + if (container.metadata.useProxy) { + final proxyPluginHealthy = await GeckoContainerProxyService() + .healthcheck(); + + if (proxyPluginHealthy) { + state = id; + return SetContainerResult.successHasProxy; + } + } else { + state = id; + return SetContainerResult.success; + } + } + } + + return SetContainerResult.failed; + } + + void clearContainer() { + state = null; + } + + @override + String? build() { + ref.listen( + fireImmediately: true, + containersWithCountProvider, + (previous, next) { + if (stateOrNull != null && next.valueOrNull != null) { + if (!next.value!.any((container) => container.id == state)) { + clearContainer(); + } + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to containersWithCountProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listen( + fireImmediately: true, + selectedTabProvider, + (previous, next) async { + if (next != null) { + final tabContainerId = await ref + .read(tabDataRepositoryProvider.notifier) + .containerTabId(next); + + if (tabContainerId != stateOrNull) { + if (tabContainerId != null) { + await setContainerId(tabContainerId); + } else { + clearContainer(); + } + } + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to selectedTabProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + return null; + } +} + +@Riverpod() +Stream selectedContainerData(Ref ref) { + final db = ref.watch(tabDatabaseProvider); + final selectedContainer = ref.watch(selectedContainerProvider); + + if (selectedContainer != null) { + return db.containerDao + .getContainerData(selectedContainer) + .watchSingleOrNull(); + } + + return Stream.value(null); +} diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart new file mode 100644 index 00000000..49aec496 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'selected_container.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$selectedContainerDataHash() => + r'1ec86a82e1fc4823a867285f05036c903633a165'; + +/// See also [selectedContainerData]. +@ProviderFor(selectedContainerData) +final selectedContainerDataProvider = + AutoDisposeStreamProvider.internal( + selectedContainerData, + name: r'selectedContainerDataProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedContainerDataHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SelectedContainerDataRef = AutoDisposeStreamProviderRef; +String _$selectedContainerHash() => r'736a60b8f19273d3bdc25d1717e3a613384e59a6'; + +/// See also [SelectedContainer]. +@ProviderFor(SelectedContainer) +final selectedContainerProvider = + NotifierProvider.internal( + SelectedContainer.new, + name: r'selectedContainerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedContainerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$SelectedContainer = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart new file mode 100644 index 00000000..34712058 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart @@ -0,0 +1,100 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/color_palette.dart'; + +part 'container.g.dart'; + +@Riverpod(keepAlive: true) +class ContainerRepository extends _$ContainerRepository { + Future addContainer(ContainerData container) { + return ref.read(tabDatabaseProvider).containerDao.addContainer(container); + } + + Future> getAllContainersWithCount() { + return ref.read(tabDatabaseProvider).containersWithCount().get(); + } + + Future replaceContainer(ContainerData container) { + return ref + .read(tabDatabaseProvider) + .containerDao + .replaceContainer(container); + } + + Future getContainerData(String id) { + return ref + .read(tabDatabaseProvider) + .containerDao + .getContainerData(id) + .getSingleOrNull(); + } + + Future> getContainerTabIds(String? id) { + return ref + .read(tabDatabaseProvider) + .containerDao + .getContainerTabIds(id) + .get(); + } + + Future deleteContainer(String id) async { + await ref + .read(tabDataRepositoryProvider.notifier) + .closeAllTabsByContainer(id); + + return ref.read(tabDatabaseProvider).containerDao.deleteContainer(id); + } + + Future> getDistinctColors() { + return ref + .read(tabDatabaseProvider) + .containerDao + .getDistinctColors() + .get() + .then((colors) => colors.toSet()); + } + + Future getLeadingOrderKey(String? containerId) { + return ref + .read(tabDatabaseProvider) + .containerDao + .generateLeadingOrderKey(containerId) + .getSingle(); + } + + Future getTrailingOrderKey(String? containerId) { + return ref + .read(tabDatabaseProvider) + .containerDao + .generateTrailingOrderKey(containerId) + .getSingle(); + } + + Future getOrderKeyAfterTab(String tabId, String? containerId) { + return ref + .read(tabDatabaseProvider) + .containerDao + .generateOrderKeyAfterTabId(containerId, tabId) + .getSingle(); + } + + Future unusedRandomContainerColor() async { + final allColors = colorTypes.flattened.toList(); + final usedColors = await getDistinctColors(); + + Color randomColor; + do { + randomColor = randomColorShade(allColors); + } while (usedColors.contains(randomColor)); + + return randomColor; + } + + @override + void build() {} +} diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart new file mode 100644 index 00000000..8c2b982f --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'container.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$containerRepositoryHash() => + r'5344ecde33c1c80c1fa2bc8383faca181da91593'; + +/// See also [ContainerRepository]. +@ProviderFor(ContainerRepository) +final containerRepositoryProvider = + NotifierProvider.internal( + ContainerRepository.new, + name: r'containerRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$containerRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ContainerRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart new file mode 100644 index 00000000..9e099cef --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -0,0 +1,62 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; + +part 'tab.g.dart'; + +@Riverpod(keepAlive: true) +class TabDataRepository extends _$TabDataRepository { + Future assignContainer(String tabId, String? containerId) { + return ref + .read(tabDatabaseProvider) + .tabDao + .assignContainer(tabId, containerId: containerId); + } + + Future assignOrderKey(String tabId, String orderKey) { + return ref + .read(tabDatabaseProvider) + .tabDao + .assignOrderKey(tabId, orderKey: orderKey); + } + + Future closeAllTabsByContainer(String? containerId) async { + final tabIds = await ref + .read(tabDatabaseProvider) + .containerDao + .getContainerTabIds(containerId) + .get(); + + if (tabIds.isNotEmpty) { + await ref.read(tabRepositoryProvider.notifier).closeTabs(tabIds); + } + } + + Future closeAllTabsByHost(String? containerId, String host) async { + final tabs = await ref + .read(tabDatabaseProvider) + .containerDao + .getContainerTabsData(containerId) + .get(); + + final filtered = tabs + .where((tab) => tab.url?.host == host) + .map((tab) => tab.id) + .toList(); + + if (filtered.isNotEmpty) { + await ref.read(tabRepositoryProvider.notifier).closeTabs(filtered); + } + } + + Future containerTabId(String tabId) { + return ref + .read(tabDatabaseProvider) + .tabDao + .getTabContainerId(tabId) + .getSingleOrNull(); + } + + @override + void build() {} +} diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart new file mode 100644 index 00000000..7de2c49b --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tabDataRepositoryHash() => r'0de1de9f948e38cc0a6d2fc18a06b1327d41c7ca'; + +/// See also [TabDataRepository]. +@ProviderFor(TabDataRepository) +final tabDataRepositoryProvider = + NotifierProvider.internal( + TabDataRepository.new, + name: r'tabDataRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabDataRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$TabDataRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart new file mode 100644 index 00000000..1b5ffc65 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; + +part 'tab_search.g.dart'; + +enum TabSearchPartition { preview, search } + +@Riverpod() +class TabSearchRepository extends _$TabSearchRepository { + Future addQuery( + String input, { + int snippetLength = 120, + String matchPrefix = '***', + String matchSuffix = '***', + String ellipsis = '…', + }) async { + if (input.isNotEmpty) { + state = await AsyncValue.guard( + () => ref + .read(tabDatabaseProvider) + .tabDao + .queryTabs( + matchPrefix: matchPrefix, + matchSuffix: matchSuffix, + ellipsis: ellipsis, + snippetLength: snippetLength, + searchString: input, + ) + .get(), + ); + } else { + state = const AsyncValue.data(null); + } + } + + @override + Future?> build(TabSearchPartition partition) { + return Future.value(); + } +} diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart new file mode 100644 index 00000000..3a73c5e9 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart @@ -0,0 +1,180 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab_search.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tabSearchRepositoryHash() => + r'd442c384a5a867f4ff28465f524a6ef67521d5ae'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$TabSearchRepository + extends BuildlessAutoDisposeAsyncNotifier?> { + late final TabSearchPartition partition; + + FutureOr?> build(TabSearchPartition partition); +} + +/// See also [TabSearchRepository]. +@ProviderFor(TabSearchRepository) +const tabSearchRepositoryProvider = TabSearchRepositoryFamily(); + +/// See also [TabSearchRepository]. +class TabSearchRepositoryFamily + extends Family?>> { + /// See also [TabSearchRepository]. + const TabSearchRepositoryFamily(); + + /// See also [TabSearchRepository]. + TabSearchRepositoryProvider call(TabSearchPartition partition) { + return TabSearchRepositoryProvider(partition); + } + + @override + TabSearchRepositoryProvider getProviderOverride( + covariant TabSearchRepositoryProvider provider, + ) { + return call(provider.partition); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'tabSearchRepositoryProvider'; +} + +/// See also [TabSearchRepository]. +class TabSearchRepositoryProvider + extends + AutoDisposeAsyncNotifierProviderImpl< + TabSearchRepository, + List? + > { + /// See also [TabSearchRepository]. + TabSearchRepositoryProvider(TabSearchPartition partition) + : this._internal( + () => TabSearchRepository()..partition = partition, + from: tabSearchRepositoryProvider, + name: r'tabSearchRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabSearchRepositoryHash, + dependencies: TabSearchRepositoryFamily._dependencies, + allTransitiveDependencies: + TabSearchRepositoryFamily._allTransitiveDependencies, + partition: partition, + ); + + TabSearchRepositoryProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.partition, + }) : super.internal(); + + final TabSearchPartition partition; + + @override + FutureOr?> runNotifierBuild( + covariant TabSearchRepository notifier, + ) { + return notifier.build(partition); + } + + @override + Override overrideWith(TabSearchRepository Function() create) { + return ProviderOverride( + origin: this, + override: TabSearchRepositoryProvider._internal( + () => create()..partition = partition, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + partition: partition, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement< + TabSearchRepository, + List? + > + createElement() { + return _TabSearchRepositoryProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TabSearchRepositoryProvider && other.partition == partition; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, partition.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin TabSearchRepositoryRef + on AutoDisposeAsyncNotifierProviderRef?> { + /// The parameter `partition` of this provider. + TabSearchPartition get partition; +} + +class _TabSearchRepositoryProviderElement + extends + AutoDisposeAsyncNotifierProviderElement< + TabSearchRepository, + List? + > + with TabSearchRepositoryRef { + _TabSearchRepositoryProviderElement(super.provider); + + @override + TabSearchPartition get partition => + (origin as TabSearchRepositoryProvider).partition; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart new file mode 100644 index 00000000..a03cb797 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/uuid.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart'; +import 'package:weblibre/features/user/domain/services/local_authentication.dart'; +import 'package:weblibre/presentation/icons/tor_icons.dart'; + +enum _DialogMode { create, edit } + +const _timeoutOptions = >[ + DropdownMenuItem(child: Text('Immediately')), + DropdownMenuItem(value: Duration(minutes: 1), child: Text('1 minute')), + DropdownMenuItem(value: Duration(minutes: 5), child: Text('5 minutes')), + DropdownMenuItem(value: Duration(minutes: 15), child: Text('15 minutes')), + DropdownMenuItem(value: Duration(hours: 1), child: Text('1 hour')), +]; + +class ContainerEditScreen extends HookConsumerWidget { + final _DialogMode _mode; + + final ContainerData initialContainer; + + const ContainerEditScreen._({ + required _DialogMode mode, + required this.initialContainer, + }) : _mode = mode; + + factory ContainerEditScreen.create({ + required ContainerData initialContainer, + }) { + return ContainerEditScreen._( + mode: _DialogMode.create, + initialContainer: initialContainer, + ); + } + + factory ContainerEditScreen.edit({required ContainerData initialContainer}) { + return ContainerEditScreen._( + mode: _DialogMode.edit, + initialContainer: initialContainer, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedColor = useState(initialContainer.color); + final contextualIdentity = useState( + initialContainer.metadata.contextualIdentity, + ); + final authSettings = useState(initialContainer.metadata.authSettings); + final useProxy = useState(initialContainer.metadata.useProxy); + + final textController = useTextEditingController( + text: initialContainer.name, + ); + + return Scaffold( + appBar: AppBar( + title: Text(switch (_mode) { + _DialogMode.create => 'New Container', + _DialogMode.edit => 'Edit Container', + }), + actions: [ + IconButton( + onPressed: () async { + final name = textController.text.trim(); + final container = initialContainer.copyWith( + name: name.isNotEmpty ? name : null, + color: selectedColor.value, + metadata: initialContainer.metadata.copyWith( + contextualIdentity: contextualIdentity.value, + authSettings: authSettings.value, + useProxy: useProxy.value && contextualIdentity.value != null, + ), + ); + + //Check for permissions, when auth is set or getting set + if (initialContainer + .metadata + .authSettings + .authenticationRequired || + container.metadata.authSettings.authenticationRequired) { + final authResult = await ref + .read(localAuthenticationServiceProvider.notifier) + .authenticate( + authKey: 'container_access::${container.id}', + localizedReason: + 'Require authentication for container ${container.name ?? 'New Container'}', + ); + + if (!authResult) { + return; + } + } + + switch (_mode) { + case _DialogMode.create: + await ref + .read(containerRepositoryProvider.notifier) + .addContainer(container); + case _DialogMode.edit: + await ref + .read(containerRepositoryProvider.notifier) + .replaceContainer(container); + } + + if (context.mounted) { + context.pop(); + } + }, + icon: const Icon(Icons.check), + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + Expanded( + child: ListView( + children: [ + TextField( + decoration: InputDecoration( + prefixIcon: Padding( + padding: const EdgeInsets.all(10.0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 24, + width: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selectedColor.value, + ), + ), + ), + label: const Text('Name'), + ), + controller: textController, + ), + TextButton.icon( + label: const Text('Select Color'), + icon: const Icon(Icons.colorize), + onPressed: () async { + final color = await showDialog( + context: context, + builder: (context) => + ColorPickerDialog(selectedColor.value), + ); + + if (color != null) { + selectedColor.value = color; + } + }, + ), + SwitchListTile.adaptive( + value: contextualIdentity.value != null, + title: const Text('Cookie Isolation'), + secondary: const Icon(MdiIcons.cookieLock), + contentPadding: EdgeInsets.zero, + onChanged: (_mode == _DialogMode.create) + ? (value) { + contextualIdentity.value = value + ? initialContainer + .metadata + .contextualIdentity ?? + uuid.v4() + : null; + + if (!value && useProxy.value) { + useProxy.value = false; + } + } + : null, + ), + SwitchListTile.adaptive( + value: useProxy.value, + title: const Text('Use Tor Proxy'), + secondary: const Icon(TorIcons.onionAlt), + contentPadding: EdgeInsets.zero, + onChanged: switch (_mode) { + _DialogMode.create => (value) { + if (value && contextualIdentity.value == null) { + contextualIdentity.value = + initialContainer.metadata.contextualIdentity ?? + uuid.v4(); + } + + useProxy.value = value; + }, + _DialogMode.edit => + (contextualIdentity.value != null) + ? (value) { + useProxy.value = value; + } + : null, + }, + ), + SwitchListTile.adaptive( + value: authSettings.value.authenticationRequired, + title: const Text('Require Authentication'), + secondary: const Icon(MdiIcons.fingerprint), + contentPadding: EdgeInsets.zero, + onChanged: (value) { + authSettings.value = authSettings.value.copyWith + .authenticationRequired(value); + }, + ), + if (authSettings.value.authenticationRequired) + CheckboxListTile.adaptive( + value: authSettings.value.lockOnAppBackground, + title: const Text('Auto-lock on background'), + controlAffinity: ListTileControlAffinity.leading, + onChanged: (value) { + authSettings.value = authSettings.value.copyWith + .lockOnAppBackground(value!); + }, + ), + if (authSettings.value.authenticationRequired) + CheckboxListTile.adaptive( + value: authSettings.value.lockTimeout != null, + title: const Text('Timeout'), + controlAffinity: ListTileControlAffinity.leading, + onChanged: (value) { + final newValue = value! + ? _timeoutOptions[1].value + : null; + + authSettings.value = authSettings.value.copyWith + .lockTimeout(newValue); + }, + secondary: DropdownButton( + value: authSettings.value.lockTimeout, + items: _timeoutOptions, + onChanged: (value) { + authSettings.value = authSettings.value.copyWith + .lockTimeout(value); + }, + ), + ), + ], + ), + ), + if (_mode == _DialogMode.edit) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + foregroundColor: Theme.of(context).colorScheme.error, + iconColor: Theme.of(context).colorScheme.error, + ), + label: const Text('Delete'), + icon: const Icon(Icons.delete), + onPressed: () async { + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Delete Container'), + content: const Text( + 'Are you sure you want to delete this container and close all attached tabs?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + + if (result == true) { + await ref + .read(containerRepositoryProvider.notifier) + .deleteContainer(initialContainer.id); + + if (context.mounted) { + context.pop(); + } + } + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart new file mode 100644 index 00000000..ad5ed52b --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart @@ -0,0 +1,152 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/core/uuid.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart'; +import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart'; + +class ContainerListScreen extends HookConsumerWidget { + const ContainerListScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Containers')), + body: HookConsumer( + builder: (context, ref, child) { + final containersAsync = ref.watch(containersWithCountProvider); + final selectedContainer = ref.watch(selectedContainerProvider); + + return Skeletonizer( + enabled: containersAsync.isLoading, + child: containersAsync.when( + skipLoadingOnReload: true, + data: (containers) => FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView.builder( + controller: controller, + itemCount: containers.length, + itemBuilder: (context, index) { + final container = containers[index]; + return Slidable( + key: ValueKey(container.id), + startActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + if (container.id != selectedContainer) + SlidableAction( + onPressed: (context) async { + final result = await ref + .read(selectedContainerProvider.notifier) + .setContainerId(container.id); + + if (context.mounted && + result == + SetContainerResult.successHasProxy) { + await ref + .read( + startProxyControllerProvider.notifier, + ) + .maybeStartProxy(context); + } + }, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + icon: Icons.check, + label: 'Select', + ) + else + SlidableAction( + onPressed: (context) { + ref + .read(selectedContainerProvider.notifier) + .clearContainer(); + }, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + icon: Icons.close, + label: 'Unselect', + ), + ], + ), + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + SlidableAction( + onPressed: (context) async { + await ref + .read(containerRepositoryProvider.notifier) + .deleteContainer(container.id); + }, + backgroundColor: Theme.of( + context, + ).colorScheme.errorContainer, + foregroundColor: Theme.of( + context, + ).colorScheme.onErrorContainer, + icon: Icons.delete, + label: 'Delete', + ), + ], + ), + child: ContainerListTile( + container, + isSelected: container.id == selectedContainer, + ), + ); + }, + ); + }, + ), + error: (error, stackTrace) => SizedBox.shrink(), + loading: () => ListView.builder( + itemCount: 3, + itemBuilder: (context, index) => ContainerListTile( + ContainerData(id: 'null', color: Colors.transparent), + isSelected: false, + ), + ), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () async { + final initialColor = await ref + .read(containerRepositoryProvider.notifier) + .unusedRandomContainerColor(); + + if (context.mounted) { + final result = await ContainerCreateRoute( + ContainerData(id: uuid.v7(), color: initialColor), + ).push(context); + + if (result != null) { + await ref + .read(containerRepositoryProvider.notifier) + .addContainer(result); + } + } + }, + label: const Text('Container'), + icon: const Icon(Icons.add), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart new file mode 100644 index 00000000..aa1b1a5c --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart'; + +class ColorPickerDialog extends HookWidget { + final Color initialColor; + + const ColorPickerDialog(this.initialColor); + + @override + Widget build(BuildContext context) { + final selectedColor = useState(initialColor); + + return AlertDialog( + titlePadding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 16.0), + contentPadding: const EdgeInsets.only( + left: 20.0, + right: 20.0, + bottom: 24.0, + ), + insetPadding: const EdgeInsets.symmetric( + horizontal: 20.0, + vertical: 24.0, + ), + title: const Text('Select Color'), + content: MaterialPicker( + pickerColor: selectedColor.value, + onColorChanged: (value) { + selectedColor.value = value; + }, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, selectedColor.value); + }, + child: const Text('Select'), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart new file mode 100644 index 00000000..26ca2854 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart @@ -0,0 +1,214 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/providers/global_drop.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/data/models/drag_data.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/presentation/widgets/selectable_chips.dart'; + +class ContainerChips extends HookConsumerWidget { + final bool displayMenu; + + final ContainerData? selectedContainer; + final bool Function(ContainerDataWithCount)? containerFilter; + final void Function(ContainerDataWithCount)? onSelected; + final void Function(ContainerDataWithCount)? onDeleted; + + final ValueListenable? searchTextListenable; + + const ContainerChips({ + required this.selectedContainer, + required this.onSelected, + required this.onDeleted, + this.containerFilter, + this.searchTextListenable, + this.displayMenu = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchText = useListenableSelector( + searchTextListenable, + () => searchTextListenable?.value.text, + ); + + final containersAsync = ref.watch( + matchSortedContainersWithCountProvider(searchText), + ); + + final dragTargetTabId = useValueNotifier(null); + + return containersAsync.when( + data: (containers) { + final availableContainers = + containerFilter.mapNotNull( + (filter) => containers.where(filter).toList(), + ) ?? + containers; + + if (selectedContainer == null && + availableContainers.isEmpty && + !displayMenu) { + return const SizedBox.shrink(); + } + + return SizedBox( + height: 48, + child: Row( + children: [ + if (selectedContainer != null || availableContainers.isNotEmpty) + Expanded( + child: SelectableChips( + deleteIcon: false, + itemId: (container) => container.id, + itemAvatar: (container) => Container( + width: 20.0, + height: 20.0, + decoration: BoxDecoration( + color: container.color, + shape: BoxShape.circle, + ), + ), + itemLabel: (container) => + Text(container.name ?? 'New Container'), + itemBadgeCount: (container) => container.tabCount, + itemWrap: (child, container) { + return HookBuilder( + builder: (context) { + final overlayController = + useOverlayPortalController(); + + return DragTarget( + onMove: (details) { + ref + .read(willAcceptDropProvider.notifier) + .setData( + ContainerDropData(details.data.tabId), + ); + + dragTargetTabId.value = details.data.tabId; + overlayController.show(); + }, + onLeave: (data) { + ref.read(willAcceptDropProvider.notifier).clear(); + + dragTargetTabId.value = null; + overlayController.hide(); + }, + onAcceptWithDetails: (details) async { + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .containerTabId(details.data.tabId); + + final containerData = await containerId + .mapNotNull( + (containerId) => ref + .read( + containerRepositoryProvider.notifier, + ) + .getContainerData(containerId), + ); + + if (container.metadata.contextualIdentity == + containerData?.metadata.contextualIdentity) { + await ref + .read(tabDataRepositoryProvider.notifier) + .assignContainer( + details.data.tabId, + container.id, + ); + } else { + await ref + .read(tabRepositoryProvider.notifier) + .duplicateTab( + selectTabId: details.data.tabId, + containerId: container.id, + ); + + await ref + .read(tabRepositoryProvider.notifier) + .closeTab(details.data.tabId); + } + + dragTargetTabId.value = null; + overlayController.hide(); + }, + builder: (context, candidateData, rejectedData) { + final renderBox = + context.findRenderObject() as RenderBox?; + final position = + renderBox?.localToGlobal(Offset.zero) ?? + Offset.zero; + + return OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) => Positioned( + top: position.dy, + left: position.dx, + child: IgnorePointer( + child: Transform.scale( + scale: 1.1, + child: child, + ), + ), + ), + child: HookBuilder( + builder: (context) { + final dragTabId = useValueListenable( + dragTargetTabId, + ); + + return Opacity( + opacity: (dragTabId == null) ? 1.0 : 0.0, + child: child, + ); + }, + ), + ); + }, + ); + }, + ); + }, + availableItems: availableContainers, + selectedItem: selectedContainer, + onSelected: onSelected, + onDeleted: onDeleted, + ), + ) + else + Visibility( + visible: displayMenu, + child: Expanded( + child: Text( + "Press '>' to manage Containers.", + style: TextStyle( + color: Theme.of(context).hintColor, + fontStyle: FontStyle.italic, + ), + ), + ), + ), + if (displayMenu) + IconButton( + onPressed: () async { + await ContainerListRoute().push(context); + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ); + }, + error: (error, stackTrace) => const SizedBox.shrink(), + loading: () => const SizedBox(height: 48, width: double.infinity), + ); + } +} diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart new file mode 100644 index 00000000..0794c325 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; + +class ContainerListTile extends HookWidget { + final ContainerData container; + final bool isSelected; + + const ContainerListTile( + this.container, { + required this.isSelected, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ListTileTheme( + selectedColor: Theme.of(context).colorScheme.onPrimaryContainer, + selectedTileColor: Theme.of(context).colorScheme.primaryContainer, + child: ListTile( + selected: isSelected, + leading: CircleAvatar(backgroundColor: container.color), + title: Text(container.name ?? 'New Container'), + onTap: () async { + await ContainerEditRoute(container).push(context); + }, + trailing: const Icon(Icons.chevron_right), + ), + ); + } +} diff --git a/app/lib/features/topics/presentation/widgets/material_color_picker.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart similarity index 71% rename from app/lib/features/topics/presentation/widgets/material_color_picker.dart rename to app/lib/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart index 58848ee3..71b0d15d 100644 --- a/app/lib/features/topics/presentation/widgets/material_color_picker.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart @@ -2,7 +2,7 @@ import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:lensai/features/topics/utils/color_palette.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/color_palette.dart'; class MaterialPicker extends StatefulWidget { const MaterialPicker({ @@ -32,7 +32,7 @@ class _MaterialPickerState extends State { void initState() { for (final colors in colorTypes) { shadingTypes(colors).forEach((Map color) { - if (widget.pickerColor.value == color.keys.first.value) { + if (widget.pickerColor.toARGB32() == color.keys.first.toARGB32()) { return setState(() { _currentColorType = colors; _currentShading = color.keys.first; @@ -47,7 +47,7 @@ class _MaterialPickerState extends State { Widget build(BuildContext context) { final isPortrait = MediaQuery.of(context).orientation == Orientation.portrait || - widget.portraitOnly; + widget.portraitOnly; Widget colorList() { return Container( @@ -65,8 +65,8 @@ class _MaterialPickerState extends State { BoxShadow( color: (Theme.of(context).brightness == Brightness.light) ? (Theme.of(context).brightness == Brightness.light) - ? Colors.grey[300]! - : Colors.black38 + ? Colors.grey[300]! + : Colors.black38 : Colors.black38, blurRadius: 10, ), @@ -88,8 +88,9 @@ class _MaterialPickerState extends State { ), ), child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith(dragDevices: PointerDeviceKind.values.toSet()), + behavior: ScrollConfiguration.of( + context, + ).copyWith(dragDevices: PointerDeviceKind.values.toSet()), child: FadingScroll( fadingSize: 25, builder: (context, controller) { @@ -130,9 +131,9 @@ class _MaterialPickerState extends State { BoxShadow( color: (Theme.of(context).brightness == - Brightness.light) - ? Colors.grey[300]! - : Colors.black38, + Brightness.light) + ? Colors.grey[300]! + : Colors.black38, blurRadius: 10, ) else @@ -144,7 +145,8 @@ class _MaterialPickerState extends State { : null, border: colorType == Theme.of(context).cardColor ? Border.all( - color: (Theme.of(context).brightness == + color: + (Theme.of(context).brightness == Brightness.light) ? Colors.grey[300]! : Colors.black38, @@ -171,8 +173,9 @@ class _MaterialPickerState extends State { Widget shadingList() { return ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith(dragDevices: PointerDeviceKind.values.toSet()), + behavior: ScrollConfiguration.of( + context, + ).copyWith(dragDevices: PointerDeviceKind.values.toSet()), child: FadingScroll( fadingSize: 25, builder: (context, controller) { @@ -184,8 +187,9 @@ class _MaterialPickerState extends State { const Padding(padding: EdgeInsets.only(top: 15)) else const Padding(padding: EdgeInsets.only(left: 15)), - ...shadingTypes(_currentColorType) - .map((Map colors) { + ...shadingTypes(_currentColorType).map(( + Map colors, + ) { final Color color = colors.keys.first; return GestureDetector( onTap: () { @@ -215,23 +219,23 @@ class _MaterialPickerState extends State { if ((color == Colors.white) || (color == Colors.black)) BoxShadow( - color: (Theme.of(context).brightness == + color: + (Theme.of(context).brightness == Brightness.light) ? Colors.grey[300]! : Colors.black38, blurRadius: 10, ) else - BoxShadow( - color: color, - blurRadius: 10, - ), + BoxShadow(color: color, blurRadius: 10), ] : null, - border: (color == Colors.white) || + border: + (color == Colors.white) || (color == Colors.black) ? Border.all( - color: (Theme.of(context).brightness == + color: + (Theme.of(context).brightness == Brightness.light) ? Colors.grey[300]! : Colors.black38, @@ -240,52 +244,58 @@ class _MaterialPickerState extends State { ), child: widget.enableLabel ? isPortrait - ? Row( - children: [ - Text( - ' ${colors.values.first}', - style: TextStyle( - color: useWhiteForeground(color) - ? Colors.white - : Colors.black, + ? Row( + children: [ + Text( + ' ${colors.values.first}', + style: TextStyle( + color: useWhiteForeground(color) + ? Colors.white + : Colors.black, + ), ), - ), - Expanded( - child: Align( - alignment: Alignment.centerRight, - child: Text( - '#${color.toString().replaceFirst('Color(0xff', '').replaceFirst(')', '').toUpperCase()} ', - style: TextStyle( - color: useWhiteForeground(color) - ? Colors.white - : Colors.black, - fontWeight: FontWeight.bold, + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: Text( + '#${color.toString().replaceFirst('Color(0xff', '').replaceFirst(')', '').toUpperCase()} ', + style: TextStyle( + color: + useWhiteForeground(color) + ? Colors.white + : Colors.black, + fontWeight: FontWeight.bold, + ), ), ), ), + ], + ) + : AnimatedOpacity( + duration: const Duration( + milliseconds: 300, ), - ], - ) - : AnimatedOpacity( - duration: - const Duration(milliseconds: 300), - opacity: _currentShading == color ? 1 : 0, - child: Container( - padding: const EdgeInsets.only(top: 16), - alignment: Alignment.topCenter, - child: Text( - colors.values.first, - style: TextStyle( - color: useWhiteForeground(color) - ? Colors.white - : Colors.black, - fontWeight: FontWeight.bold, - fontSize: 14, + opacity: _currentShading == color + ? 1 + : 0, + child: Container( + padding: const EdgeInsets.only( + top: 16, + ), + alignment: Alignment.topCenter, + child: Text( + colors.values.first, + style: TextStyle( + color: useWhiteForeground(color) + ? Colors.white + : Colors.black, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + softWrap: false, ), - softWrap: false, ), - ), - ) + ) : const SizedBox(), ), ), diff --git a/app/lib/features/topics/utils/color_palette.dart b/app/lib/features/geckoview/features/tabs/utils/color_palette.dart similarity index 73% rename from app/lib/features/topics/utils/color_palette.dart rename to app/lib/features/geckoview/features/tabs/utils/color_palette.dart index 07bd9487..9acd5221 100644 --- a/app/lib/features/topics/utils/color_palette.dart +++ b/app/lib/features/geckoview/features/tabs/utils/color_palette.dart @@ -18,9 +18,9 @@ bool useWhiteForeground(Color backgroundColor, {double bias = 0.0}) { // New: final v = sqrt( - pow(backgroundColor.red, 2) * 0.299 + - pow(backgroundColor.green, 2) * 0.587 + - pow(backgroundColor.blue, 2) * 0.114, + pow(backgroundColor.r, 2) * 0.299 + + pow(backgroundColor.g, 2) * 0.587 + + pow(backgroundColor.b, 2) * 0.114, ).round(); return v < (130 + bias); } @@ -54,9 +54,20 @@ List> shadingTypes(List colors) { for (final Color colorType in colors) { if (colorType == Colors.grey) { result.addAll( - [50, 100, 200, 300, 350, 400, 500, 600, 700, 800, 850, 900] - .map((int shade) => {Colors.grey[shade]!: shade.toString()}) - .toList(), + [ + 50, + 100, + 200, + 300, + 350, + 400, + 500, + 600, + 700, + 800, + 850, + 900, + ].map((int shade) => {Colors.grey[shade]!: shade.toString()}).toList(), ); } else if (colorType == Colors.black || colorType == Colors.white) { result.addAll([ @@ -65,15 +76,27 @@ List> shadingTypes(List colors) { ]); } else if (colorType is MaterialAccentColor) { result.addAll( - [100, 200, 400, 700] - .map((int shade) => {colorType[shade]!: 'A$shade'}) - .toList(), + [ + 100, + 200, + 400, + 700, + ].map((int shade) => {colorType[shade]!: 'A$shade'}).toList(), ); } else if (colorType is MaterialColor) { result.addAll( - [50, 100, 200, 300, 400, 500, 600, 700, 800, 900] - .map((int shade) => {colorType[shade]!: shade.toString()}) - .toList(), + [ + 50, + 100, + 200, + 300, + 400, + 500, + 600, + 700, + 800, + 900, + ].map((int shade) => {colorType[shade]!: shade.toString()}).toList(), ); } else { result.add({const Color(0x00000000): ''}); diff --git a/app/lib/features/geckoview/features/tabs/utils/setting_groups_serializer.dart b/app/lib/features/geckoview/features/tabs/utils/setting_groups_serializer.dart new file mode 100644 index 00000000..cf3c45aa --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/utils/setting_groups_serializer.dart @@ -0,0 +1,34 @@ +// ignore_for_file: avoid_dynamic_calls wo know what we do + +import 'package:weblibre/features/geckoview/features/preferences/data/models/preference_setting.dart'; + +enum PreferencePartition { + user('user'), + system('system'); + + final String key; + + const PreferencePartition(this.key); +} + +Map deserializePreferenceSettingGroups( + PreferencePartition partition, + Map content, +) { + final parititonedContent = content[partition.key] as Map; + + return parititonedContent.map( + (key, value) => MapEntry( + key, + PreferenceSettingGroup( + description: value['description'] as String?, + settings: (value['preferences'] as Map).map( + (key, value) => MapEntry( + key, + PreferenceSetting.fromJson(value as Map), + ), + ), + ), + ), + ); +} diff --git a/app/lib/features/geckoview/utils/image_helper.dart b/app/lib/features/geckoview/utils/image_helper.dart new file mode 100644 index 00000000..439b58c3 --- /dev/null +++ b/app/lib/features/geckoview/utils/image_helper.dart @@ -0,0 +1,43 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:fast_equatable/hash.dart'; +import 'package:weblibre/domain/entities/equatable_image.dart'; +import 'package:weblibre/utils/lru_cache.dart'; + +final _cache = LRUCache(100); + +Future tryDecodeImage( + Uint8List bytes, { + int? targetWidth, + int? targetHeight, + bool allowUpscaling = true, +}) async { + final digest = secureHash(bytes); + + final cached = _cache.get(digest); + if (cached != null) { + return cached; + } + + try { + final codec = await instantiateImageCodec( + bytes, + targetWidth: targetWidth, + targetHeight: targetHeight, + allowUpscaling: allowUpscaling, + ); + + final frameInfo = await codec.getNextFrame(); + final image = EquatableImage(frameInfo.image, hash: digest); + + if (image.value.width > 0) { + _cache.set(digest, image); + return image; + } + } catch (e) { + //swallow + } + + return null; +} diff --git a/app/lib/features/kagi/data/services/autosuggest.dart b/app/lib/features/kagi/data/services/autosuggest.dart deleted file mode 100644 index e286698e..00000000 --- a/app/lib/features/kagi/data/services/autosuggest.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:convert'; - -import 'package:exceptions/exceptions.dart'; -import 'package:http/http.dart' as http; -import 'package:lensai/core/http_error_handler.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'autosuggest.g.dart'; - -@Riverpod(keepAlive: true) -class KagiAutosuggestService extends _$KagiAutosuggestService { - static final _baseUrl = Uri.https('kagi.com', 'api/autosuggest'); - - late http.Client _client; - - @override - void build() { - _client = http.Client(); - } - - Future>> getSuggestions(String query) async { - return Result.fromAsync( - () async { - final response = await _client - .get(_baseUrl.replace(queryParameters: {'q': query})) - .timeout(const Duration(seconds: 5)); - - final results = jsonDecode(utf8.decode(response.bodyBytes)) as List; - return switch (results.last) { - final String result => [result], - final List resultList => resultList.cast(), - _ => [] - }; - }, - exceptionHandler: handleHttpError, - ); - } -} diff --git a/app/lib/features/kagi/data/services/chat.dart b/app/lib/features/kagi/data/services/chat.dart deleted file mode 100644 index 439417df..00000000 --- a/app/lib/features/kagi/data/services/chat.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:convert'; - -import 'package:exceptions/exceptions.dart'; -import 'package:http/http.dart' as http; -import 'package:lensai/core/http_error_handler.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'chat.g.dart'; - -@Riverpod(keepAlive: true) -class KagiChatService extends _$KagiChatService { - late http.Client _client; - - @override - void build() { - _client = http.Client(); - } - - Future> downloadChat(Uri url) { - final kagiSession = - ref.read(settingsRepositoryProvider).valueOrNull?.kagiSession; - - return Result.fromAsync( - () async { - final response = await _client - .get(url.replace(queryParameters: {'token': kagiSession})); - - return utf8.decode(response.bodyBytes); - }, - exceptionHandler: handleHttpError, - ); - } -} diff --git a/app/lib/features/kagi/domain/repositories/autosuggest.dart b/app/lib/features/kagi/domain/repositories/autosuggest.dart deleted file mode 100644 index b0e103eb..00000000 --- a/app/lib/features/kagi/domain/repositories/autosuggest.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:async'; - -import 'package:lensai/features/kagi/data/services/autosuggest.dart'; -import 'package:lensai/utils/lru_cache.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:rxdart/rxdart.dart'; - -part 'autosuggest.g.dart'; - -@Riverpod(keepAlive: true) -class AutosuggestRepository extends _$AutosuggestRepository { - final LRUCache> _cache; - - late StreamController _queryStreamController; - - AutosuggestRepository() : _cache = LRUCache(100); - - void addQuery(String query) { - _queryStreamController.add(query); - } - - @override - Raw>> build() { - _queryStreamController = StreamController(); - ref.onDispose(() async { - await _queryStreamController.close(); - }); - - return _queryStreamController.stream - .sampleTime(const Duration(milliseconds: 100)) - .switchMap>( - (query) { - if (query.isEmpty) { - return Stream.value([]); - } - - final cached = _cache.get(query); - if (cached != null) { - return Stream.value(cached); - } - - return ref - .read(kagiAutosuggestServiceProvider.notifier) - // ignore: discarded_futures - .getSuggestions(query) - // ignore: discarded_futures - .then((result) { - result.onSuccess((result) { - _cache.set(query, result); - }); - - return result.value; - }).asStream(); - }, - ).asBroadcastStream(); - } -} diff --git a/app/lib/features/kagi/domain/repositories/autosuggest.g.dart b/app/lib/features/kagi/domain/repositories/autosuggest.g.dart deleted file mode 100644 index d5fdeb1f..00000000 --- a/app/lib/features/kagi/domain/repositories/autosuggest.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'autosuggest.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$autosuggestRepositoryHash() => - r'48d6bf45d648871f8570edfe1e0b394019084695'; - -/// See also [AutosuggestRepository]. -@ProviderFor(AutosuggestRepository) -final autosuggestRepositoryProvider = - NotifierProvider>>>.internal( - AutosuggestRepository.new, - name: r'autosuggestRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$autosuggestRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$AutosuggestRepository = Notifier>>>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/onboarding/presentation/onboarding.dart b/app/lib/features/onboarding/presentation/onboarding.dart new file mode 100644 index 00000000..c159aadc --- /dev/null +++ b/app/lib/features/onboarding/presentation/onboarding.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:smooth_page_indicator/smooth_page_indicator.dart'; +import 'package:weblibre/core/providers/defaults.dart'; +import 'package:weblibre/core/providers/router.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/default_search.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/permissions.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/ublock_opt_in.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/welcome.dart'; +import 'package:weblibre/features/user/domain/repositories/onboarding.dart'; + +class OnboardingScreen extends HookConsumerWidget { + final int currentRevision; + final int targetRevision; + + const OnboardingScreen({ + super.key, + required this.currentRevision, + required this.targetRevision, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final pageController = usePageController(); + + final pages = useMemoized>(() { + return [ + const WelcomePage(), + const DefaultSearchPage(), + UBlockOptInPage(formKey: GlobalKey()), + PermissionsPage(formKey: GlobalKey()), + ]; + }, [currentRevision, targetRevision]); + + final lastPage = useRef(pageController.initialPage); + final currentPage = useState(pageController.initialPage); + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + Expanded( + child: PageView( + controller: pageController, + children: pages, + onPageChanged: (value) { + if (value > lastPage.value) { + if (pages[lastPage.value] case final IFormPage formPage) { + formPage.formKey.currentState?.save(); + } + } + + lastPage.value = value; + currentPage.value = value; + }, + ), + ), + Row( + children: [ + Expanded( + child: Visibility( + visible: currentPage.value > 0, + child: TextButton.icon( + onPressed: () async { + await pageController.previousPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); + }, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + ), + ), + Expanded( + child: Center( + child: SmoothPageIndicator( + controller: pageController, + count: pages.length, + effect: WormEffect( + dotColor: theme.colorScheme.onSurface, + activeDotColor: theme.colorScheme.primary, + ), + ), + ), + ), + if (currentPage.value < pages.length - 1) + Expanded( + child: TextButton.icon( + onPressed: () async { + await pageController.nextPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); + }, + iconAlignment: IconAlignment.end, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ) + else + Expanded( + child: TextButton.icon( + onPressed: () async { + if (pages[currentPage.value] + case final IFormPage formPage) { + formPage.formKey.currentState?.save(); + } + + await ref + .read(onboardingRepositoryProvider.notifier) + .pushRevision(targetRevision); + + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: ref.read(docsUriProvider)); + + ref.invalidate(routerProvider); + }, + iconAlignment: IconAlignment.end, + icon: const Icon(Icons.done), + label: const Text('Done'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/pages/abstract/i_form_page.dart b/app/lib/features/onboarding/presentation/pages/abstract/i_form_page.dart new file mode 100644 index 00000000..d8ccb1ff --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/abstract/i_form_page.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +abstract class IFormPage { + GlobalKey get formKey; +} diff --git a/app/lib/features/onboarding/presentation/pages/default_search.dart b/app/lib/features/onboarding/presentation/pages/default_search.dart new file mode 100644 index 00000000..49a1a151 --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/default_search.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +const defaultBangs = ['ddg', 'brave', 'startpage', 'qwant']; + +class DefaultSearchPage extends HookConsumerWidget { + const DefaultSearchPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final bangs = ref.watch(bangListProvider(triggers: defaultBangs)); + + final activeBang = ref.watch( + defaultSearchBangDataProvider.select((value) => value.valueOrNull), + ); + final activeAutosuggest = ref.watch( + generalSettingsRepositoryProvider.select( + (value) => value.defaultSearchSuggestionsProvider, + ), + ); + + Future updateSearchProvider(String trigger) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.defaultSearchProvider(trigger), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: bangs.when( + skipLoadingOnReload: true, + data: (availableBangs) { + return ListView( + children: [ + const SizedBox(height: 24), + Center( + child: Text('Search', style: theme.textTheme.headlineMedium), + ), + const SizedBox(height: 24), + const ListTile( + title: Text('Default Search Provider'), + leading: Icon(MdiIcons.cloudSearch), + contentPadding: EdgeInsets.zero, + ), + if (activeBang != null) + Align( + alignment: Alignment.centerLeft, + child: FilterChip( + showCheckmark: false, + label: Text(activeBang.websiteName), + avatar: UrlIcon([ + activeBang.getTemplateUrl(''), + ], iconSize: 20), + selected: true, + onSelected: (value) {}, + ), + ), + const Divider(), + Wrap( + spacing: 8.0, + children: [ + ...availableBangs.map( + (bang) => FilterChip( + showCheckmark: false, + label: Text(bang.websiteName), + avatar: UrlIcon([bang.getTemplateUrl('')], iconSize: 20), + selected: activeBang?.trigger == bang.trigger, + onSelected: (selected) async { + if (selected) { + await updateSearchProvider(bang.trigger); + } + }, + ), + ), + ActionChip( + label: const Text('Search more'), + avatar: const Icon(Icons.search), + onPressed: () async { + final trigger = await const BangSearchRoute() + .push(context); + + if (trigger != null) { + await updateSearchProvider(trigger); + } + }, + ), + ], + ), + const SizedBox(height: 24), + const ListTile( + title: Text('Default Autocomplete Provider'), + leading: Icon(MdiIcons.weatherCloudyArrowRight), + contentPadding: EdgeInsets.zero, + ), + DropdownMenu( + initialSelection: activeAutosuggest, + inputDecorationTheme: InputDecorationTheme( + prefixIconConstraints: BoxConstraints.tight( + const Size.square(24), + ), + ), + width: double.infinity, + leadingIcon: activeAutosuggest.relatedBang.mapNotNull( + (trigger) => BangIcon(trigger: trigger), + ), + dropdownMenuEntries: SearchSuggestionProviders.values.map(( + provider, + ) { + return DropdownMenuEntry( + value: provider, + label: provider.label, + leadingIcon: provider.relatedBang.mapNotNull( + (trigger) => BangIcon(trigger: trigger), + ), + ); + }).toList(), + onSelected: (value) async { + if (value != null) { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .defaultSearchSuggestionsProvider(value), + ); + } + }, + ), + ], + ); + }, + error: (error, stackTrace) => const SizedBox.shrink(), + loading: () => const SizedBox(height: 48, width: double.infinity), + ), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/pages/permissions.dart b/app/lib/features/onboarding/presentation/pages/permissions.dart new file mode 100644 index 00000000..68c81205 --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/permissions.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; +import 'package:weblibre/presentation/hooks/cached_future.dart'; + +class PermissionsPage extends HookConsumerWidget implements IFormPage { + @override + final GlobalKey formKey; + + const PermissionsPage({super.key, required this.formKey}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final notificationPermissionEnabled = useCachedFuture( + () => Permission.notification.isGranted, + ); + + return Form( + key: formKey, + child: ListView( + children: [ + const SizedBox(height: 24), + Center( + child: Text('Permissions', style: theme.textTheme.headlineMedium), + ), + const SizedBox(height: 24), + // Padding( + // padding: const EdgeInsets.symmetric(horizontal: 16.0), + // child: const Text('WebLibre requires following permissions'), + // ), + // const SizedBox(height: 24), + Skeletonizer( + enabled: !notificationPermissionEnabled.hasData, + child: FormField( + initialValue: true, + onSaved: (newValue) async { + if (newValue == true) { + await Permission.notification.request(); + } + }, + builder: (field) => SwitchListTile( + value: field.value ?? true, + title: const Text('Notifications'), + subtitle: const Text( + 'Required to inform about download status', + ), + onChanged: (notificationPermissionEnabled.data == true) + ? null + : (value) { + field.didChange(value); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/pages/ublock_opt_in.dart b/app/lib/features/onboarding/presentation/pages/ublock_opt_in.dart new file mode 100644 index 00000000..676a167d --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/ublock_opt_in.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; + +class UBlockOptInPage extends HookConsumerWidget implements IFormPage { + @override + final GlobalKey formKey; + + const UBlockOptInPage({super.key, required this.formKey}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Form( + key: formKey, + child: ListView( + children: [ + const SizedBox(height: 24), + SvgPicture.asset('assets/images/ublock.svg'), + const SizedBox(height: 8), + Center( + child: Text('uBlock Origin', style: theme.textTheme.headlineMedium), + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: MarkdownBody( + data: ''' +uBlock Origin (uBO) is a CPU and memory-efficient **wide-spectrum content blocker** by **Raymond Hill** and available as a browser extensions for WebLibre. + +It blocks ads, trackers, coin miners, popups, annoying anti-blockers, malware sites, etc., by default using **EasyList, EasyPrivacy, Peter Lowe's Blocklist, Online Malicious URL Blocklist, and uBO filter lists**. + +There are many other lists available to block even more. + ''', + ), + ), + const SizedBox(height: 24), + FormField( + initialValue: true, + onSaved: (newValue) { + if (newValue == true) { + unawaited( + ref + .read(browserAddonServiceProvider.notifier) + .install('uBlock0@raymondhill.net'), + ); + } + }, + builder: (field) => SwitchListTile( + value: field.value ?? false, + title: const Text('Install uBlock Origin Extension'), + onChanged: (value) { + field.didChange(value); + }, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/pages/welcome.dart b/app/lib/features/onboarding/presentation/pages/welcome.dart new file mode 100644 index 00000000..d4456c63 --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/welcome.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +class WelcomePage extends StatelessWidget { + const WelcomePage({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const SizedBox(height: 24), + FractionallySizedBox( + widthFactor: 0.5, + child: SvgPicture.asset('assets/icon/icon.svg'), + ), + const SizedBox(height: 24), + Text( + 'The Privacy-Focused Browser', + softWrap: true, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ); + } +} diff --git a/app/lib/features/search/domain/autosuggest/brave.dart b/app/lib/features/search/domain/autosuggest/brave.dart new file mode 100644 index 00000000..4e4d7ae1 --- /dev/null +++ b/app/lib/features/search/domain/autosuggest/brave.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:exceptions/exceptions.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/http_error_handler.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; + +part 'brave.g.dart'; + +@Riverpod(keepAlive: true) +class BraveAutosuggestService extends _$BraveAutosuggestService + implements ISearchSuggestionProvider { + static final _baseUrl = Uri.https('search.brave.com', 'api/suggest'); + + late http.Client _client; + + @override + void build() { + _client = http.Client(); + + ref.onDispose(() { + _client.close(); + }); + } + + @override + Future>> getSuggestions(String query) { + return Result.fromAsync(() async { + final response = await _client + .get(_baseUrl.replace(queryParameters: {'q': query})) + .timeout(const Duration(seconds: 5)); + + final results = jsonDecode(utf8.decode(response.bodyBytes)) as List; + return switch (results.last) { + final String result => [result], + final List resultList => resultList.cast(), + _ => [], + }; + }, exceptionHandler: handleHttpError); + } +} diff --git a/app/lib/features/search/domain/autosuggest/brave.g.dart b/app/lib/features/search/domain/autosuggest/brave.g.dart new file mode 100644 index 00000000..070f8b8a --- /dev/null +++ b/app/lib/features/search/domain/autosuggest/brave.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'brave.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$braveAutosuggestServiceHash() => + r'3b2f465dcf4b44b0450975d3909fc7066d171bf2'; + +/// See also [BraveAutosuggestService]. +@ProviderFor(BraveAutosuggestService) +final braveAutosuggestServiceProvider = + NotifierProvider.internal( + BraveAutosuggestService.new, + name: r'braveAutosuggestServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$braveAutosuggestServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$BraveAutosuggestService = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/search/domain/autosuggest/duckduckgo.dart b/app/lib/features/search/domain/autosuggest/duckduckgo.dart new file mode 100644 index 00000000..6d067726 --- /dev/null +++ b/app/lib/features/search/domain/autosuggest/duckduckgo.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:exceptions/exceptions.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/http_error_handler.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; + +part 'duckduckgo.g.dart'; + +@Riverpod(keepAlive: true) +class DuckDuckGoAutosuggestService extends _$DuckDuckGoAutosuggestService + implements ISearchSuggestionProvider { + static final _baseUrl = Uri.https('duckduckgo.com', 'ac/'); + + late http.Client _client; + + @override + void build() { + _client = http.Client(); + + ref.onDispose(() { + _client.close(); + }); + } + + @override + Future>> getSuggestions(String query) { + return Result.fromAsync(() async { + final response = await _client + .get(_baseUrl.replace(queryParameters: {'type': 'list', 'q': query})) + .timeout(const Duration(seconds: 5)); + + final results = jsonDecode(utf8.decode(response.bodyBytes)) as List; + return switch (results.last) { + final String result => [result], + final List resultList => resultList.cast(), + _ => [], + }; + }, exceptionHandler: handleHttpError); + } +} diff --git a/app/lib/features/search/domain/autosuggest/duckduckgo.g.dart b/app/lib/features/search/domain/autosuggest/duckduckgo.g.dart new file mode 100644 index 00000000..45f09844 --- /dev/null +++ b/app/lib/features/search/domain/autosuggest/duckduckgo.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'duckduckgo.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$duckDuckGoAutosuggestServiceHash() => + r'd9f30a577b276049b252be890bc73eb18aa118f2'; + +/// See also [DuckDuckGoAutosuggestService]. +@ProviderFor(DuckDuckGoAutosuggestService) +final duckDuckGoAutosuggestServiceProvider = + NotifierProvider.internal( + DuckDuckGoAutosuggestService.new, + name: r'duckDuckGoAutosuggestServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$duckDuckGoAutosuggestServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$DuckDuckGoAutosuggestService = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/search/domain/autosuggest/empty.dart b/app/lib/features/search/domain/autosuggest/empty.dart new file mode 100644 index 00000000..d419be31 --- /dev/null +++ b/app/lib/features/search/domain/autosuggest/empty.dart @@ -0,0 +1,17 @@ +import 'package:exceptions/exceptions.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; + +part 'empty.g.dart'; + +@Riverpod(keepAlive: true) +class EmptyAutosuggestService extends _$EmptyAutosuggestService + implements ISearchSuggestionProvider { + @override + void build() {} + + @override + Future>> getSuggestions(String query) { + return Future.value(Result.success([])); + } +} diff --git a/app/lib/features/search/domain/autosuggest/empty.g.dart b/app/lib/features/search/domain/autosuggest/empty.g.dart new file mode 100644 index 00000000..8aee4d5c --- /dev/null +++ b/app/lib/features/search/domain/autosuggest/empty.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'empty.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$emptyAutosuggestServiceHash() => + r'7fbe81807baf379cf621ef46fba00e46778978f8'; + +/// See also [EmptyAutosuggestService]. +@ProviderFor(EmptyAutosuggestService) +final emptyAutosuggestServiceProvider = + NotifierProvider.internal( + EmptyAutosuggestService.new, + name: r'emptyAutosuggestServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$emptyAutosuggestServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$EmptyAutosuggestService = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/search/domain/autosuggest/kagi.dart b/app/lib/features/search/domain/autosuggest/kagi.dart new file mode 100644 index 00000000..2cc23ca9 --- /dev/null +++ b/app/lib/features/search/domain/autosuggest/kagi.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:exceptions/exceptions.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/http_error_handler.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; + +part 'kagi.g.dart'; + +@Riverpod(keepAlive: true) +class KagiAutosuggestService extends _$KagiAutosuggestService + implements ISearchSuggestionProvider { + static final _baseUrl = Uri.https('kagi.com', 'api/autosuggest'); + + late http.Client _client; + + @override + void build() { + _client = http.Client(); + + ref.onDispose(() { + _client.close(); + }); + } + + @override + Future>> getSuggestions(String query) { + return Result.fromAsync(() async { + final response = await _client + .get(_baseUrl.replace(queryParameters: {'q': query})) + .timeout(const Duration(seconds: 5)); + + final results = jsonDecode(utf8.decode(response.bodyBytes)) as List; + return switch (results.last) { + final String result => [result], + final List resultList => resultList.cast(), + _ => [], + }; + }, exceptionHandler: handleHttpError); + } +} diff --git a/app/lib/features/kagi/data/services/autosuggest.g.dart b/app/lib/features/search/domain/autosuggest/kagi.g.dart similarity index 56% rename from app/lib/features/kagi/data/services/autosuggest.g.dart rename to app/lib/features/search/domain/autosuggest/kagi.g.dart index 43078ec2..4d3cfb10 100644 --- a/app/lib/features/kagi/data/services/autosuggest.g.dart +++ b/app/lib/features/search/domain/autosuggest/kagi.g.dart @@ -1,27 +1,27 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'autosuggest.dart'; +part of 'kagi.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** String _$kagiAutosuggestServiceHash() => - r'843b0d84b1dde2d903412a123c8c10d26e8036fd'; + r'039fe84dd480316eab38c80b41e16d16d6c14a7c'; /// See also [KagiAutosuggestService]. @ProviderFor(KagiAutosuggestService) final kagiAutosuggestServiceProvider = NotifierProvider.internal( - KagiAutosuggestService.new, - name: r'kagiAutosuggestServiceProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$kagiAutosuggestServiceHash, - dependencies: null, - allTransitiveDependencies: null, -); + KagiAutosuggestService.new, + name: r'kagiAutosuggestServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$kagiAutosuggestServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); typedef _$KagiAutosuggestService = Notifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/search/domain/autosuggest/qwant.dart b/app/lib/features/search/domain/autosuggest/qwant.dart new file mode 100644 index 00000000..3b4fdf92 --- /dev/null +++ b/app/lib/features/search/domain/autosuggest/qwant.dart @@ -0,0 +1,46 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'dart:convert'; + +import 'package:exceptions/exceptions.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/http_error_handler.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; + +part 'qwant.g.dart'; + +@Riverpod(keepAlive: true) +class QwantAutosuggestService extends _$QwantAutosuggestService + implements ISearchSuggestionProvider { + static final _baseUrl = Uri.https('api.qwant.com', 'v3/suggest'); + + late http.Client _client; + + @override + void build() { + _client = http.Client(); + + ref.onDispose(() { + _client.close(); + }); + } + + @override + Future>> getSuggestions(String query) { + return Result.fromAsync(() async { + final response = await _client + .get(_baseUrl.replace(queryParameters: {'q': query})) + .timeout(const Duration(seconds: 5)); + + final results = + jsonDecode(utf8.decode(response.bodyBytes)) as Map; + if (results['status'] != 'success') { + throw Exception('qwant api error'); + } + + final items = results['data']['items'] as List; + return items.map((item) => item['value'] as String).toList(); + }, exceptionHandler: handleHttpError); + } +} diff --git a/app/lib/features/search/domain/autosuggest/qwant.g.dart b/app/lib/features/search/domain/autosuggest/qwant.g.dart new file mode 100644 index 00000000..c81c1bac --- /dev/null +++ b/app/lib/features/search/domain/autosuggest/qwant.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'qwant.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$qwantAutosuggestServiceHash() => + r'a942cd073289d20d43647de5c0f43fb08790045f'; + +/// See also [QwantAutosuggestService]. +@ProviderFor(QwantAutosuggestService) +final qwantAutosuggestServiceProvider = + NotifierProvider.internal( + QwantAutosuggestService.new, + name: r'qwantAutosuggestServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$qwantAutosuggestServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$QwantAutosuggestService = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/query/domain/entities/abstract/i_query_builder.dart b/app/lib/features/search/domain/entities/abstract/i_query_builder.dart similarity index 56% rename from app/lib/features/query/domain/entities/abstract/i_query_builder.dart rename to app/lib/features/search/domain/entities/abstract/i_query_builder.dart index 080f704e..d3cac5b7 100644 --- a/app/lib/features/query/domain/entities/abstract/i_query_builder.dart +++ b/app/lib/features/search/domain/entities/abstract/i_query_builder.dart @@ -2,5 +2,6 @@ abstract interface class IQueryBuilder { int get ftsTokenLimit; int get ftsMinTokenLength; - String buildQuery(String input); + String buildFtsQuery(String input); + String buildLikeQuery(String input); } diff --git a/app/lib/features/search/domain/entities/abstract/i_search_suggestion_provider.dart b/app/lib/features/search/domain/entities/abstract/i_search_suggestion_provider.dart new file mode 100644 index 00000000..7e9d3548 --- /dev/null +++ b/app/lib/features/search/domain/entities/abstract/i_search_suggestion_provider.dart @@ -0,0 +1,18 @@ +import 'package:exceptions/exceptions.dart'; + +enum SearchSuggestionProviders { + none('Disabled', null), + brave('Brave', 'brave'), + ddg('DuckDuckGo', 'ddg'), + kagi('Kagi', 'kagi'), + qwant('Qwant', 'qwant'); + + final String label; + final String? relatedBang; + + const SearchSuggestionProviders(this.label, this.relatedBang); +} + +abstract interface class ISearchSuggestionProvider { + Future>> getSuggestions(String query); +} diff --git a/app/lib/features/query/domain/entities/bareword.dart b/app/lib/features/search/domain/entities/bareword.dart similarity index 100% rename from app/lib/features/query/domain/entities/bareword.dart rename to app/lib/features/search/domain/entities/bareword.dart diff --git a/app/lib/features/query/domain/tokenizer.dart b/app/lib/features/search/domain/fts_tokenizer.dart similarity index 71% rename from app/lib/features/query/domain/tokenizer.dart rename to app/lib/features/search/domain/fts_tokenizer.dart index eb93e4a6..74e379c8 100644 --- a/app/lib/features/query/domain/tokenizer.dart +++ b/app/lib/features/search/domain/fts_tokenizer.dart @@ -1,12 +1,13 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; -import 'package:lensai/features/query/domain/entities/abstract/i_query_builder.dart'; -import 'package:lensai/features/query/domain/entities/bareword.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_query_builder.dart'; +import 'package:weblibre/features/search/domain/entities/bareword.dart'; +import 'package:weblibre/features/search/domain/unix_tokenizer.dart'; typedef _Phrase = List; -sealed class QueryBuilder { +sealed class FtsQueryBuilder { ///Matching quoted strings like "xda bda" (group 1), as well as strings delimetered by ///a whitespace (group 2) static final _tokenizePattern = RegExp('"([^"]+)"|([^ ]+)'); @@ -18,15 +19,18 @@ sealed class QueryBuilder { ///https://www.sqlite.org/fts5.html#fts5_strings /// ///Elsewise ths string needs to get quoted - static final _barewordPattern = - RegExp(r'^([^\x00-\x7F]|[\w]|[\d]|[_]|[\x1A])+$'); + static final _barewordPattern = RegExp( + r'^([^\x00-\x7F]|[\w]|[\d]|[_]|[\x1A])+$', + ); static bool _isReservedBareword(Bareword input) => _reservedBarewords.contains(input.word); late final _Phrase _tokens; - QueryBuilder.tokenize({ + bool get hasTokens => _tokens.isNotEmpty; + + FtsQueryBuilder.tokenize({ required String input, required int minTokenLength, required int tokenLimit, @@ -50,7 +54,10 @@ sealed class QueryBuilder { //Merge short tokens _mergeShortBarewords(barewords, minTokenLength); - _tokens = barewords.take(tokenLimit).toList(); + _tokens = barewords + .where((bareword) => bareword.word.length >= minTokenLength) + .take(tokenLimit) + .toList(); } static void _mergeShortBarewords( @@ -60,7 +67,7 @@ sealed class QueryBuilder { for (var i = 0; i < barewords.length; i++) { final bareword = barewords[i]; - if (i != 0) { + if (i > 0) { if (bareword.word.length < minTokenLength) { barewords[i] = barewords[i - 1].join(bareword); } @@ -143,8 +150,8 @@ sealed class QueryBuilder { //Remove dependend phrases if (joinedPhrase.whereType().any( - (joined) => joined.dependencies.contains(bareword), - )) { + (joined) => joined.dependencies.contains(bareword), + )) { joinedPhrase.removeAt(i); i--; } @@ -158,9 +165,9 @@ sealed class QueryBuilder { String Function(List phrase) barewordConcat, String phraseConcat, ) { - final phrases = _generatePhrases(tokens) - .map(_removeDependendBarewords) - .expand(_combineUnenclosed); + final phrases = _generatePhrases( + tokens, + ).map(_removeDependendBarewords).expand(_combineUnenclosed); return phrases .where((phrase) => phrase.isNotEmpty) @@ -174,7 +181,7 @@ sealed class QueryBuilder { String build(); } -final class PrefixQueryBuilder extends QueryBuilder { +final class PrefixQueryBuilder extends FtsQueryBuilder { PrefixQueryBuilder.tokenize({ required super.input, required super.minTokenLength, @@ -183,21 +190,17 @@ final class PrefixQueryBuilder extends QueryBuilder { @override String build() { - return _buildQuery( - _tokens, - (phrase) { - if (phrase.length == 1) { - return '${phrase.first}*'; - } + return _buildQuery(_tokens, (phrase) { + if (phrase.length == 1) { + return '${phrase.first}*'; + } - return 'NEAR(${phrase.map((bareword) => '$bareword*').join(' ')})'; - }, - ' OR ', - ); + return 'NEAR(${phrase.map((bareword) => '$bareword*').join(' ')})'; + }, ' OR '); } } -final class TrigramQueryBuilder extends QueryBuilder { +final class TrigramQueryBuilder extends FtsQueryBuilder { TrigramQueryBuilder.tokenize({ required super.input, required super.minTokenLength, @@ -206,36 +209,70 @@ final class TrigramQueryBuilder extends QueryBuilder { @override String build() { - return _buildQuery( - _tokens, - (phrase) => phrase.join(' '), - ' OR ', - ); + return _buildQuery(_tokens, (phrase) => phrase.join(' '), ' OR '); } } mixin PrefixQueryBuilderMixin implements IQueryBuilder { @override - String buildQuery(String input) { + String buildFtsQuery(String input) { final queryBuilder = PrefixQueryBuilder.tokenize( input: input, minTokenLength: ftsMinTokenLength, tokenLimit: ftsTokenLimit, ); - return queryBuilder.build(); + if (queryBuilder.hasTokens) { + return queryBuilder.build(); + } + + return ''; + } + + @override + String buildLikeQuery(String input) { + final likeQueryBuilder = UnixLikeQueryBuilder.tokenize( + input: input, + minTokenLength: 1, + tokenLimit: 5, + ); + + if (likeQueryBuilder.hasTokens) { + return likeQueryBuilder.build(); + } + + return ''; } } mixin TrigramQueryBuilderMixin implements IQueryBuilder { @override - String buildQuery(String input) { + String buildFtsQuery(String input) { final queryBuilder = TrigramQueryBuilder.tokenize( input: input, minTokenLength: ftsMinTokenLength, tokenLimit: ftsTokenLimit, ); - return queryBuilder.build(); + if (queryBuilder.hasTokens) { + return queryBuilder.build(); + } + + return ''; + } + + @override + String buildLikeQuery(String input) { + final likeQueryBuilder = UnixLikeQueryBuilder.tokenize( + input: input, + minTokenLength: 1, + tokenLimit: 5, + ); + + if (likeQueryBuilder.hasTokens) { + return likeQueryBuilder.build(); + } + + return ''; } } diff --git a/app/lib/features/search/domain/unix_tokenizer.dart b/app/lib/features/search/domain/unix_tokenizer.dart new file mode 100644 index 00000000..e745ece8 --- /dev/null +++ b/app/lib/features/search/domain/unix_tokenizer.dart @@ -0,0 +1,68 @@ +import 'package:weblibre/features/search/domain/entities/bareword.dart'; + +typedef _Phrase = List; + +sealed class UnixTokenizer { + ///Matching quoted strings like "xda bda" (group 1), as well as strings delimetered by + ///a whitespace (group 2) + static final _tokenizePattern = RegExp('"([^"]+)"|([^ ]+)'); + + late final _Phrase _tokens; + + bool get hasTokens => _tokens.isNotEmpty; + + static void _mergeShortBarewords( + List barewords, + int minTokenLength, + ) { + for (var i = 0; i < barewords.length; i++) { + final bareword = barewords[i]; + + if (i != 0) { + if (bareword.word.length < minTokenLength) { + barewords[i] = barewords[i - 1].join(bareword); + } + } + } + } + + UnixTokenizer.tokenize({ + required String input, + required int minTokenLength, + required int tokenLimit, + }) { + final matches = _tokenizePattern.allMatches(input); + + final barewords = matches + .map((match) { + if (match.group(1) != null) { + return EnclosedBareword(match.group(1)!); + } else { + return SimpleBareword(match.group(2)!); + } + }) + .where((token) => token.word.isNotEmpty) + .toList(); + + //Merge short tokens + _mergeShortBarewords(barewords, minTokenLength); + + _tokens = barewords.take(tokenLimit).toList(); + } + + String build({bool wildcard}); +} + +final class UnixLikeQueryBuilder extends UnixTokenizer { + UnixLikeQueryBuilder.tokenize({ + required super.input, + required super.minTokenLength, + required super.tokenLimit, + }) : super.tokenize(); + + @override + String build({bool wildcard = true}) { + final joined = _tokens.join('%'); + return wildcard ? '%$joined%' : joined; + } +} diff --git a/app/lib/features/search/util/tokenized_filter.dart b/app/lib/features/search/util/tokenized_filter.dart new file mode 100644 index 00000000..fc81839f --- /dev/null +++ b/app/lib/features/search/util/tokenized_filter.dart @@ -0,0 +1,124 @@ +typedef ToString = String? Function(T); + +class TokenizedFilter { + final Iterable original; + final List filtered; + + static List _tokenize(String input) { + return input + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^\w\s]'), '') // Remove punctuation + .split(RegExp(r'\s+')) // Split on whitespace + .where((word) => word.isNotEmpty) + .toList(); + } + + static bool _matchTokens( + List queryTokens, + List targetTokens, + ) { + return queryTokens.every( + (queryToken) => + targetTokens.any((targetToken) => targetToken.contains(queryToken)), + ); + } + + static double _calculateScore( + List queryTokens, + List targetTokens, + ) { + if (queryTokens.isEmpty || targetTokens.isEmpty) return 0.0; + + var score = 0.0; + var exactMatches = 0; + var partialMatches = 0; + + for (final queryToken in queryTokens) { + var hasMatch = false; + + for (int i = 0; i < targetTokens.length; i++) { + final targetToken = targetTokens[i]; + + if (targetToken == queryToken) { + // Exact match gets 1.0 points + score += 1.0; + exactMatches++; + hasMatch = true; + break; + } else if (targetToken.contains(queryToken)) { + // Partial match gets points based on match length ratio + final ratio = queryToken.length / targetToken.length; + score += ratio * 0.8; // Partial matches worth 80% of exact matches + partialMatches++; + hasMatch = true; + break; + } + } + + if (!hasMatch) { + score -= 0.2; // Penalty for unmatched query tokens + } + } + + // Normalize score based on number of query tokens and matches + final matchRatio = + (exactMatches + partialMatches * 0.8) / queryTokens.length; + score = score / queryTokens.length; // Normalize by query length + + // Apply match ratio as a multiplier + score *= matchRatio; + + // Ensure score is between 0 and 1 + return score.clamp(0.0, 1.0); + } + + factory TokenizedFilter.sort({ + required Iterable items, + required ToString toString, + required String query, + }) { + final queryTokens = _tokenize(query); + + final scoredItems = items.map((item) { + final itemString = toString(item); + if (itemString == null || itemString.isEmpty) { + return (item, 0.0); + } + + final itemTokens = _tokenize(itemString); + final score = _calculateScore(queryTokens, itemTokens); + return (item, score); + }).toList(); + + // Sort by score in descending order + scoredItems.sort((a, b) => b.$2.compareTo(a.$2)); + + // Extract just the items in sorted order + final sorted = scoredItems.map((e) => e.$1).toList(); + + return TokenizedFilter._(items, sorted); + } + + factory TokenizedFilter.remove({ + required Iterable items, + required ToString toString, + required String query, + }) { + final queryTokens = _tokenize(query); + + final filtered = items.where((item) { + final itemString = toString(item); + if (itemString == null || itemString.isEmpty) { + return false; + } + + final itemTokens = _tokenize(itemString); + return _matchTokens(queryTokens, itemTokens); + }).toList(); + + return TokenizedFilter._(items, filtered); + } + + const TokenizedFilter._(this.original, this.filtered); +} diff --git a/app/lib/features/search_browser/domain/entities/modes.dart b/app/lib/features/search_browser/domain/entities/modes.dart deleted file mode 100644 index 90772ccf..00000000 --- a/app/lib/features/search_browser/domain/entities/modes.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; - -enum KagiTool { - search(MdiIcons.searchWeb), - summarizer(MdiIcons.text), - //Sort early access features last - assistant(MdiIcons.brain); - - final IconData icon; - - const KagiTool(this.icon); -} - -enum AssistantMode { - research(7), - code(5), - chat(4), - custom(6); - - final int value; - - const AssistantMode(this.value); -} - -enum ResearchVariant { - fast(1), - expert(2); - - final int value; - - const ResearchVariant(this.value); -} - -enum ChatModel { - gpt35Turbo('GPT 3.5 Turbo', 1), - gpt4('GPT 4', 2), - gpt4Turbo('GPT 4 Turbo', 7), - gpt4o('GPT 4o', 15), - claude3Haiku('Claude 3 Haiku', 13), - claude3Sonnet('Claude 3 Sonnet', 14), - claude3Opus('Claude 3 Opus', 12), - gemini15Pro('Gemini 1.5 Pro', 8), - mistralSmall('Mistral Small', 10), - mistralLarge('Mistral Large', 11); - - final int value; - final String label; - - const ChatModel(this.label, this.value); -} - -enum SummarizerMode { - keyMoments('takeaway'), - summary('summary'); - - final String value; - - const SummarizerMode(this.value); -} diff --git a/app/lib/features/search_browser/domain/entities/sheet.dart b/app/lib/features/search_browser/domain/entities/sheet.dart deleted file mode 100644 index 501b323f..00000000 --- a/app/lib/features/search_browser/domain/entities/sheet.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:fast_equatable/fast_equatable.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; - -// ignore: missing_override_of_must_be_overridden -sealed class Sheet with FastEquatable {} - -class CreateTab extends Sheet { - final String? content; - final KagiTool? preferredTool; - - bool get hasParameters => content != null || preferredTool != null; - - CreateTab({this.content, this.preferredTool}); - - @override - bool get cacheHash => true; - - @override - List get hashParameters => [content, preferredTool]; -} - -class ViewTabs extends Sheet { - @override - bool get cacheHash => true; - - @override - List get hashParameters => []; -} diff --git a/app/lib/features/search_browser/domain/providers.dart b/app/lib/features/search_browser/domain/providers.dart deleted file mode 100644 index 818a9044..00000000 --- a/app/lib/features/search_browser/domain/providers.dart +++ /dev/null @@ -1,207 +0,0 @@ -// ignore_for_file: use_setters_to_change_properties - -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/domain/repositories/data.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/search_browser/domain/entities/sheet.dart'; -import 'package:lensai/features/search_browser/domain/services/create_tab.dart'; -import 'package:lensai/features/topics/data/database/database.dart'; -import 'package:lensai/features/topics/domain/repositories/tab.dart'; -import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; -import 'package:lensai/features/web_view/presentation/widgets/web_view.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:rxdart/rxdart.dart'; - -part 'providers.g.dart'; - -@Riverpod() -class OverlayDialog extends _$OverlayDialog { - @override - Widget? build() { - return null; - } - - void show(Widget dialog) { - state = dialog; - } - - void dismiss() { - state = null; - } -} - -@Riverpod() -class BottomSheet extends _$BottomSheet { - @override - Sheet? build() { - //Don't use select here because it will only fire on new != previous - return ref.watch(createTabStreamProvider).valueOrNull; - } - - void show(Sheet sheet) { - state = sheet; - } - - void dismiss() { - state = null; - } -} - -@Riverpod(keepAlive: true) -class BottomSheetExtend extends _$BottomSheetExtend { - late StreamController _extentStreamController; - - void add(double extent) { - _extentStreamController.add(extent); - } - - @override - Stream build() { - _extentStreamController = StreamController(); - ref.onDispose(() async { - await _extentStreamController.close(); - }); - - return _extentStreamController.stream - .sampleTime(const Duration(milliseconds: 50)); - } -} - -@Riverpod(keepAlive: true) -class SelectedBangTrigger extends _$SelectedBangTrigger { - void setTrigger(String trigger) { - state = trigger; - } - - void clearTrigger() { - state = null; - } - - @override - String? build({String? domain}) { - return null; - } -} - -@Riverpod() -Stream selectedBangData(SelectedBangDataRef ref, {String? domain}) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - final selectedBangTrigger = - ref.watch(selectedBangTriggerProvider(domain: domain)); - return repository.watchBang(selectedBangTrigger); -} - -@Riverpod(keepAlive: true) -class LastUsedAssistantMode extends _$LastUsedAssistantMode { - void update(AssistantMode mode) { - state = mode; - } - - @override - AssistantMode build() { - return AssistantMode.research; - } -} - -@Riverpod(keepAlive: true) -class ActiveResearchVariant extends _$ActiveResearchVariant { - void update(ResearchVariant mode) { - state = mode; - } - - @override - ResearchVariant build() { - return ResearchVariant.expert; - } -} - -@Riverpod(keepAlive: true) -class ActiveChatModel extends _$ActiveChatModel { - void update(ChatModel model) { - state = model; - } - - @override - ChatModel build() { - return ChatModel.gpt4o; - } -} - -@Riverpod(keepAlive: true) -class ShowFindInPage extends _$ShowFindInPage { - void update(bool show) { - state = show; - } - - @override - bool build() { - return false; - } -} - -@Riverpod() -class WebViewWidgetCache extends _$WebViewWidgetCache { - final _requestedWidgets = {}; - - List _availableTabs = []; - - @override - List build() { - ref.listen( - tabRepositoryProvider, - (previous, next) { - if (next.hasValue && next.valueOrNull != null) { - _availableTabs = next.valueOrNull!; - - _removeClosedWidgets(); - _requestedWidgets.removeWhere(_ensureWidgetCached); - } - }, - ); - - ref.listen( - webViewTabControllerProvider, - (_, tabId) { - if (tabId != null && !_ensureWidgetCached(tabId)) { - _requestedWidgets.add(tabId); - } - }, - ); - - return stateOrNull ?? []; - } - - void _removeClosedWidgets() { - state = [...state]..removeWhere( - (element) => !_availableTabs.any((tab) => tab.id == element.tabId), - ); - } - - bool _ensureWidgetCached(String tabId) { - final index = state.indexWhere((tab) => tab.tabId == tabId); - if (index < 0) { - final tab = _availableTabs.firstWhereOrNull((tab) => tab.id == tabId); - if (tab != null) { - state = [ - ...state, - WebView( - key: GlobalKey(), - tabId: tabId, - initialUrlRequest: URLRequest(url: WebUri.uri(tab.url)), - ), - ]; - - return true; - } - - return false; - } - - return true; - } -} diff --git a/app/lib/features/search_browser/domain/providers.g.dart b/app/lib/features/search_browser/domain/providers.g.dart deleted file mode 100644 index 877ca964..00000000 --- a/app/lib/features/search_browser/domain/providers.g.dart +++ /dev/null @@ -1,432 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'providers.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$selectedBangDataHash() => r'40dd3c51694394909fe83659ff3e19d51a06ec55'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [selectedBangData]. -@ProviderFor(selectedBangData) -const selectedBangDataProvider = SelectedBangDataFamily(); - -/// See also [selectedBangData]. -class SelectedBangDataFamily extends Family> { - /// See also [selectedBangData]. - const SelectedBangDataFamily(); - - /// See also [selectedBangData]. - SelectedBangDataProvider call({ - String? domain, - }) { - return SelectedBangDataProvider( - domain: domain, - ); - } - - @override - SelectedBangDataProvider getProviderOverride( - covariant SelectedBangDataProvider provider, - ) { - return call( - domain: provider.domain, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'selectedBangDataProvider'; -} - -/// See also [selectedBangData]. -class SelectedBangDataProvider extends AutoDisposeStreamProvider { - /// See also [selectedBangData]. - SelectedBangDataProvider({ - String? domain, - }) : this._internal( - (ref) => selectedBangData( - ref as SelectedBangDataRef, - domain: domain, - ), - from: selectedBangDataProvider, - name: r'selectedBangDataProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$selectedBangDataHash, - dependencies: SelectedBangDataFamily._dependencies, - allTransitiveDependencies: - SelectedBangDataFamily._allTransitiveDependencies, - domain: domain, - ); - - SelectedBangDataProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.domain, - }) : super.internal(); - - final String? domain; - - @override - Override overrideWith( - Stream Function(SelectedBangDataRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: SelectedBangDataProvider._internal( - (ref) => create(ref as SelectedBangDataRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - domain: domain, - ), - ); - } - - @override - AutoDisposeStreamProviderElement createElement() { - return _SelectedBangDataProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is SelectedBangDataProvider && other.domain == domain; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, domain.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin SelectedBangDataRef on AutoDisposeStreamProviderRef { - /// The parameter `domain` of this provider. - String? get domain; -} - -class _SelectedBangDataProviderElement - extends AutoDisposeStreamProviderElement - with SelectedBangDataRef { - _SelectedBangDataProviderElement(super.provider); - - @override - String? get domain => (origin as SelectedBangDataProvider).domain; -} - -String _$overlayDialogHash() => r'7a0376fff0ba12c70a257a47391e7c049dabd36d'; - -/// See also [OverlayDialog]. -@ProviderFor(OverlayDialog) -final overlayDialogProvider = - AutoDisposeNotifierProvider.internal( - OverlayDialog.new, - name: r'overlayDialogProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$overlayDialogHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$OverlayDialog = AutoDisposeNotifier; -String _$bottomSheetHash() => r'4040ec50a488b4b12123bedceab6d8620e6abe3d'; - -/// See also [BottomSheet]. -@ProviderFor(BottomSheet) -final bottomSheetProvider = - AutoDisposeNotifierProvider.internal( - BottomSheet.new, - name: r'bottomSheetProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$bottomSheetHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$BottomSheet = AutoDisposeNotifier; -String _$bottomSheetExtendHash() => r'81ae254499d1d7f57309274b0f18747b94d6dda3'; - -/// See also [BottomSheetExtend]. -@ProviderFor(BottomSheetExtend) -final bottomSheetExtendProvider = - StreamNotifierProvider.internal( - BottomSheetExtend.new, - name: r'bottomSheetExtendProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$bottomSheetExtendHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$BottomSheetExtend = StreamNotifier; -String _$selectedBangTriggerHash() => - r'b0e12bc95e93d50d04f1c658230cea9f15ff0385'; - -abstract class _$SelectedBangTrigger extends BuildlessNotifier { - late final String? domain; - - String? build({ - String? domain, - }); -} - -/// See also [SelectedBangTrigger]. -@ProviderFor(SelectedBangTrigger) -const selectedBangTriggerProvider = SelectedBangTriggerFamily(); - -/// See also [SelectedBangTrigger]. -class SelectedBangTriggerFamily extends Family { - /// See also [SelectedBangTrigger]. - const SelectedBangTriggerFamily(); - - /// See also [SelectedBangTrigger]. - SelectedBangTriggerProvider call({ - String? domain, - }) { - return SelectedBangTriggerProvider( - domain: domain, - ); - } - - @override - SelectedBangTriggerProvider getProviderOverride( - covariant SelectedBangTriggerProvider provider, - ) { - return call( - domain: provider.domain, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'selectedBangTriggerProvider'; -} - -/// See also [SelectedBangTrigger]. -class SelectedBangTriggerProvider - extends NotifierProviderImpl { - /// See also [SelectedBangTrigger]. - SelectedBangTriggerProvider({ - String? domain, - }) : this._internal( - () => SelectedBangTrigger()..domain = domain, - from: selectedBangTriggerProvider, - name: r'selectedBangTriggerProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$selectedBangTriggerHash, - dependencies: SelectedBangTriggerFamily._dependencies, - allTransitiveDependencies: - SelectedBangTriggerFamily._allTransitiveDependencies, - domain: domain, - ); - - SelectedBangTriggerProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.domain, - }) : super.internal(); - - final String? domain; - - @override - String? runNotifierBuild( - covariant SelectedBangTrigger notifier, - ) { - return notifier.build( - domain: domain, - ); - } - - @override - Override overrideWith(SelectedBangTrigger Function() create) { - return ProviderOverride( - origin: this, - override: SelectedBangTriggerProvider._internal( - () => create()..domain = domain, - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - domain: domain, - ), - ); - } - - @override - NotifierProviderElement createElement() { - return _SelectedBangTriggerProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is SelectedBangTriggerProvider && other.domain == domain; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, domain.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin SelectedBangTriggerRef on NotifierProviderRef { - /// The parameter `domain` of this provider. - String? get domain; -} - -class _SelectedBangTriggerProviderElement - extends NotifierProviderElement - with SelectedBangTriggerRef { - _SelectedBangTriggerProviderElement(super.provider); - - @override - String? get domain => (origin as SelectedBangTriggerProvider).domain; -} - -String _$lastUsedAssistantModeHash() => - r'1255a2754a3ea5ea058d110fd9743897fe103a69'; - -/// See also [LastUsedAssistantMode]. -@ProviderFor(LastUsedAssistantMode) -final lastUsedAssistantModeProvider = - NotifierProvider.internal( - LastUsedAssistantMode.new, - name: r'lastUsedAssistantModeProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$lastUsedAssistantModeHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$LastUsedAssistantMode = Notifier; -String _$activeResearchVariantHash() => - r'8c65a3f5ead6cc4f9e2af85b607b7a49b0764ee3'; - -/// See also [ActiveResearchVariant]. -@ProviderFor(ActiveResearchVariant) -final activeResearchVariantProvider = - NotifierProvider.internal( - ActiveResearchVariant.new, - name: r'activeResearchVariantProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$activeResearchVariantHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$ActiveResearchVariant = Notifier; -String _$activeChatModelHash() => r'675f406af8b4aa92699d59e37b5805a81d03a799'; - -/// See also [ActiveChatModel]. -@ProviderFor(ActiveChatModel) -final activeChatModelProvider = - NotifierProvider.internal( - ActiveChatModel.new, - name: r'activeChatModelProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$activeChatModelHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$ActiveChatModel = Notifier; -String _$showFindInPageHash() => r'7ee5703f7d0c7e8a8dd6b0849d7b1e0a41fe24d9'; - -/// See also [ShowFindInPage]. -@ProviderFor(ShowFindInPage) -final showFindInPageProvider = NotifierProvider.internal( - ShowFindInPage.new, - name: r'showFindInPageProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$showFindInPageHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$ShowFindInPage = Notifier; -String _$webViewWidgetCacheHash() => - r'7d6f769e8c314d9dc579d700d36d6d4c944e600f'; - -/// See also [WebViewWidgetCache]. -@ProviderFor(WebViewWidgetCache) -final webViewWidgetCacheProvider = - AutoDisposeNotifierProvider>.internal( - WebViewWidgetCache.new, - name: r'webViewWidgetCacheProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$webViewWidgetCacheHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$WebViewWidgetCache = AutoDisposeNotifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/search_browser/domain/services/create_tab.dart b/app/lib/features/search_browser/domain/services/create_tab.dart deleted file mode 100644 index 704c1702..00000000 --- a/app/lib/features/search_browser/domain/services/create_tab.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:lensai/domain/entities/received_parameter.dart'; -import 'package:lensai/features/app_widget/domain/services/home_widget.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/search_browser/domain/entities/sheet.dart'; -import 'package:lensai/features/share_intent/domain/services/sharing_intent.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:rxdart/rxdart.dart'; - -part 'create_tab.g.dart'; - -final _parameterToCreateTabTransformer = - StreamTransformer.fromHandlers( - handleData: (parameter, sink) { - final createTab = CreateTab( - preferredTool: KagiTool.values - .firstWhereOrNull((tool) => tool.name == parameter.tool), - content: parameter.content, - ); - - if (createTab.hasParameters) { - sink.add(createTab); - } - }, -); - -@Riverpod() -class CreateTabStream extends _$CreateTabStream { - late StreamController _streamController; - - @override - Stream build() { - _streamController = StreamController(); - ref.onDispose(() async { - await _streamController.close(); - }); - - final sharingItentStream = ref.watch(sharingIntentStreamProvider); - final appWidgetLaunchStream = ref.watch(appWidgetLaunchStreamProvider); - - return MergeStream([ - sharingItentStream.transform(_parameterToCreateTabTransformer), - appWidgetLaunchStream.transform(_parameterToCreateTabTransformer), - _streamController.stream, - ]); - } - - void createTab(CreateTab parameter) { - _streamController.add(parameter); - } -} diff --git a/app/lib/features/search_browser/domain/services/create_tab.g.dart b/app/lib/features/search_browser/domain/services/create_tab.g.dart deleted file mode 100644 index 5423f2b0..00000000 --- a/app/lib/features/search_browser/domain/services/create_tab.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'create_tab.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$createTabStreamHash() => r'3e85020edbebf3ec9927dba6d05af9f06a360a14'; - -/// See also [CreateTabStream]. -@ProviderFor(CreateTabStream) -final createTabStreamProvider = - AutoDisposeStreamNotifierProvider.internal( - CreateTabStream.new, - name: r'createTabStreamProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$createTabStreamHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$CreateTabStream = AutoDisposeStreamNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/search_browser/domain/services/session.dart b/app/lib/features/search_browser/domain/services/session.dart deleted file mode 100644 index 60710093..00000000 --- a/app/lib/features/search_browser/domain/services/session.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'session.g.dart'; - -@Riverpod() -class SessionService extends _$SessionService { - final _cookieManager = CookieManager.instance(); - - Future setKagiSession(String session) async { - await _cookieManager.setCookie( - url: WebUri.uri(Uri.https('kagi.com')), - name: 'kagi_session', - value: session, - domain: 'kagi.com', - isHttpOnly: true, - isSecure: true, - sameSite: HTTPCookieSameSitePolicy.LAX, - ); - } - - Future clearAllData() async { - await _cookieManager.deleteAllCookies(); - - final webViewLoaded = Completer(); - final headlessWebView = HeadlessInAppWebView( - initialSettings: InAppWebViewSettings( - //Clears all data on (just) Android - incognito: true, - //Clear other stuff - clearCache: true, - clearSessionCache: true, - ), - onWebViewCreated: (controller) { - //wait until settings are applied for sure - webViewLoaded.complete(); - }, - ); - - unawaited(headlessWebView.run()); - - await webViewLoaded.future.whenComplete(() => headlessWebView..dispose()); - } - - @override - void build() {} -} diff --git a/app/lib/features/search_browser/presentation/dialogs/tab_action.dart b/app/lib/features/search_browser/presentation/dialogs/tab_action.dart deleted file mode 100644 index eee12fa1..00000000 --- a/app/lib/features/search_browser/presentation/dialogs/tab_action.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/topics/domain/repositories/topic.dart'; -import 'package:lensai/features/web_view/domain/entities/web_view_page.dart'; -import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; -import 'package:lensai/features/web_view/presentation/widgets/favicon.dart'; - -class TabActionDialog extends HookConsumerWidget { - final WebViewPage initialTab; - - final void Function()? onDismiss; - - const TabActionDialog({ - required this.initialTab, - this.onDismiss, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tabState = ref.watch(tabStateProvider(initialTab.id)) ?? initialTab; - final topics = - ref.watch(topicRepositoryProvider.select((value) => value.valueOrNull)); - final selectedTopic = - topics?.firstWhereOrNull((topic) => topic.id == tabState.topicId); - - final expansionController = useExpansionTileController(); - - return Stack( - children: [ - ModalBarrier( - color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54, - onDismiss: onDismiss, - ), - SimpleDialog( - titlePadding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0), - contentPadding: EdgeInsets.zero, - insetPadding: const EdgeInsets.symmetric( - horizontal: 20.0, - vertical: 24.0, - ), - title: ListTile( - leading: FaviconImage( - favicon: tabState.favicon, - url: tabState.url, - size: 24, - ), - contentPadding: EdgeInsets.zero, - title: Text(tabState.title ?? 'Unknown Title'), - subtitle: Text(tabState.url.authority), - ), - children: [ - SizedBox( - //We need this to stretch the dialog, then padding from dialog is applied - width: double.maxFinite, - child: ExpansionTile( - controller: expansionController, - leading: (selectedTopic != null) - ? CircleAvatar(backgroundColor: selectedTopic.color) - : null, - title: (selectedTopic != null) - ? Text(selectedTopic.name ?? 'New Topic') - : const Text('Assign a Topic'), - children: topics - ?.where((topic) => topic.id != tabState.topicId) - .map( - (topic) => ListTile( - leading: CircleAvatar(backgroundColor: topic.color), - title: Text(topic.name ?? 'New Topic'), - onTap: () async { - await ref - .read( - tabStateProvider(initialTab.id).notifier, - ) - .copyWith(topicId: topic.id); - - expansionController.collapse(); - }, - ), - ) - .toList() ?? - [], - ), - ), - ], - ), - ], - ); - } -} diff --git a/app/lib/features/search_browser/presentation/screens/browser.dart b/app/lib/features/search_browser/presentation/screens/browser.dart deleted file mode 100644 index 49cf2575..00000000 --- a/app/lib/features/search_browser/presentation/screens/browser.dart +++ /dev/null @@ -1,703 +0,0 @@ -import 'dart:async'; - -import 'package:animations/animations.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/routing/routes.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/search_browser/domain/entities/sheet.dart'; -import 'package:lensai/features/search_browser/domain/providers.dart'; -import 'package:lensai/features/search_browser/domain/services/create_tab.dart'; -import 'package:lensai/features/search_browser/domain/services/session.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/app_bar_title.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/landing/content.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/tabs_action_button.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/topics/domain/repositories/tab.dart'; -import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; -import 'package:lensai/features/web_view/presentation/controllers/readerability.dart'; -import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; -import 'package:lensai/features/web_view/presentation/widgets/web_page_dialog.dart'; -import 'package:lensai/presentation/hooks/overlay_portal_controller.dart'; -import 'package:lensai/presentation/widgets/animate_gradient_shader.dart'; -import 'package:lensai/presentation/widgets/animated_indexed_stack.dart'; -import 'package:lensai/utils/ui_helper.dart' as ui_helper; -import 'package:share_plus/share_plus.dart'; -import 'package:speech_to_text_google_dialog/speech_to_text_google_dialog.dart'; - -class KagiScreen extends HookConsumerWidget { - const KagiScreen({super.key}); - - double _realtiveSafeArea(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - return 1.0 - (mediaQuery.padding.top / mediaQuery.size.height); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final displayedSheet = ref.watch(bottomSheetProvider); - final displayedOverlayDialog = ref.watch(overlayDialogProvider); - - final showEarlyAccessFeatures = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .showEarlyAccessFeatures, - ), - ); - - final quickAction = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).quickAction, - ), - ); - - final quickActionVoice = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .quickActionVoiceInput, - ), - ); - - final activeTabId = ref.watch(webViewTabControllerProvider); - - final menuController = useMemoized(() => MenuController()); - - final lastBackButtonPress = useRef(null); - - final overlayController = useOverlayPortalController(); - - final activeKagiTool = useValueNotifier(null, [displayedSheet]); - - ref.listen( - overlayDialogProvider, - (previous, next) { - if (next != null) { - overlayController.show(); - } else { - overlayController.hide(); - } - }, - ); - - ref.listen( - settingsRepositoryProvider - .select((value) => value.valueOrNull?.kagiSession), - (previous, next) async { - if (next != null && next.isNotEmpty) { - await ref.read(sessionServiceProvider.notifier).setKagiSession(next); - } - }, - ); - - return Scaffold( - bottomNavigationBar: BottomAppBar( - height: AppBar().preferredSize.height, - padding: EdgeInsets.zero, - child: AppBar( - automaticallyImplyLeading: false, - titleSpacing: 8.0, - title: (activeTabId != null) - ? Consumer( - builder: (context, ref, child) { - final page = ref.watch(tabStateProvider(activeTabId)); - - return (page != null) - ? AppBarTitle( - page: page, - onTap: () { - ref.read(overlayDialogProvider.notifier).show( - WebPageDialog( - url: page.url, - precachedInfo: page, - webViewController: page.controller, - onDismiss: ref - .read(overlayDialogProvider.notifier) - .dismiss, - ), - ); - }, - ) - : const SizedBox.shrink(); - }, - ) - : HookBuilder( - builder: (context) { - final activeTool = useListenableSelector( - activeKagiTool, - () { - if (displayedSheet is SharedContentSheet) { - return null; - } - - return activeKagiTool.value; - }, - ); - - return Row( - children: [ - IconButton( - color: (activeTool == KagiTool.search) - ? Theme.of(context).colorScheme.primary - : null, - onPressed: () { - ref - .read(createTabStreamProvider.notifier) - .createTab( - CreateTab(preferredTool: KagiTool.search), - ); - }, - icon: Icon(KagiTool.search.icon), - ), - IconButton( - color: (activeTool == KagiTool.summarizer) - ? Theme.of(context).colorScheme.primary - : null, - onPressed: () { - ref - .read(createTabStreamProvider.notifier) - .createTab( - CreateTab(preferredTool: KagiTool.summarizer), - ); - }, - icon: Icon(KagiTool.summarizer.icon), - ), - if (showEarlyAccessFeatures) - IconButton( - color: (activeTool == KagiTool.assistant) - ? Theme.of(context).colorScheme.primary - : null, - onPressed: () { - ref - .read(createTabStreamProvider.notifier) - .createTab( - CreateTab( - preferredTool: KagiTool.assistant, - ), - ); - }, - icon: Icon(KagiTool.assistant.icon), - ), - ], - ); - }, - ), - actions: [ - if (activeTabId != null) - HookConsumer( - builder: (context, ref, child) { - final colorScheme = Theme.of(context).colorScheme; - - final readerabilityState = ref.watch( - readerabilityControllerProvider(activeTabId), - ); - - final enableReadability = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .enableReadability, - ), - ); - - final isReaderable = - readerabilityState.valueOrNull?.readerable ?? false; - - final readerableApplied = - readerabilityState.valueOrNull?.applied ?? false; - - final icon = useMemoized( - () => readerableApplied - ? Icon( - MdiIcons.bookOpen, - color: Theme.of(context).colorScheme.primary, - ) - : const Icon( - MdiIcons.bookOpenOutline, - color: Colors.white, - ), - [readerableApplied], - ); - - return Visibility( - visible: enableReadability, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 15.0, - horizontal: 8.0, - ), - child: readerabilityState.when( - data: (_) => Visibility( - visible: isReaderable == true || readerableApplied, - child: InkWell( - onTap: readerabilityState.isLoading - ? null - : () async { - final controller = ref.read( - webViewControllerProvider(activeTabId), - ); - - if (controller != null) { - final readabilityNotifier = ref.read( - readerabilityControllerProvider( - activeTabId, - ).notifier, - ); - - await readabilityNotifier - .toggleReaderable(); - } - }, - child: icon, - ), - ), - error: (error, stackTrace) => SizedBox.shrink(), - loading: () => AnimateGradientShader( - duration: const Duration(milliseconds: 500), - primaryEnd: Alignment.bottomLeft, - secondaryEnd: Alignment.topRight, - primaryColors: [ - colorScheme.primary, - colorScheme.primaryContainer, - ], - secondaryColors: [ - colorScheme.secondary, - colorScheme.secondaryContainer, - ], - child: icon, - ), - ), - ), - ); - }, - ), - if (quickAction != null) - InkWell( - onTap: () async { - var tab = CreateTab( - preferredTool: quickAction, - ); - - if (quickActionVoice) { - final completer = Completer(); - - final isServiceAvailable = - await SpeechToTextGoogleDialog.getInstance() - .showGoogleDialog( - onTextReceived: (data) { - completer.complete(data.toString()); - }, - // locale: "en-US", - ); - - if (!isServiceAvailable) { - if (context.mounted) { - ui_helper.showErrorMessage( - context, - 'Service is not available', - ); - } - } - - tab = CreateTab( - preferredTool: quickAction, - content: await completer.future, - ); - } - - ref.read(createTabStreamProvider.notifier).createTab(tab); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 15.0, - horizontal: 8.0, - ), - child: Icon(quickActionVoice ? Icons.mic : quickAction.icon), - ), - ), - TabsActionButton( - isActive: displayedSheet is ViewTabs, - onTap: () { - if (displayedSheet case ViewTabs()) { - ref.read(bottomSheetProvider.notifier).dismiss(); - } else { - ref.read(bottomSheetProvider.notifier).show(ViewTabs()); - } - }, - ), - MenuAnchor( - controller: menuController, - builder: (context, controller, child) { - return Padding( - padding: const EdgeInsets.only(right: 4.0), - child: InkWell( - onTap: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - child: const Padding( - padding: - EdgeInsets.symmetric(vertical: 15.0, horizontal: 8.0), - child: Icon(Icons.more_vert), - ), - ), - ); - }, - menuChildren: [ - MenuItemButton( - onPressed: () async { - await context.push(AboutRoute().location); - }, - leadingIcon: const Icon(Icons.info), - child: const Text('About'), - ), - const Divider(), - MenuItemButton( - onPressed: () async { - await context.push(SettingsRoute().location); - }, - leadingIcon: const Icon(Icons.settings), - child: const Text('Settings'), - ), - const Divider(), - MenuItemButton( - onPressed: () async { - await context.push(BangCategoriesRoute().location); - }, - leadingIcon: const Icon(MdiIcons.exclamationThick), - child: const Text('Bangs'), - ), - if (showEarlyAccessFeatures) - MenuItemButton( - onPressed: () async { - await context.push(ChatArchiveListRoute().location); - }, - leadingIcon: const Icon(MdiIcons.archive), - child: const Text('Chat Archive'), - ), - const Divider(), - if (showEarlyAccessFeatures) - MenuItemButton( - onPressed: () { - ref.read(createTabStreamProvider.notifier).createTab( - CreateTab(preferredTool: KagiTool.assistant), - ); - }, - leadingIcon: Icon(KagiTool.assistant.icon), - child: const Text('Assistant'), - ), - MenuItemButton( - onPressed: () { - ref.read(createTabStreamProvider.notifier).createTab( - CreateTab(preferredTool: KagiTool.summarizer), - ); - }, - leadingIcon: Icon(KagiTool.summarizer.icon), - child: const Text('Summarizer'), - ), - MenuItemButton( - onPressed: () { - ref.read(createTabStreamProvider.notifier).createTab( - CreateTab(preferredTool: KagiTool.search), - ); - }, - leadingIcon: Icon(KagiTool.search.icon), - child: const Text('Search'), - ), - const Divider(), - if (activeTabId != null) - MenuItemButton( - onPressed: () async { - final controller = - ref.read(webViewControllerProvider(activeTabId)); - - final url = await controller?.getUrl(); - if (url != null) { - // ignore: use_build_context_synchronously - await ui_helper.launchUrlFeedback(context, url); - } - }, - leadingIcon: const Icon(Icons.open_in_browser), - child: const Text('Launch External'), - ), - if (activeTabId != null) - MenuItemButton( - onPressed: () async { - final controller = - ref.read(webViewControllerProvider(activeTabId)); - - final url = await controller?.getUrl(); - if (url != null) { - await Share.shareUri(url); - } - }, - leadingIcon: const Icon(Icons.share), - child: const Text('Share'), - ), - if (activeTabId != null) const Divider(), - if (activeTabId != null) - MenuItemButton( - onPressed: () async { - ref.read(showFindInPageProvider.notifier).update(true); - }, - leadingIcon: const Icon(Icons.search), - child: const Text('Find in page'), - ), - if (activeTabId != null) const Divider(), - if (activeTabId != null) - MenuItemButton( - onPressed: () async { - final controller = - ref.read(webViewControllerProvider(activeTabId)); - - await controller?.reload(); - }, - leadingIcon: const Icon(Icons.refresh), - child: const Text('Reload'), - ), - if (activeTabId != null) const Divider(), - if (activeTabId != null) - Consumer( - builder: (context, ref, child) { - final history = ref.watch( - tabStateProvider(activeTabId) - .select((value) => value?.pageHistory), - ); - - return Row( - children: [ - Expanded( - child: IconButton( - onPressed: (history?.canGoBack == true) - ? () async { - final controller = ref.read( - webViewControllerProvider( - activeTabId, - ), - ); - - await controller?.goBack(); - menuController.close(); - } - : null, - icon: const Icon(Icons.arrow_back), - ), - ), - const SizedBox(height: 48, child: VerticalDivider()), - Expanded( - child: IconButton( - onPressed: (history?.canGoForward == true) - ? () async { - final controller = ref.read( - webViewControllerProvider( - activeTabId, - ), - ); - - await controller?.goForward(); - menuController.close(); - } - : null, - icon: const Icon(Icons.arrow_forward), - ), - ), - ], - ); - }, - ), - ], - ), - ], - ), - ), - body: OverlayPortal( - controller: overlayController, - overlayChildBuilder: (context) { - return displayedOverlayDialog!; - }, - child: Listener( - onPointerDown: (displayedSheet != null) - ? (_) { - ref.read(bottomSheetProvider.notifier).dismiss(); - } - : null, - child: HookConsumer( - builder: (context, ref, child) { - final webViewWidgets = ref.watch(webViewWidgetCacheProvider); - final activeWebViewStackIndex = (activeTabId != null) - ? webViewWidgets - .indexWhere((view) => view.tabId == activeTabId) - : null; - - return BackButtonListener( - onBackButtonPressed: () async { - final page = (activeTabId != null) - ? ref.read(tabStateProvider(activeTabId)) - : null; - - //Don't do anything if a child route is active - if (GoRouterState.of(context).topRoute?.name != 'KagiRoute') { - return false; - } - - if (displayedSheet != null) { - ref.read(bottomSheetProvider.notifier).dismiss(); - return true; - } - - if (displayedOverlayDialog != null) { - ref.read(overlayDialogProvider.notifier).dismiss(); - return true; - } - - if (page?.pageHistory.canGoBack == true) { - lastBackButtonPress.value = null; - - await page?.controller?.goBack(); - return true; - } - - if (lastBackButtonPress.value != null && - DateTime.now().difference(lastBackButtonPress.value!) < - const Duration(seconds: 2)) { - lastBackButtonPress.value = null; - - if (page != null && webViewWidgets.length > 1) { - await ref - .read(tabRepositoryProvider.notifier) - .deleteTab(page.id); - return true; - } else { - //Mark back as unhandled and navigator will pop - return false; - } - } else { - lastBackButtonPress.value = DateTime.now(); - ScaffoldMessenger.of(context) - ..clearSnackBars() - ..showSnackBar( - SnackBar( - content: (webViewWidgets.length > 1) - ? const Text( - 'Please click BACK again to close current tab', - ) - : const Text( - 'Please click BACK again to exit app', - ), - ), - ); - - return true; - } - }, - child: Stack( - children: [ - SafeArea( - child: AnimatedIndexedStack( - duration: const Duration(milliseconds: 250), - transitionBuilder: - (child, animation, secondaryAnimation) => - SharedAxisTransition( - animation: animation, - secondaryAnimation: secondaryAnimation, - transitionType: SharedAxisTransitionType.horizontal, - child: child, - ), - // key: ValueKey( - // (activeTabId != null) - // ? activeWebViewStackIndex - // : null, - // ), - index: (activeWebViewStackIndex != null && - activeWebViewStackIndex > -1) - ? activeWebViewStackIndex + 1 - : 0, - children: [const LandingContent(), ...webViewWidgets], - ), - ), - if (displayedSheet != null) - ModalBarrier( - color: Theme.of(context).dialogTheme.barrierColor ?? - Colors.black54, - ), - ], - ), - ); - }, - ), - ), - ), - bottomSheet: (displayedSheet != null) - ? NotificationListener( - onNotification: (notification) { - if (notification.extent <= 0.1) { - ref.read(bottomSheetProvider.notifier).dismiss(); - return true; - } else { - ref - .read(bottomSheetExtendProvider.notifier) - .add(notification.extent); - } - - return false; - }, - child: switch (displayedSheet) { - ViewTabs() => DraggableScrollableSheet( - key: ValueKey(displayedSheet), - expand: false, - minChildSize: 0.1, - maxChildSize: _realtiveSafeArea(context), - builder: (context, scrollController) { - return ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(28), - topRight: Radius.circular(28), - ), - child: ViewTabsSheet( - sheetScrollController: scrollController, - onClose: () { - ref.read(bottomSheetProvider.notifier).dismiss(); - }, - ), - ); - }, - ), - final CreateTab parameter => DraggableScrollableSheet( - key: ValueKey(displayedSheet), - expand: false, - initialChildSize: 0.8, - minChildSize: 0.1, - maxChildSize: _realtiveSafeArea(context), - builder: (context, scrollController) { - return SingleChildScrollView( - controller: scrollController, - child: SharedContentSheet( - key: ValueKey(parameter), - parameter: parameter, - onSubmit: (url) async { - await ref - .read(switchNewTabControllerProvider.notifier) - .add(url); - - ref.read(bottomSheetProvider.notifier).dismiss(); - }, - onActiveToolChanged: (tool) { - activeKagiTool.value = tool; - }, - ), - ); - }, - ), - }, - ) - : null, - ); - } -} diff --git a/app/lib/features/search_browser/presentation/widgets/app_bar_title.dart b/app/lib/features/search_browser/presentation/widgets/app_bar_title.dart deleted file mode 100644 index 6d3bb807..00000000 --- a/app/lib/features/search_browser/presentation/widgets/app_bar_title.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:lensai/features/web_view/domain/entities/web_view_page.dart'; -import 'package:lensai/features/web_view/presentation/widgets/favicon.dart'; -import 'package:text_scroll/text_scroll.dart'; - -class AppBarTitle extends StatelessWidget { - final WebViewPage page; - - final void Function()? onTap; - - const AppBarTitle({required this.page, this.onTap, super.key}); - - Icon _securityStatusIcon(BuildContext context, WebViewPage page) { - if (page.url.isScheme('http')) { - return Icon( - MdiIcons.lockOff, - color: Theme.of(context).colorScheme.error, - size: 14, - ); - } else if (page.sslError != null) { - return Icon( - MdiIcons.lockAlert, - color: Theme.of(context).colorScheme.errorContainer, - size: 14, - ); - } else { - return const Icon( - MdiIcons.lock, - size: 14, - ); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return GestureDetector( - onTap: onTap, - child: Row( - children: [ - FaviconImage( - favicon: page.favicon, - url: page.url, - ), - const SizedBox( - width: 8, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - TextScroll( - key: ValueKey(page.title), - page.title ?? 'New Tab', - style: theme.textTheme.bodyLarge - ?.copyWith(color: theme.colorScheme.onSurface), - // mode: TextScrollMode.bouncing, - velocity: const Velocity(pixelsPerSecond: Offset(75, 0)), - delayBefore: const Duration(milliseconds: 500), - pauseBetween: const Duration(milliseconds: 5000), - fadedBorder: true, - fadeBorderSide: FadeBorderSide.right, - fadedBorderWidth: 0.05, - intervalSpaces: 4, - numberOfReps: 2, - ), - Row( - children: [ - _securityStatusIcon(context, page), - const SizedBox( - width: 4, - ), - Expanded( - child: Text( - page.url.authority, - style: theme.textTheme.bodyMedium - ?.copyWith(color: theme.colorScheme.onSurface), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/app/lib/features/search_browser/presentation/widgets/landing/action.dart b/app/lib/features/search_browser/presentation/widgets/landing/action.dart deleted file mode 100644 index c5e33955..00000000 --- a/app/lib/features/search_browser/presentation/widgets/landing/action.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/routing/routes.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/error_container.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; - -class LandingAction extends HookConsumerWidget { - const LandingAction({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final sessionTokenAvailable = ref.watch( - settingsRepositoryProvider.select( - (value) => value.valueOrNull?.kagiSession?.isNotEmpty ?? false, - ), - ); - - return Visibility( - visible: !sessionTokenAvailable, - child: ErrorContainer( - content: Markdown( - shrinkWrap: true, - onTapLink: (text, href, title) async { - if (href == 'settings') { - await context.push(SettingsRoute().location); - } - }, - data: '## No Kagi Session Provided\n' - 'Please navigate to [Settings](settings) and enter your Kagi Session Token.\n' - "You must provide a valid session in order to use Kagi's features.", - ), - ), - ); - } -} diff --git a/app/lib/features/search_browser/presentation/widgets/landing/content.dart b/app/lib/features/search_browser/presentation/widgets/landing/content.dart deleted file mode 100644 index 26f74f87..00000000 --- a/app/lib/features/search_browser/presentation/widgets/landing/content.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:fading_scroll/fading_scroll.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show rootBundle; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/domain/services/app_initialization.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/error_container.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/landing/action.dart'; -import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; -import 'package:lensai/presentation/hooks/cached_future.dart'; - -class LandingContent extends HookConsumerWidget { - const LandingContent({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final textTheme = Theme.of(context).textTheme; - - final descriptionAsset = useCachedFuture( - () async => rootBundle.loadString('assets/landing/description.md'), - ); - final changelogAsset = useCachedFuture( - () async => rootBundle.loadString('assets/landing/changelog.md'), - ); - - return FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return SingleChildScrollView( - controller: controller, - child: Column( - children: [ - Text( - 'Lensai', - style: - textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - 'The Privacy-Focused & AI-Powered Research Browser with Kagi integration', - style: textTheme.titleMedium, - textAlign: TextAlign.center, - ), - ), - FractionallySizedBox( - widthFactor: 0.5, - child: Image.asset('assets/icon/icon.png'), - ), - const LandingAction(), - Consumer( - builder: (context, ref, child) { - final errors = ref.watch( - appInitializationServiceProvider - .select((result) => result.valueOrNull?.errors), - ); - - if (errors == null || errors.isEmpty) { - return const SizedBox.shrink(); - } - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...errors.map( - (error) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: ErrorContainer( - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Error during App Initialization!', - style: theme.textTheme.titleMedium, - ), - Text( - error.message, - style: theme.textTheme.titleSmall, - ), - if (error.details != null) - Text(error.details.toString()), - ], - ), - ), - ); - }, - ), - const SizedBox( - height: 8, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - width: double.infinity, - child: FilledButton.tonalIcon( - onPressed: () async { - await ref - .read( - appInitializationServiceProvider.notifier) - .reinitialize(); - }, - style: OutlinedButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - label: const Text('Restart App'), - icon: const Icon(Icons.refresh_outlined), - ), - ), - ), - ], - ); - }, - ), - Markdown( - data: descriptionAsset.data ?? '', - onTapLink: (text, href, title) async { - if (href != null) { - await ref - .read(switchNewTabControllerProvider.notifier) - .add(Uri.parse(href)); - } - }, - selectable: true, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - ), - Text( - 'Changelog', - style: - textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - Markdown( - data: changelogAsset.data ?? '', - selectable: true, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - ), - ], - ), - ); - }, - ); - } -} diff --git a/app/lib/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart b/app/lib/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart deleted file mode 100644 index ae8380c3..00000000 --- a/app/lib/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:expandable_page_view/expandable_page_view.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/search_browser/domain/entities/sheet.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/tabs/assistant_tab.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/tabs/search_tab.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/tabs/summarize_tab.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; -import 'package:lensai/presentation/hooks/sync_page_tab.dart'; - -typedef OnSubmitUri = void Function(Uri url); - -class SharedContentSheet extends HookConsumerWidget { - final CreateTab parameter; - final OnSubmitUri onSubmit; - final void Function(KagiTool tool)? onActiveToolChanged; - - const SharedContentSheet({ - required this.parameter, - required this.onSubmit, - this.onActiveToolChanged, - super.key, - }); - @override - Widget build(BuildContext context, WidgetRef ref) { - final showEarlyAccessFeatures = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .showEarlyAccessFeatures, - ), - ); - - final sharedContent = useMemoized( - () => (parameter.content != null) - ? SharedContent.parse(parameter.content!) - : null, - [parameter], - ); - - final initialIndex = useMemoized( - () => - parameter.preferredTool?.index ?? - switch (sharedContent) { - SharedText(text: final text) => - (showEarlyAccessFeatures && text.length > 25) - ? KagiTool.assistant.index - : KagiTool.search.index, - SharedUrl() => KagiTool.summarizer.index, - null => showEarlyAccessFeatures - ? KagiTool.assistant.index - : KagiTool.search.index, - }, - [sharedContent], - ); - - final tabController = useTabController( - initialLength: KagiTool.values.length - (showEarlyAccessFeatures ? 0 : 1), - initialIndex: initialIndex, - ); - final pageController = usePageController(initialPage: tabController.index); - - useSyncPageWithTab( - tabController, - pageController, - onIndexChanged: (index) { - onActiveToolChanged?.call(KagiTool.values[index]); - }, - ); - - useEffect( - () { - WidgetsBinding.instance.addPostFrameCallback((_) { - onActiveToolChanged?.call(KagiTool.values[initialIndex]); - }); - return null; - }, - [initialIndex], - ); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - TabBar( - controller: tabController, - tabs: [ - Tab( - icon: Icon(KagiTool.search.icon), - text: 'Search', - ), - Tab( - icon: Icon(KagiTool.summarizer.icon), - text: 'Summarize', - ), - if (showEarlyAccessFeatures) - Tab( - icon: Icon(KagiTool.assistant.icon), - text: 'Assistant', - ), - ], - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0), - child: ExpandablePageView( - controller: pageController, - children: [ - SearchTab( - sharedContent: sharedContent, - onSubmit: onSubmit, - ), - SummarizeTab( - sharedContent: sharedContent, - onSubmit: onSubmit, - ), - if (showEarlyAccessFeatures) - AssistantTab( - sharedContent: sharedContent, - onSubmit: onSubmit, - ), - ], - ), - ), - ], - ); - } -} diff --git a/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart b/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart deleted file mode 100644 index a32259ab..00000000 --- a/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'dart:async'; - -import 'package:fading_scroll/fading_scroll.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/search_browser/domain/providers.dart'; -import 'package:lensai/features/search_browser/presentation/dialogs/tab_action.dart'; -import 'package:lensai/features/topics/domain/providers.dart'; -import 'package:lensai/features/topics/domain/repositories/tab.dart'; -import 'package:lensai/features/topics/presentation/widgets/topic_chips.dart'; -import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; -import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; -import 'package:lensai/features/web_view/presentation/widgets/web_view_tab.dart'; - -class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate { - static const _headerSize = 104.0; - - final VoidCallback onClose; - - _SliverHeaderDelagate({required this.onClose}); - - @override - Widget build( - BuildContext context, - double shrinkOffset, - bool overlapsContent, - ) { - return Material( - child: Consumer( - builder: (context, ref, child) { - //Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555 - return Align( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton.icon( - onPressed: () async { - await ref - .read(switchNewTabControllerProvider.notifier) - .add(Uri.https('kagi.com')); - - onClose(); - }, - icon: const Icon(Icons.add), - label: const Text('New Tab'), - ), - TextButton.icon( - onPressed: () async { - final topic = ref.read(selectedTopicProvider); - await ref - .read(topicTabRepositoryProvider(topic).notifier) - .closeAllTabs(); - - onClose(); - }, - icon: const Icon(Icons.delete), - label: const Text('Close All'), - ), - ], - ), - TopicChips(), - const SizedBox(height: 8), - ], - ), - ); - }, - ), - ); - } - - @override - double get minExtent => _headerSize; - - @override - double get maxExtent => _headerSize; - - @override - bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => - false; -} - -class ViewTabsSheet extends HookConsumerWidget { - final ScrollController sheetScrollController; - final VoidCallback onClose; - - const ViewTabsSheet({ - required this.onClose, - required this.sheetScrollController, - super.key, - }); - - double _calculateItemHeight({ - required double screenWidth, - required double childAspectRatio, - required double horizontalPadding, - required double mainAxisSpacing, - required double crossAxisSpacing, - required int crossAxisCount, - }) { - final totalHorizontalPadding = horizontalPadding * 2; - final totalCrossAxisSpacing = crossAxisSpacing * (crossAxisCount - 1); - final availableWidth = - screenWidth - totalHorizontalPadding - totalCrossAxisSpacing; - final itemWidth = availableWidth / crossAxisCount; - final itemHeight = itemWidth / childAspectRatio; - final totalItemHeight = itemHeight + mainAxisSpacing; - - return totalItemHeight; - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return FadingScroll( - fadingSize: 25, - shaderPadding: - const EdgeInsets.only(top: _SliverHeaderDelagate._headerSize), - controller: sheetScrollController, - builder: (context, controller) { - return CustomScrollView( - controller: controller, - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: _SliverHeaderDelagate(onClose: onClose), - ), - HookConsumer( - builder: (context, ref, child) { - final topic = ref.watch(selectedTopicProvider); - final availableTabs = ref.watch( - topicTabRepositoryProvider(topic) - .select((value) => value.valueOrNull ?? []), - ); - final activeTab = ref.watch(webViewTabControllerProvider); - - final itemHeight = useMemoized( - () => _calculateItemHeight( - screenWidth: MediaQuery.of(context).size.width, - childAspectRatio: 0.75, - horizontalPadding: 4.0, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, - crossAxisCount: 2, - ), - [MediaQuery.of(context).size.width], - ); - - useEffect( - () { - final index = availableTabs - .indexWhere((webView) => webView == activeTab); - - if (index > -1) { - final reversedIndex = availableTabs.length - 1 - index; - final offset = (reversedIndex ~/ 2) * itemHeight; - - if (offset != controller.offset) { - unawaited( - controller.animateTo( - offset, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ), - ); - } - } - - return null; - }, - [], - ); - - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - sliver: SliverGrid.count( - //Sync values for itemHeight calculation _calculateItemHeight - childAspectRatio: 0.75, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, - crossAxisCount: 2, - children: availableTabs.reversed - .map( - (tabId) => Consumer( - key: ValueKey(tabId), - builder: (context, ref, child) { - final tab = ref.watch(tabStateProvider(tabId)); - - return (tab != null) - ? WebViewTab( - tab: tab, - isActive: tabId == activeTab, - onTap: () { - if (tabId != activeTab) { - //Close first to avoid rebuilds - onClose(); - ref - .read( - webViewTabControllerProvider - .notifier, - ) - .showTab(tab.id); - } else { - onClose(); - } - }, - onLongPress: () { - ref - .read( - overlayDialogProvider.notifier, - ) - .show( - TabActionDialog( - initialTab: tab, - onDismiss: ref - .read( - overlayDialogProvider - .notifier, - ) - .dismiss, - ), - ); - }, - onDelete: () async { - await ref - .read( - tabRepositoryProvider.notifier, - ) - .deleteTab(tab.id); - }, - ) - : const SizedBox.shrink(); - }, - ), - ) - .toList(), - ), - ); - }, - ), - ], - ); - }, - ); - } -} diff --git a/app/lib/features/search_browser/presentation/widgets/tabs/assistant_tab.dart b/app/lib/features/search_browser/presentation/widgets/tabs/assistant_tab.dart deleted file mode 100644 index e0b555e8..00000000 --- a/app/lib/features/search_browser/presentation/widgets/tabs/assistant_tab.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'package:expandable_page_view/expandable_page_view.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/search_browser/domain/providers.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/speech_to_text_button.dart'; -import 'package:lensai/features/search_browser/utils/url_builder.dart' - as uri_builder; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; -import 'package:lensai/presentation/hooks/sync_page_tab.dart'; - -class AssistantTab extends HookConsumerWidget { - final SharedContent? sharedContent; - final OnSubmitUri onSubmit; - - const AssistantTab({ - required this.sharedContent, - required this.onSubmit, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), - ); - - final researchVariant = ref.watch(activeResearchVariantProvider); - final chatModel = ref.watch(activeChatModelProvider); - - useAutomaticKeepAlive(); - - final formKey = useMemoized(() => GlobalKey()); - final textController = - useTextEditingController(text: sharedContent?.toString()); - - final tabController = useTabController( - initialLength: AssistantMode.values.length, - initialIndex: ref.read(lastUsedAssistantModeProvider).index, - ); - final pageController = usePageController(initialPage: tabController.index); - - useSyncPageWithTab( - tabController, - pageController, - onIndexChanged: (index) { - ref - .read(lastUsedAssistantModeProvider.notifier) - .update(AssistantMode.values[index]); - }, - ); - - return Form( - key: formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TabBar.secondary( - controller: tabController, - tabs: const [ - Tab( - text: 'Research', - icon: Icon(MdiIcons.layersSearch), - ), - Tab( - text: 'Code', - icon: Icon(MdiIcons.codeJson), - ), - Tab( - text: 'Chat', - icon: Icon(MdiIcons.commentTextMultiple), - ), - Tab( - text: 'Custom', - icon: Icon(MdiIcons.creation), - ), - ], - ), - ExpandablePageView( - controller: pageController, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - height: 12, - ), - SegmentedButton( - segments: const [ - ButtonSegment( - value: ResearchVariant.expert, - icon: Icon(MdiIcons.textSearch), - label: Text('Research'), - ), - ButtonSegment( - value: ResearchVariant.fast, - icon: Icon(MdiIcons.invoiceTextFast), - label: Text('Fast'), - ), - ], - selected: {researchVariant}, - onSelectionChanged: (value) { - ref - .read(activeResearchVariantProvider.notifier) - .update(value.first); - }, - ), - const SizedBox( - height: 12, - ), - _PromptField(textController, incognitoEnabled), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - height: 12, - ), - _PromptField(textController, incognitoEnabled), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - height: 12, - ), - DropdownMenu( - initialSelection: chatModel, - expandedInsets: EdgeInsets.zero, - label: const Text('Model'), - inputDecorationTheme: const InputDecorationTheme(), - dropdownMenuEntries: ChatModel.values - .map( - (model) => DropdownMenuEntry( - value: model, - label: model.label, - ), - ) - .toList(), - onSelected: (value) { - ref.read(activeChatModelProvider.notifier).update(value!); - }, - ), - const SizedBox( - height: 12, - ), - _PromptField(textController, incognitoEnabled), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - height: 12, - ), - _PromptField(textController, incognitoEnabled), - ], - ), - ], - ), - const SizedBox( - height: 12, - ), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - onSubmit( - uri_builder.assistantUri( - prompt: textController.text, - assistantMode: AssistantMode.values[tabController.index], - researchVariant: researchVariant, - chatModel: chatModel, - ), - ); - } - }, - label: const Text('Submit'), - icon: const Icon(MdiIcons.invoiceTextSend), - ), - ), - ], - ), - ); - } -} - -class _PromptField extends StatelessWidget { - final TextEditingController textController; - final bool incognitoEnabled; - - const _PromptField(this.textController, this.incognitoEnabled); - - @override - Widget build(BuildContext context) { - return TextFormField( - controller: textController, - enableIMEPersonalizedLearning: !incognitoEnabled, - decoration: InputDecoration( - label: const Text('Prompt'), - hintText: 'Enter your prompt...', - floatingLabelBehavior: FloatingLabelBehavior.always, - suffixIcon: SpeechToTextButton( - onTextReceived: (data) { - textController.text = data.toString(); - }, - ), - ), - maxLines: null, - validator: (value) { - if (value?.isEmpty ?? true) { - return ''; - } - - return null; - }, - ); - } -} diff --git a/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart b/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart deleted file mode 100644 index 0c4fa894..00000000 --- a/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/routing/routes.dart'; -import 'package:lensai/features/bangs/domain/providers.dart'; -import 'package:lensai/features/bangs/domain/repositories/data.dart'; -import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; -import 'package:lensai/features/bangs/presentation/widgets/search_field.dart'; -import 'package:lensai/features/search_browser/domain/providers.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart'; -import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; -import 'package:lensai/presentation/widgets/selectable_chips.dart'; - -class SearchTab extends HookConsumerWidget { - final SharedContent? sharedContent; - final OnSubmitUri onSubmit; - - const SearchTab({ - required this.sharedContent, - required this.onSubmit, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - useAutomaticKeepAlive(); - - final formKey = useMemoized(() => GlobalKey()); - - final selectedBang = ref - .watch(selectedBangDataProvider().select((value) => value.valueOrNull)); - final defaultSearchBang = ref - .watch(kagiSearchBangDataProvider.select((value) => value.valueOrNull)); - - final activeBang = selectedBang ?? defaultSearchBang; - - final textController = - useTextEditingController(text: sharedContent?.toString()); - - Future submitSearch() async { - if (activeBang != null && (formKey.currentState?.validate() == true)) { - await ref - .read(bangDataRepositoryProvider.notifier) - .increaseFrequency(activeBang.trigger); - - onSubmit(activeBang.getUrl(textController.text)); - } - } - - return Form( - key: formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 4), - Consumer( - builder: (context, ref, child) { - final frequentBangsAsync = - ref.watch(frequentBangDataListProvider); - - return frequentBangsAsync.when( - data: (availableBangs) => SizedBox( - height: 48, - child: Row( - children: [ - if (selectedBang != null || availableBangs.isNotEmpty) - Expanded( - child: SelectableChips( - itemId: (bang) => bang.trigger, - itemAvatar: (bang) => BangIcon(bang, iconSize: 20), - itemLabel: (bang) => Text(bang.websiteName), - availableItems: availableBangs, - selectedItem: selectedBang, - onSelected: (bang) { - ref - .read(selectedBangTriggerProvider().notifier) - .setTrigger(bang.trigger); - }, - onDeleted: (bang) async { - if (ref.read(selectedBangTriggerProvider()) == - bang.trigger) { - ref - .read( - selectedBangTriggerProvider().notifier, - ) - .clearTrigger(); - } else { - final dialogResult = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text( - 'Reset usage frequency of !${bang.trigger}?', - ), - content: const Text( - 'This will remove the Bang from quick select.', - ), - actions: [ - TextButton( - onPressed: () => - Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => - Navigator.pop(context, true), - child: const Text('Reset'), - ), - ], - ), - ); - - if (dialogResult == true) { - await ref - .read(bangDataRepositoryProvider.notifier) - .resetFrequency(bang.trigger); - } - } - }, - ), - ) - else - Expanded( - child: Text( - "Press '>' to search Bangs.", - style: TextStyle( - color: Theme.of(context).hintColor, - fontStyle: FontStyle.italic, - ), - ), - ), - IconButton( - onPressed: () async { - await context.push(BangSearchRoute().location); - }, - icon: const Icon(Icons.chevron_right), - ), - ], - ), - ), - error: (error, stackTrace) => const SizedBox.shrink(), - loading: () => const SizedBox( - height: 48, - width: double.infinity, - ), - ); - }, - ), - Consumer( - builder: (context, ref, child) { - const maxOptionsHeight = SearchField.defaultMaxOptionsHeight; - - final openDirection = ref.watch( - bottomSheetExtendProvider.select((value) { - final extend = value.valueOrNull; - if (extend == null || - (MediaQuery.of(context).size.height * (1 - extend)) > - maxOptionsHeight) { - return OptionsViewOpenDirection.up; - } else { - return OptionsViewOpenDirection.down; - } - }), - ); - - return SearchField( - textController: textController, - activeBang: activeBang, - openDirection: openDirection, - onFieldSubmitted: (_) async { - await submitSearch(); - }, - ); - }, - ), - const SizedBox( - height: 12, - ), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: submitSearch, - label: const Text('Search'), - icon: const Icon(MdiIcons.cloudSearch), - ), - ), - ], - ), - ); - } -} diff --git a/app/lib/features/search_browser/presentation/widgets/tabs/summarize_tab.dart b/app/lib/features/search_browser/presentation/widgets/tabs/summarize_tab.dart deleted file mode 100644 index 9bdafadb..00000000 --- a/app/lib/features/search_browser/presentation/widgets/tabs/summarize_tab.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'dart:async'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:flutter_pdf_text/flutter_pdf_text.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart'; -import 'package:lensai/features/search_browser/utils/url_builder.dart' - as uri_builder; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; -import 'package:lensai/presentation/widgets/website_title_tile.dart'; -import 'package:lensai/utils/uri_parser.dart' as uri_parser; - -class _InputField extends ConsumerWidget { - final TextEditingController? controller; - - const _InputField({required this.controller}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), - ); - - return TextFormField( - controller: controller, - enableIMEPersonalizedLearning: !incognitoEnabled, - decoration: const InputDecoration( - label: Text('Document'), - hintText: 'Enter URL or text to summarize', - floatingLabelBehavior: FloatingLabelBehavior.always, - ), - maxLines: null, - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) { - if (value?.isEmpty ?? true) { - return ''; - } - - return null; - }, - ); - } -} - -class SummarizeTab extends HookConsumerWidget { - final SharedContent? sharedContent; - final OnSubmitUri onSubmit; - - const SummarizeTab({ - required this.sharedContent, - required this.onSubmit, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - useAutomaticKeepAlive(); - - final formKey = useMemoized(() => GlobalKey()); - final selectedMode = useState(SummarizerMode.keyMoments); - final textController = - useTextEditingController(text: sharedContent?.toString()); - - return Form( - key: formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SegmentedButton( - segments: const [ - ButtonSegment( - value: SummarizerMode.keyMoments, - icon: Icon(MdiIcons.scriptTextKey), - label: Text('Key Moments'), - ), - ButtonSegment( - value: SummarizerMode.summary, - icon: Icon(MdiIcons.invoiceTextMinus), - label: Text('Summary'), - ), - ], - selected: {selectedMode.value}, - onSelectionChanged: (value) { - selectedMode.value = value.first; - }, - ), - const SizedBox( - height: 12, - ), - ...switch (sharedContent) { - SharedUrl() => [ - _InputField(controller: textController), - HookBuilder( - builder: (context) { - final url = - useState(uri_parser.tryParseUrl(textController.text)); - useEffect( - () { - Timer? timer; - void debounce() { - timer?.cancel(); - timer = Timer(const Duration(milliseconds: 250), () { - url.value = - uri_parser.tryParseUrl(textController.text); - }); - } - - textController.addListener(debounce); - return () => textController.removeListener(debounce); - }, - [textController], - ); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (url.value != null) WebsiteTitleTile(url.value!), - ], - ); - }, - ), - const SizedBox( - height: 12, - ), - ], - SharedText() || null => [ - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () async { - final pickResult = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['pdf'], - ); - - if (pickResult?.files.first.path - case final String filePath) { - final content = await PDFDoc.fromPath(filePath) - .then((doc) => doc.text); - textController.text = content; - } - }, - icon: const Icon(MdiIcons.fileUpload), - label: const Text('Read PDF document'), - ), - ), - _InputField(controller: textController), - const SizedBox( - height: 12, - ), - ], - }, - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - onSubmit( - uri_builder.summarizerUri( - document: SharedContent.parse(textController.text), - mode: selectedMode.value, - ), - ); - } - }, - label: const Text('Summarize'), - icon: const Icon(MdiIcons.invoiceTextSend), - ), - ), - ], - ), - ); - } -} diff --git a/app/lib/features/search_browser/utils/url_builder.dart b/app/lib/features/search_browser/utils/url_builder.dart deleted file mode 100644 index 7ccc86ef..00000000 --- a/app/lib/features/search_browser/utils/url_builder.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; - -final _baseUrl = Uri.https('kagi.com'); - -Uri assistantUri({ - required String prompt, - required AssistantMode assistantMode, - ResearchVariant? researchVariant, - ChatModel? chatModel, -}) => - _baseUrl.replace( - pathSegments: [ - 'assistant', - ], - queryParameters: { - 'mode': assistantMode.value.toString(), - 'q': prompt, - ...switch (assistantMode) { - AssistantMode.research => { - 'sub_mode': researchVariant!.value.toString(), - }, - AssistantMode.code => {}, - AssistantMode.chat => { - 'sub_mode': chatModel!.value.toString(), - }, - AssistantMode.custom => {}, - }, - }, - ); - -Uri summarizerUri({ - required SharedContent document, - required SummarizerMode mode, -}) => - _baseUrl.replace( - pathSegments: [ - 'summarizer', - //TODO: check if really necessary - 'index.html', - ], - queryParameters: { - 'summary': mode.value, - if (document case SharedUrl(url: final url)) 'url': url.toString(), - }, - fragment: switch (document) { - SharedText(text: final text) => text, - _ => null, - }, - ); diff --git a/app/lib/features/settings/data/models/settings.dart b/app/lib/features/settings/data/models/settings.dart deleted file mode 100644 index 50156eec..00000000 --- a/app/lib/features/settings/data/models/settings.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:copy_with_extension/copy_with_extension.dart'; -import 'package:fast_equatable/fast_equatable.dart'; -import 'package:flutter/material.dart'; -import 'package:lensai/features/content_block/data/models/host.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; - -part 'settings.g.dart'; - -@CopyWith() -class Settings with FastEquatable { - final String? kagiSession; - final bool showEarlyAccessFeatures; - final bool incognitoMode; - final bool enableJavascript; - final bool launchUrlExternal; - final bool enableContentBlocking; - final bool blockHttpProtocol; - final Set enableHostList; - final ThemeMode themeMode; - final KagiTool? quickAction; - final bool quickActionVoiceInput; - final bool enableReadability; - - Settings({ - required this.kagiSession, - required this.showEarlyAccessFeatures, - required this.incognitoMode, - required this.enableJavascript, - required this.launchUrlExternal, - required this.enableContentBlocking, - required this.blockHttpProtocol, - required this.enableHostList, - required this.themeMode, - required this.quickAction, - required this.quickActionVoiceInput, - required this.enableReadability, - }); - - Settings.withDefaults({ - this.kagiSession, - bool? showEarlyAccessFeatures, - bool? incognitoMode, - bool? enableJavascript, - bool? launchUrlExternal, - bool? enableContentBlocking, - bool? blockHttpProtocol, - Set? enableHostList, - ThemeMode? themeMode, - this.quickAction, - bool? quickActionVoiceInput, - bool? enableReadability, - }) : showEarlyAccessFeatures = showEarlyAccessFeatures ?? true, - incognitoMode = incognitoMode ?? true, - enableJavascript = enableJavascript ?? true, - launchUrlExternal = launchUrlExternal ?? false, - enableContentBlocking = enableContentBlocking ?? true, - blockHttpProtocol = blockHttpProtocol ?? false, - enableHostList = enableHostList ?? {HostSource.stevenBlackUnified}, - themeMode = themeMode ?? ThemeMode.dark, - quickActionVoiceInput = quickActionVoiceInput ?? false, - enableReadability = enableReadability ?? true; - - @override - bool get cacheHash => true; - - @override - List get hashParameters => [ - kagiSession, - showEarlyAccessFeatures, - incognitoMode, - enableJavascript, - launchUrlExternal, - enableContentBlocking, - blockHttpProtocol, - enableHostList, - themeMode, - quickAction, - quickActionVoiceInput, - enableReadability, - ]; -} diff --git a/app/lib/features/settings/data/models/settings.g.dart b/app/lib/features/settings/data/models/settings.g.dart deleted file mode 100644 index d42c970b..00000000 --- a/app/lib/features/settings/data/models/settings.g.dart +++ /dev/null @@ -1,198 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'settings.dart'; - -// ************************************************************************** -// CopyWithGenerator -// ************************************************************************** - -abstract class _$SettingsCWProxy { - Settings kagiSession(String? kagiSession); - - Settings showEarlyAccessFeatures(bool showEarlyAccessFeatures); - - Settings incognitoMode(bool incognitoMode); - - Settings enableJavascript(bool enableJavascript); - - Settings launchUrlExternal(bool launchUrlExternal); - - Settings enableContentBlocking(bool enableContentBlocking); - - Settings blockHttpProtocol(bool blockHttpProtocol); - - Settings enableHostList(Set enableHostList); - - Settings themeMode(ThemeMode themeMode); - - Settings quickAction(KagiTool? quickAction); - - Settings quickActionVoiceInput(bool quickActionVoiceInput); - - Settings enableReadability(bool enableReadability); - - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Settings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. - /// - /// Usage - /// ```dart - /// Settings(...).copyWith(id: 12, name: "My name") - /// ```` - Settings call({ - String? kagiSession, - bool? showEarlyAccessFeatures, - bool? incognitoMode, - bool? enableJavascript, - bool? launchUrlExternal, - bool? enableContentBlocking, - bool? blockHttpProtocol, - Set? enableHostList, - ThemeMode? themeMode, - KagiTool? quickAction, - bool? quickActionVoiceInput, - bool? enableReadability, - }); -} - -/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfSettings.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfSettings.copyWith.fieldName(...)` -class _$SettingsCWProxyImpl implements _$SettingsCWProxy { - const _$SettingsCWProxyImpl(this._value); - - final Settings _value; - - @override - Settings kagiSession(String? kagiSession) => this(kagiSession: kagiSession); - - @override - Settings showEarlyAccessFeatures(bool showEarlyAccessFeatures) => - this(showEarlyAccessFeatures: showEarlyAccessFeatures); - - @override - Settings incognitoMode(bool incognitoMode) => - this(incognitoMode: incognitoMode); - - @override - Settings enableJavascript(bool enableJavascript) => - this(enableJavascript: enableJavascript); - - @override - Settings launchUrlExternal(bool launchUrlExternal) => - this(launchUrlExternal: launchUrlExternal); - - @override - Settings enableContentBlocking(bool enableContentBlocking) => - this(enableContentBlocking: enableContentBlocking); - - @override - Settings blockHttpProtocol(bool blockHttpProtocol) => - this(blockHttpProtocol: blockHttpProtocol); - - @override - Settings enableHostList(Set enableHostList) => - this(enableHostList: enableHostList); - - @override - Settings themeMode(ThemeMode themeMode) => this(themeMode: themeMode); - - @override - Settings quickAction(KagiTool? quickAction) => this(quickAction: quickAction); - - @override - Settings quickActionVoiceInput(bool quickActionVoiceInput) => - this(quickActionVoiceInput: quickActionVoiceInput); - - @override - Settings enableReadability(bool enableReadability) => - this(enableReadability: enableReadability); - - @override - - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Settings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. - /// - /// Usage - /// ```dart - /// Settings(...).copyWith(id: 12, name: "My name") - /// ```` - Settings call({ - Object? kagiSession = const $CopyWithPlaceholder(), - Object? showEarlyAccessFeatures = const $CopyWithPlaceholder(), - Object? incognitoMode = const $CopyWithPlaceholder(), - Object? enableJavascript = const $CopyWithPlaceholder(), - Object? launchUrlExternal = const $CopyWithPlaceholder(), - Object? enableContentBlocking = const $CopyWithPlaceholder(), - Object? blockHttpProtocol = const $CopyWithPlaceholder(), - Object? enableHostList = const $CopyWithPlaceholder(), - Object? themeMode = const $CopyWithPlaceholder(), - Object? quickAction = const $CopyWithPlaceholder(), - Object? quickActionVoiceInput = const $CopyWithPlaceholder(), - Object? enableReadability = const $CopyWithPlaceholder(), - }) { - return Settings( - kagiSession: kagiSession == const $CopyWithPlaceholder() - ? _value.kagiSession - // ignore: cast_nullable_to_non_nullable - : kagiSession as String?, - showEarlyAccessFeatures: - showEarlyAccessFeatures == const $CopyWithPlaceholder() || - showEarlyAccessFeatures == null - ? _value.showEarlyAccessFeatures - // ignore: cast_nullable_to_non_nullable - : showEarlyAccessFeatures as bool, - incognitoMode: - incognitoMode == const $CopyWithPlaceholder() || incognitoMode == null - ? _value.incognitoMode - // ignore: cast_nullable_to_non_nullable - : incognitoMode as bool, - enableJavascript: enableJavascript == const $CopyWithPlaceholder() || - enableJavascript == null - ? _value.enableJavascript - // ignore: cast_nullable_to_non_nullable - : enableJavascript as bool, - launchUrlExternal: launchUrlExternal == const $CopyWithPlaceholder() || - launchUrlExternal == null - ? _value.launchUrlExternal - // ignore: cast_nullable_to_non_nullable - : launchUrlExternal as bool, - enableContentBlocking: - enableContentBlocking == const $CopyWithPlaceholder() || - enableContentBlocking == null - ? _value.enableContentBlocking - // ignore: cast_nullable_to_non_nullable - : enableContentBlocking as bool, - blockHttpProtocol: blockHttpProtocol == const $CopyWithPlaceholder() || - blockHttpProtocol == null - ? _value.blockHttpProtocol - // ignore: cast_nullable_to_non_nullable - : blockHttpProtocol as bool, - enableHostList: enableHostList == const $CopyWithPlaceholder() || - enableHostList == null - ? _value.enableHostList - // ignore: cast_nullable_to_non_nullable - : enableHostList as Set, - themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null - ? _value.themeMode - // ignore: cast_nullable_to_non_nullable - : themeMode as ThemeMode, - quickAction: quickAction == const $CopyWithPlaceholder() - ? _value.quickAction - // ignore: cast_nullable_to_non_nullable - : quickAction as KagiTool?, - quickActionVoiceInput: - quickActionVoiceInput == const $CopyWithPlaceholder() || - quickActionVoiceInput == null - ? _value.quickActionVoiceInput - // ignore: cast_nullable_to_non_nullable - : quickActionVoiceInput as bool, - enableReadability: enableReadability == const $CopyWithPlaceholder() || - enableReadability == null - ? _value.enableReadability - // ignore: cast_nullable_to_non_nullable - : enableReadability as bool, - ); - } -} - -extension $SettingsCopyWith on Settings { - /// Returns a callable class that can be used as follows: `instanceOfSettings.copyWith(...)` or like so:`instanceOfSettings.copyWith.fieldName(...)`. - // ignore: library_private_types_in_public_api - _$SettingsCWProxy get copyWith => _$SettingsCWProxyImpl(this); -} diff --git a/app/lib/features/settings/data/repositories/settings_repository.dart b/app/lib/features/settings/data/repositories/settings_repository.dart deleted file mode 100644 index 0260c219..00000000 --- a/app/lib/features/settings/data/repositories/settings_repository.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/utils/preference_parser.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -part 'settings_repository.g.dart'; - -typedef UpdateSettingsFunc = Settings Function(Settings currentSettings); - -enum _StorageKeys { - kagiSession('kagi_session'), - showEarlyAccessFeatures('show_early_access_features'), - incognito('enabe_incognito'), - javascript('enable_js'), - launchExternal('enable_launch_external'), - contentBlocking('enable_content_blocking'), - blockHttpProtocol('block_http'), - enableHostList('enable_host_lists'), - themeMode('theme_mode'), - quickAction('enable_quick_action'), - quickActionVoiceInput('enable_quick_action_voice_input'), - enableReadability('enable_readability'); - - final String key; - - const _StorageKeys(this.key); -} - -@Riverpod(keepAlive: true) -class SettingsRepository extends _$SettingsRepository { - final FlutterSecureStorage _flutterSecureStorage; - final Future _sharedPreferences; - - SettingsRepository() - : _flutterSecureStorage = const FlutterSecureStorage(), - _sharedPreferences = SharedPreferences.getInstance(); - - Future updateSettings(UpdateSettingsFunc updateWithCurrent) async { - final sharedPreferences = await _sharedPreferences; - final oldSettings = state.value!; - final newSettings = updateWithCurrent(oldSettings); - - if (oldSettings != newSettings) { - if (oldSettings.kagiSession != newSettings.kagiSession) { - await _flutterSecureStorage.write( - key: _StorageKeys.kagiSession.key, - value: (newSettings.kagiSession?.isNotEmpty ?? false) - ? newSettings.kagiSession - : null, - ); - } - - if (newSettings.showEarlyAccessFeatures != - oldSettings.showEarlyAccessFeatures) { - await sharedPreferences.setBool( - _StorageKeys.showEarlyAccessFeatures.key, - newSettings.showEarlyAccessFeatures, - ); - } - - if (newSettings.incognitoMode != oldSettings.incognitoMode) { - await sharedPreferences.setBool( - _StorageKeys.incognito.key, - newSettings.incognitoMode, - ); - } - - if (newSettings.enableJavascript != oldSettings.enableJavascript) { - await sharedPreferences.setBool( - _StorageKeys.javascript.key, - newSettings.enableJavascript, - ); - } - - if (newSettings.launchUrlExternal != oldSettings.launchUrlExternal) { - await sharedPreferences.setBool( - _StorageKeys.launchExternal.key, - newSettings.launchUrlExternal, - ); - } - - if (newSettings.enableContentBlocking != - oldSettings.enableContentBlocking) { - await sharedPreferences.setBool( - _StorageKeys.contentBlocking.key, - newSettings.enableContentBlocking, - ); - } - - if (newSettings.blockHttpProtocol != oldSettings.blockHttpProtocol) { - await sharedPreferences.setBool( - _StorageKeys.blockHttpProtocol.key, - newSettings.blockHttpProtocol, - ); - } - - if (!const DeepCollectionEquality.unordered().equals( - newSettings.enableHostList, - oldSettings.enableHostList, - )) { - await sharedPreferences.setStringList( - _StorageKeys.enableHostList.key, - newSettings.enableHostList.map((list) => list.name).toList(), - ); - } - - if (newSettings.themeMode != oldSettings.themeMode) { - await sharedPreferences.setInt( - _StorageKeys.themeMode.key, - newSettings.themeMode.index, - ); - } - - if (newSettings.quickAction != oldSettings.quickAction) { - final index = newSettings.quickAction?.index; - if (index != null) { - await sharedPreferences.setInt( - _StorageKeys.quickAction.key, - index, - ); - } else { - await sharedPreferences.remove(_StorageKeys.quickAction.key); - } - } - - if (newSettings.quickActionVoiceInput != - oldSettings.quickActionVoiceInput) { - await sharedPreferences.setBool( - _StorageKeys.quickActionVoiceInput.key, - newSettings.quickActionVoiceInput, - ); - } - - if (newSettings.enableReadability != oldSettings.enableReadability) { - await sharedPreferences.setBool( - _StorageKeys.enableReadability.key, - newSettings.enableReadability, - ); - } - - ref.invalidateSelf(); - } - } - - @override - FutureOr build() async { - final sharedPreferences = await _sharedPreferences; - - return Settings.withDefaults( - kagiSession: - await _flutterSecureStorage.read(key: _StorageKeys.kagiSession.key), - showEarlyAccessFeatures: - sharedPreferences.getBool(_StorageKeys.showEarlyAccessFeatures.key), - incognitoMode: sharedPreferences.getBool(_StorageKeys.incognito.key), - enableJavascript: sharedPreferences.getBool(_StorageKeys.javascript.key), - launchUrlExternal: - sharedPreferences.getBool(_StorageKeys.launchExternal.key), - enableContentBlocking: - sharedPreferences.getBool(_StorageKeys.contentBlocking.key), - blockHttpProtocol: - sharedPreferences.getBool(_StorageKeys.blockHttpProtocol.key), - enableHostList: parseHostSources( - sharedPreferences.getStringList(_StorageKeys.enableHostList.key), - ), - themeMode: - parseThemeMode(sharedPreferences.getInt(_StorageKeys.themeMode.key)), - quickAction: - parseKagiTool(sharedPreferences.getInt(_StorageKeys.quickAction.key)), - quickActionVoiceInput: - sharedPreferences.getBool(_StorageKeys.quickActionVoiceInput.key), - enableReadability: - sharedPreferences.getBool(_StorageKeys.enableReadability.key), - ); - } -} diff --git a/app/lib/features/settings/data/repositories/settings_repository.g.dart b/app/lib/features/settings/data/repositories/settings_repository.g.dart deleted file mode 100644 index 1a88eb75..00000000 --- a/app/lib/features/settings/data/repositories/settings_repository.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'settings_repository.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$settingsRepositoryHash() => - r'f7c42efda64f0fb647e5daf0a92c2d118596cc52'; - -/// See also [SettingsRepository]. -@ProviderFor(SettingsRepository) -final settingsRepositoryProvider = - AsyncNotifierProvider.internal( - SettingsRepository.new, - name: r'settingsRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$settingsRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$SettingsRepository = AsyncNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/settings/presentation/controllers/save_settings.dart b/app/lib/features/settings/presentation/controllers/save_settings.dart index 5a76ec57..916c4c23 100644 --- a/app/lib/features/settings/presentation/controllers/save_settings.dart +++ b/app/lib/features/settings/presentation/controllers/save_settings.dart @@ -1,18 +1,38 @@ -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; part 'save_settings.g.dart'; @Riverpod(keepAlive: true) -class SaveSettingsController extends _$SaveSettingsController { +class SaveGeneralSettingsController extends _$SaveGeneralSettingsController { @override - FutureOr build() {} + Future build() { + return Future.value(); + } - Future save(UpdateSettingsFunc updateSettings) async { + Future save(UpdateGeneralSettingsFunc updateSettings) async { state = const AsyncLoading(); state = await AsyncValue.guard( () => ref - .read(settingsRepositoryProvider.notifier) + .read(generalSettingsRepositoryProvider.notifier) + .updateSettings(updateSettings), + ); + } +} + +@Riverpod(keepAlive: true) +class SaveEngineSettingsController extends _$SaveEngineSettingsController { + @override + Future build() { + return Future.value(); + } + + Future save(UpdateEngineSettingsFunc updateSettings) async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref + .read(engineSettingsRepositoryProvider.notifier) .updateSettings(updateSettings), ); } diff --git a/app/lib/features/settings/presentation/controllers/save_settings.g.dart b/app/lib/features/settings/presentation/controllers/save_settings.g.dart index 8b5ea1f2..36418339 100644 --- a/app/lib/features/settings/presentation/controllers/save_settings.g.dart +++ b/app/lib/features/settings/presentation/controllers/save_settings.g.dart @@ -6,22 +6,39 @@ part of 'save_settings.dart'; // RiverpodGenerator // ************************************************************************** -String _$saveSettingsControllerHash() => - r'7ffcef95b48dce1f80b56fb3580282ff3df240b4'; +String _$saveGeneralSettingsControllerHash() => + r'b75c9c52452a9cb687f69dad1523f1ed5205847e'; -/// See also [SaveSettingsController]. -@ProviderFor(SaveSettingsController) -final saveSettingsControllerProvider = - AsyncNotifierProvider.internal( - SaveSettingsController.new, - name: r'saveSettingsControllerProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$saveSettingsControllerHash, - dependencies: null, - allTransitiveDependencies: null, -); +/// See also [SaveGeneralSettingsController]. +@ProviderFor(SaveGeneralSettingsController) +final saveGeneralSettingsControllerProvider = + AsyncNotifierProvider.internal( + SaveGeneralSettingsController.new, + name: r'saveGeneralSettingsControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$saveGeneralSettingsControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); -typedef _$SaveSettingsController = AsyncNotifier; +typedef _$SaveGeneralSettingsController = AsyncNotifier; +String _$saveEngineSettingsControllerHash() => + r'd3d74a6aadc89532b1d81c424d92efbfa43002d5'; + +/// See also [SaveEngineSettingsController]. +@ProviderFor(SaveEngineSettingsController) +final saveEngineSettingsControllerProvider = + AsyncNotifierProvider.internal( + SaveEngineSettingsController.new, + name: r'saveEngineSettingsControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$saveEngineSettingsControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$SaveEngineSettingsController = AsyncNotifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/settings/presentation/screens/bang_settings.dart b/app/lib/features/settings/presentation/screens/bang_settings.dart new file mode 100644 index 00000000..5d6c3d13 --- /dev/null +++ b/app/lib/features/settings/presentation/screens/bang_settings.dart @@ -0,0 +1,63 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; +import 'package:weblibre/features/bangs/domain/repositories/data.dart'; +import 'package:weblibre/features/settings/presentation/widgets/bang_group_list_tile.dart'; +import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart'; +import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; + +class BangSettingsScreen extends HookConsumerWidget { + const BangSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Bang Settings')), + body: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + children: [ + CustomListTile( + title: 'Bang Frequencies', + subtitle: 'Tracked usage for Bang recommendations', + suffix: FilledButton.icon( + onPressed: () async { + await ref + .read(bangDataRepositoryProvider.notifier) + .resetFrequencies(); + }, + icon: const Icon(Icons.delete), + label: const Text('Clear'), + ), + ), + const SettingSubSection(name: 'Repositories'), + const BangGroupListTile( + group: BangGroup.general, + title: 'General Bangs', + subtitle: 'Automatically syncs every 7 days', + ), + const BangGroupListTile( + group: BangGroup.assistant, + title: 'Assistant Bangs', + subtitle: 'Automatically syncs every 7 days', + ), + const BangGroupListTile( + group: BangGroup.kagi, + title: 'Kagi Bangs', + subtitle: 'Automatically syncs every 7 days', + ), + const BangGroupListTile( + group: BangGroup.custom, + title: 'WebLibre Bangs', + subtitle: 'Automatically syncs every 7 days', + ), + ], + ); + }, + ), + ); + } +} diff --git a/app/lib/features/settings/presentation/screens/general_settings.dart b/app/lib/features/settings/presentation/screens/general_settings.dart new file mode 100644 index 00000000..6eef664f --- /dev/null +++ b/app/lib/features/settings/presentation/screens/general_settings.dart @@ -0,0 +1,301 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/settings/presentation/widgets/bang_icon.dart'; +import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart'; +import 'package:weblibre/features/settings/presentation/widgets/default_search_selector.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/providers.dart'; +import 'package:weblibre/features/user/domain/repositories/cache.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +class GeneralSettingsScreen extends HookConsumerWidget { + const GeneralSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final generalSettings = ref.watch(generalSettingsRepositoryProvider); + + return Scaffold( + appBar: AppBar(title: const Text('General Settings')), + body: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text('Theme'), + leading: Icon(Icons.palette), + contentPadding: EdgeInsets.zero, + ), + Center( + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: ThemeMode.system, + icon: Icon(Icons.brightness_auto), + label: Text('System'), + ), + ButtonSegment( + value: ThemeMode.light, + icon: Icon(Icons.light_mode), + label: Text('Light'), + ), + ButtonSegment( + value: ThemeMode.dark, + icon: Icon(Icons.dark_mode), + label: Text('Dark'), + ), + ], + selected: {generalSettings.themeMode}, + onSelectionChanged: (value) async { + await ref + .read( + saveGeneralSettingsControllerProvider.notifier, + ) + .save( + (currentSettings) => currentSettings.copyWith + .themeMode(value.first), + ); + }, + ), + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + title: Text('Default Search Provider'), + leading: Icon(MdiIcons.cloudSearch), + contentPadding: EdgeInsets.zero, + ), + Padding( + padding: EdgeInsets.only(left: 40), + child: DefaultSearchSelector(), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text('Default Autocomplete Provider'), + leading: Icon(MdiIcons.weatherCloudyArrowRight), + contentPadding: EdgeInsets.zero, + ), + Padding( + padding: const EdgeInsets.only(left: 40), + child: DropdownMenu( + initialSelection: + generalSettings.defaultSearchSuggestionsProvider, + inputDecorationTheme: InputDecorationTheme( + prefixIconConstraints: BoxConstraints.tight( + const Size.square(24), + ), + ), + width: double.infinity, + leadingIcon: generalSettings + .defaultSearchSuggestionsProvider + .relatedBang + .mapNotNull( + (trigger) => BangIcon(trigger: trigger), + ), + dropdownMenuEntries: SearchSuggestionProviders.values + .map((provider) { + return DropdownMenuEntry( + value: provider, + label: provider.label, + leadingIcon: provider.relatedBang.mapNotNull( + (trigger) => BangIcon(trigger: trigger), + ), + ); + }) + .toList(), + onSelected: (value) async { + if (value != null) { + await ref + .read( + saveGeneralSettingsControllerProvider + .notifier, + ) + .save( + (currentSettings) => currentSettings.copyWith + .defaultSearchSuggestionsProvider(value), + ); + } + }, + ), + ), + ], + ), + ), + SwitchListTile.adaptive( + title: const Text('Enable Reader Mode'), + subtitle: const Text( + 'Optional browser app bar tool that extracts and simplifies web pages for improved readability by removing ads, sidebars, and other non-essential elements.', + ), + secondary: const Icon(MdiIcons.bookOpen), + value: generalSettings.enableReadability, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.enableReadability(value), + ); + }, + ), + SwitchListTile.adaptive( + title: const Text('Enforce Reader Mode'), + subtitle: const Text( + 'Override readability probability of websites and always show Reader Mode capabilities even the site might not be compatible.', + ), + secondary: const Icon(MdiIcons.bookCheck), + value: + generalSettings.enableReadability && + generalSettings.enforceReadability, + onChanged: generalSettings.enableReadability + ? (value) async { + await ref + .read( + saveGeneralSettingsControllerProvider.notifier, + ) + .save( + (currentSettings) => currentSettings.copyWith + .enforceReadability(value), + ); + } + : null, + ), + SwitchListTile.adaptive( + title: const Text('Create Child Tabs'), + subtitle: const Text( + 'Display a button to create a child tab under the current tab (tree view only)', + ), + secondary: const Icon(MdiIcons.fileTree), + value: generalSettings.createChildTabsOption, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .createChildTabsOption(value), + ); + }, + ), + Consumer( + builder: (context, ref, child) { + final size = ref.watch( + iconCacheSizeMegabytesProvider.select( + (value) => value.valueOrNull, + ), + ); + + return CustomListTile( + title: 'Icon Cache', + subtitle: 'Stored favicons', + prefix: Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Icon( + Icons.image, + size: 24, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + content: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: DefaultTextStyle( + style: GoogleFonts.robotoMono( + textStyle: DefaultTextStyle.of(context).style, + ), + child: Table( + columnWidths: const {0: FixedColumnWidth(100)}, + children: [ + TableRow( + children: [ + const Text('Size'), + Text('${size?.toStringAsFixed(2) ?? 0} MB'), + ], + ), + ], + ), + ), + ), + suffix: FilledButton.icon( + onPressed: () async { + await ref + .read(cacheRepositoryProvider.notifier) + .clearCache(); + }, + icon: const Icon(Icons.delete), + label: const Text('Clear'), + ), + ); + }, + ), + CustomListTile( + title: 'Error Logs', + subtitle: 'Copy logs for issue reporting', + prefix: Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Icon( + Icons.bug_report, + size: 24, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + suffix: FilledButton.icon( + onPressed: () async { + await Clipboard.setData( + ClipboardData( + text: loggerMemory.buffer + .map((e) => e.lines.join('\n')) + .join('\n\n'), + ), + ); + + if (context.mounted) { + showInfoMessage(context, 'Logs copied'); + } + }, + icon: const Icon(Icons.copy), + label: const Text('Copy'), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/app/lib/features/settings/presentation/screens/settings.dart b/app/lib/features/settings/presentation/screens/settings.dart index e00fc66c..5dbf893b 100644 --- a/app/lib/features/settings/presentation/screens/settings.dart +++ b/app/lib/features/settings/presentation/screens/settings.dart @@ -1,86 +1,14 @@ import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; -import 'package:lensai/features/bangs/domain/providers.dart'; -import 'package:lensai/features/bangs/domain/repositories/data.dart'; -import 'package:lensai/features/content_block/data/models/host.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/settings/presentation/controllers/save_settings.dart'; -import 'package:lensai/features/settings/presentation/widgets/bang_group_list_tile.dart'; -import 'package:lensai/features/settings/presentation/widgets/custom_list_tile.dart'; -import 'package:lensai/features/settings/presentation/widgets/host_list_tile.dart'; -import 'package:lensai/features/settings/utils/session_link_extractor.dart'; -import 'package:lensai/presentation/hooks/listenable_callback.dart'; -import 'package:lensai/utils/ui_helper.dart' as ui_helper; +import 'package:weblibre/core/routing/routes.dart'; class SettingsScreen extends HookConsumerWidget { const SettingsScreen({super.key}); - Widget _buildSection(ThemeData theme, String name) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(name, style: theme.textTheme.titleLarge), - const Divider(), - ], - ), - ); - - Widget _buildSubSection(ThemeData theme, String name) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), - child: Text( - name, - style: theme.textTheme.titleLarge?.copyWith(fontSize: 18), - ), - ); - @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - - final settings = ref.watch( - settingsRepositoryProvider.select( - (value) => value.valueOrNull ?? Settings.withDefaults(), - ), - ); - - final kagiSessionTextController = useTextEditingController( - text: settings.kagiSession, - ); - final hideSessionText = useState(true); - - useListenableCallback(kagiSessionTextController, () async { - var text = kagiSessionTextController.text; - if (Uri.tryParse(text) case final Uri uri) { - if (extractKagiSession(uri) case final String session) { - text = session; - } - } - - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => currentSettings.copyWith.kagiSession(text), - ); - }); - - ref.listen( - settingsRepositoryProvider.select( - (settings) => settings.valueOrNull?.kagiSession, - ), (previous, next) { - if (next != null && - next.isNotEmpty && - kagiSessionTextController.text != next) { - kagiSessionTextController.text = next; - } - }); - return Scaffold( appBar: AppBar(title: const Text('Settings')), body: FadingScroll( @@ -88,377 +16,57 @@ class SettingsScreen extends HookConsumerWidget { builder: (context, controller) { return ListView( controller: controller, + padding: const EdgeInsets.symmetric(horizontal: 12.0), children: [ - _buildSection(theme, 'Kagi'), - Padding( - padding: const EdgeInsets.only( - left: 16, - right: 16, - bottom: 8, - ), - child: TextField( - enableIMEPersonalizedLearning: false, - autocorrect: false, - controller: kagiSessionTextController, - obscureText: hideSessionText.value, - decoration: InputDecoration( - label: const Text('Kagi Session Token'), - hintText: 'https://kagi.com/search?token=...', - helperMaxLines: 2, - helper: Markdown( - shrinkWrap: true, - padding: EdgeInsets.zero, - data: - 'You can visit your [Kagi Account Settings](user_details) to get your Session Link.', - onTapLink: (text, href, title) async { - if (href == 'user_details') { - await ui_helper.launchUrlFeedback( - context, - Uri.parse( - 'https://kagi.com/settings?p=user_details', - ), - ); - } - }, - ), - suffixIcon: IconButton( - onPressed: () { - hideSessionText.value = !hideSessionText.value; - }, - icon: Icon( - hideSessionText.value - ? Icons.visibility - : Icons.visibility_off, - ), - ), + Card( + color: Theme.of(context).highlightColor, + clipBehavior: Clip.antiAlias, + child: ListTile( + title: const Text('General'), + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, ), - ), - ), - SwitchListTile.adaptive( - title: const Text('Show Early Access Features'), - subtitle: const Text( - "Displays Kagi's early access features in the UI. As an Ultimate subscriber, you will likely want to have this enabled.", - ), - value: settings.showEarlyAccessFeatures, - onChanged: (value) async { - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => currentSettings.copyWith - .showEarlyAccessFeatures(value), - ); - }, - ), - const SizedBox( - height: 16, - ), - _buildSection(theme, 'General'), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Theme', - style: theme.textTheme.bodyLarge, - ), - Center( - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: ThemeMode.system, - icon: Icon(Icons.brightness_auto), - label: Text('System'), - ), - ButtonSegment( - value: ThemeMode.light, - icon: Icon(Icons.light_mode), - label: Text('Light'), - ), - ButtonSegment( - value: ThemeMode.dark, - icon: Icon(Icons.dark_mode), - label: Text('Dark'), - ), - ], - selected: {settings.themeMode}, - onSelectionChanged: (value) async { - await ref - .read(saveSettingsControllerProvider.notifier) - .save( - (currentSettings) => currentSettings.copyWith - .themeMode(value.first), - ); - }, - ), - ), - ], - ), - ), - SwitchListTile.adaptive( - title: const Text('Incognito Mode'), - subtitle: const Text( - 'Deletes all browsing data upon app restart for enhanced privacy.', - ), - value: settings.incognitoMode, - onChanged: (value) async { - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => - currentSettings.copyWith.incognitoMode(value), - ); - }, - ), - SwitchListTile.adaptive( - title: const Text('Enable JavaScript'), - subtitle: const Text( - 'While turning off JavaScript boosts security, privacy, and speed, it may cause some sites to not work as intended.', - ), - value: settings.enableJavascript, - onChanged: (value) async { - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => - currentSettings.copyWith.enableJavascript(value), - ); - }, - ), - SwitchListTile.adaptive( - title: const Text('Block HTTP Protocol'), - subtitle: const Text( - 'Prevents the loading of unsecure HTTP content. When enabled, only secure HTTPS content is allowed.', - ), - value: settings.blockHttpProtocol, - onChanged: (value) async { - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => - currentSettings.copyWith.blockHttpProtocol(value), - ); - }, - ), - SwitchListTile.adaptive( - title: const Text('Launch Links Externally'), - subtitle: const Text( - 'Opens all links (except for kagi.com) in your default browser.', - ), - value: settings.launchUrlExternal, - onChanged: (value) async { - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => - currentSettings.copyWith.launchUrlExternal(value), - ); - }, - ), - SwitchListTile.adaptive( - title: const Text('Enable Reader Mode'), - subtitle: const Text( - 'Optional browser app bar tool that extracts and simplifies web pages for improved readability by removing ads, sidebars, and other non-essential elements.', - ), - value: settings.enableReadability, - onChanged: (value) async { - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => - currentSettings.copyWith.enableReadability(value), - ); - }, - ), - const SizedBox( - height: 16, - ), - _buildSection(theme, 'Appearance'), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const CustomListTile( - title: 'Quick Action', - subtitle: - 'Appears in the browser app bar between website title and tab count.', - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Center( - child: SegmentedButton( - emptySelectionAllowed: true, - segments: [ - ButtonSegment( - value: KagiTool.search, - icon: Icon(KagiTool.search.icon), - label: const Text('Search'), - ), - ButtonSegment( - value: KagiTool.summarizer, - icon: Icon(KagiTool.summarizer.icon), - label: const Text('Summarizer'), - ), - ButtonSegment( - value: KagiTool.assistant, - icon: Icon(KagiTool.assistant.icon), - label: const Text('Assistant'), - ), - ], - selected: { - if (settings.quickAction != null) - settings.quickAction!, - }, - onSelectionChanged: (value) async { - await ref - .read(saveSettingsControllerProvider.notifier) - .save( - (currentSettings) => - currentSettings.copyWith.quickAction( - value.isNotEmpty ? value.first : null, - ), - ); - }, - ), - ), - ), - ], - ), - ), - SwitchListTile.adaptive( - title: const Text('Quick Action - STT'), - subtitle: const Text( - 'Quick option will open with speech-to-text input.', - ), - value: settings.quickActionVoiceInput, - onChanged: (settings.quickAction != null) - ? (value) async { - await ref - .read(saveSettingsControllerProvider.notifier) - .save( - (currentSettings) => currentSettings.copyWith - .quickActionVoiceInput(value), - ); - } - : null, - ), - _buildSection(theme, 'Content Blocking'), - SwitchListTile.adaptive( - title: const Text('Enable Content Blocking'), - subtitle: const Text( - 'Prevents access to unwanted websites and ads, as defined in the selected lists below.', - ), - value: settings.enableContentBlocking, - onChanged: (value) async { - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => currentSettings.copyWith - .enableContentBlocking(value), - ); - }, - ), - _buildSubSection(theme, 'Lists'), - HostListTile( - enableContentBlocking: settings.enableContentBlocking, - enableHostLists: settings.enableHostList, - source: HostSource.stevenBlackUnified, - title: 'StevenBlack: Unified', - subtitle: - 'Blocks domains containing adware, malware and trackers.', - ), - HostListTile( - enableContentBlocking: settings.enableContentBlocking, - enableHostLists: settings.enableHostList, - source: HostSource.stevenBlackFakeNews, - title: 'StevenBlack: Fake News', - subtitle: 'Blocks domains known for spreading fake news.', - ), - HostListTile( - enableContentBlocking: settings.enableContentBlocking, - enableHostLists: settings.enableHostList, - source: HostSource.stevenBlackGambling, - title: 'StevenBlack: Gambling', - subtitle: 'Blocks domains related to gambling.', - ), - HostListTile( - enableContentBlocking: settings.enableContentBlocking, - enableHostLists: settings.enableHostList, - source: HostSource.stevenBlackPorn, - title: 'StevenBlack: Porn', - subtitle: 'Blocks adult content domains.', - ), - HostListTile( - enableContentBlocking: settings.enableContentBlocking, - enableHostLists: settings.enableHostList, - source: HostSource.stevenBlackSocial, - title: 'StevenBlack: Social', - subtitle: 'Blocks social media domains.', - ), - const SizedBox( - height: 16, - ), - _buildSection(theme, 'Bangs'), - CustomListTile( - title: 'Bang Frequencies', - subtitle: 'Tracked usage for Bang recommendations', - suffix: FilledButton.icon( - onPressed: () async { - await ref - .read(bangDataRepositoryProvider.notifier) - .resetFrequencies(); + leading: const Icon(Icons.settings), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await GeneralSettingsRoute().push(context); }, - icon: const Icon(Icons.delete), - label: const Text('Clear'), ), ), - Consumer( - builder: (context, ref, child) { - final size = ref.watch( - bangIconCacheSizeMegabytesProvider.select( - (value) => value.valueOrNull, - ), - ); - - return CustomListTile( - title: 'Icon Cache', - subtitle: 'Stored favicons for Bangs', - content: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: DefaultTextStyle( - style: GoogleFonts.robotoMono( - textStyle: DefaultTextStyle.of(context).style, - ), - child: Table( - columnWidths: const {0: FixedColumnWidth(100)}, - children: [ - TableRow( - children: [ - const Text('Size'), - Text('${size?.toStringAsFixed(2) ?? 0} MB'), - ], - ), - ], - ), - ), - ), - suffix: FilledButton.icon( - onPressed: () async { - await ref - .read(bangDataRepositoryProvider.notifier) - .clearIconData(); - }, - icon: const Icon(Icons.delete), - label: const Text('Clear'), - ), - ); - }, + const SizedBox(height: 8), + Card( + color: Theme.of(context).highlightColor, + clipBehavior: Clip.antiAlias, + child: ListTile( + title: const Text('Web Engine'), + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + leading: const Icon(MdiIcons.web), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await WebEngineSettingsRoute().push(context); + }, + ), ), - _buildSubSection(theme, 'Repositories'), - const BangGroupListTile( - group: BangGroup.general, - title: 'General Bangs', - subtitle: 'Automatically syncs every 7 days', - ), - const BangGroupListTile( - group: BangGroup.assistant, - title: 'Assistant Bangs', - subtitle: 'Automatically syncs every 7 days', - ), - const BangGroupListTile( - group: BangGroup.kagi, - title: 'Kagi Bangs', - subtitle: 'Automatically syncs every 7 days', + const SizedBox(height: 8), + Card( + color: Theme.of(context).highlightColor, + clipBehavior: Clip.antiAlias, + child: ListTile( + title: const Text('Bangs'), + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + leading: const Icon(MdiIcons.exclamationThick), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await BangSettingsRoute().push(context); + }, + ), ), ], ); diff --git a/app/lib/features/settings/presentation/screens/web_engine_hardening.dart b/app/lib/features/settings/presentation/screens/web_engine_hardening.dart new file mode 100644 index 00000000..ebd0a891 --- /dev/null +++ b/app/lib/features/settings/presentation/screens/web_engine_hardening.dart @@ -0,0 +1,117 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/setting_groups_serializer.dart'; +import 'package:weblibre/features/settings/presentation/widgets/hardening_group_icon.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; + +class WebEngineHardeningScreen extends HookConsumerWidget { + const WebEngineHardeningScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final preferenceGroups = ref.watch( + unifiedPreferenceSettingsRepositoryProvider(PreferencePartition.user), + ); + + final allGroupsActive = useMemoized( + () => + preferenceGroups.valueOrNull?.values.every( + (element) => element.isActiveOrOptional, + ) ?? + false, + [EquatableValue(preferenceGroups.valueOrNull)], + ); + + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Web Engine Hardening')), + body: preferenceGroups.when( + skipLoadingOnReload: true, + data: (data) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + color: theme.colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SwitchListTile( + value: allGroupsActive, + title: Text( + 'Complete Hardening', + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + onChanged: (value) async { + final notifier = ref.read( + unifiedPreferenceSettingsRepositoryProvider( + PreferencePartition.user, + ).notifier, + ); + + if (value) { + await notifier.apply(); + } else { + await notifier.reset(); + } + }, + ), + ), + ), + ), + Expanded( + child: ListView( + children: data.entries.map((group) { + return Row( + children: [ + Expanded( + child: ListTile( + title: Text(group.key), + subtitle: group.value.description.mapNotNull( + (description) => Text(description), + ), + leading: Badge( + isLabelVisible: group.value.hasInactiveOptional, + child: HardeningGroupIcon( + isActive: group.value.isActiveOrOptional, + isPartlyActive: group.value.isPartlyActive, + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await WebEngineHardeningGroupRoute( + group: group.key, + ).push(context); + }, + ), + ), + ], + ); + }).toList(), + ), + ), + ], + ); + }, + error: (error, stackTrace) => FailureWidget( + title: 'Could not load preference settings', + exception: error, + onRetry: () => ref.refresh( + unifiedPreferenceSettingsRepositoryProvider( + PreferencePartition.user, + ), + ), + ), + loading: () => const SizedBox.shrink(), + ), + ); + } +} diff --git a/app/lib/features/settings/presentation/screens/web_engine_hardening_group.dart b/app/lib/features/settings/presentation/screens/web_engine_hardening_group.dart new file mode 100644 index 00000000..3b2c556a --- /dev/null +++ b/app/lib/features/settings/presentation/screens/web_engine_hardening_group.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/setting_groups_serializer.dart'; +import 'package:weblibre/features/settings/presentation/widgets/hardening_group_icon.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; + +class WebEngineHardeningGroupScreen extends HookConsumerWidget { + final String groupName; + + const WebEngineHardeningGroupScreen({super.key, required this.groupName}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch( + preferenceSettingsGroupRepositoryProvider( + PreferencePartition.user, + groupName, + ), + ); + + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: Text(groupName)), + body: settings.when( + data: (group) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + color: theme.colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SwitchListTile( + value: group.isActiveOrOptional, + title: Text( + groupName, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + subtitle: group.description.mapNotNull( + (description) => Text( + description, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + onChanged: (value) async { + final notifier = ref.read( + preferenceSettingsGroupRepositoryProvider( + PreferencePartition.user, + groupName, + ).notifier, + ); + + if (value) { + await notifier.apply(); + } else { + await notifier.reset(); + } + }, + ), + ), + ), + ), + Expanded( + child: ListView( + children: group.settings.entries.map((setting) { + var value = setting.value.value.toString(); + if (value.length > 160) { + value = '${value.substring(0, 160)}…'; + } + + return Tooltip( + message: '${setting.key}: $value', + child: Row( + children: [ + if (!setting.value.shouldBeDefault || + !setting.value.isActive) + Expanded( + child: SwitchListTile( + value: setting.value.isActive, + title: Text(setting.value.title ?? setting.key), + subtitle: Text.rich( + TextSpan( + children: [ + if (setting.value.requireUserOptIn) + WidgetSpan( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + margin: const EdgeInsets.only( + right: 8, + ), + decoration: BoxDecoration( + color: theme.colorScheme.error, + borderRadius: + BorderRadius.circular(4), + ), + child: Text( + 'Optional', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: + theme.colorScheme.onError, + ), + ), + ), + ), + if (setting.value.description != null) + TextSpan( + text: setting.value.description, + // style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + secondary: HardeningGroupIcon( + isActive: setting.value.isActive, + ), + onChanged: (value) async { + final notifier = ref.read( + preferenceSettingsGroupRepositoryProvider( + PreferencePartition.user, + groupName, + ).notifier, + ); + + if (value) { + await notifier.apply(filter: [setting.key]); + } else { + await notifier.reset(filter: [setting.key]); + } + }, + ), + ) + else + Expanded( + child: ListTile( + title: Text(setting.value.title ?? setting.key), + subtitle: setting.value.description.mapNotNull( + (description) => Text(description), + ), + leading: HardeningGroupIcon( + isActive: setting.value.isActive, + ), + trailing: const Padding( + padding: EdgeInsets.only(right: 18.0), + child: Icon(Icons.check), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + }, + error: (error, stackTrace) => FailureWidget( + title: 'Could not load preference settings', + exception: error, + onRetry: () => ref.refresh( + preferenceSettingsGroupRepositoryProvider( + PreferencePartition.user, + groupName, + ), + ), + ), + loading: () => const SizedBox.shrink(), + ), + ); + } +} diff --git a/app/lib/features/settings/presentation/screens/web_engine_settings.dart b/app/lib/features/settings/presentation/screens/web_engine_settings.dart new file mode 100644 index 00000000..139f0aa1 --- /dev/null +++ b/app/lib/features/settings/presentation/screens/web_engine_settings.dart @@ -0,0 +1,376 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:universal_io/io.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/user/data/models/engine_settings.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +class WebEngineSettingsScreen extends HookConsumerWidget { + const WebEngineSettingsScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final generalSettings = ref.watch(generalSettingsRepositoryProvider); + final engineSettings = ref.watch(engineSettingsRepositoryProvider); + + final userAgentTextController = useTextEditingController( + text: engineSettings.userAgent, + keys: [engineSettings.userAgent], + ); + + return Scaffold( + appBar: AppBar(title: const Text('Web Engine Settings')), + body: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + children: [ + SwitchListTile.adaptive( + title: const Text('Incognito Mode'), + subtitle: const Text( + 'Deletes all browsing data upon app restart for enhanced privacy.', + ), + secondary: const Icon(MdiIcons.incognito), + value: generalSettings.deleteBrowsingDataOnQuit != null, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => value + ? currentSettings.copyWith.deleteBrowsingDataOnQuit( + {}, + ) + : currentSettings.copyWith.deleteBrowsingDataOnQuit( + null, + ), + ); + }, + ), + if (generalSettings.deleteBrowsingDataOnQuit != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8, + ), + child: Column( + children: [ + for (final type in DeleteBrowsingDataType.values) + CheckboxListTile.adaptive( + value: generalSettings.deleteBrowsingDataOnQuit! + .contains(type), + controlAffinity: ListTileControlAffinity.leading, + title: Text(type.title), + subtitle: type.description.mapNotNull( + (description) => Text(description), + ), + onChanged: (value) async { + final notifier = ref.read( + saveGeneralSettingsControllerProvider.notifier, + ); + + if (value == true) { + await notifier.save( + (currentSettings) => currentSettings.copyWith + .deleteBrowsingDataOnQuit({ + ...currentSettings + .deleteBrowsingDataOnQuit!, + type, + }), + ); + } else { + await notifier.save( + (currentSettings) => currentSettings.copyWith + .deleteBrowsingDataOnQuit( + { + ...currentSettings + .deleteBrowsingDataOnQuit!, + }..remove(type), + ), + ); + } + }, + ), + ], + ), + ), + SwitchListTile.adaptive( + title: const Text('Enable JavaScript'), + subtitle: const Text( + 'While turning off JavaScript boosts security, privacy, and speed, it may cause some sites to not work as intended.', + ), + // ignore: deprecated_member_use use this icon for now + secondary: const Icon(MdiIcons.languageJavascript), + value: engineSettings.javascriptEnabled, + onChanged: (value) async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.javascriptEnabled(value), + ); + }, + ), + SwitchListTile.adaptive( + title: const Text('Enable Global Privacy Control (GPC)'), + secondary: const Icon(MdiIcons.incognitoCircleOff), + value: engineSettings.globalPrivacyControlEnabled, + onChanged: (value) async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .globalPrivacyControlEnabled(value), + ); + }, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text('Block insecure HTTP connections'), + leading: Icon(MdiIcons.lockOpen), + contentPadding: EdgeInsets.zero, + ), + Center( + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: HttpsOnlyMode.disabled, + label: Text('Disabled'), + ), + ButtonSegment( + value: HttpsOnlyMode.enabled, + label: Text('Enabled'), + ), + ButtonSegment( + value: HttpsOnlyMode.privateOnly, + label: Text('Private mode only'), + ), + ], + selected: {engineSettings.httpsOnlyMode}, + onSelectionChanged: (value) async { + await ref + .read( + saveEngineSettingsControllerProvider.notifier, + ) + .save( + (currentSettings) => currentSettings.copyWith + .httpsOnlyMode(value.first), + ); + }, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text('Enhanced Tracking Protection'), + leading: Icon(MdiIcons.incognitoCircleOff), + contentPadding: EdgeInsets.zero, + ), + RadioListTile.adaptive( + value: TrackingProtectionPolicy.none, + title: const Text('Disabled'), + groupValue: engineSettings.trackingProtectionPolicy, + onChanged: (value) async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .trackingProtectionPolicy(value), + ); + }, + ), + RadioListTile.adaptive( + value: TrackingProtectionPolicy.recommended, + title: const Text('Standard'), + subtitle: const Text( + 'Pages will load normally, but block fewer trackers.', + ), + groupValue: engineSettings.trackingProtectionPolicy, + onChanged: (value) async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .trackingProtectionPolicy(value), + ); + }, + ), + RadioListTile.adaptive( + value: TrackingProtectionPolicy.strict, + title: const Text('Strict'), + subtitle: const Text( + 'Stronger tracking protection and faster performance, but some sites may not work properly.', + ), + groupValue: engineSettings.trackingProtectionPolicy, + onChanged: (value) async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .trackingProtectionPolicy(value), + ); + }, + ), + RadioListTile.adaptive( + value: TrackingProtectionPolicy.custom, + title: const Text('Custom'), + subtitle: const Text( + 'Choose which trackers and scripts to block.', + ), + groupValue: engineSettings.trackingProtectionPolicy, + onChanged: null, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text('Cookie Banner Blocker'), + leading: Icon(MdiIcons.cookieSettings), + contentPadding: EdgeInsets.zero, + ), + CheckboxListTile.adaptive( + value: + engineSettings.cookieBannerHandlingMode == + CookieBannerHandlingMode.rejectAll, + title: const Text('In normal browsing'), + controlAffinity: ListTileControlAffinity.leading, + onChanged: (value) async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .cookieBannerHandlingMode( + value! + ? CookieBannerHandlingMode.rejectAll + : CookieBannerHandlingMode.disabled, + ), + ); + }, + ), + CheckboxListTile.adaptive( + value: + engineSettings + .cookieBannerHandlingModePrivateBrowsing == + CookieBannerHandlingMode.rejectAll, + title: const Text('In private browsing'), + controlAffinity: ListTileControlAffinity.leading, + onChanged: (value) async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .cookieBannerHandlingModePrivateBrowsing( + value! + ? CookieBannerHandlingMode.rejectAll + : CookieBannerHandlingMode.disabled, + ), + ); + }, + ), + ], + ), + ), + ListTile( + leading: const Icon(MdiIcons.cardAccountDetails), + title: TextField( + controller: userAgentTextController, + decoration: const InputDecoration( + labelText: 'Custom User Agent', + floatingLabelBehavior: FloatingLabelBehavior.always, + hintText: 'Mozilla/5.0 …', + ), + onSubmitted: (value) async { + await ref + .read(saveEngineSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.userAgent(value), + ); + + if (context.mounted) { + final restart = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('User Agent Changed'), + content: const Text( + 'The app needs to restart for the new user agent to take effect', + ), + actions: [ + TextButton( + onPressed: () { + context.pop(false); + }, + child: const Text('Later'), + ), + TextButton( + onPressed: () { + context.pop(true); + }, + child: const Text('Restart Now'), + ), + ], + ), + ); + + if (restart == true) { + exit(0); + } + } + }, + ), + ), + ListTile( + title: const Text('Web Engine Hardening'), + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + leading: const Icon(MdiIcons.shieldLock), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await WebEngineHardeningRoute().push(context); + }, + ), + ], + ); + }, + ), + ); + } +} diff --git a/app/lib/features/settings/presentation/widgets/bang_group_list_tile.dart b/app/lib/features/settings/presentation/widgets/bang_group_list_tile.dart index 9c19181f..77973e49 100644 --- a/app/lib/features/settings/presentation/widgets/bang_group_list_tile.dart +++ b/app/lib/features/settings/presentation/widgets/bang_group_list_tile.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/bangs/data/models/bang.dart'; -import 'package:lensai/features/bangs/domain/providers.dart'; -import 'package:lensai/features/bangs/domain/repositories/sync.dart'; -import 'package:lensai/features/settings/presentation/widgets/custom_list_tile.dart'; -import 'package:lensai/features/settings/presentation/widgets/sync_details_table.dart'; +import 'package:weblibre/features/bangs/data/models/bang_group.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/domain/repositories/sync.dart'; +import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart'; +import 'package:weblibre/features/settings/presentation/widgets/sync_details_table.dart'; class BangGroupListTile extends HookConsumerWidget { final BangGroup group; diff --git a/app/lib/features/settings/presentation/widgets/bang_icon.dart b/app/lib/features/settings/presentation/widgets/bang_icon.dart new file mode 100644 index 00000000..1b19aae8 --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/bang_icon.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class BangIcon extends ConsumerWidget { + final String trigger; + final double iconSize; + + const BangIcon({super.key, required this.trigger, this.iconSize = 20}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bang = ref.watch( + bangDataProvider(trigger).select((value) => value.valueOrNull), + ); + + return UrlIcon([ + if (bang != null) bang.getTemplateUrl(''), + ], iconSize: iconSize); + } +} diff --git a/app/lib/features/settings/presentation/widgets/custom_list_tile.dart b/app/lib/features/settings/presentation/widgets/custom_list_tile.dart index 677b1051..e6d474cc 100644 --- a/app/lib/features/settings/presentation/widgets/custom_list_tile.dart +++ b/app/lib/features/settings/presentation/widgets/custom_list_tile.dart @@ -23,8 +23,9 @@ class CustomListTile extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final subtitleTextTheme = theme.textTheme.bodyMedium! - .copyWith(color: theme.colorScheme.onSurfaceVariant); + final subtitleTextTheme = theme.textTheme.bodyMedium!.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), @@ -40,15 +41,17 @@ class CustomListTile extends StatelessWidget { title, style: enabled ? theme.textTheme.bodyLarge - : theme.textTheme.bodyLarge - ?.copyWith(color: theme.disabledColor), + : theme.textTheme.bodyLarge?.copyWith( + color: theme.disabledColor, + ), ), Text( subtitle, style: enabled ? subtitleTextTheme - : theme.textTheme.bodyMedium - ?.copyWith(color: theme.disabledColor), + : theme.textTheme.bodyMedium?.copyWith( + color: theme.disabledColor, + ), ), if (content != null) content!, ], diff --git a/app/lib/features/settings/presentation/widgets/default_search_selector.dart b/app/lib/features/settings/presentation/widgets/default_search_selector.dart new file mode 100644 index 00000000..b6bc3597 --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/default_search_selector.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/presentation/widgets/selectable_chips.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class DefaultSearchSelector extends HookConsumerWidget { + const DefaultSearchSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeBang = ref.watch( + defaultSearchBangDataProvider.select((value) => value.valueOrNull), + ); + final availableBangs = ref.watch(frequentBangListProvider); + + Future updateSearchProvider(String trigger) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.defaultSearchProvider(trigger), + ); + } + + return availableBangs.when( + skipLoadingOnReload: true, + data: (availableBangs) { + return SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + child: SelectableChips( + itemId: (bang) => bang.trigger, + itemAvatar: (bang) => + UrlIcon([bang.getTemplateUrl('')], iconSize: 20), + itemLabel: (bang) => Text(bang.websiteName), + itemTooltip: (bang) => bang.trigger, + availableItems: availableBangs, + selectedItem: activeBang, + onSelected: (bang) async { + await updateSearchProvider(bang.trigger); + }, + ), + ), + IconButton( + onPressed: () async { + final trigger = await const BangSearchRoute().push( + context, + ); + + if (trigger != null) { + await updateSearchProvider(trigger); + } + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ); + }, + error: (error, stackTrace) => const SizedBox.shrink(), + loading: () => const SizedBox(height: 48, width: double.infinity), + ); + } +} diff --git a/app/lib/features/settings/presentation/widgets/hardening_group_icon.dart b/app/lib/features/settings/presentation/widgets/hardening_group_icon.dart new file mode 100644 index 00000000..fd3d3931 --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/hardening_group_icon.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; + +class HardeningGroupIcon extends StatelessWidget { + final bool isActive; + final bool isPartlyActive; + + const HardeningGroupIcon({ + super.key, + required this.isActive, + this.isPartlyActive = false, + }); + + @override + Widget build(BuildContext context) { + if (isActive) { + return Icon( + MdiIcons.shieldLockOutline, + color: Theme.of(context).colorScheme.secondary, + ); + } else if (isPartlyActive) { + return Icon( + MdiIcons.shieldLockOpenOutline, + color: Theme.of(context).colorScheme.secondary, + ); + } + + return Icon( + MdiIcons.shieldOffOutline, + color: Theme.of(context).colorScheme.error, + ); + } +} diff --git a/app/lib/features/settings/presentation/widgets/host_list_tile.dart b/app/lib/features/settings/presentation/widgets/host_list_tile.dart deleted file mode 100644 index 74eb3187..00000000 --- a/app/lib/features/settings/presentation/widgets/host_list_tile.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/content_block/data/models/host.dart'; -import 'package:lensai/features/content_block/domain/providers.dart'; -import 'package:lensai/features/content_block/domain/repositories/sync.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/presentation/controllers/save_settings.dart'; -import 'package:lensai/features/settings/presentation/widgets/custom_list_tile.dart'; -import 'package:lensai/features/settings/presentation/widgets/sync_details_table.dart'; - -class HostListTile extends HookConsumerWidget { - final bool enabled; - - final bool enableContentBlocking; - final Set enableHostLists; - final HostSource source; - - final String title; - final String subtitle; - - const HostListTile({ - required this.enableContentBlocking, - required this.enableHostLists, - required this.source, - required this.title, - required this.subtitle, - this.enabled = true, - super.key, - }); - @override - Widget build(BuildContext context, WidgetRef ref) { - final enabled = enableHostLists.contains(source); - - final lastSync = ref.watch( - lastSyncOfSourceProvider(source).select((value) => value.valueOrNull), - ); - - final count = ref.watch( - hostCountOfSourceProvider(source).select((value) => value.valueOrNull), - ); - - Future toggleHostLists(HostSource source) async { - final lists = enabled - ? ({...enableHostLists}..remove(source)) - : {...enableHostLists, source}; - - await ref.read(saveSettingsControllerProvider.notifier).save( - (currentSettings) => currentSettings.copyWith.enableHostList(lists), - ); - } - - return CustomListTile( - enabled: enableContentBlocking, - title: title, - subtitle: subtitle, - prefix: Checkbox.adaptive( - value: enableHostLists.contains(source), - onChanged: enableContentBlocking - ? (_) async { - await toggleHostLists(source); - } - : null, - ), - suffix: FilledButton.icon( - onPressed: (enabled && enableContentBlocking) - ? () async { - await ref - .read(hostSyncRepositoryProvider.notifier) - .syncHostSource(source, null); - } - : null, - icon: const Icon(Icons.sync), - label: const Text('Sync'), - ), - content: (enabled && enableContentBlocking) - ? Padding( - padding: const EdgeInsets.only(top: 8.0), - child: SyncDetailsTable(count, lastSync), - ) - : null, - ); - } -} diff --git a/app/lib/features/settings/presentation/widgets/sections.dart b/app/lib/features/settings/presentation/widgets/sections.dart new file mode 100644 index 00000000..e554a9cb --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/sections.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class SettingSection extends StatelessWidget { + final String name; + + const SettingSection({required this.name}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: Theme.of(context).textTheme.titleLarge), + const Divider(), + ], + ), + ); + } +} + +class SettingSubSection extends StatelessWidget { + final String name; + + const SettingSubSection({required this.name}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + child: Text( + name, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 18), + ), + ); + } +} diff --git a/app/lib/features/settings/presentation/widgets/sync_details_table.dart b/app/lib/features/settings/presentation/widgets/sync_details_table.dart index b1ea418e..f78f9fef 100644 --- a/app/lib/features/settings/presentation/widgets/sync_details_table.dart +++ b/app/lib/features/settings/presentation/widgets/sync_details_table.dart @@ -18,17 +18,12 @@ class SyncDetailsTable extends StatelessWidget { columnWidths: const {0: FixedColumnWidth(100)}, children: [ TableRow( - children: [ - const Text('Entries'), - Text(count?.toString() ?? 'N/A'), - ], + children: [const Text('Entries'), Text(count?.toString() ?? 'N/A')], ), TableRow( children: [ const Text('Last Sync'), - Text( - (lastSync != null) ? timeago.format(lastSync!) : 'N/A', - ), + Text((lastSync != null) ? timeago.format(lastSync!) : 'N/A'), ], ), ], diff --git a/app/lib/features/settings/utils/preference_parser.dart b/app/lib/features/settings/utils/preference_parser.dart deleted file mode 100644 index 7616d2ae..00000000 --- a/app/lib/features/settings/utils/preference_parser.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:lensai/features/content_block/data/models/host.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; - -Set? parseHostSources(List? input) => input - ?.map( - (list) => - HostSource.values.firstWhereOrNull((source) => source.name == list), - ) - .whereNotNull() - .toSet(); - -ThemeMode? parseThemeMode(int? index) { - if (index != null && index < ThemeMode.values.length) { - return ThemeMode.values[index]; - } - - return null; -} - -KagiTool? parseKagiTool(int? index) { - if (index != null && index < ThemeMode.values.length) { - return KagiTool.values[index]; - } - - return null; -} diff --git a/app/lib/features/settings/utils/session_link_extractor.dart b/app/lib/features/settings/utils/session_link_extractor.dart deleted file mode 100644 index 4ee236f3..00000000 --- a/app/lib/features/settings/utils/session_link_extractor.dart +++ /dev/null @@ -1,7 +0,0 @@ -String? extractKagiSession(Uri uri) { - if (uri.authority == 'kagi.com') { - return uri.queryParameters['token']; - } - - return null; -} diff --git a/app/lib/features/share_intent/domain/entities/shared_content.dart b/app/lib/features/share_intent/domain/entities/shared_content.dart index 4c49b3d8..ea803692 100644 --- a/app/lib/features/share_intent/domain/entities/shared_content.dart +++ b/app/lib/features/share_intent/domain/entities/shared_content.dart @@ -1,7 +1,8 @@ -import 'package:lensai/utils/uri_parser.dart' as uri_parser; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/utils/uri_parser.dart' as uri_parser; -sealed class SharedContent { - const SharedContent(); +sealed class SharedContent with FastEquatable { + SharedContent(); factory SharedContent.parse(String content) { if (uri_parser.tryParseUrl(content, eagerParsing: true) @@ -16,17 +17,23 @@ sealed class SharedContent { final class SharedUrl extends SharedContent { final Uri url; - const SharedUrl(this.url); + SharedUrl(this.url); @override String toString() => url.toString(); + + @override + List get hashParameters => [url]; } final class SharedText extends SharedContent { final String text; - const SharedText(this.text); + SharedText(this.text); @override String toString() => text; + + @override + List get hashParameters => [text]; } diff --git a/app/lib/features/share_intent/domain/services/sharing_intent.dart b/app/lib/features/share_intent/domain/services/sharing_intent.dart index a45fea1b..0f8445fd 100644 --- a/app/lib/features/share_intent/domain/services/sharing_intent.dart +++ b/app/lib/features/share_intent/domain/services/sharing_intent.dart @@ -1,64 +1,48 @@ import 'dart:async'; -import 'package:collection/collection.dart'; -import 'package:flutter_pdf_text/flutter_pdf_text.dart'; -import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; -import 'package:flutter_sharing_intent/model/sharing_file.dart'; -import 'package:lensai/core/logger.dart'; -import 'package:lensai/domain/entities/received_parameter.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; import 'package:mime/mime.dart' as mime; +import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:rxdart/rxdart.dart'; +import 'package:simple_intent_receiver/simple_intent_receiver.dart'; import 'package:uri_to_file/uri_to_file.dart' as uri_to_file; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/data/models/received_intent_parameter.dart'; part 'sharing_intent.g.dart'; final _sharingIntentTransformer = - StreamTransformer, ReceivedParameter>.fromHandlers( - handleData: (files, sink) async { - //For now only one file is supported to share with the app - final data = files.firstOrNull; + StreamTransformer.fromHandlers( + handleData: (intent, sink) async { + final data = switch (intent.action) { + 'android.intent.action.WEB_SEARCH' => + intent.extra['query'] as String?, + 'android.intent.action.VIEW' => intent.data, + 'android.intent.action.SEND' => + intent.extra['android.intent.extra.STREAM'] as String? ?? + intent.extra['android.intent.extra.TEXT'] as String?, + _ => null, + }; - if (data != null && data.value != null) { - switch (data.type) { - case SharedMediaType.TEXT: - case SharedMediaType.URL: - if (uri_to_file.isUriSupported(data.value!)) { - final file = await uri_to_file.toFile(data.value!); + if (data != null) { + if (uri_to_file.isUriSupported(data)) { + final file = await uri_to_file.toFile(data); final mimeType = mime.lookupMimeType(file.path); switch (mimeType) { case 'application/pdf': - final content = - await PDFDoc.fromFile(file).then((doc) => doc.text); - sink.add( - ReceivedParameter(content, KagiTool.summarizer.name), - ); + sink.add(ReceivedIntentParameter(data, null)); default: logger.w('Unhandled mime type: $mimeType'); } } else { - sink.add(ReceivedParameter(data.value, null)); + sink.add(ReceivedIntentParameter(data, null)); } - default: - logger.w('Unhandled media type: $data'); - } - } - }, -); + } + }, + ); -@Riverpod() -Raw> sharingIntentStream(SharingIntentStreamRef ref) { - final initialStream = FlutterSharingIntent.instance - // ignore: discarded_futures - .getInitialSharing() - // ignore: discarded_futures - .then((event) { - FlutterSharingIntent.instance.reset(); - return event; - }).asStream(); +@Riverpod(keepAlive: true) +Raw> sharingIntentStream(Ref ref) { + final receiver = IntentReceiver.setUp(); - return ConcatStream( - [initialStream, FlutterSharingIntent.instance.getMediaStream()], - ).transform(_sharingIntentTransformer); + return receiver.events.transform(_sharingIntentTransformer); } diff --git a/app/lib/features/share_intent/domain/services/sharing_intent.g.dart b/app/lib/features/share_intent/domain/services/sharing_intent.g.dart index 6de1b8e0..014c317f 100644 --- a/app/lib/features/share_intent/domain/services/sharing_intent.g.dart +++ b/app/lib/features/share_intent/domain/services/sharing_intent.g.dart @@ -7,22 +7,24 @@ part of 'sharing_intent.dart'; // ************************************************************************** String _$sharingIntentStreamHash() => - r'ce060aadf885ab0a14d90acfe986447ecb05f4f9'; + r'486f994fc0e01a2cffdb19d93f0a332f96a9850c'; /// See also [sharingIntentStream]. @ProviderFor(sharingIntentStream) final sharingIntentStreamProvider = - AutoDisposeProvider>>.internal( - sharingIntentStream, - name: r'sharingIntentStreamProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$sharingIntentStreamHash, - dependencies: null, - allTransitiveDependencies: null, -); + Provider>>.internal( + sharingIntentStream, + name: r'sharingIntentStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$sharingIntentStreamHash, + dependencies: null, + allTransitiveDependencies: null, + ); -typedef SharingIntentStreamRef - = AutoDisposeProviderRef>>; +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SharingIntentStreamRef = + ProviderRef>>; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/topics/data/database/daos/tab.dart b/app/lib/features/topics/data/database/daos/tab.dart deleted file mode 100644 index 148292fa..00000000 --- a/app/lib/features/topics/data/database/daos/tab.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:lensai/features/topics/data/database/database.dart'; -import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; - -part 'tab.g.dart'; - -@DriftAccessor() -class TabDao extends DatabaseAccessor with _$TabDaoMixin { - TabDao(super.db); - - Stream> watchTabs() { - return (db.tab.select()..orderBy([(u) => OrderingTerm.asc(u.id)])).watch(); - } - - Stream watchTab(String tabId) { - return (db.tab.select()..where((t) => t.id.equals(tabId))) - .watchSingleOrNull(); - } - - Stream watchTabExisiting(String tabId) { - final existsStatement = - existsQuery(db.tab.select()..where((t) => t.id.equals(tabId))); - - return selectExpressions([existsStatement]) - .map((row) => row.read(existsStatement)!) - .watchSingle(); - } - - Stream> watchTopicTabs(String? topicId) { - final query = (selectOnly(db.tab) - ..addColumns([db.tab.id]) - ..where( - (topicId == null) - ? db.tab.topicId.isNull() - : db.tab.topicId.equals(topicId), - ) - ..orderBy([OrderingTerm.asc(db.tab.id)])); - - return query.map((row) => row.read(db.tab.id)!).watch(); - } - - Future upsertTab(ITab tab, DateTime timestamp) { - return db.tab.insertOne( - TabCompanion.insert( - id: tab.id, - timestamp: timestamp, - url: tab.url, - topicId: Value(tab.topicId), - title: Value(tab.title), - screenshot: Value(tab.screenshot), - ), - mode: InsertMode.insertOrReplace, - ); - } - - Future updateTab( - String id, { - required DateTime timestamp, - Value url = const Value.absent(), - Value title = const Value.absent(), - Value topicId = const Value.absent(), - Value screenshot = const Value.absent(), - }) { - final statement = db.tab.update()..where((t) => t.id.equals(id)); - - return statement.write( - TabCompanion( - timestamp: Value(timestamp), - url: url, - title: title, - topicId: topicId, - screenshot: screenshot, - ), - ); - } - - Future deleteTab(String id) { - return db.tab.deleteOne(TabCompanion.custom(id: Variable(id))); - } - - Future deleteTopicTabs(String? topicId) { - return (db.tab.delete() - ..where( - (t) => (topicId == null) - ? t.topicId.isNull() - : t.topicId.equals(topicId), - )) - .go(); - } -} diff --git a/app/lib/features/topics/data/database/daos/topic.dart b/app/lib/features/topics/data/database/daos/topic.dart deleted file mode 100644 index d8e38a45..00000000 --- a/app/lib/features/topics/data/database/daos/topic.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'dart:ui'; - -import 'package:drift/drift.dart'; -import 'package:lensai/core/uuid.dart'; -import 'package:lensai/features/topics/data/database/database.dart'; -import 'package:lensai/features/topics/data/models/topic_data.dart'; - -part 'topic.g.dart'; - -@DriftAccessor() -class TopicDao extends DatabaseAccessor with _$TopicDaoMixin { - TopicDao(super.db); - - Future addTopic({String? name, required Color color}) { - return db.topic.insertOne( - TopicCompanion.insert( - id: uuid.v7(), - name: Value(name), - color: color, - ), - ); - } - - Future replaceTopic( - String id, { - required String? name, - required Color color, - }) { - return db.topic.replaceOne( - TopicCompanion( - id: Value(id), - name: Value(name), - color: Value(color), - ), - ); - } - - Future deleteTopic(String id) { - return db.topic.deleteOne(TopicCompanion.custom(id: Variable(id))); - } - - SingleOrNullSelectable getTopicData(String id) { - return select(db.topic)..where((t) => t.id.equals(id)); - } - - Selectable getDistinctColors() { - final query = db.selectOnly(db.topic, distinct: true) - ..addColumns([db.topic.color]) - ..where(db.topic.color.isNotNull()); - - return query - .map((row) => row.readWithConverter(db.topic.color)!); - } -} diff --git a/app/lib/features/topics/data/database/daos/topic.g.dart b/app/lib/features/topics/data/database/daos/topic.g.dart deleted file mode 100644 index 30a65165..00000000 --- a/app/lib/features/topics/data/database/daos/topic.g.dart +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'topic.dart'; - -// ignore_for_file: type=lint -mixin _$TopicDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/topics/data/database/database.dart b/app/lib/features/topics/data/database/database.dart deleted file mode 100644 index 258969dd..00000000 --- a/app/lib/features/topics/data/database/database.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:ui'; - -import 'package:drift/drift.dart'; -import 'package:lensai/features/topics/data/database/daos/tab.dart'; -import 'package:lensai/features/topics/data/database/daos/topic.dart'; -import 'package:lensai/features/topics/data/database/drift/converters/color.dart'; -import 'package:lensai/features/topics/data/database/drift/converters/uri.dart'; -import 'package:lensai/features/topics/data/models/topic_data.dart'; - -part 'database.g.dart'; - -@DriftDatabase( - include: {'database.drift'}, - daos: [TopicDao, TabDao], -) -class TabDatabase extends _$TabDatabase { - @override - final int schemaVersion = 1; - - @override - MigrationStrategy get migration => MigrationStrategy( - beforeOpen: (details) async { - await customStatement('PRAGMA foreign_keys = ON;'); - }, - ); - - TabDatabase(super.e); -} diff --git a/app/lib/features/topics/data/database/database.drift b/app/lib/features/topics/data/database/database.drift deleted file mode 100644 index ada2d0c5..00000000 --- a/app/lib/features/topics/data/database/database.drift +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:lensai/features/topics/data/database/drift/converters/color.dart'; -import 'package:lensai/features/topics/data/database/drift/converters/uri.dart'; -import 'package:lensai/features/topics/data/models/topic_data.dart'; - -CREATE TABLE topic ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT, - color INTEGER NOT NULL MAPPED BY `const ColorConverter()` -) WITH TopicData; - -CREATE TABLE tab ( - id TEXT PRIMARY KEY NOT NULL, - topic_id TEXT REFERENCES topic (id) ON DELETE CASCADE, - timestamp DATETIME NOT NULL, - url TEXT NOT NULL MAPPED BY `const UriConverter()`, - title TEXT, - screenshot BLOB -); - -topicsWithCount WITH TopicDataWithCount: - SELECT - topic.*, - tab_agg.tab_count - FROM topic - LEFT JOIN ( - SELECT - topic_id, - COUNT(*) AS tab_count, - MAX(timestamp) AS last_updated - FROM tab - GROUP BY topic_id - ) AS tab_agg ON topic.id = tab_agg.topic_id - ORDER BY tab_agg.last_updated DESC NULLS FIRST; - diff --git a/app/lib/features/topics/data/database/database.g.dart b/app/lib/features/topics/data/database/database.g.dart deleted file mode 100644 index cc0200e9..00000000 --- a/app/lib/features/topics/data/database/database.g.dart +++ /dev/null @@ -1,860 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'database.dart'; - -// ignore_for_file: type=lint -class Topic extends Table with TableInfo { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - Topic(this.attachedDatabase, [this._alias]); - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'PRIMARY KEY NOT NULL'); - late final GeneratedColumn name = GeneratedColumn( - 'name', aliasedName, true, - type: DriftSqlType.string, - requiredDuringInsert: false, - $customConstraints: ''); - late final GeneratedColumnWithTypeConverter color = - GeneratedColumn('color', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL') - .withConverter(Topic.$convertercolor); - @override - List get $columns => [id, name, color]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'topic'; - @override - Set get $primaryKey => {id}; - @override - TopicData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return TopicData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id'])!, - name: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}name']), - color: Topic.$convertercolor.fromSql(attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}color'])!), - ); - } - - @override - Topic createAlias(String alias) { - return Topic(attachedDatabase, alias); - } - - static TypeConverter $convertercolor = const ColorConverter(); - @override - bool get dontWriteConstraints => true; -} - -class TopicCompanion extends UpdateCompanion { - final Value id; - final Value name; - final Value color; - final Value rowid; - const TopicCompanion({ - this.id = const Value.absent(), - this.name = const Value.absent(), - this.color = const Value.absent(), - this.rowid = const Value.absent(), - }); - TopicCompanion.insert({ - required String id, - this.name = const Value.absent(), - required Color color, - this.rowid = const Value.absent(), - }) : id = Value(id), - color = Value(color); - static Insertable custom({ - Expression? id, - Expression? name, - Expression? color, - Expression? rowid, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (name != null) 'name': name, - if (color != null) 'color': color, - if (rowid != null) 'rowid': rowid, - }); - } - - TopicCompanion copyWith( - {Value? id, - Value? name, - Value? color, - Value? rowid}) { - return TopicCompanion( - id: id ?? this.id, - name: name ?? this.name, - color: color ?? this.color, - rowid: rowid ?? this.rowid, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (name.present) { - map['name'] = Variable(name.value); - } - if (color.present) { - map['color'] = Variable(Topic.$convertercolor.toSql(color.value)); - } - if (rowid.present) { - map['rowid'] = Variable(rowid.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('TopicCompanion(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('color: $color, ') - ..write('rowid: $rowid') - ..write(')')) - .toString(); - } -} - -class Tab extends Table with TableInfo { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - Tab(this.attachedDatabase, [this._alias]); - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'PRIMARY KEY NOT NULL'); - late final GeneratedColumn topicId = GeneratedColumn( - 'topic_id', aliasedName, true, - type: DriftSqlType.string, - requiredDuringInsert: false, - $customConstraints: 'REFERENCES topic(id)ON DELETE CASCADE'); - late final GeneratedColumn timestamp = GeneratedColumn( - 'timestamp', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL'); - late final GeneratedColumnWithTypeConverter url = - GeneratedColumn('url', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL') - .withConverter(Tab.$converterurl); - late final GeneratedColumn title = GeneratedColumn( - 'title', aliasedName, true, - type: DriftSqlType.string, - requiredDuringInsert: false, - $customConstraints: ''); - late final GeneratedColumn screenshot = GeneratedColumn( - 'screenshot', aliasedName, true, - type: DriftSqlType.blob, - requiredDuringInsert: false, - $customConstraints: ''); - @override - List get $columns => - [id, topicId, timestamp, url, title, screenshot]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'tab'; - @override - Set get $primaryKey => {id}; - @override - TabData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return TabData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id'])!, - topicId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}topic_id']), - timestamp: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}timestamp'])!, - url: Tab.$converterurl.fromSql(attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}url'])!), - title: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}title']), - screenshot: attachedDatabase.typeMapping - .read(DriftSqlType.blob, data['${effectivePrefix}screenshot']), - ); - } - - @override - Tab createAlias(String alias) { - return Tab(attachedDatabase, alias); - } - - static TypeConverter $converterurl = const UriConverter(); - @override - bool get dontWriteConstraints => true; -} - -class TabData extends DataClass implements Insertable { - final String id; - final String? topicId; - final DateTime timestamp; - final Uri url; - final String? title; - final Uint8List? screenshot; - const TabData( - {required this.id, - this.topicId, - required this.timestamp, - required this.url, - this.title, - this.screenshot}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - if (!nullToAbsent || topicId != null) { - map['topic_id'] = Variable(topicId); - } - map['timestamp'] = Variable(timestamp); - { - map['url'] = Variable(Tab.$converterurl.toSql(url)); - } - if (!nullToAbsent || title != null) { - map['title'] = Variable(title); - } - if (!nullToAbsent || screenshot != null) { - map['screenshot'] = Variable(screenshot); - } - return map; - } - - factory TabData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return TabData( - id: serializer.fromJson(json['id']), - topicId: serializer.fromJson(json['topic_id']), - timestamp: serializer.fromJson(json['timestamp']), - url: serializer.fromJson(json['url']), - title: serializer.fromJson(json['title']), - screenshot: serializer.fromJson(json['screenshot']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'topic_id': serializer.toJson(topicId), - 'timestamp': serializer.toJson(timestamp), - 'url': serializer.toJson(url), - 'title': serializer.toJson(title), - 'screenshot': serializer.toJson(screenshot), - }; - } - - TabData copyWith( - {String? id, - Value topicId = const Value.absent(), - DateTime? timestamp, - Uri? url, - Value title = const Value.absent(), - Value screenshot = const Value.absent()}) => - TabData( - id: id ?? this.id, - topicId: topicId.present ? topicId.value : this.topicId, - timestamp: timestamp ?? this.timestamp, - url: url ?? this.url, - title: title.present ? title.value : this.title, - screenshot: screenshot.present ? screenshot.value : this.screenshot, - ); - TabData copyWithCompanion(TabCompanion data) { - return TabData( - id: data.id.present ? data.id.value : this.id, - topicId: data.topicId.present ? data.topicId.value : this.topicId, - timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, - url: data.url.present ? data.url.value : this.url, - title: data.title.present ? data.title.value : this.title, - screenshot: - data.screenshot.present ? data.screenshot.value : this.screenshot, - ); - } - - @override - String toString() { - return (StringBuffer('TabData(') - ..write('id: $id, ') - ..write('topicId: $topicId, ') - ..write('timestamp: $timestamp, ') - ..write('url: $url, ') - ..write('title: $title, ') - ..write('screenshot: $screenshot') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash( - id, topicId, timestamp, url, title, $driftBlobEquality.hash(screenshot)); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is TabData && - other.id == this.id && - other.topicId == this.topicId && - other.timestamp == this.timestamp && - other.url == this.url && - other.title == this.title && - $driftBlobEquality.equals(other.screenshot, this.screenshot)); -} - -class TabCompanion extends UpdateCompanion { - final Value id; - final Value topicId; - final Value timestamp; - final Value url; - final Value title; - final Value screenshot; - final Value rowid; - const TabCompanion({ - this.id = const Value.absent(), - this.topicId = const Value.absent(), - this.timestamp = const Value.absent(), - this.url = const Value.absent(), - this.title = const Value.absent(), - this.screenshot = const Value.absent(), - this.rowid = const Value.absent(), - }); - TabCompanion.insert({ - required String id, - this.topicId = const Value.absent(), - required DateTime timestamp, - required Uri url, - this.title = const Value.absent(), - this.screenshot = const Value.absent(), - this.rowid = const Value.absent(), - }) : id = Value(id), - timestamp = Value(timestamp), - url = Value(url); - static Insertable custom({ - Expression? id, - Expression? topicId, - Expression? timestamp, - Expression? url, - Expression? title, - Expression? screenshot, - Expression? rowid, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (topicId != null) 'topic_id': topicId, - if (timestamp != null) 'timestamp': timestamp, - if (url != null) 'url': url, - if (title != null) 'title': title, - if (screenshot != null) 'screenshot': screenshot, - if (rowid != null) 'rowid': rowid, - }); - } - - TabCompanion copyWith( - {Value? id, - Value? topicId, - Value? timestamp, - Value? url, - Value? title, - Value? screenshot, - Value? rowid}) { - return TabCompanion( - id: id ?? this.id, - topicId: topicId ?? this.topicId, - timestamp: timestamp ?? this.timestamp, - url: url ?? this.url, - title: title ?? this.title, - screenshot: screenshot ?? this.screenshot, - rowid: rowid ?? this.rowid, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (topicId.present) { - map['topic_id'] = Variable(topicId.value); - } - if (timestamp.present) { - map['timestamp'] = Variable(timestamp.value); - } - if (url.present) { - map['url'] = Variable(Tab.$converterurl.toSql(url.value)); - } - if (title.present) { - map['title'] = Variable(title.value); - } - if (screenshot.present) { - map['screenshot'] = Variable(screenshot.value); - } - if (rowid.present) { - map['rowid'] = Variable(rowid.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('TabCompanion(') - ..write('id: $id, ') - ..write('topicId: $topicId, ') - ..write('timestamp: $timestamp, ') - ..write('url: $url, ') - ..write('title: $title, ') - ..write('screenshot: $screenshot, ') - ..write('rowid: $rowid') - ..write(')')) - .toString(); - } -} - -abstract class _$TabDatabase extends GeneratedDatabase { - _$TabDatabase(QueryExecutor e) : super(e); - $TabDatabaseManager get managers => $TabDatabaseManager(this); - late final Topic topic = Topic(this); - late final Tab tab = Tab(this); - late final TopicDao topicDao = TopicDao(this as TabDatabase); - late final TabDao tabDao = TabDao(this as TabDatabase); - Selectable topicsWithCount() { - return customSelect( - 'SELECT topic.*, tab_agg.tab_count FROM topic LEFT JOIN (SELECT topic_id, COUNT(*) AS tab_count, MAX(timestamp) AS last_updated FROM tab GROUP BY topic_id) AS tab_agg ON topic.id = tab_agg.topic_id ORDER BY tab_agg.last_updated DESC NULLS FIRST', - variables: [], - readsFrom: { - topic, - tab, - }).map((QueryRow row) => TopicDataWithCount( - id: row.read('id'), - name: row.readNullable('name'), - color: Topic.$convertercolor.fromSql(row.read('color')), - tabCount: row.readNullable('tab_count'), - )); - } - - @override - Iterable> get allTables => - allSchemaEntities.whereType>(); - @override - List get allSchemaEntities => [topic, tab]; - @override - StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( - [ - WritePropagation( - on: TableUpdateQuery.onTableName('topic', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('tab', kind: UpdateKind.delete), - ], - ), - ], - ); -} - -typedef $TopicCreateCompanionBuilder = TopicCompanion Function({ - required String id, - Value name, - required Color color, - Value rowid, -}); -typedef $TopicUpdateCompanionBuilder = TopicCompanion Function({ - Value id, - Value name, - Value color, - Value rowid, -}); - -final class $TopicReferences - extends BaseReferences<_$TabDatabase, Topic, TopicData> { - $TopicReferences(super.$_db, super.$_table, super.$_typedResult); - - static MultiTypedResultKey> _tabRefsTable( - _$TabDatabase db) => - MultiTypedResultKey.fromTable(db.tab, - aliasName: $_aliasNameGenerator(db.topic.id, db.tab.topicId)); - - $TabProcessedTableManager get tabRefs { - final manager = - $TabTableManager($_db, $_db.tab).filter((f) => f.topicId.id($_item.id)); - - final cache = $_typedResult.readTableOrNull(_tabRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); - } -} - -class $TopicFilterComposer extends FilterComposer<_$TabDatabase, Topic> { - $TopicFilterComposer(super.$state); - ColumnFilters get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get name => $state.composableBuilder( - column: $state.table.name, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnWithTypeConverterFilters get color => - $state.composableBuilder( - column: $state.table.color, - builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( - column, - joinBuilders: joinBuilders)); - - ComposableFilter tabRefs(ComposableFilter Function($TabFilterComposer f) f) { - final $TabFilterComposer composer = $state.composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $state.db.tab, - getReferencedColumn: (t) => t.topicId, - builder: (joinBuilder, parentComposers) => $TabFilterComposer( - ComposerState( - $state.db, $state.db.tab, joinBuilder, parentComposers))); - return f(composer); - } -} - -class $TopicOrderingComposer extends OrderingComposer<_$TabDatabase, Topic> { - $TopicOrderingComposer(super.$state); - ColumnOrderings get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get name => $state.composableBuilder( - column: $state.table.name, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get color => $state.composableBuilder( - column: $state.table.color, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); -} - -class $TopicTableManager extends RootTableManager< - _$TabDatabase, - Topic, - TopicData, - $TopicFilterComposer, - $TopicOrderingComposer, - $TopicCreateCompanionBuilder, - $TopicUpdateCompanionBuilder, - (TopicData, $TopicReferences), - TopicData, - PrefetchHooks Function({bool tabRefs})> { - $TopicTableManager(_$TabDatabase db, Topic table) - : super(TableManagerState( - db: db, - table: table, - filteringComposer: $TopicFilterComposer(ComposerState(db, table)), - orderingComposer: $TopicOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value name = const Value.absent(), - Value color = const Value.absent(), - Value rowid = const Value.absent(), - }) => - TopicCompanion( - id: id, - name: name, - color: color, - rowid: rowid, - ), - createCompanionCallback: ({ - required String id, - Value name = const Value.absent(), - required Color color, - Value rowid = const Value.absent(), - }) => - TopicCompanion.insert( - id: id, - name: name, - color: color, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), $TopicReferences(db, table, e))) - .toList(), - prefetchHooksCallback: ({tabRefs = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [if (tabRefs) db.tab], - addJoins: null, - getPrefetchedDataCallback: (items) async { - return [ - if (tabRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: $TopicReferences._tabRefsTable(db), - managerFromTypedResult: (p0) => - $TopicReferences(db, table, p0).tabRefs, - referencedItemsForCurrentItem: (item, - referencedItems) => - referencedItems.where((e) => e.topicId == item.id), - typedResults: items) - ]; - }, - ); - }, - )); -} - -typedef $TopicProcessedTableManager = ProcessedTableManager< - _$TabDatabase, - Topic, - TopicData, - $TopicFilterComposer, - $TopicOrderingComposer, - $TopicCreateCompanionBuilder, - $TopicUpdateCompanionBuilder, - (TopicData, $TopicReferences), - TopicData, - PrefetchHooks Function({bool tabRefs})>; -typedef $TabCreateCompanionBuilder = TabCompanion Function({ - required String id, - Value topicId, - required DateTime timestamp, - required Uri url, - Value title, - Value screenshot, - Value rowid, -}); -typedef $TabUpdateCompanionBuilder = TabCompanion Function({ - Value id, - Value topicId, - Value timestamp, - Value url, - Value title, - Value screenshot, - Value rowid, -}); - -final class $TabReferences extends BaseReferences<_$TabDatabase, Tab, TabData> { - $TabReferences(super.$_db, super.$_table, super.$_typedResult); - - static Topic _topicIdTable(_$TabDatabase db) => - db.topic.createAlias($_aliasNameGenerator(db.tab.topicId, db.topic.id)); - - $TopicProcessedTableManager? get topicId { - if ($_item.topicId == null) return null; - final manager = $TopicTableManager($_db, $_db.topic) - .filter((f) => f.id($_item.topicId!)); - final item = $_typedResult.readTableOrNull(_topicIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); - } -} - -class $TabFilterComposer extends FilterComposer<_$TabDatabase, Tab> { - $TabFilterComposer(super.$state); - ColumnFilters get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get timestamp => $state.composableBuilder( - column: $state.table.timestamp, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnWithTypeConverterFilters get url => - $state.composableBuilder( - column: $state.table.url, - builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( - column, - joinBuilders: joinBuilders)); - - ColumnFilters get title => $state.composableBuilder( - column: $state.table.title, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get screenshot => $state.composableBuilder( - column: $state.table.screenshot, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - $TopicFilterComposer get topicId { - final $TopicFilterComposer composer = $state.composerBuilder( - composer: this, - getCurrentColumn: (t) => t.topicId, - referencedTable: $state.db.topic, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, parentComposers) => $TopicFilterComposer( - ComposerState( - $state.db, $state.db.topic, joinBuilder, parentComposers))); - return composer; - } -} - -class $TabOrderingComposer extends OrderingComposer<_$TabDatabase, Tab> { - $TabOrderingComposer(super.$state); - ColumnOrderings get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get timestamp => $state.composableBuilder( - column: $state.table.timestamp, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get url => $state.composableBuilder( - column: $state.table.url, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get title => $state.composableBuilder( - column: $state.table.title, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get screenshot => $state.composableBuilder( - column: $state.table.screenshot, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - $TopicOrderingComposer get topicId { - final $TopicOrderingComposer composer = $state.composerBuilder( - composer: this, - getCurrentColumn: (t) => t.topicId, - referencedTable: $state.db.topic, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, parentComposers) => $TopicOrderingComposer( - ComposerState( - $state.db, $state.db.topic, joinBuilder, parentComposers))); - return composer; - } -} - -class $TabTableManager extends RootTableManager< - _$TabDatabase, - Tab, - TabData, - $TabFilterComposer, - $TabOrderingComposer, - $TabCreateCompanionBuilder, - $TabUpdateCompanionBuilder, - (TabData, $TabReferences), - TabData, - PrefetchHooks Function({bool topicId})> { - $TabTableManager(_$TabDatabase db, Tab table) - : super(TableManagerState( - db: db, - table: table, - filteringComposer: $TabFilterComposer(ComposerState(db, table)), - orderingComposer: $TabOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value topicId = const Value.absent(), - Value timestamp = const Value.absent(), - Value url = const Value.absent(), - Value title = const Value.absent(), - Value screenshot = const Value.absent(), - Value rowid = const Value.absent(), - }) => - TabCompanion( - id: id, - topicId: topicId, - timestamp: timestamp, - url: url, - title: title, - screenshot: screenshot, - rowid: rowid, - ), - createCompanionCallback: ({ - required String id, - Value topicId = const Value.absent(), - required DateTime timestamp, - required Uri url, - Value title = const Value.absent(), - Value screenshot = const Value.absent(), - Value rowid = const Value.absent(), - }) => - TabCompanion.insert( - id: id, - topicId: topicId, - timestamp: timestamp, - url: url, - title: title, - screenshot: screenshot, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), $TabReferences(db, table, e))) - .toList(), - prefetchHooksCallback: ({topicId = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic>>(state) { - if (topicId) { - state = state.withJoin( - currentTable: table, - currentColumn: table.topicId, - referencedTable: $TabReferences._topicIdTable(db), - referencedColumn: $TabReferences._topicIdTable(db).id, - ) as T; - } - - return state; - }, - getPrefetchedDataCallback: (items) async { - return []; - }, - ); - }, - )); -} - -typedef $TabProcessedTableManager = ProcessedTableManager< - _$TabDatabase, - Tab, - TabData, - $TabFilterComposer, - $TabOrderingComposer, - $TabCreateCompanionBuilder, - $TabUpdateCompanionBuilder, - (TabData, $TabReferences), - TabData, - PrefetchHooks Function({bool topicId})>; - -class $TabDatabaseManager { - final _$TabDatabase _db; - $TabDatabaseManager(this._db); - $TopicTableManager get topic => $TopicTableManager(_db, _db.topic); - $TabTableManager get tab => $TabTableManager(_db, _db.tab); -} diff --git a/app/lib/features/topics/data/database/drift/converters/color.dart b/app/lib/features/topics/data/database/drift/converters/color.dart deleted file mode 100644 index 74a2df06..00000000 --- a/app/lib/features/topics/data/database/drift/converters/color.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:ui'; - -import 'package:drift/drift.dart'; - -class ColorConverter extends TypeConverter { - const ColorConverter(); - - @override - Color fromSql(int fromDb) { - return Color(fromDb); - } - - @override - int toSql(Color value) { - return value.value; - } -} diff --git a/app/lib/features/topics/data/database/drift/converters/uri.dart b/app/lib/features/topics/data/database/drift/converters/uri.dart deleted file mode 100644 index cb39388b..00000000 --- a/app/lib/features/topics/data/database/drift/converters/uri.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:lensai/utils/uri_parser.dart' as uri_parser; - -class UriConverter extends TypeConverter { - const UriConverter(); - - @override - Uri fromSql(String fromDb) { - return uri_parser.tryParseUrl(fromDb, eagerParsing: true)!; - } - - @override - String toSql(Uri value) { - return value.toString(); - } -} diff --git a/app/lib/features/topics/data/models/topic_data.dart b/app/lib/features/topics/data/models/topic_data.dart deleted file mode 100644 index cd1a2d14..00000000 --- a/app/lib/features/topics/data/models/topic_data.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:ui'; - -import 'package:fast_equatable/fast_equatable.dart'; - -class TopicData with FastEquatable { - final String id; - final String? name; - final Color color; - - TopicData({required this.id, this.name, required this.color}); - - @override - bool get cacheHash => true; - - @override - List get hashParameters => [ - id, - name, - color, - ]; -} - -class TopicDataWithCount extends TopicData { - final int? tabCount; - - TopicDataWithCount({ - required super.id, - super.name, - required super.color, - required this.tabCount, - }); - - @override - bool get cacheHash => true; - - @override - List get hashParameters => [ - ...super.hashParameters, - tabCount, - ]; -} diff --git a/app/lib/features/topics/data/providers.g.dart b/app/lib/features/topics/data/providers.g.dart deleted file mode 100644 index 4682778d..00000000 --- a/app/lib/features/topics/data/providers.g.dart +++ /dev/null @@ -1,299 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'providers.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$tabDatabaseHash() => r'b886b089ae5fdb9bdcc0982efe5fcbe9bc66486f'; - -/// See also [tabDatabase]. -@ProviderFor(tabDatabase) -final tabDatabaseProvider = Provider.internal( - tabDatabase, - name: r'tabDatabaseProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$tabDatabaseHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef TabDatabaseRef = ProviderRef; -String _$isTabExistingHash() => r'98fc7bf073ba8f73c20df6ba7076672ff497246f'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [isTabExisting]. -@ProviderFor(isTabExisting) -const isTabExistingProvider = IsTabExistingFamily(); - -/// See also [isTabExisting]. -class IsTabExistingFamily extends Family> { - /// See also [isTabExisting]. - const IsTabExistingFamily(); - - /// See also [isTabExisting]. - IsTabExistingProvider call( - String tabId, - ) { - return IsTabExistingProvider( - tabId, - ); - } - - @override - IsTabExistingProvider getProviderOverride( - covariant IsTabExistingProvider provider, - ) { - return call( - provider.tabId, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'isTabExistingProvider'; -} - -/// See also [isTabExisting]. -class IsTabExistingProvider extends AutoDisposeStreamProvider { - /// See also [isTabExisting]. - IsTabExistingProvider( - String tabId, - ) : this._internal( - (ref) => isTabExisting( - ref as IsTabExistingRef, - tabId, - ), - from: isTabExistingProvider, - name: r'isTabExistingProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$isTabExistingHash, - dependencies: IsTabExistingFamily._dependencies, - allTransitiveDependencies: - IsTabExistingFamily._allTransitiveDependencies, - tabId: tabId, - ); - - IsTabExistingProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.tabId, - }) : super.internal(); - - final String tabId; - - @override - Override overrideWith( - Stream Function(IsTabExistingRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: IsTabExistingProvider._internal( - (ref) => create(ref as IsTabExistingRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - tabId: tabId, - ), - ); - } - - @override - AutoDisposeStreamProviderElement createElement() { - return _IsTabExistingProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is IsTabExistingProvider && other.tabId == tabId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, tabId.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin IsTabExistingRef on AutoDisposeStreamProviderRef { - /// The parameter `tabId` of this provider. - String get tabId; -} - -class _IsTabExistingProviderElement - extends AutoDisposeStreamProviderElement with IsTabExistingRef { - _IsTabExistingProviderElement(super.provider); - - @override - String get tabId => (origin as IsTabExistingProvider).tabId; -} - -String _$tabDataHash() => r'8b799386305d0c31103bc48a5a467df41d57d065'; - -/// See also [tabData]. -@ProviderFor(tabData) -const tabDataProvider = TabDataFamily(); - -/// See also [tabData]. -class TabDataFamily extends Family> { - /// See also [tabData]. - const TabDataFamily(); - - /// See also [tabData]. - TabDataProvider call( - String tabId, - ) { - return TabDataProvider( - tabId, - ); - } - - @override - TabDataProvider getProviderOverride( - covariant TabDataProvider provider, - ) { - return call( - provider.tabId, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'tabDataProvider'; -} - -/// See also [tabData]. -class TabDataProvider extends AutoDisposeStreamProvider { - /// See also [tabData]. - TabDataProvider( - String tabId, - ) : this._internal( - (ref) => tabData( - ref as TabDataRef, - tabId, - ), - from: tabDataProvider, - name: r'tabDataProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$tabDataHash, - dependencies: TabDataFamily._dependencies, - allTransitiveDependencies: TabDataFamily._allTransitiveDependencies, - tabId: tabId, - ); - - TabDataProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.tabId, - }) : super.internal(); - - final String tabId; - - @override - Override overrideWith( - Stream Function(TabDataRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: TabDataProvider._internal( - (ref) => create(ref as TabDataRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - tabId: tabId, - ), - ); - } - - @override - AutoDisposeStreamProviderElement createElement() { - return _TabDataProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is TabDataProvider && other.tabId == tabId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, tabId.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin TabDataRef on AutoDisposeStreamProviderRef { - /// The parameter `tabId` of this provider. - String get tabId; -} - -class _TabDataProviderElement extends AutoDisposeStreamProviderElement - with TabDataRef { - _TabDataProviderElement(super.provider); - - @override - String get tabId => (origin as TabDataProvider).tabId; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/domain/providers.dart b/app/lib/features/topics/domain/providers.dart deleted file mode 100644 index d58dea5a..00000000 --- a/app/lib/features/topics/domain/providers.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'dart:ui'; - -import 'package:collection/collection.dart'; -import 'package:lensai/features/topics/data/models/topic_data.dart'; -import 'package:lensai/features/topics/data/providers.dart'; -import 'package:lensai/features/topics/domain/repositories/topic.dart'; -import 'package:lensai/features/topics/utils/color_palette.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'providers.g.dart'; - -@Riverpod(keepAlive: true) -class SelectedTopic extends _$SelectedTopic { - void setTopic(String id) { - state = id; - } - - void toggleTopic(String id) { - if (state == id) { - clearTopic(); - } else { - setTopic(id); - } - } - - void clearTopic() { - state = null; - } - - @override - String? build() { - return null; - } -} - -@Riverpod() -Stream selectedTopicData(SelectedTopicDataRef ref) { - final db = ref.watch(tabDatabaseProvider); - final selectedTopic = ref.watch(selectedTopicProvider); - - if (selectedTopic != null) { - return db.topicDao.getTopicData(selectedTopic).watchSingleOrNull(); - } - - return Stream.value(null); -} - -@Riverpod() -Future unusedRandomTopicColor(UnusedRandomTopicColorRef ref) async { - final repository = ref.watch(topicRepositoryProvider.notifier); - - final allColors = colorTypes.flattened.toList(); - final usedColors = await repository.getDistinctColors(); - - Color randomColor; - do { - randomColor = randomColorShade(allColors); - } while (usedColors.contains(randomColor)); - - return randomColor; -} diff --git a/app/lib/features/topics/domain/providers.g.dart b/app/lib/features/topics/domain/providers.g.dart deleted file mode 100644 index 89808504..00000000 --- a/app/lib/features/topics/domain/providers.g.dart +++ /dev/null @@ -1,58 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'providers.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$selectedTopicDataHash() => r'fa450784052498014f592c356bde4ff5ec675281'; - -/// See also [selectedTopicData]. -@ProviderFor(selectedTopicData) -final selectedTopicDataProvider = - AutoDisposeStreamProvider.internal( - selectedTopicData, - name: r'selectedTopicDataProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$selectedTopicDataHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef SelectedTopicDataRef = AutoDisposeStreamProviderRef; -String _$unusedRandomTopicColorHash() => - r'099730b0d987cc37bcae4ef5c999cbebfa67d7da'; - -/// See also [unusedRandomTopicColor]. -@ProviderFor(unusedRandomTopicColor) -final unusedRandomTopicColorProvider = - AutoDisposeFutureProvider.internal( - unusedRandomTopicColor, - name: r'unusedRandomTopicColorProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$unusedRandomTopicColorHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef UnusedRandomTopicColorRef = AutoDisposeFutureProviderRef; -String _$selectedTopicHash() => r'f4e1c0620971a0b7501ee9aff0d5b41da3130ba0'; - -/// See also [SelectedTopic]. -@ProviderFor(SelectedTopic) -final selectedTopicProvider = NotifierProvider.internal( - SelectedTopic.new, - name: r'selectedTopicProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$selectedTopicHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$SelectedTopic = Notifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/domain/repositories/tab.dart b/app/lib/features/topics/domain/repositories/tab.dart deleted file mode 100644 index 4e6b5b94..00000000 --- a/app/lib/features/topics/domain/repositories/tab.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:lensai/features/topics/data/database/database.dart'; -import 'package:lensai/features/topics/data/providers.dart'; -import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'tab.g.dart'; - -@Riverpod(keepAlive: true) -class TabRepository extends _$TabRepository { - late TabDatabase _db; - - @override - Stream> build() { - _db = ref.watch(tabDatabaseProvider); - return _db.tabDao.watchTabs(); - } - - Future updateTab( - String id, { - Value url = const Value.absent(), - Value title = const Value.absent(), - Value topicId = const Value.absent(), - Value screenshot = const Value.absent(), - }) { - return _db.tabDao.updateTab( - id, - timestamp: DateTime.now(), - url: url, - title: title, - topicId: topicId, - screenshot: screenshot, - ); - } - - Future deleteTab(String id) { - return _db.tabDao.deleteTab(id); - } -} - -@Riverpod() -class TopicTabRepository extends _$TopicTabRepository { - late TabDatabase _db; - - @override - Stream> build(String? topicId) { - _db = ref.watch(tabDatabaseProvider); - return _db.tabDao.watchTopicTabs(topicId); - } - - Future addTab(ITab tab) { - assert(tab.topicId == topicId); - return _db.tabDao.upsertTab(tab, DateTime.now()); - } - - Future closeAllTabs() { - return _db.tabDao.deleteTopicTabs(topicId); - } -} diff --git a/app/lib/features/topics/domain/repositories/tab.g.dart b/app/lib/features/topics/domain/repositories/tab.g.dart deleted file mode 100644 index 36169d8c..00000000 --- a/app/lib/features/topics/domain/repositories/tab.g.dart +++ /dev/null @@ -1,192 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'tab.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$tabRepositoryHash() => r'454e182a99910512be72279f68caa0af125ed165'; - -/// See also [TabRepository]. -@ProviderFor(TabRepository) -final tabRepositoryProvider = - StreamNotifierProvider>.internal( - TabRepository.new, - name: r'tabRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$tabRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$TabRepository = StreamNotifier>; -String _$topicTabRepositoryHash() => - r'52ceb013e0b31046fdd0a00d031392ba84b9ecda'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -abstract class _$TopicTabRepository - extends BuildlessAutoDisposeStreamNotifier> { - late final String? topicId; - - Stream> build( - String? topicId, - ); -} - -/// See also [TopicTabRepository]. -@ProviderFor(TopicTabRepository) -const topicTabRepositoryProvider = TopicTabRepositoryFamily(); - -/// See also [TopicTabRepository]. -class TopicTabRepositoryFamily extends Family>> { - /// See also [TopicTabRepository]. - const TopicTabRepositoryFamily(); - - /// See also [TopicTabRepository]. - TopicTabRepositoryProvider call( - String? topicId, - ) { - return TopicTabRepositoryProvider( - topicId, - ); - } - - @override - TopicTabRepositoryProvider getProviderOverride( - covariant TopicTabRepositoryProvider provider, - ) { - return call( - provider.topicId, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'topicTabRepositoryProvider'; -} - -/// See also [TopicTabRepository]. -class TopicTabRepositoryProvider extends AutoDisposeStreamNotifierProviderImpl< - TopicTabRepository, List> { - /// See also [TopicTabRepository]. - TopicTabRepositoryProvider( - String? topicId, - ) : this._internal( - () => TopicTabRepository()..topicId = topicId, - from: topicTabRepositoryProvider, - name: r'topicTabRepositoryProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$topicTabRepositoryHash, - dependencies: TopicTabRepositoryFamily._dependencies, - allTransitiveDependencies: - TopicTabRepositoryFamily._allTransitiveDependencies, - topicId: topicId, - ); - - TopicTabRepositoryProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.topicId, - }) : super.internal(); - - final String? topicId; - - @override - Stream> runNotifierBuild( - covariant TopicTabRepository notifier, - ) { - return notifier.build( - topicId, - ); - } - - @override - Override overrideWith(TopicTabRepository Function() create) { - return ProviderOverride( - origin: this, - override: TopicTabRepositoryProvider._internal( - () => create()..topicId = topicId, - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - topicId: topicId, - ), - ); - } - - @override - AutoDisposeStreamNotifierProviderElement> - createElement() { - return _TopicTabRepositoryProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is TopicTabRepositoryProvider && other.topicId == topicId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, topicId.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin TopicTabRepositoryRef - on AutoDisposeStreamNotifierProviderRef> { - /// The parameter `topicId` of this provider. - String? get topicId; -} - -class _TopicTabRepositoryProviderElement - extends AutoDisposeStreamNotifierProviderElement> with TopicTabRepositoryRef { - _TopicTabRepositoryProviderElement(super.provider); - - @override - String? get topicId => (origin as TopicTabRepositoryProvider).topicId; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/domain/repositories/topic.dart b/app/lib/features/topics/domain/repositories/topic.dart deleted file mode 100644 index 5321cb12..00000000 --- a/app/lib/features/topics/domain/repositories/topic.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:ui'; - -import 'package:lensai/features/topics/data/database/database.dart'; -import 'package:lensai/features/topics/data/models/topic_data.dart'; -import 'package:lensai/features/topics/data/providers.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'topic.g.dart'; - -@Riverpod(keepAlive: true) -class TopicRepository extends _$TopicRepository { - late TabDatabase _db; - - @override - Stream> build() { - _db = ref.watch(tabDatabaseProvider); - return _db.topicsWithCount().watch(); - } - - Future addTopic({required String? name, required Color color}) { - return _db.topicDao.addTopic(name: name, color: color); - } - - Future replaceTopic({ - required String id, - required String? name, - required Color color, - }) { - return _db.topicDao.replaceTopic(id, name: name, color: color); - } - - Future deleteTopic(String id) { - return _db.topicDao.deleteTopic(id); - } - - Future> getDistinctColors() { - return _db.topicDao - .getDistinctColors() - .get() - .then((colors) => colors.toSet()); - } -} diff --git a/app/lib/features/topics/domain/repositories/topic.g.dart b/app/lib/features/topics/domain/repositories/topic.g.dart deleted file mode 100644 index d1cff0fa..00000000 --- a/app/lib/features/topics/domain/repositories/topic.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'topic.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$topicRepositoryHash() => r'3b75c5fb1a081ba6d90e2ccb781469d0aa87c415'; - -/// See also [TopicRepository]. -@ProviderFor(TopicRepository) -final topicRepositoryProvider = - StreamNotifierProvider>.internal( - TopicRepository.new, - name: r'topicRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$topicRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$TopicRepository = StreamNotifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/presentation/screens/topic_list.dart b/app/lib/features/topics/presentation/screens/topic_list.dart deleted file mode 100644 index 3d6ab4d3..00000000 --- a/app/lib/features/topics/presentation/screens/topic_list.dart +++ /dev/null @@ -1,210 +0,0 @@ -import 'package:fading_scroll/fading_scroll.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/features/topics/data/models/topic_data.dart'; -import 'package:lensai/features/topics/domain/providers.dart'; -import 'package:lensai/features/topics/domain/repositories/topic.dart'; -import 'package:lensai/features/topics/presentation/widgets/topic_dialog.dart'; -import 'package:skeletonizer/skeletonizer.dart'; - -class _TopicTile extends HookWidget { - final TopicData topic; - final bool isSelected; - - final void Function(TopicResult edited) onEdit; - final void Function() onDelete; - final void Function() onTap; - - const _TopicTile( - this.topic, { - required this.isSelected, - required this.onEdit, - required this.onDelete, - required this.onTap, - super.key, - }); - - @override - Widget build(BuildContext context) { - final menuController = useMemoized(() => MenuController()); - - return ListTile( - selected: isSelected, - leading: CircleAvatar(backgroundColor: topic.color), - title: Text(topic.name ?? 'New Topic'), - trailing: MenuAnchor( - controller: menuController, - builder: (context, controller, child) { - return Padding( - padding: const EdgeInsets.only(right: 4.0), - child: InkWell( - onTap: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 15.0, horizontal: 8.0), - child: Icon(Icons.more_vert), - ), - ), - ); - }, - menuChildren: [ - MenuItemButton( - onPressed: () async { - final result = await showDialog( - context: context, - builder: (context) => TopicDialog.edit( - name: topic.name, - initialColor: topic.color, - ), - ); - - if (result != null) { - onEdit(result); - } - }, - leadingIcon: const Icon(Icons.edit), - child: const Text('Edit'), - ), - MenuItemButton( - onPressed: () async { - final result = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Delete Topic'), - content: const Text( - 'Are you sure you want to delete this topic and all attached tabs?', - ), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context, false); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - Navigator.pop(context, true); - }, - child: const Text('Delete'), - ), - ], - ); - }, - ); - - if (result == true) { - onDelete(); - } - }, - leadingIcon: const Icon(Icons.delete), - child: const Text('Delete'), - ), - ], - ), - onTap: onTap, - ); - } -} - -class TopicListScreen extends HookConsumerWidget { - const TopicListScreen(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar( - title: const Text('Topics'), - actions: [ - IconButton( - onPressed: () async { - final initialColor = - await ref.read(unusedRandomTopicColorProvider.future); - - if (context.mounted) { - final result = await showDialog( - context: context, - builder: (context) => TopicDialog.create( - initialColor: initialColor, - ), - ); - - if (result != null) { - await ref - .read(topicRepositoryProvider.notifier) - .addTopic(name: result.name, color: result.color); - } - } - }, - icon: const Icon(Icons.add), - ), - ], - ), - body: HookConsumer( - builder: (context, ref, child) { - final topicsAsync = ref.watch(topicRepositoryProvider); - final selectedTopic = ref.watch(selectedTopicProvider); - - return Skeletonizer( - enabled: topicsAsync.isLoading, - child: topicsAsync.when( - data: (topics) => FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView.builder( - controller: controller, - itemCount: topics.length, - itemBuilder: (context, index) { - final topic = topics[index]; - return _TopicTile( - topic, - key: ValueKey(topic.id), - isSelected: topic.id == selectedTopic, - onEdit: (edited) async { - await ref - .read(topicRepositoryProvider.notifier) - .replaceTopic( - id: topic.id, - name: edited.name, - color: edited.color, - ); - }, - onDelete: () async { - await ref - .read(topicRepositoryProvider.notifier) - .deleteTopic(topic.id); - }, - onTap: () { - ref - .read(selectedTopicProvider.notifier) - .toggleTopic(topic.id); - }, - ); - }, - ); - }, - ), - error: (error, stackTrace) => SizedBox.shrink(), - loading: () => ListView.builder( - itemCount: 3, - itemBuilder: (context, index) => _TopicTile( - TopicData(id: 'null', color: Colors.transparent), - isSelected: false, - onEdit: (_) {}, - onDelete: () {}, - onTap: () {}, - ), - ), - ), - ); - }, - ), - ); - } -} diff --git a/app/lib/features/topics/presentation/widgets/topic_chips.dart b/app/lib/features/topics/presentation/widgets/topic_chips.dart deleted file mode 100644 index 14939f92..00000000 --- a/app/lib/features/topics/presentation/widgets/topic_chips.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/routing/routes.dart'; -import 'package:lensai/features/topics/domain/providers.dart'; -import 'package:lensai/features/topics/domain/repositories/topic.dart'; -import 'package:lensai/presentation/widgets/selectable_chips.dart'; - -class TopicChips extends HookConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final topicsAsync = ref.watch(topicRepositoryProvider); - final selectedTopic = ref - .watch(selectedTopicDataProvider.select((value) => value.valueOrNull)); - - return topicsAsync.when( - data: (availableTopics) => SizedBox( - height: 48, - child: Row( - children: [ - const SizedBox(width: 16), - if (selectedTopic != null || availableTopics.isNotEmpty) - Expanded( - child: SelectableChips( - deleteIcon: false, - itemId: (topic) => topic.id, - itemAvatar: (topic) => Container( - width: 20.0, - height: 20.0, - decoration: BoxDecoration( - color: topic.color, - shape: BoxShape.circle, - ), - ), - itemLabel: (topic) => Text(topic.name ?? 'New Topic'), - itemBadgeCount: (topic) => topic.tabCount, - availableItems: availableTopics, - selectedItem: selectedTopic, - onSelected: (topic) { - ref.read(selectedTopicProvider.notifier).setTopic(topic.id); - }, - onDeleted: (topic) async { - ref.read(selectedTopicProvider.notifier).clearTopic(); - }, - ), - ) - else - Expanded( - child: Text( - "Press '>' to manage Topics.", - style: TextStyle( - color: Theme.of(context).hintColor, - fontStyle: FontStyle.italic, - ), - ), - ), - IconButton( - onPressed: () async { - await context.push(TopicListRoute().location); - }, - icon: const Icon(Icons.chevron_right), - ), - ], - ), - ), - error: (error, stackTrace) => const SizedBox.shrink(), - loading: () => const SizedBox( - height: 48, - width: double.infinity, - ), - ); - } -} diff --git a/app/lib/features/topics/presentation/widgets/topic_dialog.dart b/app/lib/features/topics/presentation/widgets/topic_dialog.dart deleted file mode 100644 index 6ecc4c7b..00000000 --- a/app/lib/features/topics/presentation/widgets/topic_dialog.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:lensai/features/topics/presentation/widgets/material_color_picker.dart'; - -typedef TopicResult = ({String? name, Color color}); - -enum _DialogMode { create, edit } - -class TopicDialog extends HookWidget { - final _DialogMode _mode; - - final String? initialName; - final Color initialColor; - - const TopicDialog._({ - required _DialogMode mode, - required this.initialColor, - this.initialName, - }) : _mode = mode; - - factory TopicDialog.create({required Color initialColor}) { - return TopicDialog._( - mode: _DialogMode.create, - initialColor: initialColor, - ); - } - - factory TopicDialog.edit({ - required String? name, - required Color initialColor, - }) { - return TopicDialog._( - mode: _DialogMode.edit, - initialColor: initialColor, - initialName: name, - ); - } - - @override - Widget build(BuildContext context) { - final selectedColor = useState(initialColor); - final textController = useTextEditingController(text: initialName); - - return SimpleDialog( - titlePadding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 16.0), - contentPadding: const EdgeInsets.only( - left: 20.0, - right: 20.0, - bottom: 24.0, - ), - insetPadding: const EdgeInsets.symmetric( - horizontal: 20.0, - vertical: 24.0, - ), - title: Text( - switch (_mode) { - _DialogMode.create => 'New Topic', - _DialogMode.edit => 'Edit Topic', - }, - ), - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: TextField( - decoration: InputDecoration( - prefixIcon: Padding( - padding: const EdgeInsets.all(10.0), - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: 24, - width: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: selectedColor.value, - ), - ), - ), - label: const Text('Name'), - ), - controller: textController, - ), - ), - const SizedBox(height: 16), - MaterialPicker( - pickerColor: selectedColor.value, - onColorChanged: (value) { - selectedColor.value = value; - }, - ), - const SizedBox(height: 24), - OverflowBar( - alignment: MainAxisAlignment.end, - spacing: 8.0, - children: [ - TextButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - final name = textController.text.trim(); - Navigator.pop( - context, - ( - name: name.isNotEmpty ? name : null, - color: selectedColor.value, - ), - ); - }, - child: Text( - switch (_mode) { - _DialogMode.create => 'Add', - _DialogMode.edit => 'Edit', - }, - ), - ), - ], - ), - ], - ); - } -} diff --git a/app/lib/features/tor/domain/services/tor_proxy.dart b/app/lib/features/tor/domain/services/tor_proxy.dart new file mode 100644 index 00000000..3a42c3c1 --- /dev/null +++ b/app/lib/features/tor/domain/services/tor_proxy.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers/lifecycle.dart'; +import 'package:weblibre/features/tor/utils/tor_entrypoint.dart'; + +part 'tor_proxy.g.dart'; + +class _TorService { + final service = FlutterBackgroundService(); + + final _portSubject = BehaviorSubject.seeded(null); + + ValueStream get portStream => _portSubject.stream; + + Future start() async { + await service.startService(); + } + + Future requestSync() async { + service.invoke("sync"); + } + + Future sendHeartbeat() async { + service.invoke("heartbeat"); + } + + Future stop() async { + service.invoke("stop"); + } + + Future initializeService() async { + await service.configure( + iosConfiguration: IosConfiguration(autoStart: false), + androidConfiguration: AndroidConfiguration( + autoStart: false, + onStart: onStart, + isForegroundMode: true, + autoStartOnBoot: false, + initialNotificationTitle: 'Tor Service', + initialNotificationContent: 'Running in background', + ), + ); + + service.on('portUpdate').listen((data) { + final port = data!['port'] as int; + _portSubject.add((port > -1) ? port : null); + }); + } + + Future dispose() async { + await stop(); + await _portSubject.close(); + } +} + +@Riverpod(keepAlive: true) +class TorProxyService extends _$TorProxyService { + final _tor = _TorService(); + + Timer? _heartbeatUpdate; + //This is managed by widget state changes to resume a timer + bool _timerPaused = false; + + void _enableHeartbeatTimer() { + _heartbeatUpdate?.cancel(); + _timerPaused = false; + _heartbeatUpdate = Timer.periodic(const Duration(seconds: 30), ( + timer, + ) async { + await _tor.sendHeartbeat(); + }); + } + + Future connect({bool forceReconnect = false}) async { + final currentPort = await requestSync(); + + if (currentPort == null || forceReconnect) { + state = const AsyncLoading(); + await _tor.start(); + _enableHeartbeatTimer(); + } + } + + Future requestSync() async { + final nextPortUpdate = _tor.portStream.first; + + await _tor.requestSync(); + return nextPortUpdate; + } + + Future disconnect() async { + state = const AsyncLoading(); + + _heartbeatUpdate?.cancel(); + _heartbeatUpdate = null; + _timerPaused = false; + + await _tor.stop(); + } + + @override + Future build() async { + final portSub = _tor.portStream.distinct().listen((port) { + state = AsyncData(port); + }); + + await _tor.initializeService(); + + ref.listen( + fireImmediately: true, + browserViewLifecycleProvider, + (previous, next) { + switch (next) { + case AppLifecycleState.resumed: + if (_timerPaused) { + _enableHeartbeatTimer(); + } + case AppLifecycleState.detached: + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + case null: + if (_heartbeatUpdate?.isActive == true) { + _heartbeatUpdate?.cancel(); + _timerPaused = true; + } + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to browserViewLifecycleProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.onDispose(() async { + _heartbeatUpdate?.cancel(); + _heartbeatUpdate = null; + _timerPaused = false; + + await portSub.cancel(); + await _tor.dispose(); + }); + + return _tor.portStream.valueOrNull; + } +} diff --git a/app/lib/features/tor/domain/services/tor_proxy.g.dart b/app/lib/features/tor/domain/services/tor_proxy.g.dart new file mode 100644 index 00000000..25eb9524 --- /dev/null +++ b/app/lib/features/tor/domain/services/tor_proxy.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tor_proxy.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$torProxyServiceHash() => r'f991652e4e228d3e7fdc09a6088b6c5427d6231c'; + +/// See also [TorProxyService]. +@ProviderFor(TorProxyService) +final torProxyServiceProvider = + AsyncNotifierProvider.internal( + TorProxyService.new, + name: r'torProxyServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$torProxyServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$TorProxyService = AsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/tor/presentation/controllers/start_tor_proxy.dart b/app/lib/features/tor/presentation/controllers/start_tor_proxy.dart new file mode 100644 index 00000000..d57119ca --- /dev/null +++ b/app/lib/features/tor/presentation/controllers/start_tor_proxy.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart'; +import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; +import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart'; +import 'package:weblibre/features/tor/presentation/widgets/tor_notification.dart'; + +part 'start_tor_proxy.g.dart'; + +@Riverpod() +class StartProxyController extends _$StartProxyController { + // ignore: document_ignores is used for dialog + // ignore: avoid_build_context_in_providers + Future maybeStartProxy(BuildContext context) async { + final torProxyRunning = await ref + .read(torProxyServiceProvider.notifier) + .requestSync(); + + if (torProxyRunning == null) { + if (context.mounted) { + final result = await showDialog( + context: context, + builder: (context) { + return TorDialog(); + }, + ); + + if (result == true) { + final connection = ref + .read(torProxyServiceProvider.notifier) + .connect(); + + ref + .read(overlayControllerProvider.notifier) + .show(Positioned(top: 0, left: 0, child: TorNotification())); + + await connection; + } + } + } + } + + @override + void build() {} +} diff --git a/app/lib/features/tor/presentation/controllers/start_tor_proxy.g.dart b/app/lib/features/tor/presentation/controllers/start_tor_proxy.g.dart new file mode 100644 index 00000000..b19327e6 --- /dev/null +++ b/app/lib/features/tor/presentation/controllers/start_tor_proxy.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'start_tor_proxy.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$startProxyControllerHash() => + r'b83cdc1b6d1c358a2042ff2b7cc2f19f82e5d5ed'; + +/// See also [StartProxyController]. +@ProviderFor(StartProxyController) +final startProxyControllerProvider = + AutoDisposeNotifierProvider.internal( + StartProxyController.new, + name: r'startProxyControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$startProxyControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$StartProxyController = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/tor/presentation/screens/tor_proxy.dart b/app/lib/features/tor/presentation/screens/tor_proxy.dart new file mode 100644 index 00000000..97d3c26f --- /dev/null +++ b/app/lib/features/tor/presentation/screens/tor_proxy.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/hooks/on_initialization.dart'; + +class TorProxyScreen extends HookConsumerWidget { + const TorProxyScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final torProxyPort = ref.watch(torProxyServiceProvider); + final proxyPrivateTabsTor = ref.watch( + generalSettingsRepositoryProvider.select( + (value) => value.proxyPrivateTabsTor, + ), + ); + + useOnInitialization(() async { + await ref.read(torProxyServiceProvider.notifier).requestSync(); + }); + + return Scaffold( + appBar: AppBar(title: const Text('Tor Proxy')), + body: ColoredBox( + color: const Color(0xFF7D4698), + child: Column( + children: [ + Flexible( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SvgPicture.asset('assets/images/tor_white.svg', width: 200), + const SizedBox(height: 16), + ListTileTheme( + iconColor: Colors.white, + textColor: Colors.white, + child: SwitchListTile.adaptive( + inactiveThumbColor: Colors.white, + activeColor: const Color(0xFF68B030), + trackColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.isEmpty) { + return const Color(0xFF333A41); + } + return null; // Use the default color. + }), + trackOutlineColor: + WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.isEmpty) { + return Colors.white; + } + return null; // Use the default color. + }), + thumbIcon: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.selected)) { + return const Icon(MdiIcons.axisArrowLock); + } + return null; // Use the default color. + }), + value: torProxyPort.valueOrNull != null, + title: const Text('Tor Proxy'), + secondary: const Icon(MdiIcons.power), + onChanged: torProxyPort.isLoading + ? null + : (value) async { + if (value) { + await ref + .read(torProxyServiceProvider.notifier) + .connect(); + } else { + await ref + .read(torProxyServiceProvider.notifier) + .disconnect(); + } + }, + ), + ), + ListTileTheme( + iconColor: Colors.white, + textColor: Colors.white, + child: SwitchListTile.adaptive( + inactiveThumbColor: Colors.white, + activeColor: const Color(0xFF68B030), + trackColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.isEmpty) { + return const Color(0xFF333A41); + } + return null; // Use the default color. + }), + trackOutlineColor: + WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.isEmpty) { + return Colors.white; + } + return null; // Use the default color. + }), + thumbIcon: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.selected)) { + return const Icon(MdiIcons.incognito); + } + return null; // Use the default color. + }), + value: proxyPrivateTabsTor, + title: const Text('Proxy Private Tabs'), + subtitle: const Text( + 'When enabled, all Private Tabs will be tunneled through Tor', + ), + secondary: const Icon(MdiIcons.arrowDecision), + onChanged: (value) async { + await ref + .read( + saveGeneralSettingsControllerProvider.notifier, + ) + .save( + (currentSettings) => currentSettings.copyWith + .proxyPrivateTabsTor(value), + ); + }, + ), + ), + ], + ), + ), + Flexible( + child: Column( + children: [ + if (torProxyPort.isLoading) + const Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 8), + LinearProgressIndicator( + backgroundColor: Color(0xFF333A41), + color: Color(0xFF68B030), + ), + SizedBox(height: 8), + Text( + 'Establishing connection...', + style: TextStyle(color: Colors.white), + ), + ], + ), + if (torProxyPort.valueOrNull != null) + Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + MdiIcons.shieldLockOutline, + color: Color(0xFF68B030), + size: 32, + ), + const SizedBox(width: 12), + Text( + 'Connected to the Tor Network', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(color: const Color(0xFF68B030)), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/tor/presentation/widgets/tor_dialog.dart b/app/lib/features/tor/presentation/widgets/tor_dialog.dart new file mode 100644 index 00000000..b6414fe4 --- /dev/null +++ b/app/lib/features/tor/presentation/widgets/tor_dialog.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:weblibre/presentation/icons/tor_icons.dart'; + +class TorDialog extends StatelessWidget { + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: const Icon(TorIcons.onionAlt, color: Color(0xFF7D4698)), + title: const Text('Tor Proxy'), + content: const Text( + 'This container requires a Tor proxy for secure connections, which is not currently running.', + ), + actions: [ + TextButton( + onPressed: () { + context.pop(false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + context.pop(true); + }, + child: const Text('Enable'), + ), + ], + ); + } +} diff --git a/app/lib/features/tor/presentation/widgets/tor_notification.dart b/app/lib/features/tor/presentation/widgets/tor_notification.dart new file mode 100644 index 00000000..f8652f2b --- /dev/null +++ b/app/lib/features/tor/presentation/widgets/tor_notification.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart'; +import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; +import 'package:weblibre/presentation/icons/tor_icons.dart'; +import 'package:weblibre/presentation/widgets/animate_gradient_shader.dart'; + +class TorNotification extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(torProxyServiceProvider, (previous, next) { + if (!next.isLoading) { + ref.read(overlayControllerProvider.notifier).dismiss(); + } + }); + + return SafeArea( + child: ColoredBox( + color: const Color(0xFF7D4698), + child: SizedBox( + height: 56, + width: MediaQuery.of(context).size.width, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + AnimateGradientShader( + duration: const Duration(milliseconds: 500), + primaryEnd: Alignment.bottomLeft, + secondaryEnd: Alignment.topRight, + primaryColors: const [Color(0xFF68B030), Color(0xFF68B030)], + secondaryColors: const [Colors.white, Colors.white], + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Icon(TorIcons.onionAlt), + ), + ), + Expanded( + child: Text( + 'Tor Proxy is connecting...', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.white), + ), + ), + IconButton( + onPressed: () { + ref.read(overlayControllerProvider.notifier).dismiss(); + }, + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/tor/utils/tor_entrypoint.dart b/app/lib/features/tor/utils/tor_entrypoint.dart new file mode 100644 index 00000000..902946b6 --- /dev/null +++ b/app/lib/features/tor/utils/tor_entrypoint.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:tor/tor.dart'; + +@pragma('vm:entry-point') +Future onStart(ServiceInstance service) async { + Timer? timeout; + + await Tor.init(); + + final portSub = Tor.instance.events.stream.listen((port) { + service.invoke('portUpdate', {'port': port}); + }); + + Future stopService() async { + timeout?.cancel(); + timeout = null; + + Tor.instance.stop(); + service.invoke('portUpdate', {'port': -1}); + + await portSub.cancel(); + + await service.stopSelf(); + } + + void addHeartbeat() { + timeout?.cancel(); + timeout = Timer(const Duration(minutes: 15), () async { + // logger.i('Terminating tor due to timeout'); + await stopService(); + }); + } + + await Tor.instance.start(); + + service.on("heartbeat").listen((event) { + // logger.d('Received tor heartbeat'); + addHeartbeat(); + }); + + service.on("sync").listen((event) { + service.invoke('portUpdate', {'port': Tor.instance.port}); + }); + + service.on("stop").listen((event) async { + await stopService(); + }); +} diff --git a/app/lib/features/user/data/database/daos/cache.dart b/app/lib/features/user/data/database/daos/cache.dart new file mode 100644 index 00000000..7c03f068 --- /dev/null +++ b/app/lib/features/user/data/database/daos/cache.dart @@ -0,0 +1,42 @@ +import 'package:drift/drift.dart'; +import 'package:weblibre/data/database/extensions/database_table_size.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; + +part 'cache.g.dart'; + +@DriftAccessor() +class CacheDao extends DatabaseAccessor with _$CacheDaoMixin { + CacheDao(super.attachedDatabase); + + SingleSelectable getIconCacheSize() { + return db.tableSize(db.iconCache); + } + + Future clearIconCache() { + return db.iconCache.deleteAll(); + } + + SingleOrNullSelectable getCachedIcon(String origin) { + final query = selectOnly(db.iconCache) + ..addColumns([db.iconCache.iconData]) + ..where(db.iconCache.origin.equals(origin)); + + return query.map((row) => row.read(db.iconCache.iconData)); + } + + Future cacheIcon(String origin, Uint8List bytes) { + return db.iconCache.insertOne( + IconCacheCompanion.insert( + origin: origin, + iconData: bytes, + fetchDate: DateTime.now(), + ), + onConflict: DoUpdate( + (old) => IconCacheCompanion( + iconData: Value(bytes), + fetchDate: Value(DateTime.now()), + ), + ), + ); + } +} diff --git a/app/lib/features/user/data/database/daos/cache.g.dart b/app/lib/features/user/data/database/daos/cache.g.dart new file mode 100644 index 00000000..5b292224 --- /dev/null +++ b/app/lib/features/user/data/database/daos/cache.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cache.dart'; + +// ignore_for_file: type=lint +mixin _$CacheDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/user/data/database/daos/onboarding.dart b/app/lib/features/user/data/database/daos/onboarding.dart new file mode 100644 index 00000000..3988c2a0 --- /dev/null +++ b/app/lib/features/user/data/database/daos/onboarding.dart @@ -0,0 +1,28 @@ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; + +part 'onboarding.g.dart'; + +@DriftAccessor() +class OnboardingDao extends DatabaseAccessor + with _$OnboardingDaoMixin { + OnboardingDao(super.attachedDatabase); + + SingleOrNullSelectable getLastRevision() { + final maxRevision = db.onboarding.revision.max(); + + final query = selectOnly(db.onboarding)..addColumns([maxRevision]); + + return query.map((row) => row.read(maxRevision)); + } + + Future pushRevision(int revision, DateTime completionDate) { + return db.onboarding.insertOne( + OnboardingCompanion.insert( + revision: revision, + completionDate: completionDate, + ), + mode: InsertMode.insertOrReplace, + ); + } +} diff --git a/app/lib/features/user/data/database/daos/onboarding.g.dart b/app/lib/features/user/data/database/daos/onboarding.g.dart new file mode 100644 index 00000000..17352560 --- /dev/null +++ b/app/lib/features/user/data/database/daos/onboarding.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'onboarding.dart'; + +// ignore_for_file: type=lint +mixin _$OnboardingDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/user/data/database/daos/setting.dart b/app/lib/features/user/data/database/daos/setting.dart new file mode 100644 index 00000000..0e9ee9bd --- /dev/null +++ b/app/lib/features/user/data/database/daos/setting.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; + +part 'setting.g.dart'; + +@DriftAccessor() +class SettingDao extends DatabaseAccessor with _$SettingDaoMixin { + SettingDao(super.attachedDatabase); + + Future updateSetting(String key, String? partitionKey, Object? value) { + final normalizedValue = (value is Iterable) ? jsonEncode(value) : value; + + final driftvalue = normalizedValue.mapNotNull( + (normalizedValue) => DriftAny(normalizedValue), + ); + + return db.setting.insertOne( + SettingCompanion.insert( + key: key, + partitionKey: Value(partitionKey), + value: Value(driftvalue), + ), + onConflict: DoUpdate((old) => SettingCompanion(value: Value(driftvalue))), + ); + } + + Selectable> getAllSettingsOfPartitionKey( + String? partitionKey, + ) { + final query = db.setting.select() + ..where((r) => r.partitionKey.equalsNullable(partitionKey)); + + return query.map((row) => MapEntry(row.key, row.value)); + } +} diff --git a/app/lib/features/user/data/database/daos/setting.g.dart b/app/lib/features/user/data/database/daos/setting.g.dart new file mode 100644 index 00000000..a031d6ce --- /dev/null +++ b/app/lib/features/user/data/database/daos/setting.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'setting.dart'; + +// ignore_for_file: type=lint +mixin _$SettingDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/user/data/database/database.dart b/app/lib/features/user/data/database/database.dart new file mode 100644 index 00000000..8d57e579 --- /dev/null +++ b/app/lib/features/user/data/database/database.dart @@ -0,0 +1,24 @@ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/user/data/database/daos/cache.dart'; +import 'package:weblibre/features/user/data/database/daos/onboarding.dart'; +import 'package:weblibre/features/user/data/database/daos/setting.dart'; + +part 'database.g.dart'; + +@DriftDatabase( + include: {'database.drift'}, + daos: [SettingDao, CacheDao, OnboardingDao], +) +class UserDatabase extends _$UserDatabase { + @override + final int schemaVersion = 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON;'); + }, + ); + + UserDatabase(super.e); +} diff --git a/app/lib/features/user/data/database/database.drift b/app/lib/features/user/data/database/database.drift new file mode 100644 index 00000000..af338803 --- /dev/null +++ b/app/lib/features/user/data/database/database.drift @@ -0,0 +1,25 @@ +CREATE TABLE setting ( + "key" TEXT PRIMARY KEY NOT NULL, + partition_key TEXT, + "value" ANY +) STRICT; + +CREATE TABLE icon_cache ( + origin TEXT PRIMARY KEY NOT NULL, + icon_data BLOB NOT NULL, + fetch_date DATETIME NOT NULL +); + +CREATE TABLE onboarding ( + revision INTEGER NOT NULL, + completion_date DATETIME NOT NULL +); + +evictCacheEntries: + DELETE FROM icon_cache + WHERE rowid IN ( + SELECT rowid + FROM icon_cache + ORDER BY fetch_date DESC + LIMIT -1 OFFSET :limit + ); \ No newline at end of file diff --git a/app/lib/features/user/data/database/database.g.dart b/app/lib/features/user/data/database/database.g.dart new file mode 100644 index 00000000..44a3e5ad --- /dev/null +++ b/app/lib/features/user/data/database/database.g.dart @@ -0,0 +1,1125 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class Setting extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Setting(this.attachedDatabase, [this._alias]); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn partitionKey = GeneratedColumn( + 'partition_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + true, + type: DriftSqlType.any, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [key, partitionKey, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'setting'; + @override + Set get $primaryKey => {key}; + @override + SettingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SettingData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + partitionKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}partition_key'], + ), + value: attachedDatabase.typeMapping.read( + DriftSqlType.any, + data['${effectivePrefix}value'], + ), + ); + } + + @override + Setting createAlias(String alias) { + return Setting(attachedDatabase, alias); + } + + @override + bool get isStrict => true; + @override + bool get dontWriteConstraints => true; +} + +class SettingData extends DataClass implements Insertable { + final String key; + final String? partitionKey; + final DriftAny? value; + const SettingData({required this.key, this.partitionKey, this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + if (!nullToAbsent || partitionKey != null) { + map['partition_key'] = Variable(partitionKey); + } + if (!nullToAbsent || value != null) { + map['value'] = Variable(value); + } + return map; + } + + factory SettingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SettingData( + key: serializer.fromJson(json['key']), + partitionKey: serializer.fromJson(json['partition_key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'partition_key': serializer.toJson(partitionKey), + 'value': serializer.toJson(value), + }; + } + + SettingData copyWith({ + String? key, + Value partitionKey = const Value.absent(), + Value value = const Value.absent(), + }) => SettingData( + key: key ?? this.key, + partitionKey: partitionKey.present ? partitionKey.value : this.partitionKey, + value: value.present ? value.value : this.value, + ); + SettingData copyWithCompanion(SettingCompanion data) { + return SettingData( + key: data.key.present ? data.key.value : this.key, + partitionKey: data.partitionKey.present + ? data.partitionKey.value + : this.partitionKey, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('SettingData(') + ..write('key: $key, ') + ..write('partitionKey: $partitionKey, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, partitionKey, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SettingData && + other.key == this.key && + other.partitionKey == this.partitionKey && + other.value == this.value); +} + +class SettingCompanion extends UpdateCompanion { + final Value key; + final Value partitionKey; + final Value value; + final Value rowid; + const SettingCompanion({ + this.key = const Value.absent(), + this.partitionKey = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + SettingCompanion.insert({ + required String key, + this.partitionKey = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }) : key = Value(key); + static Insertable custom({ + Expression? key, + Expression? partitionKey, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (partitionKey != null) 'partition_key': partitionKey, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + SettingCompanion copyWith({ + Value? key, + Value? partitionKey, + Value? value, + Value? rowid, + }) { + return SettingCompanion( + key: key ?? this.key, + partitionKey: partitionKey ?? this.partitionKey, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (partitionKey.present) { + map['partition_key'] = Variable(partitionKey.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SettingCompanion(') + ..write('key: $key, ') + ..write('partitionKey: $partitionKey, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class IconCache extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + IconCache(this.attachedDatabase, [this._alias]); + late final GeneratedColumn origin = GeneratedColumn( + 'origin', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn iconData = GeneratedColumn( + 'icon_data', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn fetchDate = GeneratedColumn( + 'fetch_date', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [origin, iconData, fetchDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'icon_cache'; + @override + Set get $primaryKey => {origin}; + @override + IconCacheData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return IconCacheData( + origin: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}origin'], + )!, + iconData: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}icon_data'], + )!, + fetchDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}fetch_date'], + )!, + ); + } + + @override + IconCache createAlias(String alias) { + return IconCache(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class IconCacheData extends DataClass implements Insertable { + final String origin; + final Uint8List iconData; + final DateTime fetchDate; + const IconCacheData({ + required this.origin, + required this.iconData, + required this.fetchDate, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['origin'] = Variable(origin); + map['icon_data'] = Variable(iconData); + map['fetch_date'] = Variable(fetchDate); + return map; + } + + factory IconCacheData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return IconCacheData( + origin: serializer.fromJson(json['origin']), + iconData: serializer.fromJson(json['icon_data']), + fetchDate: serializer.fromJson(json['fetch_date']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'origin': serializer.toJson(origin), + 'icon_data': serializer.toJson(iconData), + 'fetch_date': serializer.toJson(fetchDate), + }; + } + + IconCacheData copyWith({ + String? origin, + Uint8List? iconData, + DateTime? fetchDate, + }) => IconCacheData( + origin: origin ?? this.origin, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + ); + IconCacheData copyWithCompanion(IconCacheCompanion data) { + return IconCacheData( + origin: data.origin.present ? data.origin.value : this.origin, + iconData: data.iconData.present ? data.iconData.value : this.iconData, + fetchDate: data.fetchDate.present ? data.fetchDate.value : this.fetchDate, + ); + } + + @override + String toString() { + return (StringBuffer('IconCacheData(') + ..write('origin: $origin, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(origin, $driftBlobEquality.hash(iconData), fetchDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is IconCacheData && + other.origin == this.origin && + $driftBlobEquality.equals(other.iconData, this.iconData) && + other.fetchDate == this.fetchDate); +} + +class IconCacheCompanion extends UpdateCompanion { + final Value origin; + final Value iconData; + final Value fetchDate; + final Value rowid; + const IconCacheCompanion({ + this.origin = const Value.absent(), + this.iconData = const Value.absent(), + this.fetchDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + IconCacheCompanion.insert({ + required String origin, + required Uint8List iconData, + required DateTime fetchDate, + this.rowid = const Value.absent(), + }) : origin = Value(origin), + iconData = Value(iconData), + fetchDate = Value(fetchDate); + static Insertable custom({ + Expression? origin, + Expression? iconData, + Expression? fetchDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (origin != null) 'origin': origin, + if (iconData != null) 'icon_data': iconData, + if (fetchDate != null) 'fetch_date': fetchDate, + if (rowid != null) 'rowid': rowid, + }); + } + + IconCacheCompanion copyWith({ + Value? origin, + Value? iconData, + Value? fetchDate, + Value? rowid, + }) { + return IconCacheCompanion( + origin: origin ?? this.origin, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (origin.present) { + map['origin'] = Variable(origin.value); + } + if (iconData.present) { + map['icon_data'] = Variable(iconData.value); + } + if (fetchDate.present) { + map['fetch_date'] = Variable(fetchDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('IconCacheCompanion(') + ..write('origin: $origin, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Onboarding extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Onboarding(this.attachedDatabase, [this._alias]); + late final GeneratedColumn revision = GeneratedColumn( + 'revision', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn completionDate = + GeneratedColumn( + 'completion_date', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [revision, completionDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'onboarding'; + @override + Set get $primaryKey => const {}; + @override + OnboardingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return OnboardingData( + revision: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}revision'], + )!, + completionDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}completion_date'], + )!, + ); + } + + @override + Onboarding createAlias(String alias) { + return Onboarding(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class OnboardingData extends DataClass implements Insertable { + final int revision; + final DateTime completionDate; + const OnboardingData({required this.revision, required this.completionDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['revision'] = Variable(revision); + map['completion_date'] = Variable(completionDate); + return map; + } + + factory OnboardingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return OnboardingData( + revision: serializer.fromJson(json['revision']), + completionDate: serializer.fromJson(json['completion_date']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'revision': serializer.toJson(revision), + 'completion_date': serializer.toJson(completionDate), + }; + } + + OnboardingData copyWith({int? revision, DateTime? completionDate}) => + OnboardingData( + revision: revision ?? this.revision, + completionDate: completionDate ?? this.completionDate, + ); + OnboardingData copyWithCompanion(OnboardingCompanion data) { + return OnboardingData( + revision: data.revision.present ? data.revision.value : this.revision, + completionDate: data.completionDate.present + ? data.completionDate.value + : this.completionDate, + ); + } + + @override + String toString() { + return (StringBuffer('OnboardingData(') + ..write('revision: $revision, ') + ..write('completionDate: $completionDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(revision, completionDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is OnboardingData && + other.revision == this.revision && + other.completionDate == this.completionDate); +} + +class OnboardingCompanion extends UpdateCompanion { + final Value revision; + final Value completionDate; + final Value rowid; + const OnboardingCompanion({ + this.revision = const Value.absent(), + this.completionDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + OnboardingCompanion.insert({ + required int revision, + required DateTime completionDate, + this.rowid = const Value.absent(), + }) : revision = Value(revision), + completionDate = Value(completionDate); + static Insertable custom({ + Expression? revision, + Expression? completionDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (revision != null) 'revision': revision, + if (completionDate != null) 'completion_date': completionDate, + if (rowid != null) 'rowid': rowid, + }); + } + + OnboardingCompanion copyWith({ + Value? revision, + Value? completionDate, + Value? rowid, + }) { + return OnboardingCompanion( + revision: revision ?? this.revision, + completionDate: completionDate ?? this.completionDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (revision.present) { + map['revision'] = Variable(revision.value); + } + if (completionDate.present) { + map['completion_date'] = Variable(completionDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('OnboardingCompanion(') + ..write('revision: $revision, ') + ..write('completionDate: $completionDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$UserDatabase extends GeneratedDatabase { + _$UserDatabase(QueryExecutor e) : super(e); + $UserDatabaseManager get managers => $UserDatabaseManager(this); + late final Setting setting = Setting(this); + late final IconCache iconCache = IconCache(this); + late final Onboarding onboarding = Onboarding(this); + late final SettingDao settingDao = SettingDao(this as UserDatabase); + late final CacheDao cacheDao = CacheDao(this as UserDatabase); + late final OnboardingDao onboardingDao = OnboardingDao(this as UserDatabase); + Future evictCacheEntries({required int limit}) { + return customUpdate( + 'DELETE FROM icon_cache WHERE "rowid" IN (SELECT "rowid" FROM icon_cache ORDER BY fetch_date DESC LIMIT -1 OFFSET ?1)', + variables: [Variable(limit)], + updates: {iconCache}, + updateKind: UpdateKind.delete, + ); + } + + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + setting, + iconCache, + onboarding, + ]; +} + +typedef $SettingCreateCompanionBuilder = + SettingCompanion Function({ + required String key, + Value partitionKey, + Value value, + Value rowid, + }); +typedef $SettingUpdateCompanionBuilder = + SettingCompanion Function({ + Value key, + Value partitionKey, + Value value, + Value rowid, + }); + +class $SettingFilterComposer extends Composer<_$UserDatabase, Setting> { + $SettingFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get partitionKey => $composableBuilder( + column: $table.partitionKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnFilters(column), + ); +} + +class $SettingOrderingComposer extends Composer<_$UserDatabase, Setting> { + $SettingOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get partitionKey => $composableBuilder( + column: $table.partitionKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnOrderings(column), + ); +} + +class $SettingAnnotationComposer extends Composer<_$UserDatabase, Setting> { + $SettingAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get key => + $composableBuilder(column: $table.key, builder: (column) => column); + + GeneratedColumn get partitionKey => $composableBuilder( + column: $table.partitionKey, + builder: (column) => column, + ); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); +} + +class $SettingTableManager + extends + RootTableManager< + _$UserDatabase, + Setting, + SettingData, + $SettingFilterComposer, + $SettingOrderingComposer, + $SettingAnnotationComposer, + $SettingCreateCompanionBuilder, + $SettingUpdateCompanionBuilder, + (SettingData, BaseReferences<_$UserDatabase, Setting, SettingData>), + SettingData, + PrefetchHooks Function() + > { + $SettingTableManager(_$UserDatabase db, Setting table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $SettingFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $SettingOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $SettingAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value key = const Value.absent(), + Value partitionKey = const Value.absent(), + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => SettingCompanion( + key: key, + partitionKey: partitionKey, + value: value, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String key, + Value partitionKey = const Value.absent(), + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => SettingCompanion.insert( + key: key, + partitionKey: partitionKey, + value: value, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $SettingProcessedTableManager = + ProcessedTableManager< + _$UserDatabase, + Setting, + SettingData, + $SettingFilterComposer, + $SettingOrderingComposer, + $SettingAnnotationComposer, + $SettingCreateCompanionBuilder, + $SettingUpdateCompanionBuilder, + (SettingData, BaseReferences<_$UserDatabase, Setting, SettingData>), + SettingData, + PrefetchHooks Function() + >; +typedef $IconCacheCreateCompanionBuilder = + IconCacheCompanion Function({ + required String origin, + required Uint8List iconData, + required DateTime fetchDate, + Value rowid, + }); +typedef $IconCacheUpdateCompanionBuilder = + IconCacheCompanion Function({ + Value origin, + Value iconData, + Value fetchDate, + Value rowid, + }); + +class $IconCacheFilterComposer extends Composer<_$UserDatabase, IconCache> { + $IconCacheFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get origin => $composableBuilder( + column: $table.origin, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get iconData => $composableBuilder( + column: $table.iconData, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fetchDate => $composableBuilder( + column: $table.fetchDate, + builder: (column) => ColumnFilters(column), + ); +} + +class $IconCacheOrderingComposer extends Composer<_$UserDatabase, IconCache> { + $IconCacheOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get origin => $composableBuilder( + column: $table.origin, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get iconData => $composableBuilder( + column: $table.iconData, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fetchDate => $composableBuilder( + column: $table.fetchDate, + builder: (column) => ColumnOrderings(column), + ); +} + +class $IconCacheAnnotationComposer extends Composer<_$UserDatabase, IconCache> { + $IconCacheAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get origin => + $composableBuilder(column: $table.origin, builder: (column) => column); + + GeneratedColumn get iconData => + $composableBuilder(column: $table.iconData, builder: (column) => column); + + GeneratedColumn get fetchDate => + $composableBuilder(column: $table.fetchDate, builder: (column) => column); +} + +class $IconCacheTableManager + extends + RootTableManager< + _$UserDatabase, + IconCache, + IconCacheData, + $IconCacheFilterComposer, + $IconCacheOrderingComposer, + $IconCacheAnnotationComposer, + $IconCacheCreateCompanionBuilder, + $IconCacheUpdateCompanionBuilder, + ( + IconCacheData, + BaseReferences<_$UserDatabase, IconCache, IconCacheData>, + ), + IconCacheData, + PrefetchHooks Function() + > { + $IconCacheTableManager(_$UserDatabase db, IconCache table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $IconCacheFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $IconCacheOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $IconCacheAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value origin = const Value.absent(), + Value iconData = const Value.absent(), + Value fetchDate = const Value.absent(), + Value rowid = const Value.absent(), + }) => IconCacheCompanion( + origin: origin, + iconData: iconData, + fetchDate: fetchDate, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String origin, + required Uint8List iconData, + required DateTime fetchDate, + Value rowid = const Value.absent(), + }) => IconCacheCompanion.insert( + origin: origin, + iconData: iconData, + fetchDate: fetchDate, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $IconCacheProcessedTableManager = + ProcessedTableManager< + _$UserDatabase, + IconCache, + IconCacheData, + $IconCacheFilterComposer, + $IconCacheOrderingComposer, + $IconCacheAnnotationComposer, + $IconCacheCreateCompanionBuilder, + $IconCacheUpdateCompanionBuilder, + (IconCacheData, BaseReferences<_$UserDatabase, IconCache, IconCacheData>), + IconCacheData, + PrefetchHooks Function() + >; +typedef $OnboardingCreateCompanionBuilder = + OnboardingCompanion Function({ + required int revision, + required DateTime completionDate, + Value rowid, + }); +typedef $OnboardingUpdateCompanionBuilder = + OnboardingCompanion Function({ + Value revision, + Value completionDate, + Value rowid, + }); + +class $OnboardingFilterComposer extends Composer<_$UserDatabase, Onboarding> { + $OnboardingFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get revision => $composableBuilder( + column: $table.revision, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get completionDate => $composableBuilder( + column: $table.completionDate, + builder: (column) => ColumnFilters(column), + ); +} + +class $OnboardingOrderingComposer extends Composer<_$UserDatabase, Onboarding> { + $OnboardingOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get revision => $composableBuilder( + column: $table.revision, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get completionDate => $composableBuilder( + column: $table.completionDate, + builder: (column) => ColumnOrderings(column), + ); +} + +class $OnboardingAnnotationComposer + extends Composer<_$UserDatabase, Onboarding> { + $OnboardingAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get revision => + $composableBuilder(column: $table.revision, builder: (column) => column); + + GeneratedColumn get completionDate => $composableBuilder( + column: $table.completionDate, + builder: (column) => column, + ); +} + +class $OnboardingTableManager + extends + RootTableManager< + _$UserDatabase, + Onboarding, + OnboardingData, + $OnboardingFilterComposer, + $OnboardingOrderingComposer, + $OnboardingAnnotationComposer, + $OnboardingCreateCompanionBuilder, + $OnboardingUpdateCompanionBuilder, + ( + OnboardingData, + BaseReferences<_$UserDatabase, Onboarding, OnboardingData>, + ), + OnboardingData, + PrefetchHooks Function() + > { + $OnboardingTableManager(_$UserDatabase db, Onboarding table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $OnboardingFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $OnboardingOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $OnboardingAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value revision = const Value.absent(), + Value completionDate = const Value.absent(), + Value rowid = const Value.absent(), + }) => OnboardingCompanion( + revision: revision, + completionDate: completionDate, + rowid: rowid, + ), + createCompanionCallback: + ({ + required int revision, + required DateTime completionDate, + Value rowid = const Value.absent(), + }) => OnboardingCompanion.insert( + revision: revision, + completionDate: completionDate, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $OnboardingProcessedTableManager = + ProcessedTableManager< + _$UserDatabase, + Onboarding, + OnboardingData, + $OnboardingFilterComposer, + $OnboardingOrderingComposer, + $OnboardingAnnotationComposer, + $OnboardingCreateCompanionBuilder, + $OnboardingUpdateCompanionBuilder, + ( + OnboardingData, + BaseReferences<_$UserDatabase, Onboarding, OnboardingData>, + ), + OnboardingData, + PrefetchHooks Function() + >; + +class $UserDatabaseManager { + final _$UserDatabase _db; + $UserDatabaseManager(this._db); + $SettingTableManager get setting => $SettingTableManager(_db, _db.setting); + $IconCacheTableManager get iconCache => + $IconCacheTableManager(_db, _db.iconCache); + $OnboardingTableManager get onboarding => + $OnboardingTableManager(_db, _db.onboarding); +} diff --git a/app/lib/features/user/data/models/engine_settings.dart b/app/lib/features/user/data/models/engine_settings.dart new file mode 100644 index 00000000..c1dbf310 --- /dev/null +++ b/app/lib/features/user/data/models/engine_settings.dart @@ -0,0 +1,104 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'engine_settings.g.dart'; + +@CopyWith() +@JsonSerializable(includeIfNull: true, constructor: 'withDefaults') +class EngineSettings extends GeckoEngineSettings with FastEquatable { + @override + bool get javascriptEnabled => super.javascriptEnabled!; + @override + TrackingProtectionPolicy get trackingProtectionPolicy => + super.trackingProtectionPolicy!; + @override + HttpsOnlyMode get httpsOnlyMode => super.httpsOnlyMode!; + @override + ColorScheme get preferredColorScheme => super.preferredColorScheme!; + @override + bool get globalPrivacyControlEnabled => super.globalPrivacyControlEnabled!; + @override + CookieBannerHandlingMode get cookieBannerHandlingMode => + super.cookieBannerHandlingMode!; + @override + CookieBannerHandlingMode get cookieBannerHandlingModePrivateBrowsing => + super.cookieBannerHandlingModePrivateBrowsing!; + @override + bool get cookieBannerHandlingGlobalRules => + super.cookieBannerHandlingGlobalRules!; + @override + bool get cookieBannerHandlingGlobalRulesSubFrames => + super.cookieBannerHandlingGlobalRulesSubFrames!; + @override + WebContentIsolationStrategy get webContentIsolationStrategy => + super.webContentIsolationStrategy!; + + EngineSettings({ + required super.javascriptEnabled, + required super.trackingProtectionPolicy, + required super.httpsOnlyMode, + required super.globalPrivacyControlEnabled, + required super.preferredColorScheme, + required super.cookieBannerHandlingMode, + required super.cookieBannerHandlingModePrivateBrowsing, + required super.cookieBannerHandlingGlobalRules, + required super.cookieBannerHandlingGlobalRulesSubFrames, + required super.webContentIsolationStrategy, + required super.userAgent, + }); + + EngineSettings.withDefaults({ + bool? javascriptEnabled, + TrackingProtectionPolicy? trackingProtectionPolicy, + HttpsOnlyMode? httpsOnlyMode, + bool? globalPrivacyControlEnabled, + ColorScheme? preferredColorScheme, + CookieBannerHandlingMode? cookieBannerHandlingMode, + CookieBannerHandlingMode? cookieBannerHandlingModePrivateBrowsing, + bool? cookieBannerHandlingGlobalRules, + bool? cookieBannerHandlingGlobalRulesSubFrames, + WebContentIsolationStrategy? webContentIsolationStrategy, + super.userAgent, + }) : super( + javascriptEnabled: javascriptEnabled ?? true, + trackingProtectionPolicy: + trackingProtectionPolicy ?? TrackingProtectionPolicy.strict, + httpsOnlyMode: httpsOnlyMode ?? HttpsOnlyMode.enabled, + globalPrivacyControlEnabled: globalPrivacyControlEnabled ?? true, + preferredColorScheme: preferredColorScheme ?? ColorScheme.system, + cookieBannerHandlingMode: + cookieBannerHandlingMode ?? CookieBannerHandlingMode.rejectAll, + cookieBannerHandlingModePrivateBrowsing: + cookieBannerHandlingModePrivateBrowsing ?? + CookieBannerHandlingMode.rejectAll, + cookieBannerHandlingGlobalRules: + cookieBannerHandlingGlobalRules ?? true, + cookieBannerHandlingGlobalRulesSubFrames: + cookieBannerHandlingGlobalRulesSubFrames ?? true, + webContentIsolationStrategy: + webContentIsolationStrategy ?? + WebContentIsolationStrategy.isolateHighValue, + ); + + factory EngineSettings.fromJson(Map json) => + _$EngineSettingsFromJson(json); + + Map toJson() => _$EngineSettingsToJson(this); + + @override + List get hashParameters => [ + super.javascriptEnabled, + super.trackingProtectionPolicy, + super.httpsOnlyMode, + super.globalPrivacyControlEnabled, + super.preferredColorScheme, + super.cookieBannerHandlingMode, + super.cookieBannerHandlingModePrivateBrowsing, + super.cookieBannerHandlingGlobalRules, + super.cookieBannerHandlingGlobalRulesSubFrames, + super.webContentIsolationStrategy, + super.userAgent, + ]; +} diff --git a/app/lib/features/user/data/models/engine_settings.g.dart b/app/lib/features/user/data/models/engine_settings.g.dart new file mode 100644 index 00000000..62144558 --- /dev/null +++ b/app/lib/features/user/data/models/engine_settings.g.dart @@ -0,0 +1,305 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'engine_settings.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$EngineSettingsCWProxy { + EngineSettings javascriptEnabled(bool? javascriptEnabled); + + EngineSettings trackingProtectionPolicy( + TrackingProtectionPolicy? trackingProtectionPolicy, + ); + + EngineSettings httpsOnlyMode(HttpsOnlyMode? httpsOnlyMode); + + EngineSettings globalPrivacyControlEnabled(bool? globalPrivacyControlEnabled); + + EngineSettings preferredColorScheme(ColorScheme? preferredColorScheme); + + EngineSettings cookieBannerHandlingMode( + CookieBannerHandlingMode? cookieBannerHandlingMode, + ); + + EngineSettings cookieBannerHandlingModePrivateBrowsing( + CookieBannerHandlingMode? cookieBannerHandlingModePrivateBrowsing, + ); + + EngineSettings cookieBannerHandlingGlobalRules( + bool? cookieBannerHandlingGlobalRules, + ); + + EngineSettings cookieBannerHandlingGlobalRulesSubFrames( + bool? cookieBannerHandlingGlobalRulesSubFrames, + ); + + EngineSettings webContentIsolationStrategy( + WebContentIsolationStrategy? webContentIsolationStrategy, + ); + + EngineSettings userAgent(String? userAgent); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `EngineSettings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// EngineSettings(...).copyWith(id: 12, name: "My name") + /// ```` + EngineSettings call({ + bool? javascriptEnabled, + TrackingProtectionPolicy? trackingProtectionPolicy, + HttpsOnlyMode? httpsOnlyMode, + bool? globalPrivacyControlEnabled, + ColorScheme? preferredColorScheme, + CookieBannerHandlingMode? cookieBannerHandlingMode, + CookieBannerHandlingMode? cookieBannerHandlingModePrivateBrowsing, + bool? cookieBannerHandlingGlobalRules, + bool? cookieBannerHandlingGlobalRulesSubFrames, + WebContentIsolationStrategy? webContentIsolationStrategy, + String? userAgent, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfEngineSettings.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfEngineSettings.copyWith.fieldName(...)` +class _$EngineSettingsCWProxyImpl implements _$EngineSettingsCWProxy { + const _$EngineSettingsCWProxyImpl(this._value); + + final EngineSettings _value; + + @override + EngineSettings javascriptEnabled(bool? javascriptEnabled) => + this(javascriptEnabled: javascriptEnabled); + + @override + EngineSettings trackingProtectionPolicy( + TrackingProtectionPolicy? trackingProtectionPolicy, + ) => this(trackingProtectionPolicy: trackingProtectionPolicy); + + @override + EngineSettings httpsOnlyMode(HttpsOnlyMode? httpsOnlyMode) => + this(httpsOnlyMode: httpsOnlyMode); + + @override + EngineSettings globalPrivacyControlEnabled( + bool? globalPrivacyControlEnabled, + ) => this(globalPrivacyControlEnabled: globalPrivacyControlEnabled); + + @override + EngineSettings preferredColorScheme(ColorScheme? preferredColorScheme) => + this(preferredColorScheme: preferredColorScheme); + + @override + EngineSettings cookieBannerHandlingMode( + CookieBannerHandlingMode? cookieBannerHandlingMode, + ) => this(cookieBannerHandlingMode: cookieBannerHandlingMode); + + @override + EngineSettings cookieBannerHandlingModePrivateBrowsing( + CookieBannerHandlingMode? cookieBannerHandlingModePrivateBrowsing, + ) => this( + cookieBannerHandlingModePrivateBrowsing: + cookieBannerHandlingModePrivateBrowsing, + ); + + @override + EngineSettings cookieBannerHandlingGlobalRules( + bool? cookieBannerHandlingGlobalRules, + ) => this(cookieBannerHandlingGlobalRules: cookieBannerHandlingGlobalRules); + + @override + EngineSettings cookieBannerHandlingGlobalRulesSubFrames( + bool? cookieBannerHandlingGlobalRulesSubFrames, + ) => this( + cookieBannerHandlingGlobalRulesSubFrames: + cookieBannerHandlingGlobalRulesSubFrames, + ); + + @override + EngineSettings webContentIsolationStrategy( + WebContentIsolationStrategy? webContentIsolationStrategy, + ) => this(webContentIsolationStrategy: webContentIsolationStrategy); + + @override + EngineSettings userAgent(String? userAgent) => this(userAgent: userAgent); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `EngineSettings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// EngineSettings(...).copyWith(id: 12, name: "My name") + /// ```` + EngineSettings call({ + Object? javascriptEnabled = const $CopyWithPlaceholder(), + Object? trackingProtectionPolicy = const $CopyWithPlaceholder(), + Object? httpsOnlyMode = const $CopyWithPlaceholder(), + Object? globalPrivacyControlEnabled = const $CopyWithPlaceholder(), + Object? preferredColorScheme = const $CopyWithPlaceholder(), + Object? cookieBannerHandlingMode = const $CopyWithPlaceholder(), + Object? cookieBannerHandlingModePrivateBrowsing = + const $CopyWithPlaceholder(), + Object? cookieBannerHandlingGlobalRules = const $CopyWithPlaceholder(), + Object? cookieBannerHandlingGlobalRulesSubFrames = + const $CopyWithPlaceholder(), + Object? webContentIsolationStrategy = const $CopyWithPlaceholder(), + Object? userAgent = const $CopyWithPlaceholder(), + }) { + return EngineSettings( + javascriptEnabled: javascriptEnabled == const $CopyWithPlaceholder() + ? _value.javascriptEnabled + // ignore: cast_nullable_to_non_nullable + : javascriptEnabled as bool?, + trackingProtectionPolicy: + trackingProtectionPolicy == const $CopyWithPlaceholder() + ? _value.trackingProtectionPolicy + // ignore: cast_nullable_to_non_nullable + : trackingProtectionPolicy as TrackingProtectionPolicy?, + httpsOnlyMode: httpsOnlyMode == const $CopyWithPlaceholder() + ? _value.httpsOnlyMode + // ignore: cast_nullable_to_non_nullable + : httpsOnlyMode as HttpsOnlyMode?, + globalPrivacyControlEnabled: + globalPrivacyControlEnabled == const $CopyWithPlaceholder() + ? _value.globalPrivacyControlEnabled + // ignore: cast_nullable_to_non_nullable + : globalPrivacyControlEnabled as bool?, + preferredColorScheme: preferredColorScheme == const $CopyWithPlaceholder() + ? _value.preferredColorScheme + // ignore: cast_nullable_to_non_nullable + : preferredColorScheme as ColorScheme?, + cookieBannerHandlingMode: + cookieBannerHandlingMode == const $CopyWithPlaceholder() + ? _value.cookieBannerHandlingMode + // ignore: cast_nullable_to_non_nullable + : cookieBannerHandlingMode as CookieBannerHandlingMode?, + cookieBannerHandlingModePrivateBrowsing: + cookieBannerHandlingModePrivateBrowsing == + const $CopyWithPlaceholder() + ? _value.cookieBannerHandlingModePrivateBrowsing + // ignore: cast_nullable_to_non_nullable + : cookieBannerHandlingModePrivateBrowsing + as CookieBannerHandlingMode?, + cookieBannerHandlingGlobalRules: + cookieBannerHandlingGlobalRules == const $CopyWithPlaceholder() + ? _value.cookieBannerHandlingGlobalRules + // ignore: cast_nullable_to_non_nullable + : cookieBannerHandlingGlobalRules as bool?, + cookieBannerHandlingGlobalRulesSubFrames: + cookieBannerHandlingGlobalRulesSubFrames == + const $CopyWithPlaceholder() + ? _value.cookieBannerHandlingGlobalRulesSubFrames + // ignore: cast_nullable_to_non_nullable + : cookieBannerHandlingGlobalRulesSubFrames as bool?, + webContentIsolationStrategy: + webContentIsolationStrategy == const $CopyWithPlaceholder() + ? _value.webContentIsolationStrategy + // ignore: cast_nullable_to_non_nullable + : webContentIsolationStrategy as WebContentIsolationStrategy?, + userAgent: userAgent == const $CopyWithPlaceholder() + ? _value.userAgent + // ignore: cast_nullable_to_non_nullable + : userAgent as String?, + ); + } +} + +extension $EngineSettingsCopyWith on EngineSettings { + /// Returns a callable class that can be used as follows: `instanceOfEngineSettings.copyWith(...)` or like so:`instanceOfEngineSettings.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$EngineSettingsCWProxy get copyWith => _$EngineSettingsCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +EngineSettings _$EngineSettingsFromJson(Map json) => + EngineSettings.withDefaults( + javascriptEnabled: json['javascriptEnabled'] as bool?, + trackingProtectionPolicy: $enumDecodeNullable( + _$TrackingProtectionPolicyEnumMap, + json['trackingProtectionPolicy'], + ), + httpsOnlyMode: $enumDecodeNullable( + _$HttpsOnlyModeEnumMap, + json['httpsOnlyMode'], + ), + globalPrivacyControlEnabled: json['globalPrivacyControlEnabled'] as bool?, + preferredColorScheme: $enumDecodeNullable( + _$ColorSchemeEnumMap, + json['preferredColorScheme'], + ), + cookieBannerHandlingMode: $enumDecodeNullable( + _$CookieBannerHandlingModeEnumMap, + json['cookieBannerHandlingMode'], + ), + cookieBannerHandlingModePrivateBrowsing: $enumDecodeNullable( + _$CookieBannerHandlingModeEnumMap, + json['cookieBannerHandlingModePrivateBrowsing'], + ), + cookieBannerHandlingGlobalRules: + json['cookieBannerHandlingGlobalRules'] as bool?, + cookieBannerHandlingGlobalRulesSubFrames: + json['cookieBannerHandlingGlobalRulesSubFrames'] as bool?, + webContentIsolationStrategy: $enumDecodeNullable( + _$WebContentIsolationStrategyEnumMap, + json['webContentIsolationStrategy'], + ), + userAgent: json['userAgent'] as String?, + ); + +Map _$EngineSettingsToJson( + EngineSettings instance, +) => { + 'userAgent': instance.userAgent, + 'javascriptEnabled': instance.javascriptEnabled, + 'trackingProtectionPolicy': + _$TrackingProtectionPolicyEnumMap[instance.trackingProtectionPolicy]!, + 'httpsOnlyMode': _$HttpsOnlyModeEnumMap[instance.httpsOnlyMode]!, + 'preferredColorScheme': _$ColorSchemeEnumMap[instance.preferredColorScheme]!, + 'globalPrivacyControlEnabled': instance.globalPrivacyControlEnabled, + 'cookieBannerHandlingMode': + _$CookieBannerHandlingModeEnumMap[instance.cookieBannerHandlingMode]!, + 'cookieBannerHandlingModePrivateBrowsing': + _$CookieBannerHandlingModeEnumMap[instance + .cookieBannerHandlingModePrivateBrowsing]!, + 'cookieBannerHandlingGlobalRules': instance.cookieBannerHandlingGlobalRules, + 'cookieBannerHandlingGlobalRulesSubFrames': + instance.cookieBannerHandlingGlobalRulesSubFrames, + 'webContentIsolationStrategy': + _$WebContentIsolationStrategyEnumMap[instance + .webContentIsolationStrategy]!, +}; + +const _$TrackingProtectionPolicyEnumMap = { + TrackingProtectionPolicy.none: 'none', + TrackingProtectionPolicy.recommended: 'recommended', + TrackingProtectionPolicy.strict: 'strict', + TrackingProtectionPolicy.custom: 'custom', +}; + +const _$HttpsOnlyModeEnumMap = { + HttpsOnlyMode.disabled: 'disabled', + HttpsOnlyMode.privateOnly: 'privateOnly', + HttpsOnlyMode.enabled: 'enabled', +}; + +const _$ColorSchemeEnumMap = { + ColorScheme.system: 'system', + ColorScheme.light: 'light', + ColorScheme.dark: 'dark', +}; + +const _$CookieBannerHandlingModeEnumMap = { + CookieBannerHandlingMode.disabled: 'disabled', + CookieBannerHandlingMode.rejectAll: 'rejectAll', + CookieBannerHandlingMode.rejectOrAcceptAll: 'rejectOrAcceptAll', +}; + +const _$WebContentIsolationStrategyEnumMap = { + WebContentIsolationStrategy.isolateNothing: 'isolateNothing', + WebContentIsolationStrategy.isolateEverything: 'isolateEverything', + WebContentIsolationStrategy.isolateHighValue: 'isolateHighValue', +}; diff --git a/app/lib/features/user/data/models/general_settings.dart b/app/lib/features/user/data/models/general_settings.dart new file mode 100644 index 00000000..d2358976 --- /dev/null +++ b/app/lib/features/user/data/models/general_settings.dart @@ -0,0 +1,84 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart'; + +part 'general_settings.g.dart'; + +const _fallbackSearchProvider = 'wikipedia'; +const _fallbackAutocompleteProvider = SearchSuggestionProviders.none; + +enum DeleteBrowsingDataType { + tabs('Open tabs'), + history('Browsing history'), + cookies('Cookies and site data', 'You’ll be logged out of most sites'), + cache('Cached images and files', 'Frees up storage space'), + permissions('Site permissions'), + downloads('Downloads'); + + final String title; + final String? description; + + const DeleteBrowsingDataType(this.title, [this.description]); +} + +@CopyWith() +@JsonSerializable(includeIfNull: true, constructor: 'withDefaults') +class GeneralSettings with FastEquatable { + final ThemeMode themeMode; + final bool enableReadability; + final bool enforceReadability; + final Set? deleteBrowsingDataOnQuit; + final String defaultSearchProvider; + final SearchSuggestionProviders defaultSearchSuggestionsProvider; + final bool createChildTabsOption; + + final bool proxyPrivateTabsTor; + + GeneralSettings({ + required this.themeMode, + required this.enableReadability, + required this.enforceReadability, + required this.deleteBrowsingDataOnQuit, + required this.defaultSearchProvider, + required this.defaultSearchSuggestionsProvider, + required this.createChildTabsOption, + required this.proxyPrivateTabsTor, + }); + + GeneralSettings.withDefaults({ + ThemeMode? themeMode, + bool? enableReadability, + bool? enforceReadability, + this.deleteBrowsingDataOnQuit, + String? defaultSearchProvider, + SearchSuggestionProviders? defaultSearchSuggestionsProvider, + bool? createChildTabsOption, + bool? proxyPrivateTabsTor, + }) : themeMode = themeMode ?? ThemeMode.dark, + enableReadability = enableReadability ?? true, + enforceReadability = enforceReadability ?? false, + defaultSearchProvider = defaultSearchProvider ?? _fallbackSearchProvider, + defaultSearchSuggestionsProvider = + defaultSearchSuggestionsProvider ?? _fallbackAutocompleteProvider, + createChildTabsOption = createChildTabsOption ?? false, + proxyPrivateTabsTor = proxyPrivateTabsTor ?? false; + + factory GeneralSettings.fromJson(Map json) => + _$GeneralSettingsFromJson(json); + + Map toJson() => _$GeneralSettingsToJson(this); + + @override + List get hashParameters => [ + themeMode, + enableReadability, + enforceReadability, + deleteBrowsingDataOnQuit, + defaultSearchProvider, + defaultSearchSuggestionsProvider, + createChildTabsOption, + proxyPrivateTabsTor, + ]; +} diff --git a/app/lib/features/user/data/models/general_settings.g.dart b/app/lib/features/user/data/models/general_settings.g.dart new file mode 100644 index 00000000..32be39a8 --- /dev/null +++ b/app/lib/features/user/data/models/general_settings.g.dart @@ -0,0 +1,210 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'general_settings.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$GeneralSettingsCWProxy { + GeneralSettings themeMode(ThemeMode themeMode); + + GeneralSettings enableReadability(bool enableReadability); + + GeneralSettings enforceReadability(bool enforceReadability); + + GeneralSettings deleteBrowsingDataOnQuit( + Set? deleteBrowsingDataOnQuit, + ); + + GeneralSettings defaultSearchProvider(String defaultSearchProvider); + + GeneralSettings defaultSearchSuggestionsProvider( + SearchSuggestionProviders defaultSearchSuggestionsProvider, + ); + + GeneralSettings createChildTabsOption(bool createChildTabsOption); + + GeneralSettings proxyPrivateTabsTor(bool proxyPrivateTabsTor); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `GeneralSettings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// GeneralSettings(...).copyWith(id: 12, name: "My name") + /// ```` + GeneralSettings call({ + ThemeMode themeMode, + bool enableReadability, + bool enforceReadability, + Set? deleteBrowsingDataOnQuit, + String defaultSearchProvider, + SearchSuggestionProviders defaultSearchSuggestionsProvider, + bool createChildTabsOption, + bool proxyPrivateTabsTor, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfGeneralSettings.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfGeneralSettings.copyWith.fieldName(...)` +class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { + const _$GeneralSettingsCWProxyImpl(this._value); + + final GeneralSettings _value; + + @override + GeneralSettings themeMode(ThemeMode themeMode) => this(themeMode: themeMode); + + @override + GeneralSettings enableReadability(bool enableReadability) => + this(enableReadability: enableReadability); + + @override + GeneralSettings enforceReadability(bool enforceReadability) => + this(enforceReadability: enforceReadability); + + @override + GeneralSettings deleteBrowsingDataOnQuit( + Set? deleteBrowsingDataOnQuit, + ) => this(deleteBrowsingDataOnQuit: deleteBrowsingDataOnQuit); + + @override + GeneralSettings defaultSearchProvider(String defaultSearchProvider) => + this(defaultSearchProvider: defaultSearchProvider); + + @override + GeneralSettings defaultSearchSuggestionsProvider( + SearchSuggestionProviders defaultSearchSuggestionsProvider, + ) => this(defaultSearchSuggestionsProvider: defaultSearchSuggestionsProvider); + + @override + GeneralSettings createChildTabsOption(bool createChildTabsOption) => + this(createChildTabsOption: createChildTabsOption); + + @override + GeneralSettings proxyPrivateTabsTor(bool proxyPrivateTabsTor) => + this(proxyPrivateTabsTor: proxyPrivateTabsTor); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `GeneralSettings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// GeneralSettings(...).copyWith(id: 12, name: "My name") + /// ```` + GeneralSettings call({ + Object? themeMode = const $CopyWithPlaceholder(), + Object? enableReadability = const $CopyWithPlaceholder(), + Object? enforceReadability = const $CopyWithPlaceholder(), + Object? deleteBrowsingDataOnQuit = const $CopyWithPlaceholder(), + Object? defaultSearchProvider = const $CopyWithPlaceholder(), + Object? defaultSearchSuggestionsProvider = const $CopyWithPlaceholder(), + Object? createChildTabsOption = const $CopyWithPlaceholder(), + Object? proxyPrivateTabsTor = const $CopyWithPlaceholder(), + }) { + return GeneralSettings( + themeMode: themeMode == const $CopyWithPlaceholder() + ? _value.themeMode + // ignore: cast_nullable_to_non_nullable + : themeMode as ThemeMode, + enableReadability: enableReadability == const $CopyWithPlaceholder() + ? _value.enableReadability + // ignore: cast_nullable_to_non_nullable + : enableReadability as bool, + enforceReadability: enforceReadability == const $CopyWithPlaceholder() + ? _value.enforceReadability + // ignore: cast_nullable_to_non_nullable + : enforceReadability as bool, + deleteBrowsingDataOnQuit: + deleteBrowsingDataOnQuit == const $CopyWithPlaceholder() + ? _value.deleteBrowsingDataOnQuit + // ignore: cast_nullable_to_non_nullable + : deleteBrowsingDataOnQuit as Set?, + defaultSearchProvider: + defaultSearchProvider == const $CopyWithPlaceholder() + ? _value.defaultSearchProvider + // ignore: cast_nullable_to_non_nullable + : defaultSearchProvider as String, + defaultSearchSuggestionsProvider: + defaultSearchSuggestionsProvider == const $CopyWithPlaceholder() + ? _value.defaultSearchSuggestionsProvider + // ignore: cast_nullable_to_non_nullable + : defaultSearchSuggestionsProvider as SearchSuggestionProviders, + createChildTabsOption: + createChildTabsOption == const $CopyWithPlaceholder() + ? _value.createChildTabsOption + // ignore: cast_nullable_to_non_nullable + : createChildTabsOption as bool, + proxyPrivateTabsTor: proxyPrivateTabsTor == const $CopyWithPlaceholder() + ? _value.proxyPrivateTabsTor + // ignore: cast_nullable_to_non_nullable + : proxyPrivateTabsTor as bool, + ); + } +} + +extension $GeneralSettingsCopyWith on GeneralSettings { + /// Returns a callable class that can be used as follows: `instanceOfGeneralSettings.copyWith(...)` or like so:`instanceOfGeneralSettings.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$GeneralSettingsCWProxy get copyWith => _$GeneralSettingsCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GeneralSettings _$GeneralSettingsFromJson(Map json) => + GeneralSettings.withDefaults( + themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']), + enableReadability: json['enableReadability'] as bool?, + enforceReadability: json['enforceReadability'] as bool?, + deleteBrowsingDataOnQuit: + (json['deleteBrowsingDataOnQuit'] as List?) + ?.map((e) => $enumDecode(_$DeleteBrowsingDataTypeEnumMap, e)) + .toSet(), + defaultSearchProvider: json['defaultSearchProvider'] as String?, + defaultSearchSuggestionsProvider: $enumDecodeNullable( + _$SearchSuggestionProvidersEnumMap, + json['defaultSearchSuggestionsProvider'], + ), + createChildTabsOption: json['createChildTabsOption'] as bool?, + proxyPrivateTabsTor: json['proxyPrivateTabsTor'] as bool?, + ); + +Map _$GeneralSettingsToJson(GeneralSettings instance) => + { + 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, + 'enableReadability': instance.enableReadability, + 'enforceReadability': instance.enforceReadability, + 'deleteBrowsingDataOnQuit': instance.deleteBrowsingDataOnQuit + ?.map((e) => _$DeleteBrowsingDataTypeEnumMap[e]!) + .toList(), + 'defaultSearchProvider': instance.defaultSearchProvider, + 'defaultSearchSuggestionsProvider': + _$SearchSuggestionProvidersEnumMap[instance + .defaultSearchSuggestionsProvider]!, + 'createChildTabsOption': instance.createChildTabsOption, + 'proxyPrivateTabsTor': instance.proxyPrivateTabsTor, + }; + +const _$ThemeModeEnumMap = { + ThemeMode.system: 'system', + ThemeMode.light: 'light', + ThemeMode.dark: 'dark', +}; + +const _$DeleteBrowsingDataTypeEnumMap = { + DeleteBrowsingDataType.tabs: 'tabs', + DeleteBrowsingDataType.history: 'history', + DeleteBrowsingDataType.cookies: 'cookies', + DeleteBrowsingDataType.cache: 'cache', + DeleteBrowsingDataType.permissions: 'permissions', + DeleteBrowsingDataType.downloads: 'downloads', +}; + +const _$SearchSuggestionProvidersEnumMap = { + SearchSuggestionProviders.none: 'none', + SearchSuggestionProviders.brave: 'brave', + SearchSuggestionProviders.ddg: 'ddg', + SearchSuggestionProviders.kagi: 'kagi', + SearchSuggestionProviders.qwant: 'qwant', +}; diff --git a/app/lib/features/content_block/data/providers.dart b/app/lib/features/user/data/providers.dart similarity index 81% rename from app/lib/features/content_block/data/providers.dart rename to app/lib/features/user/data/providers.dart index 08ef7ec3..6912f6a9 100644 --- a/app/lib/features/content_block/data/providers.dart +++ b/app/lib/features/user/data/providers.dart @@ -1,23 +1,24 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; -import 'package:lensai/features/content_block/data/database/database.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; import 'package:universal_io/io.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; part 'providers.g.dart'; @Riverpod(keepAlive: true) -HostDatabase hostDatabase(HostDatabaseRef ref) { - return HostDatabase( +UserDatabase userDatabase(Ref ref) { + final db = UserDatabase( LazyDatabase(() async { // put the database file, called db.sqlite here, into the documents folder // for your app. final dbFolder = await path_provider.getApplicationDocumentsDirectory(); - final file = File(p.join(dbFolder.path, 'host.db')); + final file = File(p.join(dbFolder.path, 'user.db')); // Also work around limitations on old Android versions if (Platform.isAndroid) { @@ -34,4 +35,10 @@ HostDatabase hostDatabase(HostDatabaseRef ref) { return NativeDatabase.createInBackground(file); }), ); + + ref.onDispose(() async { + await db.close(); + }); + + return db; } diff --git a/app/lib/features/user/data/providers.g.dart b/app/lib/features/user/data/providers.g.dart new file mode 100644 index 00000000..592fd910 --- /dev/null +++ b/app/lib/features/user/data/providers.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$userDatabaseHash() => r'b925780435806f0241d7ddb635e301e9fc8baf6e'; + +/// See also [userDatabase]. +@ProviderFor(userDatabase) +final userDatabaseProvider = Provider.internal( + userDatabase, + name: r'userDatabaseProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userDatabaseHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UserDatabaseRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/user/domain/entities/auth_exception.dart b/app/lib/features/user/domain/entities/auth_exception.dart new file mode 100644 index 00000000..5e44fcf8 --- /dev/null +++ b/app/lib/features/user/domain/entities/auth_exception.dart @@ -0,0 +1,10 @@ +class AuthException implements Exception { + final String message; + + AuthException(this.message); + + @override + String toString() { + return message; + } +} diff --git a/app/lib/features/user/domain/providers.dart b/app/lib/features/user/domain/providers.dart new file mode 100644 index 00000000..396b39d4 --- /dev/null +++ b/app/lib/features/user/domain/providers.dart @@ -0,0 +1,30 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/data/providers.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +part 'providers.g.dart'; + +const _authKey = 'pb_auth'; + +@Riverpod() +Future _storedAuthData(Ref ref) { + const secureStorage = FlutterSecureStorage(); + return secureStorage.read(key: _authKey); +} + +@Riverpod() +Stream iconCacheSizeMegabytes(Ref ref) { + final repository = ref.watch(userDatabaseProvider); + return repository.cacheDao.getIconCacheSize().watchSingle(); +} + +@Riverpod() +bool incognitoModeEnabled(Ref ref) { + return ref.watch( + generalSettingsRepositoryProvider.select( + (value) => value.deleteBrowsingDataOnQuit != null, + ), + ); +} diff --git a/app/lib/features/user/domain/providers.g.dart b/app/lib/features/user/domain/providers.g.dart new file mode 100644 index 00000000..22ab44a7 --- /dev/null +++ b/app/lib/features/user/domain/providers.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$storedAuthDataHash() => r'5f7e3ef6233a2036f7ce3728131901a46b1e548e'; + +/// See also [_storedAuthData]. +@ProviderFor(_storedAuthData) +final _storedAuthDataProvider = AutoDisposeFutureProvider.internal( + _storedAuthData, + name: r'_storedAuthDataProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$storedAuthDataHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef _StoredAuthDataRef = AutoDisposeFutureProviderRef; +String _$iconCacheSizeMegabytesHash() => + r'5d7f5f6485060b08ce4fd8fa634f07bf8bfdbd2d'; + +/// See also [iconCacheSizeMegabytes]. +@ProviderFor(iconCacheSizeMegabytes) +final iconCacheSizeMegabytesProvider = + AutoDisposeStreamProvider.internal( + iconCacheSizeMegabytes, + name: r'iconCacheSizeMegabytesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$iconCacheSizeMegabytesHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef IconCacheSizeMegabytesRef = AutoDisposeStreamProviderRef; +String _$incognitoModeEnabledHash() => + r'3968c2d4945be09a0bc08ce0d70b0390187c44e8'; + +/// See also [incognitoModeEnabled]. +@ProviderFor(incognitoModeEnabled) +final incognitoModeEnabledProvider = AutoDisposeProvider.internal( + incognitoModeEnabled, + name: r'incognitoModeEnabledProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$incognitoModeEnabledHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef IncognitoModeEnabledRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/user/domain/repositories/cache.dart b/app/lib/features/user/domain/repositories/cache.dart new file mode 100644 index 00000000..1a6efd10 --- /dev/null +++ b/app/lib/features/user/domain/repositories/cache.dart @@ -0,0 +1,43 @@ +import 'dart:typed_data'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'cache.g.dart'; + +@Riverpod(keepAlive: true) +class CacheRepository extends _$CacheRepository { + Future clearCache() { + return ref.read(userDatabaseProvider).cacheDao.clearIconCache(); + } + + Future cacheIcon(Uri url, Uint8List bytes) { + return ref.read(userDatabaseProvider).cacheDao.cacheIcon(url.origin, bytes); + } + + Future getCachedIcon(String origin) { + return ref + .read(userDatabaseProvider) + .cacheDao + .getCachedIcon(origin) + .getSingleOrNull(); + } + + @override + void build() { + final eventService = ref.watch(eventServiceProvider); + + final db = ref.watch(userDatabaseProvider); + + final sub = eventService.iconUpdateEvents.listen((event) async { + if (Uri.tryParse(event.url) case final Uri url) { + await db.cacheDao.cacheIcon(url.origin, event.bytes); + } + }); + + ref.onDispose(() async { + await sub.cancel(); + }); + } +} diff --git a/app/lib/features/user/domain/repositories/cache.g.dart b/app/lib/features/user/domain/repositories/cache.g.dart new file mode 100644 index 00000000..f793320b --- /dev/null +++ b/app/lib/features/user/domain/repositories/cache.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cache.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$cacheRepositoryHash() => r'548b7cba9a23c21bba39f0918d7d90acaaf8d8e9'; + +/// See also [CacheRepository]. +@ProviderFor(CacheRepository) +final cacheRepositoryProvider = + NotifierProvider.internal( + CacheRepository.new, + name: r'cacheRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$cacheRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$CacheRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/user/domain/repositories/engine_settings.dart b/app/lib/features/user/domain/repositories/engine_settings.dart new file mode 100644 index 00000000..9de84f99 --- /dev/null +++ b/app/lib/features/user/domain/repositories/engine_settings.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/data/models/engine_settings.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'engine_settings.g.dart'; + +typedef UpdateEngineSettingsFunc = + EngineSettings Function(EngineSettings currentSettings); + +@Riverpod(keepAlive: true) +class EngineSettingsRepository extends _$EngineSettingsRepository { + final _partitionKey = 'engine'; + + Future updateSettings(UpdateEngineSettingsFunc updateWithCurrent) { + final oldJson = state.toJson(); + final newJson = updateWithCurrent(state).toJson(); + + return ref.read(userDatabaseProvider).transaction(() async { + for (final MapEntry(:key, :value) in newJson.entries) { + if (oldJson[key] != value) { + await ref + .read(userDatabaseProvider) + .settingDao + .updateSetting(key, _partitionKey, value); + } + } + }); + } + + @override + EngineSettings build() { + final db = ref.watch(userDatabaseProvider); + + final watchSub = db.settingDao + .getAllSettingsOfPartitionKey(_partitionKey) + .watch() + .listen((entries) { + final settings = Map.fromEntries(entries); + + state = EngineSettings.fromJson({ + 'incognitoMode': settings['incognitoMode']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'javascriptEnabled': settings['javascriptEnabled']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'trackingProtectionPolicy': settings['trackingProtectionPolicy'] + ?.readAs(DriftSqlType.string, db.typeMapping), + 'httpsOnlyMode': settings['httpsOnlyMode']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), + 'globalPrivacyControlEnabled': + settings['globalPrivacyControlEnabled']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'cookieBannerHandlingMode': settings['cookieBannerHandlingMode'] + ?.readAs(DriftSqlType.string, db.typeMapping), + 'cookieBannerHandlingModePrivateBrowsing': + settings['cookieBannerHandlingModePrivateBrowsing']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), + 'cookieBannerHandlingGlobalRules': + settings['cookieBannerHandlingGlobalRules']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'cookieBannerHandlingGlobalRulesSubFrames': + settings['cookieBannerHandlingGlobalRulesSubFrames']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'webContentIsolationStrategy': + settings['webContentIsolationStrategy']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), + 'userAgent': settings['userAgent']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), + }); + }); + + ref.onDispose(() async { + await watchSub.cancel(); + }); + + return EngineSettings.withDefaults(); + } +} diff --git a/app/lib/features/user/domain/repositories/engine_settings.g.dart b/app/lib/features/user/domain/repositories/engine_settings.g.dart new file mode 100644 index 00000000..492bb0f7 --- /dev/null +++ b/app/lib/features/user/domain/repositories/engine_settings.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'engine_settings.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$engineSettingsRepositoryHash() => + r'87e308c8bd4390d1d5c401532a0c35cd96f6176e'; + +/// See also [EngineSettingsRepository]. +@ProviderFor(EngineSettingsRepository) +final engineSettingsRepositoryProvider = + NotifierProvider.internal( + EngineSettingsRepository.new, + name: r'engineSettingsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$engineSettingsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$EngineSettingsRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/user/domain/repositories/general_settings.dart b/app/lib/features/user/domain/repositories/general_settings.dart new file mode 100644 index 00000000..76988344 --- /dev/null +++ b/app/lib/features/user/domain/repositories/general_settings.dart @@ -0,0 +1,104 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:nullability/nullability.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'general_settings.g.dart'; + +typedef UpdateGeneralSettingsFunc = + GeneralSettings Function(GeneralSettings currentSettings); + +@Riverpod(keepAlive: true) +class GeneralSettingsRepository extends _$GeneralSettingsRepository { + final _partitionKey = 'general'; + + GeneralSettings _deserializeSettings( + List> entries, + ) { + final settings = Map.fromEntries(entries); + + final db = ref.read(userDatabaseProvider); + + return GeneralSettings.fromJson({ + 'themeMode': settings['themeMode']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), + 'enableReadability': settings['enableReadability']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'enforceReadability': settings['enforceReadability']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'deleteBrowsingDataOnQuit': settings['deleteBrowsingDataOnQuit'] + ?.readAs(DriftSqlType.string, db.typeMapping) + .mapNotNull(jsonDecode), + 'defaultSearchProvider': settings['defaultSearchProvider']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), + 'defaultSearchSuggestionsProvider': + settings['defaultSearchSuggestionsProvider']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), + 'createChildTabsOption': settings['createChildTabsOption']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'proxyPrivateTabsTor': settings['proxyPrivateTabsTor']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + }); + } + + //Eager fetch, when up to date settings are required + Future fetch() { + return ref + .read(userDatabaseProvider) + .settingDao + .getAllSettingsOfPartitionKey(_partitionKey) + .get() + .then(_deserializeSettings); + } + + Future updateSettings(UpdateGeneralSettingsFunc updateWithCurrent) { + final oldJson = state.toJson(); + final newJson = updateWithCurrent(state).toJson(); + + final db = ref.read(userDatabaseProvider); + + return db.transaction(() async { + for (final MapEntry(:key, :value) in newJson.entries) { + if (oldJson[key] != value) { + await db.settingDao.updateSetting(key, _partitionKey, value); + } + } + }); + } + + @override + GeneralSettings build() { + final db = ref.watch(userDatabaseProvider); + + final watchSub = db.settingDao + .getAllSettingsOfPartitionKey(_partitionKey) + .watch() + .listen((event) { + state = _deserializeSettings(event); + }); + + ref.onDispose(() async { + await watchSub.cancel(); + }); + + return GeneralSettings.withDefaults(); + } +} diff --git a/app/lib/features/user/domain/repositories/general_settings.g.dart b/app/lib/features/user/domain/repositories/general_settings.g.dart new file mode 100644 index 00000000..63b48338 --- /dev/null +++ b/app/lib/features/user/domain/repositories/general_settings.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'general_settings.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$generalSettingsRepositoryHash() => + r'5335acea5fb175b665b80b2241ce463ae8ad9f89'; + +/// See also [GeneralSettingsRepository]. +@ProviderFor(GeneralSettingsRepository) +final generalSettingsRepositoryProvider = + NotifierProvider.internal( + GeneralSettingsRepository.new, + name: r'generalSettingsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$generalSettingsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$GeneralSettingsRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/user/domain/repositories/onboarding.dart b/app/lib/features/user/domain/repositories/onboarding.dart new file mode 100644 index 00000000..36b40fe2 --- /dev/null +++ b/app/lib/features/user/domain/repositories/onboarding.dart @@ -0,0 +1,34 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'onboarding.g.dart'; + +@Riverpod(keepAlive: true) +class OnboardingRepository extends _$OnboardingRepository { + static const targetRevision = 1; + + Future getCurrentRevision() { + return ref + .read(userDatabaseProvider) + .onboardingDao + .getLastRevision() + .getSingleOrNull(); + } + + Future pushRevision(int revision) { + return ref + .read(userDatabaseProvider) + .onboardingDao + .pushRevision(revision, DateTime.now()); + } + + Future isOutdated() async { + final current = await getCurrentRevision(); + return current != targetRevision; + } + + @override + void build() { + return; + } +} diff --git a/app/lib/features/user/domain/repositories/onboarding.g.dart b/app/lib/features/user/domain/repositories/onboarding.g.dart new file mode 100644 index 00000000..ad730489 --- /dev/null +++ b/app/lib/features/user/domain/repositories/onboarding.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'onboarding.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$onboardingRepositoryHash() => + r'37b76aa313ae6dfed6ab5695b4df8c4a0cfe0483'; + +/// See also [OnboardingRepository]. +@ProviderFor(OnboardingRepository) +final onboardingRepositoryProvider = + NotifierProvider.internal( + OnboardingRepository.new, + name: r'onboardingRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$onboardingRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$OnboardingRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/user/domain/services/local_authentication.dart b/app/lib/features/user/domain/services/local_authentication.dart new file mode 100644 index 00000000..251aead9 --- /dev/null +++ b/app/lib/features/user/domain/services/local_authentication.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; + +part 'local_authentication.g.dart'; + +@Riverpod(keepAlive: true) +class LocalAuthenticationService extends _$LocalAuthenticationService { + final _auth = LocalAuthentication(); + final _cache = {}; + + bool _cacheAuth(String authKey) { + final auth = _cache[authKey]; + if (auth != null && auth.$2.lockTimeout != null) { + return DateTime.now().difference(auth.$1) < auth.$2.lockTimeout!; + } + + return false; + } + + void evictCacheOnBackground() { + _cache.removeWhere((key, value) => value.$2.lockOnAppBackground); + } + + Future authenticate({ + required String authKey, + required String localizedReason, + ContainerAuthSettings? settings, + bool useAuthCache = false, + }) async { + try { + var result = useAuthCache && _cacheAuth(authKey); + + if (!result) { + result = await _auth.authenticate(localizedReason: localizedReason); + } + + if (result && settings != null) { + _cache[authKey] = (DateTime.now(), settings); + } + + return result; + } on PlatformException catch (e, s) { + logger.e('Could not authenticate', error: e, stackTrace: s); + return false; + } + } + + @override + Future build() { + return _auth.canCheckBiometrics; + } +} diff --git a/app/lib/features/user/domain/services/local_authentication.g.dart b/app/lib/features/user/domain/services/local_authentication.g.dart new file mode 100644 index 00000000..736584ba --- /dev/null +++ b/app/lib/features/user/domain/services/local_authentication.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'local_authentication.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$localAuthenticationServiceHash() => + r'4893be7a11833d77a575f7aa248a9ba65d6717da'; + +/// See also [LocalAuthenticationService]. +@ProviderFor(LocalAuthenticationService) +final localAuthenticationServiceProvider = + AsyncNotifierProvider.internal( + LocalAuthenticationService.new, + name: r'localAuthenticationServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$localAuthenticationServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$LocalAuthenticationService = AsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/data/database/converters/feed_authors.dart b/app/lib/features/web_feed/data/database/converters/feed_authors.dart new file mode 100644 index 00000000..e13beded --- /dev/null +++ b/app/lib/features/web_feed/data/database/converters/feed_authors.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_author.dart'; + +class FeedAuthorsConverter extends TypeConverter, String> { + const FeedAuthorsConverter(); + + @override + List fromSql(String fromDb) { + final authors = jsonDecode(fromDb) as List; + return authors + .map((author) => FeedAuthor.fromJson(author as Map)) + .toList(); + } + + @override + String toSql(List value) { + return jsonEncode(value.map((author) => author.toJson()).toList()); + } +} diff --git a/app/lib/features/web_feed/data/database/converters/feed_categories.dart b/app/lib/features/web_feed/data/database/converters/feed_categories.dart new file mode 100644 index 00000000..614de1a1 --- /dev/null +++ b/app/lib/features/web_feed/data/database/converters/feed_categories.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_category.dart'; + +class FeedCategoriesConverter + extends TypeConverter, String> { + const FeedCategoriesConverter(); + + @override + List fromSql(String fromDb) { + final categories = jsonDecode(fromDb) as List; + return categories + .map( + (category) => FeedCategory.fromJson(category as Map), + ) + .toList(); + } + + @override + String toSql(List value) { + return jsonEncode(value.map((category) => category.toJson()).toList()); + } +} diff --git a/app/lib/features/web_feed/data/database/converters/feed_data.dart b/app/lib/features/web_feed/data/database/converters/feed_data.dart new file mode 100644 index 00000000..5381b89e --- /dev/null +++ b/app/lib/features/web_feed/data/database/converters/feed_data.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; + +class FeedDataConverter extends JsonConverter> { + const FeedDataConverter(); + + @override + FeedData fromJson(Map json) { + return FeedData.fromJson(json); + } + + @override + Map toJson(FeedData object) { + return object.toJson(); + } +} diff --git a/app/lib/features/web_feed/data/database/converters/feed_links.dart b/app/lib/features/web_feed/data/database/converters/feed_links.dart new file mode 100644 index 00000000..842c2ecf --- /dev/null +++ b/app/lib/features/web_feed/data/database/converters/feed_links.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; + +class FeedLinksConverter extends TypeConverter, String> { + const FeedLinksConverter(); + + @override + List fromSql(String fromDb) { + final links = jsonDecode(fromDb) as List; + return links + .map((link) => FeedLink.fromJson(link as Map)) + .toList(); + } + + @override + String toSql(List value) { + return jsonEncode(value.map((link) => link.toJson()).toList()); + } +} diff --git a/app/lib/features/web_feed/data/database/daos/article.dart b/app/lib/features/web_feed/data/database/daos/article.dart new file mode 100644 index 00000000..d6824a95 --- /dev/null +++ b/app/lib/features/web_feed/data/database/daos/article.dart @@ -0,0 +1,151 @@ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article_query_result.dart'; + +part 'article.g.dart'; + +@DriftAccessor() +class ArticleDao extends DatabaseAccessor with _$ArticleDaoMixin { + ArticleDao(super.attachedDatabase); + + Selectable getFeedArticles(Uri? url) { + final select = db.articleView.select(); + + if (url != null) { + select.where((article) => article.feedId.equalsValue(url)); + } + + return select..orderBy([ + (row) => OrderingTerm( + expression: coalesce([row.updated, row.created]), + mode: OrderingMode.desc, + ), + ]); + } + + Selectable getUnprocessedArticles() { + return db.articleView.select()..where( + (article) => + (article.contentHtml.isNotNull() & + (article.contentMarkdown.isNull() | + article.contentPlain.isNull())) | + (article.summaryHtml.isNotNull() & + (article.summaryMarkdown.isNull() | + article.summaryPlain.isNull())), + ); + } + + SingleOrNullSelectable getArticleById(String articleId) { + return db.articleView.select()..where((row) => row.id.equals(articleId)); + } + + Future updateArticleContent(List articles) { + return db.transaction(() async { + await Future.wait( + articles.map((newArticle) { + final statement = db.article.update() + ..where((article) => article.id.equals(newArticle.id)); + + return statement.write( + ArticleCompanion( + summaryHtml: Value(newArticle.summaryHtml), + summaryMarkdown: Value(newArticle.summaryMarkdown), + summaryPlain: Value(newArticle.summaryPlain), + contentHtml: Value(newArticle.contentHtml), + contentMarkdown: Value(newArticle.contentMarkdown), + contentPlain: Value(newArticle.contentPlain), + ), + ); + }), + ); + }); + } + + Future upsertArticles(List articles) { + return db.transaction(() async { + await Future.wait( + articles + .map( + (article) => db.article.insertOne( + article, + onConflict: DoUpdate( + (old) { + return ArticleCompanion( + authors: Value(article.authors), + contentHtml: Value(article.contentHtml), + contentMarkdown: Value(article.contentMarkdown), + contentPlain: Value(article.contentPlain), + links: Value(article.links), + summaryHtml: Value(article.summaryHtml), + summaryMarkdown: Value(article.summaryMarkdown), + summaryPlain: Value(article.summaryPlain), + tags: Value(article.tags), + title: Value(article.title), + updated: Value(article.updated), + ); + }, + where: (old) => + old.updated.isNotNull() & + old.updated.isSmallerThanValue( + article.updated ?? DateTime(0), + ), + ), + ), + ) + .toList(), + ); + }); + } + + Future updateArticleRead(String articleId, DateTime? read) { + final statement = db.article.update() + ..where((article) => article.id.equals(articleId)); + + return statement.write(ArticleCompanion(lastRead: Value(read))); + } + + Selectable<(String, int)> getUnreadArticleCount() { + final count = countAll(); + + final countByFeed = db.article.selectOnly() + ..addColumns([db.article.feedId, count]) + ..where( + db.article.lastRead.isNull() | + (db.article.updated.isNotNull() & + db.article.lastRead.isSmallerThan(db.article.lastRead)), + ) + ..groupBy([db.article.feedId]); + + return countByFeed.map( + (result) => (result.read(db.article.feedId)!, result.read(count)!), + ); + } + + Selectable queryArticles({ + required String matchPrefix, + required String matchSuffix, + required String ellipsis, + required int snippetLength, + required String searchString, + required Uri? feedId, + }) { + final ftsQuery = db.buildFtsQuery(searchString); + + if (ftsQuery.isNotEmpty) { + return db.queryArticlesFullContent( + feedId: feedId?.toString(), + query: ftsQuery, + snippetLength: snippetLength, + beforeMatch: matchPrefix, + afterMatch: matchSuffix, + ellipsis: ellipsis, + ); + } else { + return db.queryArticlesBasic( + feedId: feedId?.toString(), + query: db.buildLikeQuery(searchString), + ); + } + } +} diff --git a/app/lib/features/web_feed/data/database/daos/article.g.dart b/app/lib/features/web_feed/data/database/daos/article.g.dart new file mode 100644 index 00000000..a2904668 --- /dev/null +++ b/app/lib/features/web_feed/data/database/daos/article.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'article.dart'; + +// ignore_for_file: type=lint +mixin _$ArticleDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/web_feed/data/database/daos/feed.dart b/app/lib/features/web_feed/data/database/daos/feed.dart new file mode 100644 index 00000000..58f1e3b9 --- /dev/null +++ b/app/lib/features/web_feed/data/database/daos/feed.dart @@ -0,0 +1,42 @@ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; + +part 'feed.g.dart'; + +@DriftAccessor() +class FeedDao extends DatabaseAccessor with _$FeedDaoMixin { + FeedDao(super.attachedDatabase); + + Selectable getFeeds() { + return db.feed.select(); + } + + SingleOrNullSelectable getFeed(Uri feedId) { + return db.feed.select()..where((feed) => feed.url.equalsValue(feedId)); + } + + Future updateFeedFetched(Uri feedId, DateTime fetched) { + final statement = db.feed.update() + ..where((feed) => feed.url.equalsValue(feedId)); + + return statement.write(FeedCompanion(lastFetched: Value(fetched))); + } + + Future deleteFeed(Uri feedId) { + return db.feed.deleteWhere((feed) => feed.url.equals(feedId.toString())); + } + + Future upsertFeed(FeedData feedData) { + return db.feed.insertOne( + feedData, + onConflict: DoUpdate((old) { + return FeedCompanion( + authors: Value(feedData.authors), + title: Value(feedData.title), + description: Value(feedData.description), + tags: Value(feedData.tags), + ); + }), + ); + } +} diff --git a/app/lib/features/web_feed/data/database/daos/feed.g.dart b/app/lib/features/web_feed/data/database/daos/feed.g.dart new file mode 100644 index 00000000..95d0ecec --- /dev/null +++ b/app/lib/features/web_feed/data/database/daos/feed.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed.dart'; + +// ignore_for_file: type=lint +mixin _$FeedDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/web_feed/data/database/database.dart b/app/lib/features/web_feed/data/database/database.dart new file mode 100644 index 00000000..0eda2687 --- /dev/null +++ b/app/lib/features/web_feed/data/database/database.dart @@ -0,0 +1,36 @@ +import 'package:drift/drift.dart'; +import 'package:weblibre/data/database/converters/uri.dart'; +import 'package:weblibre/features/search/domain/fts_tokenizer.dart'; +import 'package:weblibre/features/web_feed/data/database/converters/feed_authors.dart'; +import 'package:weblibre/features/web_feed/data/database/converters/feed_categories.dart'; +import 'package:weblibre/features/web_feed/data/database/converters/feed_links.dart'; +import 'package:weblibre/features/web_feed/data/database/daos/article.dart'; +import 'package:weblibre/features/web_feed/data/database/daos/feed.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article_query_result.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_author.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_category.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; + +part 'database.g.dart'; + +@DriftDatabase(include: {'database.drift'}, daos: [ArticleDao, FeedDao]) +class FeedDatabase extends _$FeedDatabase with TrigramQueryBuilderMixin { + @override + final int schemaVersion = 1; + + @override + final int ftsTokenLimit = 10; + @override + final int ftsMinTokenLength = 3; + + @override + MigrationStrategy get migration => MigrationStrategy( + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON;'); + await optimizeFtsIndex(); + }, + ); + + FeedDatabase(super.e); +} diff --git a/app/lib/features/web_feed/data/database/database.drift b/app/lib/features/web_feed/data/database/database.drift new file mode 100644 index 00000000..445bdf38 --- /dev/null +++ b/app/lib/features/web_feed/data/database/database.drift @@ -0,0 +1,135 @@ +import 'package:weblibre/data/database/converters/uri.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article_query_result.dart'; +import 'package:weblibre/features/web_feed/data/database/converters/feed_authors.dart'; +import 'package:weblibre/features/web_feed/data/database/converters/feed_categories.dart'; +import 'package:weblibre/features/web_feed/data/database/converters/feed_links.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; + +CREATE TABLE feed ( + url TEXT PRIMARY KEY NOT NULL MAPPED BY `const UriConverter()`, + title TEXT, + description TEXT, + icon TEXT MAPPED BY `const UriConverterNullable()`, + site_link TEXT MAPPED BY `const UriConverterNullable()`, + authors TEXT MAPPED BY `const FeedAuthorsConverter()`, + tags TEXT MAPPED BY `const FeedCategoriesConverter()`, + last_fetched DATETIME +); + +CREATE TABLE article ( + id TEXT PRIMARY KEY NOT NULL, + feed_id TEXT NOT NULL MAPPED BY `const UriConverter()` REFERENCES feed ("url") ON DELETE CASCADE, + fetched DATETIME NOT NULL, + created DATETIME, + updated DATETIME, + last_read DATETIME, + title TEXT, + authors TEXT MAPPED BY `const FeedAuthorsConverter()`, + tags TEXT MAPPED BY `const FeedCategoriesConverter()`, + links TEXT MAPPED BY `const FeedLinksConverter()`, + summaryHtml TEXT, + summaryMarkdown TEXT, + summaryPlain TEXT, + contentHtml TEXT, + contentMarkdown TEXT, + contentPlain TEXT +) WITH FeedArticle; + +CREATE VIEW article_view WITH FeedArticle AS + SELECT + a.*, + f.icon, + f.site_link + FROM + article a + INNER JOIN + feed f on f.url = a.feed_id; + +CREATE INDEX article_feed_id ON article (feed_id); + +CREATE VIRTUAL TABLE article_fts + USING fts5( + title, + summaryPlain, + contentPlain, + content=article, + tokenize="trigram" + ); + +-- Triggers to keep the FTS index up to date. +CREATE TRIGGER article_after_insert AFTER INSERT ON article BEGIN + INSERT INTO + article_fts(rowid, title, summaryPlain, contentPlain) + VALUES (new.rowid, new.title, new.summaryPlain, new.contentPlain); +END; +CREATE TRIGGER article_after_delete AFTER DELETE ON article BEGIN + INSERT INTO + article_fts(article_fts, rowid, title, summaryPlain, contentPlain) + VALUES('delete', old.rowid, old.title, old.summaryPlain, old.contentPlain); +END; +CREATE TRIGGER article_after_update AFTER UPDATE ON article BEGIN + INSERT INTO + article_fts(article_fts, rowid, title, summaryPlain, contentPlain) + VALUES('delete', old.rowid, old.title, old.summaryPlain, old.contentPlain); + INSERT INTO + article_fts(rowid, title, summaryPlain, contentPlain) + VALUES (new.rowid, new.title, new.summaryPlain, new.contentPlain); +END; + +optimizeFtsIndex: + INSERT INTO article_fts(article_fts) VALUES ('optimize'); + +queryArticlesBasic(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: + WITH weights AS ( + SELECT + -- Customize these weights (higher = more important) + 1.0 as title_weight -- Title matches are most important + ) + SELECT + a.*, + f.icon, + ( + bm25(article_fts, weights.title_weight) + ) AS weighted_rank + FROM article_fts fts + INNER JOIN + article a ON a.rowid = fts.rowid + INNER JOIN + feed f ON f.url = a.feed_id + CROSS JOIN weights + WHERE + fts.title LIKE :query AND + (:feed_id IS NULL OR a.feed_id = :feed_id) + ORDER BY + weighted_rank ASC, + a.created DESC NULLS LAST; + +queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: + WITH weights AS ( + SELECT + -- Customize these weights (higher = more important) + 10.0 as title_weight, -- Title matches are most important + 3.0 as summary_weight, -- Summary matches are quite important + 1.0 as content_weight -- Content matches are basic + ) + SELECT + a.*, + f.icon, + highlight(article_fts, 0, :beforeMatch, :afterMatch) AS title_highlight, + snippet(article_fts, 1, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS summary_snippet, + snippet(article_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS content_snippet, + ( + bm25(article_fts, weights.title_weight, weights.summary_weight, + weights.content_weight) + ) AS weighted_rank + FROM article_fts(:query) fts + INNER JOIN + article a ON a.rowid = fts.rowid + INNER JOIN + feed f ON f.url = a.feed_id + CROSS JOIN weights + WHERE + :feed_id IS NULL OR a.feed_id = :feed_id + ORDER BY + weighted_rank ASC, + a.created DESC NULLS LAST; \ No newline at end of file diff --git a/app/lib/features/web_feed/data/database/database.g.dart b/app/lib/features/web_feed/data/database/database.g.dart new file mode 100644 index 00000000..30c38493 --- /dev/null +++ b/app/lib/features/web_feed/data/database/database.g.dart @@ -0,0 +1,2671 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class Feed extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Feed(this.attachedDatabase, [this._alias]); + late final GeneratedColumnWithTypeConverter url = + GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ).withConverter(Feed.$converterurl); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumnWithTypeConverter icon = + GeneratedColumn( + 'icon', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(Feed.$convertericon); + late final GeneratedColumnWithTypeConverter siteLink = + GeneratedColumn( + 'site_link', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(Feed.$convertersiteLink); + late final GeneratedColumnWithTypeConverter?, String> + authors = GeneratedColumn( + 'authors', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(Feed.$converterauthorsn); + late final GeneratedColumnWithTypeConverter?, String> + tags = GeneratedColumn( + 'tags', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(Feed.$convertertagsn); + late final GeneratedColumn lastFetched = GeneratedColumn( + 'last_fetched', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + url, + title, + description, + icon, + siteLink, + authors, + tags, + lastFetched, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'feed'; + @override + Set get $primaryKey => {url}; + @override + FeedData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FeedData( + url: Feed.$converterurl.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + icon: Feed.$convertericon.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}icon'], + ), + ), + siteLink: Feed.$convertersiteLink.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}site_link'], + ), + ), + authors: Feed.$converterauthorsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}authors'], + ), + ), + tags: Feed.$convertertagsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags'], + ), + ), + lastFetched: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_fetched'], + ), + ); + } + + @override + Feed createAlias(String alias) { + return Feed(attachedDatabase, alias); + } + + static TypeConverter $converterurl = const UriConverter(); + static TypeConverter $convertericon = + const UriConverterNullable(); + static TypeConverter $convertersiteLink = + const UriConverterNullable(); + static TypeConverter, String> $converterauthors = + const FeedAuthorsConverter(); + static TypeConverter?, String?> $converterauthorsn = + NullAwareTypeConverter.wrap($converterauthors); + static TypeConverter, String> $convertertags = + const FeedCategoriesConverter(); + static TypeConverter?, String?> $convertertagsn = + NullAwareTypeConverter.wrap($convertertags); + @override + bool get dontWriteConstraints => true; +} + +class FeedData extends DataClass implements Insertable { + final Uri url; + final String? title; + final String? description; + final Uri? icon; + final Uri? siteLink; + final List? authors; + final List? tags; + final DateTime? lastFetched; + const FeedData({ + required this.url, + this.title, + this.description, + this.icon, + this.siteLink, + this.authors, + this.tags, + this.lastFetched, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + { + map['url'] = Variable(Feed.$converterurl.toSql(url)); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || icon != null) { + map['icon'] = Variable(Feed.$convertericon.toSql(icon)); + } + if (!nullToAbsent || siteLink != null) { + map['site_link'] = Variable( + Feed.$convertersiteLink.toSql(siteLink), + ); + } + if (!nullToAbsent || authors != null) { + map['authors'] = Variable(Feed.$converterauthorsn.toSql(authors)); + } + if (!nullToAbsent || tags != null) { + map['tags'] = Variable(Feed.$convertertagsn.toSql(tags)); + } + if (!nullToAbsent || lastFetched != null) { + map['last_fetched'] = Variable(lastFetched); + } + return map; + } + + factory FeedData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return FeedData( + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + description: serializer.fromJson(json['description']), + icon: serializer.fromJson(json['icon']), + siteLink: serializer.fromJson(json['site_link']), + authors: serializer.fromJson?>(json['authors']), + tags: serializer.fromJson?>(json['tags']), + lastFetched: serializer.fromJson(json['last_fetched']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'description': serializer.toJson(description), + 'icon': serializer.toJson(icon), + 'site_link': serializer.toJson(siteLink), + 'authors': serializer.toJson?>(authors), + 'tags': serializer.toJson?>(tags), + 'last_fetched': serializer.toJson(lastFetched), + }; + } + + FeedData copyWith({ + Uri? url, + Value title = const Value.absent(), + Value description = const Value.absent(), + Value icon = const Value.absent(), + Value siteLink = const Value.absent(), + Value?> authors = const Value.absent(), + Value?> tags = const Value.absent(), + Value lastFetched = const Value.absent(), + }) => FeedData( + url: url ?? this.url, + title: title.present ? title.value : this.title, + description: description.present ? description.value : this.description, + icon: icon.present ? icon.value : this.icon, + siteLink: siteLink.present ? siteLink.value : this.siteLink, + authors: authors.present ? authors.value : this.authors, + tags: tags.present ? tags.value : this.tags, + lastFetched: lastFetched.present ? lastFetched.value : this.lastFetched, + ); + FeedData copyWithCompanion(FeedCompanion data) { + return FeedData( + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + description: data.description.present + ? data.description.value + : this.description, + icon: data.icon.present ? data.icon.value : this.icon, + siteLink: data.siteLink.present ? data.siteLink.value : this.siteLink, + authors: data.authors.present ? data.authors.value : this.authors, + tags: data.tags.present ? data.tags.value : this.tags, + lastFetched: data.lastFetched.present + ? data.lastFetched.value + : this.lastFetched, + ); + } + + @override + String toString() { + return (StringBuffer('FeedData(') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('icon: $icon, ') + ..write('siteLink: $siteLink, ') + ..write('authors: $authors, ') + ..write('tags: $tags, ') + ..write('lastFetched: $lastFetched') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + url, + title, + description, + icon, + siteLink, + authors, + tags, + lastFetched, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is FeedData && + other.url == this.url && + other.title == this.title && + other.description == this.description && + other.icon == this.icon && + other.siteLink == this.siteLink && + other.authors == this.authors && + other.tags == this.tags && + other.lastFetched == this.lastFetched); +} + +class FeedCompanion extends UpdateCompanion { + final Value url; + final Value title; + final Value description; + final Value icon; + final Value siteLink; + final Value?> authors; + final Value?> tags; + final Value lastFetched; + final Value rowid; + const FeedCompanion({ + this.url = const Value.absent(), + this.title = const Value.absent(), + this.description = const Value.absent(), + this.icon = const Value.absent(), + this.siteLink = const Value.absent(), + this.authors = const Value.absent(), + this.tags = const Value.absent(), + this.lastFetched = const Value.absent(), + this.rowid = const Value.absent(), + }); + FeedCompanion.insert({ + required Uri url, + this.title = const Value.absent(), + this.description = const Value.absent(), + this.icon = const Value.absent(), + this.siteLink = const Value.absent(), + this.authors = const Value.absent(), + this.tags = const Value.absent(), + this.lastFetched = const Value.absent(), + this.rowid = const Value.absent(), + }) : url = Value(url); + static Insertable custom({ + Expression? url, + Expression? title, + Expression? description, + Expression? icon, + Expression? siteLink, + Expression? authors, + Expression? tags, + Expression? lastFetched, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (url != null) 'url': url, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (icon != null) 'icon': icon, + if (siteLink != null) 'site_link': siteLink, + if (authors != null) 'authors': authors, + if (tags != null) 'tags': tags, + if (lastFetched != null) 'last_fetched': lastFetched, + if (rowid != null) 'rowid': rowid, + }); + } + + FeedCompanion copyWith({ + Value? url, + Value? title, + Value? description, + Value? icon, + Value? siteLink, + Value?>? authors, + Value?>? tags, + Value? lastFetched, + Value? rowid, + }) { + return FeedCompanion( + url: url ?? this.url, + title: title ?? this.title, + description: description ?? this.description, + icon: icon ?? this.icon, + siteLink: siteLink ?? this.siteLink, + authors: authors ?? this.authors, + tags: tags ?? this.tags, + lastFetched: lastFetched ?? this.lastFetched, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (url.present) { + map['url'] = Variable(Feed.$converterurl.toSql(url.value)); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (icon.present) { + map['icon'] = Variable(Feed.$convertericon.toSql(icon.value)); + } + if (siteLink.present) { + map['site_link'] = Variable( + Feed.$convertersiteLink.toSql(siteLink.value), + ); + } + if (authors.present) { + map['authors'] = Variable( + Feed.$converterauthorsn.toSql(authors.value), + ); + } + if (tags.present) { + map['tags'] = Variable(Feed.$convertertagsn.toSql(tags.value)); + } + if (lastFetched.present) { + map['last_fetched'] = Variable(lastFetched.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FeedCompanion(') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('icon: $icon, ') + ..write('siteLink: $siteLink, ') + ..write('authors: $authors, ') + ..write('tags: $tags, ') + ..write('lastFetched: $lastFetched, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Article extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Article(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumnWithTypeConverter feedId = + GeneratedColumn( + 'feed_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES feed(url)ON DELETE CASCADE', + ).withConverter(Article.$converterfeedId); + late final GeneratedColumn fetched = GeneratedColumn( + 'fetched', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn created = GeneratedColumn( + 'created', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn updated = GeneratedColumn( + 'updated', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn lastRead = GeneratedColumn( + 'last_read', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumnWithTypeConverter?, String> + authors = GeneratedColumn( + 'authors', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(Article.$converterauthorsn); + late final GeneratedColumnWithTypeConverter?, String> + tags = GeneratedColumn( + 'tags', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(Article.$convertertagsn); + late final GeneratedColumnWithTypeConverter?, String> links = + GeneratedColumn( + 'links', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(Article.$converterlinksn); + late final GeneratedColumn summaryHtml = GeneratedColumn( + 'summaryHtml', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn summaryMarkdown = GeneratedColumn( + 'summaryMarkdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn summaryPlain = GeneratedColumn( + 'summaryPlain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn contentHtml = GeneratedColumn( + 'contentHtml', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn contentMarkdown = GeneratedColumn( + 'contentMarkdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn contentPlain = GeneratedColumn( + 'contentPlain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + id, + feedId, + fetched, + created, + updated, + lastRead, + title, + authors, + tags, + links, + summaryHtml, + summaryMarkdown, + summaryPlain, + contentHtml, + contentMarkdown, + contentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'article'; + @override + Set get $primaryKey => {id}; + @override + FeedArticle map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FeedArticle( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + feedId: Article.$converterfeedId.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}feed_id'], + )!, + ), + fetched: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}fetched'], + )!, + created: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created'], + ), + updated: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated'], + ), + lastRead: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_read'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + authors: Article.$converterauthorsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}authors'], + ), + ), + tags: Article.$convertertagsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags'], + ), + ), + links: Article.$converterlinksn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}links'], + ), + ), + summaryHtml: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryHtml'], + ), + summaryMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryMarkdown'], + ), + summaryPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryPlain'], + ), + contentHtml: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentHtml'], + ), + contentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentMarkdown'], + ), + contentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentPlain'], + ), + ); + } + + @override + Article createAlias(String alias) { + return Article(attachedDatabase, alias); + } + + static TypeConverter $converterfeedId = const UriConverter(); + static TypeConverter, String> $converterauthors = + const FeedAuthorsConverter(); + static TypeConverter?, String?> $converterauthorsn = + NullAwareTypeConverter.wrap($converterauthors); + static TypeConverter, String> $convertertags = + const FeedCategoriesConverter(); + static TypeConverter?, String?> $convertertagsn = + NullAwareTypeConverter.wrap($convertertags); + static TypeConverter, String> $converterlinks = + const FeedLinksConverter(); + static TypeConverter?, String?> $converterlinksn = + NullAwareTypeConverter.wrap($converterlinks); + @override + bool get dontWriteConstraints => true; +} + +class ArticleCompanion extends UpdateCompanion { + final Value id; + final Value feedId; + final Value fetched; + final Value created; + final Value updated; + final Value lastRead; + final Value title; + final Value?> authors; + final Value?> tags; + final Value?> links; + final Value summaryHtml; + final Value summaryMarkdown; + final Value summaryPlain; + final Value contentHtml; + final Value contentMarkdown; + final Value contentPlain; + final Value rowid; + const ArticleCompanion({ + this.id = const Value.absent(), + this.feedId = const Value.absent(), + this.fetched = const Value.absent(), + this.created = const Value.absent(), + this.updated = const Value.absent(), + this.lastRead = const Value.absent(), + this.title = const Value.absent(), + this.authors = const Value.absent(), + this.tags = const Value.absent(), + this.links = const Value.absent(), + this.summaryHtml = const Value.absent(), + this.summaryMarkdown = const Value.absent(), + this.summaryPlain = const Value.absent(), + this.contentHtml = const Value.absent(), + this.contentMarkdown = const Value.absent(), + this.contentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + ArticleCompanion.insert({ + required String id, + required Uri feedId, + required DateTime fetched, + this.created = const Value.absent(), + this.updated = const Value.absent(), + this.lastRead = const Value.absent(), + this.title = const Value.absent(), + this.authors = const Value.absent(), + this.tags = const Value.absent(), + this.links = const Value.absent(), + this.summaryHtml = const Value.absent(), + this.summaryMarkdown = const Value.absent(), + this.summaryPlain = const Value.absent(), + this.contentHtml = const Value.absent(), + this.contentMarkdown = const Value.absent(), + this.contentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + feedId = Value(feedId), + fetched = Value(fetched); + static Insertable custom({ + Expression? id, + Expression? feedId, + Expression? fetched, + Expression? created, + Expression? updated, + Expression? lastRead, + Expression? title, + Expression? authors, + Expression? tags, + Expression? links, + Expression? summaryHtml, + Expression? summaryMarkdown, + Expression? summaryPlain, + Expression? contentHtml, + Expression? contentMarkdown, + Expression? contentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (feedId != null) 'feed_id': feedId, + if (fetched != null) 'fetched': fetched, + if (created != null) 'created': created, + if (updated != null) 'updated': updated, + if (lastRead != null) 'last_read': lastRead, + if (title != null) 'title': title, + if (authors != null) 'authors': authors, + if (tags != null) 'tags': tags, + if (links != null) 'links': links, + if (summaryHtml != null) 'summaryHtml': summaryHtml, + if (summaryMarkdown != null) 'summaryMarkdown': summaryMarkdown, + if (summaryPlain != null) 'summaryPlain': summaryPlain, + if (contentHtml != null) 'contentHtml': contentHtml, + if (contentMarkdown != null) 'contentMarkdown': contentMarkdown, + if (contentPlain != null) 'contentPlain': contentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + ArticleCompanion copyWith({ + Value? id, + Value? feedId, + Value? fetched, + Value? created, + Value? updated, + Value? lastRead, + Value? title, + Value?>? authors, + Value?>? tags, + Value?>? links, + Value? summaryHtml, + Value? summaryMarkdown, + Value? summaryPlain, + Value? contentHtml, + Value? contentMarkdown, + Value? contentPlain, + Value? rowid, + }) { + return ArticleCompanion( + id: id ?? this.id, + feedId: feedId ?? this.feedId, + fetched: fetched ?? this.fetched, + created: created ?? this.created, + updated: updated ?? this.updated, + lastRead: lastRead ?? this.lastRead, + title: title ?? this.title, + authors: authors ?? this.authors, + tags: tags ?? this.tags, + links: links ?? this.links, + summaryHtml: summaryHtml ?? this.summaryHtml, + summaryMarkdown: summaryMarkdown ?? this.summaryMarkdown, + summaryPlain: summaryPlain ?? this.summaryPlain, + contentHtml: contentHtml ?? this.contentHtml, + contentMarkdown: contentMarkdown ?? this.contentMarkdown, + contentPlain: contentPlain ?? this.contentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (feedId.present) { + map['feed_id'] = Variable( + Article.$converterfeedId.toSql(feedId.value), + ); + } + if (fetched.present) { + map['fetched'] = Variable(fetched.value); + } + if (created.present) { + map['created'] = Variable(created.value); + } + if (updated.present) { + map['updated'] = Variable(updated.value); + } + if (lastRead.present) { + map['last_read'] = Variable(lastRead.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (authors.present) { + map['authors'] = Variable( + Article.$converterauthorsn.toSql(authors.value), + ); + } + if (tags.present) { + map['tags'] = Variable(Article.$convertertagsn.toSql(tags.value)); + } + if (links.present) { + map['links'] = Variable( + Article.$converterlinksn.toSql(links.value), + ); + } + if (summaryHtml.present) { + map['summaryHtml'] = Variable(summaryHtml.value); + } + if (summaryMarkdown.present) { + map['summaryMarkdown'] = Variable(summaryMarkdown.value); + } + if (summaryPlain.present) { + map['summaryPlain'] = Variable(summaryPlain.value); + } + if (contentHtml.present) { + map['contentHtml'] = Variable(contentHtml.value); + } + if (contentMarkdown.present) { + map['contentMarkdown'] = Variable(contentMarkdown.value); + } + if (contentPlain.present) { + map['contentPlain'] = Variable(contentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ArticleCompanion(') + ..write('id: $id, ') + ..write('feedId: $feedId, ') + ..write('fetched: $fetched, ') + ..write('created: $created, ') + ..write('updated: $updated, ') + ..write('lastRead: $lastRead, ') + ..write('title: $title, ') + ..write('authors: $authors, ') + ..write('tags: $tags, ') + ..write('links: $links, ') + ..write('summaryHtml: $summaryHtml, ') + ..write('summaryMarkdown: $summaryMarkdown, ') + ..write('summaryPlain: $summaryPlain, ') + ..write('contentHtml: $contentHtml, ') + ..write('contentMarkdown: $contentMarkdown, ') + ..write('contentPlain: $contentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class ArticleView extends ViewInfo + implements HasResultSet { + final String? _alias; + @override + final _$FeedDatabase attachedDatabase; + ArticleView(this.attachedDatabase, [this._alias]); + @override + List get $columns => [ + id, + feedId, + fetched, + created, + updated, + lastRead, + title, + authors, + tags, + links, + summaryHtml, + summaryMarkdown, + summaryPlain, + contentHtml, + contentMarkdown, + contentPlain, + icon, + siteLink, + ]; + @override + String get aliasedName => _alias ?? entityName; + @override + String get entityName => 'article_view'; + @override + Map get createViewStatements => { + SqlDialect.sqlite: + 'CREATE VIEW article_view AS SELECT a.*, f.icon, f.site_link FROM article AS a INNER JOIN feed AS f ON f.url = a.feed_id', + }; + @override + ArticleView get asDslTable => this; + @override + FeedArticle map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FeedArticle( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + feedId: Article.$converterfeedId.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}feed_id'], + )!, + ), + fetched: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}fetched'], + )!, + created: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created'], + ), + updated: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated'], + ), + lastRead: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_read'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + authors: Article.$converterauthorsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}authors'], + ), + ), + tags: Article.$convertertagsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags'], + ), + ), + links: Article.$converterlinksn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}links'], + ), + ), + summaryHtml: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryHtml'], + ), + summaryMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryMarkdown'], + ), + summaryPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryPlain'], + ), + contentHtml: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentHtml'], + ), + contentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentMarkdown'], + ), + contentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentPlain'], + ), + icon: Feed.$convertericon.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}icon'], + ), + ), + siteLink: Feed.$convertersiteLink.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}site_link'], + ), + ), + ); + } + + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + ); + late final GeneratedColumnWithTypeConverter feedId = + GeneratedColumn( + 'feed_id', + aliasedName, + false, + type: DriftSqlType.string, + ).withConverter(Article.$converterfeedId); + late final GeneratedColumn fetched = GeneratedColumn( + 'fetched', + aliasedName, + false, + type: DriftSqlType.dateTime, + ); + late final GeneratedColumn created = GeneratedColumn( + 'created', + aliasedName, + true, + type: DriftSqlType.dateTime, + ); + late final GeneratedColumn updated = GeneratedColumn( + 'updated', + aliasedName, + true, + type: DriftSqlType.dateTime, + ); + late final GeneratedColumn lastRead = GeneratedColumn( + 'last_read', + aliasedName, + true, + type: DriftSqlType.dateTime, + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumnWithTypeConverter?, String> + authors = GeneratedColumn( + 'authors', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter?>(Article.$converterauthorsn); + late final GeneratedColumnWithTypeConverter?, String> + tags = GeneratedColumn( + 'tags', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter?>(Article.$convertertagsn); + late final GeneratedColumnWithTypeConverter?, String> links = + GeneratedColumn( + 'links', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter?>(Article.$converterlinksn); + late final GeneratedColumn summaryHtml = GeneratedColumn( + 'summaryHtml', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumn summaryMarkdown = GeneratedColumn( + 'summaryMarkdown', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumn summaryPlain = GeneratedColumn( + 'summaryPlain', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumn contentHtml = GeneratedColumn( + 'contentHtml', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumn contentMarkdown = GeneratedColumn( + 'contentMarkdown', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumn contentPlain = GeneratedColumn( + 'contentPlain', + aliasedName, + true, + type: DriftSqlType.string, + ); + late final GeneratedColumnWithTypeConverter icon = + GeneratedColumn( + 'icon', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter(Feed.$convertericon); + late final GeneratedColumnWithTypeConverter siteLink = + GeneratedColumn( + 'site_link', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter(Feed.$convertersiteLink); + @override + ArticleView createAlias(String alias) { + return ArticleView(attachedDatabase, alias); + } + + @override + Query? get query => null; + @override + Set get readTables => const {'article', 'feed'}; +} + +class ArticleFts extends Table + with + TableInfo, + VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ArticleFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final GeneratedColumn summaryPlain = GeneratedColumn( + 'summaryPlain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final GeneratedColumn contentPlain = GeneratedColumn( + 'contentPlain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + @override + List get $columns => [title, summaryPlain, contentPlain]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'article_fts'; + @override + Set get $primaryKey => const {}; + @override + ArticleFt map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ArticleFt( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + summaryPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryPlain'], + )!, + contentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentPlain'], + )!, + ); + } + + @override + ArticleFts createAlias(String alias) { + return ArticleFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, summaryPlain, contentPlain, content=article, tokenize="trigram")'; +} + +class ArticleFt extends DataClass implements Insertable { + final String title; + final String summaryPlain; + final String contentPlain; + const ArticleFt({ + required this.title, + required this.summaryPlain, + required this.contentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['title'] = Variable(title); + map['summaryPlain'] = Variable(summaryPlain); + map['contentPlain'] = Variable(contentPlain); + return map; + } + + factory ArticleFt.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ArticleFt( + title: serializer.fromJson(json['title']), + summaryPlain: serializer.fromJson(json['summaryPlain']), + contentPlain: serializer.fromJson(json['contentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'summaryPlain': serializer.toJson(summaryPlain), + 'contentPlain': serializer.toJson(contentPlain), + }; + } + + ArticleFt copyWith({ + String? title, + String? summaryPlain, + String? contentPlain, + }) => ArticleFt( + title: title ?? this.title, + summaryPlain: summaryPlain ?? this.summaryPlain, + contentPlain: contentPlain ?? this.contentPlain, + ); + ArticleFt copyWithCompanion(ArticleFtsCompanion data) { + return ArticleFt( + title: data.title.present ? data.title.value : this.title, + summaryPlain: data.summaryPlain.present + ? data.summaryPlain.value + : this.summaryPlain, + contentPlain: data.contentPlain.present + ? data.contentPlain.value + : this.contentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('ArticleFt(') + ..write('title: $title, ') + ..write('summaryPlain: $summaryPlain, ') + ..write('contentPlain: $contentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(title, summaryPlain, contentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ArticleFt && + other.title == this.title && + other.summaryPlain == this.summaryPlain && + other.contentPlain == this.contentPlain); +} + +class ArticleFtsCompanion extends UpdateCompanion { + final Value title; + final Value summaryPlain; + final Value contentPlain; + final Value rowid; + const ArticleFtsCompanion({ + this.title = const Value.absent(), + this.summaryPlain = const Value.absent(), + this.contentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + ArticleFtsCompanion.insert({ + required String title, + required String summaryPlain, + required String contentPlain, + this.rowid = const Value.absent(), + }) : title = Value(title), + summaryPlain = Value(summaryPlain), + contentPlain = Value(contentPlain); + static Insertable custom({ + Expression? title, + Expression? summaryPlain, + Expression? contentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (summaryPlain != null) 'summaryPlain': summaryPlain, + if (contentPlain != null) 'contentPlain': contentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + ArticleFtsCompanion copyWith({ + Value? title, + Value? summaryPlain, + Value? contentPlain, + Value? rowid, + }) { + return ArticleFtsCompanion( + title: title ?? this.title, + summaryPlain: summaryPlain ?? this.summaryPlain, + contentPlain: contentPlain ?? this.contentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (summaryPlain.present) { + map['summaryPlain'] = Variable(summaryPlain.value); + } + if (contentPlain.present) { + map['contentPlain'] = Variable(contentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ArticleFtsCompanion(') + ..write('title: $title, ') + ..write('summaryPlain: $summaryPlain, ') + ..write('contentPlain: $contentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$FeedDatabase extends GeneratedDatabase { + _$FeedDatabase(QueryExecutor e) : super(e); + $FeedDatabaseManager get managers => $FeedDatabaseManager(this); + late final Feed feed = Feed(this); + late final Article article = Article(this); + late final ArticleView articleView = ArticleView(this); + late final Index articleFeedId = Index( + 'article_feed_id', + 'CREATE INDEX article_feed_id ON article (feed_id)', + ); + late final ArticleFts articleFts = ArticleFts(this); + late final Trigger articleAfterInsert = Trigger( + 'CREATE TRIGGER article_after_insert AFTER INSERT ON article BEGIN INSERT INTO article_fts ("rowid", title, summaryPlain, contentPlain) VALUES (new."rowid", new.title, new.summaryPlain, new.contentPlain);END', + 'article_after_insert', + ); + late final Trigger articleAfterDelete = Trigger( + 'CREATE TRIGGER article_after_delete AFTER DELETE ON article BEGIN INSERT INTO article_fts (article_fts, "rowid", title, summaryPlain, contentPlain) VALUES (\'delete\', old."rowid", old.title, old.summaryPlain, old.contentPlain);END', + 'article_after_delete', + ); + late final Trigger articleAfterUpdate = Trigger( + 'CREATE TRIGGER article_after_update AFTER UPDATE ON article BEGIN INSERT INTO article_fts (article_fts, "rowid", title, summaryPlain, contentPlain) VALUES (\'delete\', old."rowid", old.title, old.summaryPlain, old.contentPlain);INSERT INTO article_fts ("rowid", title, summaryPlain, contentPlain) VALUES (new."rowid", new.title, new.summaryPlain, new.contentPlain);END', + 'article_after_update', + ); + late final ArticleDao articleDao = ArticleDao(this as FeedDatabase); + late final FeedDao feedDao = FeedDao(this as FeedDatabase); + Future optimizeFtsIndex() { + return customInsert( + 'INSERT INTO article_fts (article_fts) VALUES (\'optimize\')', + variables: [], + updates: {articleFts}, + ); + } + + Selectable queryArticlesBasic({ + required String query, + String? feedId, + }) { + return customSelect( + 'WITH weights AS (SELECT 1.0 AS title_weight) SELECT a.*, f.icon,(bm25(article_fts, weights.title_weight))AS weighted_rank FROM article_fts AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE fts.title LIKE ?1 AND(?2 IS NULL OR a.feed_id = ?2)ORDER BY weighted_rank ASC, a.created DESC NULLS LAST', + variables: [Variable(query), Variable(feedId)], + readsFrom: {feed, articleFts, article}, + ).map( + (QueryRow row) => FeedArticleQueryResult( + id: row.read('id'), + feedId: Article.$converterfeedId.fromSql(row.read('feed_id')), + fetched: row.read('fetched'), + weightedRank: row.read('weighted_rank'), + created: row.readNullable('created'), + updated: row.readNullable('updated'), + lastRead: row.readNullable('last_read'), + title: row.readNullable('title'), + authors: NullAwareTypeConverter.wrapFromSql( + Article.$converterauthors, + row.readNullable('authors'), + ), + tags: NullAwareTypeConverter.wrapFromSql( + Article.$convertertags, + row.readNullable('tags'), + ), + links: NullAwareTypeConverter.wrapFromSql( + Article.$converterlinks, + row.readNullable('links'), + ), + summaryHtml: row.readNullable('summaryHtml'), + summaryMarkdown: row.readNullable('summaryMarkdown'), + summaryPlain: row.readNullable('summaryPlain'), + contentHtml: row.readNullable('contentHtml'), + contentMarkdown: row.readNullable('contentMarkdown'), + contentPlain: row.readNullable('contentPlain'), + icon: Feed.$convertericon.fromSql(row.readNullable('icon')), + ), + ); + } + + Selectable queryArticlesFullContent({ + required String beforeMatch, + required String afterMatch, + required String ellipsis, + required int snippetLength, + required String query, + String? feedId, + }) { + return customSelect( + 'WITH weights AS (SELECT 10.0 AS title_weight, 3.0 AS summary_weight, 1.0 AS content_weight) SELECT a.*, f.icon, highlight(article_fts, 0, ?1, ?2) AS title_highlight, snippet(article_fts, 1, ?1, ?2, ?3, ?4) AS summary_snippet, snippet(article_fts, 2, ?1, ?2, ?3, ?4) AS content_snippet,(bm25(article_fts, weights.title_weight, weights.summary_weight, weights.content_weight))AS weighted_rank FROM article_fts(?5)AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE ?6 IS NULL OR a.feed_id = ?6 ORDER BY weighted_rank ASC, a.created DESC NULLS LAST', + variables: [ + Variable(beforeMatch), + Variable(afterMatch), + Variable(ellipsis), + Variable(snippetLength), + Variable(query), + Variable(feedId), + ], + readsFrom: {feed, articleFts, article}, + ).map( + (QueryRow row) => FeedArticleQueryResult( + id: row.read('id'), + feedId: Article.$converterfeedId.fromSql(row.read('feed_id')), + fetched: row.read('fetched'), + weightedRank: row.read('weighted_rank'), + created: row.readNullable('created'), + updated: row.readNullable('updated'), + lastRead: row.readNullable('last_read'), + title: row.readNullable('title'), + authors: NullAwareTypeConverter.wrapFromSql( + Article.$converterauthors, + row.readNullable('authors'), + ), + tags: NullAwareTypeConverter.wrapFromSql( + Article.$convertertags, + row.readNullable('tags'), + ), + links: NullAwareTypeConverter.wrapFromSql( + Article.$converterlinks, + row.readNullable('links'), + ), + summaryHtml: row.readNullable('summaryHtml'), + summaryMarkdown: row.readNullable('summaryMarkdown'), + summaryPlain: row.readNullable('summaryPlain'), + contentHtml: row.readNullable('contentHtml'), + contentMarkdown: row.readNullable('contentMarkdown'), + contentPlain: row.readNullable('contentPlain'), + icon: Feed.$convertericon.fromSql(row.readNullable('icon')), + titleHighlight: row.readNullable('title_highlight'), + summarySnippet: row.readNullable('summary_snippet'), + contentSnippet: row.readNullable('content_snippet'), + ), + ); + } + + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + feed, + article, + articleView, + articleFeedId, + articleFts, + articleAfterInsert, + articleAfterDelete, + articleAfterUpdate, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'feed', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('article', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'article', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('article_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'article', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('article_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'article', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('article_fts', kind: UpdateKind.insert)], + ), + ]); +} + +typedef $FeedCreateCompanionBuilder = + FeedCompanion Function({ + required Uri url, + Value title, + Value description, + Value icon, + Value siteLink, + Value?> authors, + Value?> tags, + Value lastFetched, + Value rowid, + }); +typedef $FeedUpdateCompanionBuilder = + FeedCompanion Function({ + Value url, + Value title, + Value description, + Value icon, + Value siteLink, + Value?> authors, + Value?> tags, + Value lastFetched, + Value rowid, + }); + +final class $FeedReferences + extends BaseReferences<_$FeedDatabase, Feed, FeedData> { + $FeedReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey> _articleRefsTable( + _$FeedDatabase db, + ) => MultiTypedResultKey.fromTable( + db.article, + aliasName: $_aliasNameGenerator(db.feed.url, db.article.feedId), + ); + + $ArticleProcessedTableManager get articleRefs { + final manager = $ArticleTableManager( + $_db, + $_db.article, + ).filter((f) => f.feedId.url.sqlEquals($_itemColumn('url')!)); + + final cache = $_typedResult.readTableOrNull(_articleRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $FeedFilterComposer extends Composer<_$FeedDatabase, Feed> { + $FeedFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnWithTypeConverterFilters get url => + $composableBuilder( + column: $table.url, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get icon => + $composableBuilder( + column: $table.icon, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get siteLink => + $composableBuilder( + column: $table.siteLink, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters?, List, String> + get authors => $composableBuilder( + column: $table.authors, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters< + List?, + List, + String + > + get tags => $composableBuilder( + column: $table.tags, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get lastFetched => $composableBuilder( + column: $table.lastFetched, + builder: (column) => ColumnFilters(column), + ); + + Expression articleRefs( + Expression Function($ArticleFilterComposer f) f, + ) { + final $ArticleFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.url, + referencedTable: $db.article, + getReferencedColumn: (t) => t.feedId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $ArticleFilterComposer( + $db: $db, + $table: $db.article, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $FeedOrderingComposer extends Composer<_$FeedDatabase, Feed> { + $FeedOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get icon => $composableBuilder( + column: $table.icon, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get siteLink => $composableBuilder( + column: $table.siteLink, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get authors => $composableBuilder( + column: $table.authors, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tags => $composableBuilder( + column: $table.tags, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastFetched => $composableBuilder( + column: $table.lastFetched, + builder: (column) => ColumnOrderings(column), + ); +} + +class $FeedAnnotationComposer extends Composer<_$FeedDatabase, Feed> { + $FeedAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumnWithTypeConverter get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter get icon => + $composableBuilder(column: $table.icon, builder: (column) => column); + + GeneratedColumnWithTypeConverter get siteLink => + $composableBuilder(column: $table.siteLink, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get authors => + $composableBuilder(column: $table.authors, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get tags => + $composableBuilder(column: $table.tags, builder: (column) => column); + + GeneratedColumn get lastFetched => $composableBuilder( + column: $table.lastFetched, + builder: (column) => column, + ); + + Expression articleRefs( + Expression Function($ArticleAnnotationComposer a) f, + ) { + final $ArticleAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.url, + referencedTable: $db.article, + getReferencedColumn: (t) => t.feedId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $ArticleAnnotationComposer( + $db: $db, + $table: $db.article, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $FeedTableManager + extends + RootTableManager< + _$FeedDatabase, + Feed, + FeedData, + $FeedFilterComposer, + $FeedOrderingComposer, + $FeedAnnotationComposer, + $FeedCreateCompanionBuilder, + $FeedUpdateCompanionBuilder, + (FeedData, $FeedReferences), + FeedData, + PrefetchHooks Function({bool articleRefs}) + > { + $FeedTableManager(_$FeedDatabase db, Feed table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $FeedFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $FeedOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $FeedAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value url = const Value.absent(), + Value title = const Value.absent(), + Value description = const Value.absent(), + Value icon = const Value.absent(), + Value siteLink = const Value.absent(), + Value?> authors = const Value.absent(), + Value?> tags = const Value.absent(), + Value lastFetched = const Value.absent(), + Value rowid = const Value.absent(), + }) => FeedCompanion( + url: url, + title: title, + description: description, + icon: icon, + siteLink: siteLink, + authors: authors, + tags: tags, + lastFetched: lastFetched, + rowid: rowid, + ), + createCompanionCallback: + ({ + required Uri url, + Value title = const Value.absent(), + Value description = const Value.absent(), + Value icon = const Value.absent(), + Value siteLink = const Value.absent(), + Value?> authors = const Value.absent(), + Value?> tags = const Value.absent(), + Value lastFetched = const Value.absent(), + Value rowid = const Value.absent(), + }) => FeedCompanion.insert( + url: url, + title: title, + description: description, + icon: icon, + siteLink: siteLink, + authors: authors, + tags: tags, + lastFetched: lastFetched, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), $FeedReferences(db, table, e))) + .toList(), + prefetchHooksCallback: ({articleRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (articleRefs) db.article], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (articleRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $FeedReferences._articleRefsTable(db), + managerFromTypedResult: (p0) => + $FeedReferences(db, table, p0).articleRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.feedId == item.url), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $FeedProcessedTableManager = + ProcessedTableManager< + _$FeedDatabase, + Feed, + FeedData, + $FeedFilterComposer, + $FeedOrderingComposer, + $FeedAnnotationComposer, + $FeedCreateCompanionBuilder, + $FeedUpdateCompanionBuilder, + (FeedData, $FeedReferences), + FeedData, + PrefetchHooks Function({bool articleRefs}) + >; +typedef $ArticleCreateCompanionBuilder = + ArticleCompanion Function({ + required String id, + required Uri feedId, + required DateTime fetched, + Value created, + Value updated, + Value lastRead, + Value title, + Value?> authors, + Value?> tags, + Value?> links, + Value summaryHtml, + Value summaryMarkdown, + Value summaryPlain, + Value contentHtml, + Value contentMarkdown, + Value contentPlain, + Value rowid, + }); +typedef $ArticleUpdateCompanionBuilder = + ArticleCompanion Function({ + Value id, + Value feedId, + Value fetched, + Value created, + Value updated, + Value lastRead, + Value title, + Value?> authors, + Value?> tags, + Value?> links, + Value summaryHtml, + Value summaryMarkdown, + Value summaryPlain, + Value contentHtml, + Value contentMarkdown, + Value contentPlain, + Value rowid, + }); + +final class $ArticleReferences + extends BaseReferences<_$FeedDatabase, Article, FeedArticle> { + $ArticleReferences(super.$_db, super.$_table, super.$_typedResult); + + static Feed _feedIdTable(_$FeedDatabase db) => + db.feed.createAlias($_aliasNameGenerator(db.article.feedId, db.feed.url)); + + $FeedProcessedTableManager get feedId { + final $_column = $_itemColumn('feed_id')!; + + final manager = $FeedTableManager( + $_db, + $_db.feed, + ).filter((f) => f.url.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_feedIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $ArticleFilterComposer extends Composer<_$FeedDatabase, Article> { + $ArticleFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fetched => $composableBuilder( + column: $table.fetched, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get created => $composableBuilder( + column: $table.created, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updated => $composableBuilder( + column: $table.updated, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastRead => $composableBuilder( + column: $table.lastRead, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters?, List, String> + get authors => $composableBuilder( + column: $table.authors, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters< + List?, + List, + String + > + get tags => $composableBuilder( + column: $table.tags, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters?, List, String> + get links => $composableBuilder( + column: $table.links, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get summaryHtml => $composableBuilder( + column: $table.summaryHtml, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summaryMarkdown => $composableBuilder( + column: $table.summaryMarkdown, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentHtml => $composableBuilder( + column: $table.contentHtml, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentMarkdown => $composableBuilder( + column: $table.contentMarkdown, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => ColumnFilters(column), + ); + + $FeedFilterComposer get feedId { + final $FeedFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.feedId, + referencedTable: $db.feed, + getReferencedColumn: (t) => t.url, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $FeedFilterComposer( + $db: $db, + $table: $db.feed, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $ArticleOrderingComposer extends Composer<_$FeedDatabase, Article> { + $ArticleOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fetched => $composableBuilder( + column: $table.fetched, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get created => $composableBuilder( + column: $table.created, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updated => $composableBuilder( + column: $table.updated, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastRead => $composableBuilder( + column: $table.lastRead, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get authors => $composableBuilder( + column: $table.authors, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tags => $composableBuilder( + column: $table.tags, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get links => $composableBuilder( + column: $table.links, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summaryHtml => $composableBuilder( + column: $table.summaryHtml, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summaryMarkdown => $composableBuilder( + column: $table.summaryMarkdown, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentHtml => $composableBuilder( + column: $table.contentHtml, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentMarkdown => $composableBuilder( + column: $table.contentMarkdown, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => ColumnOrderings(column), + ); + + $FeedOrderingComposer get feedId { + final $FeedOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.feedId, + referencedTable: $db.feed, + getReferencedColumn: (t) => t.url, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $FeedOrderingComposer( + $db: $db, + $table: $db.feed, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $ArticleAnnotationComposer extends Composer<_$FeedDatabase, Article> { + $ArticleAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get fetched => + $composableBuilder(column: $table.fetched, builder: (column) => column); + + GeneratedColumn get created => + $composableBuilder(column: $table.created, builder: (column) => column); + + GeneratedColumn get updated => + $composableBuilder(column: $table.updated, builder: (column) => column); + + GeneratedColumn get lastRead => + $composableBuilder(column: $table.lastRead, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get authors => + $composableBuilder(column: $table.authors, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get tags => + $composableBuilder(column: $table.tags, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get links => + $composableBuilder(column: $table.links, builder: (column) => column); + + GeneratedColumn get summaryHtml => $composableBuilder( + column: $table.summaryHtml, + builder: (column) => column, + ); + + GeneratedColumn get summaryMarkdown => $composableBuilder( + column: $table.summaryMarkdown, + builder: (column) => column, + ); + + GeneratedColumn get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => column, + ); + + GeneratedColumn get contentHtml => $composableBuilder( + column: $table.contentHtml, + builder: (column) => column, + ); + + GeneratedColumn get contentMarkdown => $composableBuilder( + column: $table.contentMarkdown, + builder: (column) => column, + ); + + GeneratedColumn get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => column, + ); + + $FeedAnnotationComposer get feedId { + final $FeedAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.feedId, + referencedTable: $db.feed, + getReferencedColumn: (t) => t.url, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $FeedAnnotationComposer( + $db: $db, + $table: $db.feed, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $ArticleTableManager + extends + RootTableManager< + _$FeedDatabase, + Article, + FeedArticle, + $ArticleFilterComposer, + $ArticleOrderingComposer, + $ArticleAnnotationComposer, + $ArticleCreateCompanionBuilder, + $ArticleUpdateCompanionBuilder, + (FeedArticle, $ArticleReferences), + FeedArticle, + PrefetchHooks Function({bool feedId}) + > { + $ArticleTableManager(_$FeedDatabase db, Article table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $ArticleFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $ArticleOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $ArticleAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value feedId = const Value.absent(), + Value fetched = const Value.absent(), + Value created = const Value.absent(), + Value updated = const Value.absent(), + Value lastRead = const Value.absent(), + Value title = const Value.absent(), + Value?> authors = const Value.absent(), + Value?> tags = const Value.absent(), + Value?> links = const Value.absent(), + Value summaryHtml = const Value.absent(), + Value summaryMarkdown = const Value.absent(), + Value summaryPlain = const Value.absent(), + Value contentHtml = const Value.absent(), + Value contentMarkdown = const Value.absent(), + Value contentPlain = const Value.absent(), + Value rowid = const Value.absent(), + }) => ArticleCompanion( + id: id, + feedId: feedId, + fetched: fetched, + created: created, + updated: updated, + lastRead: lastRead, + title: title, + authors: authors, + tags: tags, + links: links, + summaryHtml: summaryHtml, + summaryMarkdown: summaryMarkdown, + summaryPlain: summaryPlain, + contentHtml: contentHtml, + contentMarkdown: contentMarkdown, + contentPlain: contentPlain, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required Uri feedId, + required DateTime fetched, + Value created = const Value.absent(), + Value updated = const Value.absent(), + Value lastRead = const Value.absent(), + Value title = const Value.absent(), + Value?> authors = const Value.absent(), + Value?> tags = const Value.absent(), + Value?> links = const Value.absent(), + Value summaryHtml = const Value.absent(), + Value summaryMarkdown = const Value.absent(), + Value summaryPlain = const Value.absent(), + Value contentHtml = const Value.absent(), + Value contentMarkdown = const Value.absent(), + Value contentPlain = const Value.absent(), + Value rowid = const Value.absent(), + }) => ArticleCompanion.insert( + id: id, + feedId: feedId, + fetched: fetched, + created: created, + updated: updated, + lastRead: lastRead, + title: title, + authors: authors, + tags: tags, + links: links, + summaryHtml: summaryHtml, + summaryMarkdown: summaryMarkdown, + summaryPlain: summaryPlain, + contentHtml: contentHtml, + contentMarkdown: contentMarkdown, + contentPlain: contentPlain, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => (e.readTable(table), $ArticleReferences(db, table, e)), + ) + .toList(), + prefetchHooksCallback: ({feedId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (feedId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.feedId, + referencedTable: $ArticleReferences + ._feedIdTable(db), + referencedColumn: $ArticleReferences + ._feedIdTable(db) + .url, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $ArticleProcessedTableManager = + ProcessedTableManager< + _$FeedDatabase, + Article, + FeedArticle, + $ArticleFilterComposer, + $ArticleOrderingComposer, + $ArticleAnnotationComposer, + $ArticleCreateCompanionBuilder, + $ArticleUpdateCompanionBuilder, + (FeedArticle, $ArticleReferences), + FeedArticle, + PrefetchHooks Function({bool feedId}) + >; +typedef $ArticleFtsCreateCompanionBuilder = + ArticleFtsCompanion Function({ + required String title, + required String summaryPlain, + required String contentPlain, + Value rowid, + }); +typedef $ArticleFtsUpdateCompanionBuilder = + ArticleFtsCompanion Function({ + Value title, + Value summaryPlain, + Value contentPlain, + Value rowid, + }); + +class $ArticleFtsFilterComposer extends Composer<_$FeedDatabase, ArticleFts> { + $ArticleFtsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => ColumnFilters(column), + ); +} + +class $ArticleFtsOrderingComposer extends Composer<_$FeedDatabase, ArticleFts> { + $ArticleFtsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => ColumnOrderings(column), + ); +} + +class $ArticleFtsAnnotationComposer + extends Composer<_$FeedDatabase, ArticleFts> { + $ArticleFtsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => column, + ); + + GeneratedColumn get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => column, + ); +} + +class $ArticleFtsTableManager + extends + RootTableManager< + _$FeedDatabase, + ArticleFts, + ArticleFt, + $ArticleFtsFilterComposer, + $ArticleFtsOrderingComposer, + $ArticleFtsAnnotationComposer, + $ArticleFtsCreateCompanionBuilder, + $ArticleFtsUpdateCompanionBuilder, + (ArticleFt, BaseReferences<_$FeedDatabase, ArticleFts, ArticleFt>), + ArticleFt, + PrefetchHooks Function() + > { + $ArticleFtsTableManager(_$FeedDatabase db, ArticleFts table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $ArticleFtsFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $ArticleFtsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $ArticleFtsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value title = const Value.absent(), + Value summaryPlain = const Value.absent(), + Value contentPlain = const Value.absent(), + Value rowid = const Value.absent(), + }) => ArticleFtsCompanion( + title: title, + summaryPlain: summaryPlain, + contentPlain: contentPlain, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String title, + required String summaryPlain, + required String contentPlain, + Value rowid = const Value.absent(), + }) => ArticleFtsCompanion.insert( + title: title, + summaryPlain: summaryPlain, + contentPlain: contentPlain, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $ArticleFtsProcessedTableManager = + ProcessedTableManager< + _$FeedDatabase, + ArticleFts, + ArticleFt, + $ArticleFtsFilterComposer, + $ArticleFtsOrderingComposer, + $ArticleFtsAnnotationComposer, + $ArticleFtsCreateCompanionBuilder, + $ArticleFtsUpdateCompanionBuilder, + (ArticleFt, BaseReferences<_$FeedDatabase, ArticleFts, ArticleFt>), + ArticleFt, + PrefetchHooks Function() + >; + +class $FeedDatabaseManager { + final _$FeedDatabase _db; + $FeedDatabaseManager(this._db); + $FeedTableManager get feed => $FeedTableManager(_db, _db.feed); + $ArticleTableManager get article => $ArticleTableManager(_db, _db.article); + $ArticleFtsTableManager get articleFts => + $ArticleFtsTableManager(_db, _db.articleFts); +} diff --git a/app/lib/features/web_feed/data/models/feed_article.dart b/app/lib/features/web_feed/data/models/feed_article.dart new file mode 100644 index 00000000..223cfec0 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_article.dart @@ -0,0 +1,135 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:drift/drift.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_author.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_category.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; + +part 'feed_article.g.dart'; + +@JsonSerializable() +@CopyWith() +class FeedArticle with FastEquatable implements Insertable { + final String id; + final Uri feedId; + final DateTime fetched; + final DateTime? created; + final DateTime? updated; + final DateTime? lastRead; + final String? title; + final List? authors; + final List? tags; + final List? links; + final String? summaryHtml; + final String? summaryMarkdown; + final String? summaryPlain; + final String? contentHtml; + final String? contentMarkdown; + final String? contentPlain; + + //Derived by view from feed table, should not get inserted + final Uri? icon; + final Uri? siteLink; + + FeedArticle({ + required this.id, + required this.feedId, + required this.fetched, + this.created, + this.updated, + this.lastRead, + this.title, + this.authors, + this.tags, + this.links, + this.summaryHtml, + this.summaryMarkdown, + this.summaryPlain, + this.contentHtml, + this.contentMarkdown, + this.contentPlain, + this.icon, + this.siteLink, + }); + + factory FeedArticle.fromJson(Map json) => + _$FeedArticleFromJson(json); + + Map toJson() => _$FeedArticleToJson(this); + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + { + map['feed_id'] = Variable(Article.$converterfeedId.toSql(feedId)); + } + map['fetched'] = Variable(fetched); + if (!nullToAbsent || created != null) { + map['created'] = Variable(created); + } + if (!nullToAbsent || updated != null) { + map['updated'] = Variable(updated); + } + if (!nullToAbsent || lastRead != null) { + map['last_read'] = Variable(lastRead); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || authors != null) { + map['authors'] = Variable( + Article.$converterauthorsn.toSql(authors), + ); + } + if (!nullToAbsent || tags != null) { + map['tags'] = Variable(Article.$convertertagsn.toSql(tags)); + } + if (!nullToAbsent || links != null) { + map['links'] = Variable(Article.$converterlinksn.toSql(links)); + } + if (!nullToAbsent || summaryHtml != null) { + map['summaryHtml'] = Variable(summaryHtml); + } + if (!nullToAbsent || summaryMarkdown != null) { + map['summaryMarkdown'] = Variable(summaryMarkdown); + } + if (!nullToAbsent || summaryPlain != null) { + map['summaryPlain'] = Variable(summaryPlain); + } + if (!nullToAbsent || contentHtml != null) { + map['contentHtml'] = Variable(contentHtml); + } + if (!nullToAbsent || contentMarkdown != null) { + map['contentMarkdown'] = Variable(contentMarkdown); + } + if (!nullToAbsent || contentPlain != null) { + map['contentPlain'] = Variable(contentPlain); + } + return map; + } + + @override + List get hashParameters => [ + id, + feedId, + fetched, + created, + updated, + lastRead, + title, + authors, + tags, + links, + summaryHtml, + summaryMarkdown, + summaryPlain, + contentHtml, + contentMarkdown, + contentPlain, + icon, + siteLink, + ]; +} diff --git a/app/lib/features/web_feed/data/models/feed_article.g.dart b/app/lib/features/web_feed/data/models/feed_article.g.dart new file mode 100644 index 00000000..305330b2 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_article.g.dart @@ -0,0 +1,309 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_article.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$FeedArticleCWProxy { + FeedArticle id(String id); + + FeedArticle feedId(Uri feedId); + + FeedArticle fetched(DateTime fetched); + + FeedArticle created(DateTime? created); + + FeedArticle updated(DateTime? updated); + + FeedArticle lastRead(DateTime? lastRead); + + FeedArticle title(String? title); + + FeedArticle authors(List? authors); + + FeedArticle tags(List? tags); + + FeedArticle links(List? links); + + FeedArticle summaryHtml(String? summaryHtml); + + FeedArticle summaryMarkdown(String? summaryMarkdown); + + FeedArticle summaryPlain(String? summaryPlain); + + FeedArticle contentHtml(String? contentHtml); + + FeedArticle contentMarkdown(String? contentMarkdown); + + FeedArticle contentPlain(String? contentPlain); + + FeedArticle icon(Uri? icon); + + FeedArticle siteLink(Uri? siteLink); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FeedArticle(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// FeedArticle(...).copyWith(id: 12, name: "My name") + /// ```` + FeedArticle call({ + String id, + Uri feedId, + DateTime fetched, + DateTime? created, + DateTime? updated, + DateTime? lastRead, + String? title, + List? authors, + List? tags, + List? links, + String? summaryHtml, + String? summaryMarkdown, + String? summaryPlain, + String? contentHtml, + String? contentMarkdown, + String? contentPlain, + Uri? icon, + Uri? siteLink, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfFeedArticle.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfFeedArticle.copyWith.fieldName(...)` +class _$FeedArticleCWProxyImpl implements _$FeedArticleCWProxy { + const _$FeedArticleCWProxyImpl(this._value); + + final FeedArticle _value; + + @override + FeedArticle id(String id) => this(id: id); + + @override + FeedArticle feedId(Uri feedId) => this(feedId: feedId); + + @override + FeedArticle fetched(DateTime fetched) => this(fetched: fetched); + + @override + FeedArticle created(DateTime? created) => this(created: created); + + @override + FeedArticle updated(DateTime? updated) => this(updated: updated); + + @override + FeedArticle lastRead(DateTime? lastRead) => this(lastRead: lastRead); + + @override + FeedArticle title(String? title) => this(title: title); + + @override + FeedArticle authors(List? authors) => this(authors: authors); + + @override + FeedArticle tags(List? tags) => this(tags: tags); + + @override + FeedArticle links(List? links) => this(links: links); + + @override + FeedArticle summaryHtml(String? summaryHtml) => + this(summaryHtml: summaryHtml); + + @override + FeedArticle summaryMarkdown(String? summaryMarkdown) => + this(summaryMarkdown: summaryMarkdown); + + @override + FeedArticle summaryPlain(String? summaryPlain) => + this(summaryPlain: summaryPlain); + + @override + FeedArticle contentHtml(String? contentHtml) => + this(contentHtml: contentHtml); + + @override + FeedArticle contentMarkdown(String? contentMarkdown) => + this(contentMarkdown: contentMarkdown); + + @override + FeedArticle contentPlain(String? contentPlain) => + this(contentPlain: contentPlain); + + @override + FeedArticle icon(Uri? icon) => this(icon: icon); + + @override + FeedArticle siteLink(Uri? siteLink) => this(siteLink: siteLink); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FeedArticle(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// FeedArticle(...).copyWith(id: 12, name: "My name") + /// ```` + FeedArticle call({ + Object? id = const $CopyWithPlaceholder(), + Object? feedId = const $CopyWithPlaceholder(), + Object? fetched = const $CopyWithPlaceholder(), + Object? created = const $CopyWithPlaceholder(), + Object? updated = const $CopyWithPlaceholder(), + Object? lastRead = const $CopyWithPlaceholder(), + Object? title = const $CopyWithPlaceholder(), + Object? authors = const $CopyWithPlaceholder(), + Object? tags = const $CopyWithPlaceholder(), + Object? links = const $CopyWithPlaceholder(), + Object? summaryHtml = const $CopyWithPlaceholder(), + Object? summaryMarkdown = const $CopyWithPlaceholder(), + Object? summaryPlain = const $CopyWithPlaceholder(), + Object? contentHtml = const $CopyWithPlaceholder(), + Object? contentMarkdown = const $CopyWithPlaceholder(), + Object? contentPlain = const $CopyWithPlaceholder(), + Object? icon = const $CopyWithPlaceholder(), + Object? siteLink = const $CopyWithPlaceholder(), + }) { + return FeedArticle( + id: id == const $CopyWithPlaceholder() + ? _value.id + // ignore: cast_nullable_to_non_nullable + : id as String, + feedId: feedId == const $CopyWithPlaceholder() + ? _value.feedId + // ignore: cast_nullable_to_non_nullable + : feedId as Uri, + fetched: fetched == const $CopyWithPlaceholder() + ? _value.fetched + // ignore: cast_nullable_to_non_nullable + : fetched as DateTime, + created: created == const $CopyWithPlaceholder() + ? _value.created + // ignore: cast_nullable_to_non_nullable + : created as DateTime?, + updated: updated == const $CopyWithPlaceholder() + ? _value.updated + // ignore: cast_nullable_to_non_nullable + : updated as DateTime?, + lastRead: lastRead == const $CopyWithPlaceholder() + ? _value.lastRead + // ignore: cast_nullable_to_non_nullable + : lastRead as DateTime?, + title: title == const $CopyWithPlaceholder() + ? _value.title + // ignore: cast_nullable_to_non_nullable + : title as String?, + authors: authors == const $CopyWithPlaceholder() + ? _value.authors + // ignore: cast_nullable_to_non_nullable + : authors as List?, + tags: tags == const $CopyWithPlaceholder() + ? _value.tags + // ignore: cast_nullable_to_non_nullable + : tags as List?, + links: links == const $CopyWithPlaceholder() + ? _value.links + // ignore: cast_nullable_to_non_nullable + : links as List?, + summaryHtml: summaryHtml == const $CopyWithPlaceholder() + ? _value.summaryHtml + // ignore: cast_nullable_to_non_nullable + : summaryHtml as String?, + summaryMarkdown: summaryMarkdown == const $CopyWithPlaceholder() + ? _value.summaryMarkdown + // ignore: cast_nullable_to_non_nullable + : summaryMarkdown as String?, + summaryPlain: summaryPlain == const $CopyWithPlaceholder() + ? _value.summaryPlain + // ignore: cast_nullable_to_non_nullable + : summaryPlain as String?, + contentHtml: contentHtml == const $CopyWithPlaceholder() + ? _value.contentHtml + // ignore: cast_nullable_to_non_nullable + : contentHtml as String?, + contentMarkdown: contentMarkdown == const $CopyWithPlaceholder() + ? _value.contentMarkdown + // ignore: cast_nullable_to_non_nullable + : contentMarkdown as String?, + contentPlain: contentPlain == const $CopyWithPlaceholder() + ? _value.contentPlain + // ignore: cast_nullable_to_non_nullable + : contentPlain as String?, + icon: icon == const $CopyWithPlaceholder() + ? _value.icon + // ignore: cast_nullable_to_non_nullable + : icon as Uri?, + siteLink: siteLink == const $CopyWithPlaceholder() + ? _value.siteLink + // ignore: cast_nullable_to_non_nullable + : siteLink as Uri?, + ); + } +} + +extension $FeedArticleCopyWith on FeedArticle { + /// Returns a callable class that can be used as follows: `instanceOfFeedArticle.copyWith(...)` or like so:`instanceOfFeedArticle.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$FeedArticleCWProxy get copyWith => _$FeedArticleCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FeedArticle _$FeedArticleFromJson(Map json) => FeedArticle( + id: json['id'] as String, + feedId: Uri.parse(json['feedId'] as String), + fetched: DateTime.parse(json['fetched'] as String), + created: json['created'] == null + ? null + : DateTime.parse(json['created'] as String), + updated: json['updated'] == null + ? null + : DateTime.parse(json['updated'] as String), + lastRead: json['lastRead'] == null + ? null + : DateTime.parse(json['lastRead'] as String), + title: json['title'] as String?, + authors: (json['authors'] as List?) + ?.map((e) => FeedAuthor.fromJson(e as Map)) + .toList(), + tags: (json['tags'] as List?) + ?.map((e) => FeedCategory.fromJson(e as Map)) + .toList(), + links: (json['links'] as List?) + ?.map((e) => FeedLink.fromJson(e as Map)) + .toList(), + summaryHtml: json['summaryHtml'] as String?, + summaryMarkdown: json['summaryMarkdown'] as String?, + summaryPlain: json['summaryPlain'] as String?, + contentHtml: json['contentHtml'] as String?, + contentMarkdown: json['contentMarkdown'] as String?, + contentPlain: json['contentPlain'] as String?, + icon: json['icon'] == null ? null : Uri.parse(json['icon'] as String), + siteLink: json['siteLink'] == null + ? null + : Uri.parse(json['siteLink'] as String), +); + +Map _$FeedArticleToJson(FeedArticle instance) => + { + 'id': instance.id, + 'feedId': instance.feedId.toString(), + 'fetched': instance.fetched.toIso8601String(), + 'created': instance.created?.toIso8601String(), + 'updated': instance.updated?.toIso8601String(), + 'lastRead': instance.lastRead?.toIso8601String(), + 'title': instance.title, + 'authors': instance.authors?.map((e) => e.toJson()).toList(), + 'tags': instance.tags?.map((e) => e.toJson()).toList(), + 'links': instance.links?.map((e) => e.toJson()).toList(), + 'summaryHtml': instance.summaryHtml, + 'summaryMarkdown': instance.summaryMarkdown, + 'summaryPlain': instance.summaryPlain, + 'contentHtml': instance.contentHtml, + 'contentMarkdown': instance.contentMarkdown, + 'contentPlain': instance.contentPlain, + 'icon': instance.icon?.toString(), + 'siteLink': instance.siteLink?.toString(), + }; diff --git a/app/lib/features/web_feed/data/models/feed_article_query_result.dart b/app/lib/features/web_feed/data/models/feed_article_query_result.dart new file mode 100644 index 00000000..d7289d75 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_article_query_result.dart @@ -0,0 +1,42 @@ +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; + +class FeedArticleQueryResult extends FeedArticle { + final String? titleHighlight; + final String? summarySnippet; + final String? contentSnippet; + + final double weightedRank; + + FeedArticleQueryResult({ + required super.id, + required super.feedId, + required super.fetched, + required this.weightedRank, + required super.created, + required super.updated, + required super.lastRead, + required super.title, + required super.authors, + required super.tags, + required super.links, + required super.summaryHtml, + required super.summaryMarkdown, + required super.summaryPlain, + required super.contentHtml, + required super.contentMarkdown, + required super.contentPlain, + required super.icon, + this.titleHighlight, + this.summarySnippet, + this.contentSnippet, + }); + + @override + List get hashParameters => [ + ...super.hashParameters, + weightedRank, + summarySnippet, + contentSnippet, + titleHighlight, + ]; +} diff --git a/app/lib/features/web_feed/data/models/feed_author.dart b/app/lib/features/web_feed/data/models/feed_author.dart new file mode 100644 index 00000000..0a64e2e9 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_author.dart @@ -0,0 +1,20 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'feed_author.g.dart'; + +@JsonSerializable() +class FeedAuthor with FastEquatable { + final String? name; + final String? email; + + FeedAuthor({this.name, this.email}); + + factory FeedAuthor.fromJson(Map json) => + _$FeedAuthorFromJson(json); + + Map toJson() => _$FeedAuthorToJson(this); + + @override + List get hashParameters => [name, email]; +} diff --git a/app/lib/features/web_feed/data/models/feed_author.g.dart b/app/lib/features/web_feed/data/models/feed_author.g.dart new file mode 100644 index 00000000..09bed8d5 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_author.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_author.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FeedAuthor _$FeedAuthorFromJson(Map json) => + FeedAuthor(name: json['name'] as String?, email: json['email'] as String?); + +Map _$FeedAuthorToJson(FeedAuthor instance) => + {'name': instance.name, 'email': instance.email}; diff --git a/app/lib/features/web_feed/data/models/feed_category.dart b/app/lib/features/web_feed/data/models/feed_category.dart new file mode 100644 index 00000000..e16331ec --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_category.dart @@ -0,0 +1,20 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'feed_category.g.dart'; + +@JsonSerializable() +class FeedCategory with FastEquatable { + final String id; + final String? title; + + FeedCategory({required this.id, this.title}); + + factory FeedCategory.fromJson(Map json) => + _$FeedCategoryFromJson(json); + + Map toJson() => _$FeedCategoryToJson(this); + + @override + List get hashParameters => [id, title]; +} diff --git a/app/lib/features/web_feed/data/models/feed_category.g.dart b/app/lib/features/web_feed/data/models/feed_category.g.dart new file mode 100644 index 00000000..524c9c44 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_category.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_category.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FeedCategory _$FeedCategoryFromJson(Map json) => + FeedCategory(id: json['id'] as String, title: json['title'] as String?); + +Map _$FeedCategoryToJson(FeedCategory instance) => + {'id': instance.id, 'title': instance.title}; diff --git a/app/lib/features/web_feed/data/models/feed_link.dart b/app/lib/features/web_feed/data/models/feed_link.dart new file mode 100644 index 00000000..3228c7ba --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_link.dart @@ -0,0 +1,38 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'feed_link.g.dart'; + +enum FeedLinkRelation { + ///an alternate representation of the entry or feed, for example a permalink to the html version of the entry, or the front page of the weblog. + alternate, + + ///a related resource which is potentially large in size and might require special handling, for example an audio or video recording. + enclosure, + + ///an document related to the entry or feed. + related, + + ///the feed itself. + self, + + ///the source of the information provided in the entry. + via, +} + +@JsonSerializable() +class FeedLink with FastEquatable { + final Uri uri; + final FeedLinkRelation? relation; + final String? title; + + FeedLink({required this.uri, this.relation, this.title}); + + factory FeedLink.fromJson(Map json) => + _$FeedLinkFromJson(json); + + Map toJson() => _$FeedLinkToJson(this); + + @override + List get hashParameters => [uri, relation, title]; +} diff --git a/app/lib/features/web_feed/data/models/feed_link.g.dart b/app/lib/features/web_feed/data/models/feed_link.g.dart new file mode 100644 index 00000000..3df8ce30 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_link.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_link.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FeedLink _$FeedLinkFromJson(Map json) => FeedLink( + uri: Uri.parse(json['uri'] as String), + relation: $enumDecodeNullable(_$FeedLinkRelationEnumMap, json['relation']), + title: json['title'] as String?, +); + +Map _$FeedLinkToJson(FeedLink instance) => { + 'uri': instance.uri.toString(), + 'relation': _$FeedLinkRelationEnumMap[instance.relation], + 'title': instance.title, +}; + +const _$FeedLinkRelationEnumMap = { + FeedLinkRelation.alternate: 'alternate', + FeedLinkRelation.enclosure: 'enclosure', + FeedLinkRelation.related: 'related', + FeedLinkRelation.self: 'self', + FeedLinkRelation.via: 'via', +}; diff --git a/app/lib/features/web_feed/data/models/feed_parse_result.dart b/app/lib/features/web_feed/data/models/feed_parse_result.dart new file mode 100644 index 00000000..61ef59a7 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_parse_result.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/features/web_feed/data/database/converters/feed_data.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; + +part 'feed_parse_result.g.dart'; + +@JsonSerializable() +class FeedParseResult { + @FeedDataConverter() + final FeedData feedData; + final List articleData; + + FeedParseResult({required this.feedData, required this.articleData}); + + factory FeedParseResult.fromJson(Map json) => + _$FeedParseResultFromJson(json); + + Map toJson() => _$FeedParseResultToJson(this); +} diff --git a/app/lib/features/web_feed/data/models/feed_parse_result.g.dart b/app/lib/features/web_feed/data/models/feed_parse_result.g.dart new file mode 100644 index 00000000..07a8d1e1 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_parse_result.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_parse_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FeedParseResult _$FeedParseResultFromJson(Map json) => + FeedParseResult( + feedData: const FeedDataConverter().fromJson( + json['feedData'] as Map, + ), + articleData: (json['articleData'] as List) + .map((e) => FeedArticle.fromJson(e as Map)) + .toList(), + ); + +Map _$FeedParseResultToJson(FeedParseResult instance) => + { + 'feedData': const FeedDataConverter().toJson(instance.feedData), + 'articleData': instance.articleData.map((e) => e.toJson()).toList(), + }; diff --git a/app/lib/features/topics/data/providers.dart b/app/lib/features/web_feed/data/providers.dart similarity index 71% rename from app/lib/features/topics/data/providers.dart rename to app/lib/features/web_feed/data/providers.dart index ab56083a..b5505864 100644 --- a/app/lib/features/topics/data/providers.dart +++ b/app/lib/features/web_feed/data/providers.dart @@ -1,23 +1,24 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; -import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart' as path_provider; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; import 'package:universal_io/io.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; part 'providers.g.dart'; @Riverpod(keepAlive: true) -TabDatabase tabDatabase(TabDatabaseRef ref) { - return TabDatabase( +FeedDatabase feedDatabase(Ref ref) { + final db = FeedDatabase( LazyDatabase(() async { // put the database file, called db.sqlite here, into the documents folder // for your app. final dbFolder = await path_provider.getApplicationDocumentsDirectory(); - final file = File(p.join(dbFolder.path, 'tab.db')); + final file = File(p.join(dbFolder.path, 'feed.db')); // Also work around limitations on old Android versions if (Platform.isAndroid) { @@ -34,16 +35,10 @@ TabDatabase tabDatabase(TabDatabaseRef ref) { return NativeDatabase.createInBackground(file); }), ); -} -@Riverpod() -Stream isTabExisting(IsTabExistingRef ref, String tabId) { - final db = ref.watch(tabDatabaseProvider); - return db.tabDao.watchTabExisiting(tabId); -} + ref.onDispose(() async { + await db.close(); + }); -@Riverpod() -Stream tabData(TabDataRef ref, String tabId) { - final db = ref.watch(tabDatabaseProvider); - return db.tabDao.watchTab(tabId); + return db; } diff --git a/app/lib/features/web_feed/data/providers.g.dart b/app/lib/features/web_feed/data/providers.g.dart new file mode 100644 index 00000000..a53338cc --- /dev/null +++ b/app/lib/features/web_feed/data/providers.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$feedDatabaseHash() => r'c3b20e867da5af6e92d1e8c1efa96b79988837a1'; + +/// See also [feedDatabase]. +@ProviderFor(feedDatabase) +final feedDatabaseProvider = Provider.internal( + feedDatabase, + name: r'feedDatabaseProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedDatabaseHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FeedDatabaseRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/providers.dart b/app/lib/features/web_feed/domain/providers.dart new file mode 100644 index 00000000..54500898 --- /dev/null +++ b/app/lib/features/web_feed/domain/providers.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:nullability/nullability.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_parse_result.dart'; +import 'package:weblibre/features/web_feed/data/providers.dart'; +import 'package:weblibre/features/web_feed/domain/providers/article_filter.dart'; +import 'package:weblibre/features/web_feed/domain/repositories/feed_repository.dart'; +import 'package:weblibre/features/web_feed/domain/services/feed_reader.dart'; + +part 'providers.g.dart'; + +@Riverpod() +class ArticleSearch extends _$ArticleSearch { + late StreamController> _streamController; + + Future search( + String input, { + int snippetLength = 120, + String matchPrefix = '***', + String matchSuffix = '***', + String ellipsis = '…', + }) async { + if (input.isNotEmpty) { + await ref + .read(feedDatabaseProvider) + .articleDao + .queryArticles( + matchPrefix: matchPrefix, + matchSuffix: matchSuffix, + ellipsis: ellipsis, + snippetLength: snippetLength, + searchString: input, + feedId: feedId, + ) + .get() + .then((value) { + if (!_streamController.isClosed) { + _streamController.add(value); + } + }); + } + } + + @override + Stream> build(Uri? feedId) { + _streamController = StreamController(); + + ref.onDispose(() async { + await _streamController.close(); + }); + + return ConcatStream([Stream.value([]), _streamController.stream]); + } +} + +@Riverpod() +Stream> feedList(Ref ref) { + final repository = ref.watch(feedRepositoryProvider.notifier); + return repository.watchFeeds(); +} + +@Riverpod() +Stream feedData(Ref ref, Uri? feedId) { + final repository = ref.watch(feedRepositoryProvider.notifier); + + if (feedId == null) { + return Stream.value(null); + } + + return repository.watchFeed(feedId); +} + +@Riverpod() +Stream> feedArticleList(Ref ref, Uri? feedId) { + final repository = ref.watch(feedRepositoryProvider.notifier); + return repository.watchFeedArticles(feedId); +} + +@Riverpod() +class FilteredArticleList extends _$FilteredArticleList { + bool _hasSearch = false; + + void search(String input) { + if (input.isNotEmpty) { + if (!_hasSearch) { + _hasSearch = true; + ref.invalidateSelf(); + } + + //Don't block + unawaited(ref.read(articleSearchProvider(feedId).notifier).search(input)); + } else if (_hasSearch) { + _hasSearch = false; + ref.invalidateSelf(); + } + } + + @override + AsyncValue> build(Uri? feedId) { + final filterTags = ref.watch(articleFilterProvider); + + final articlesAsync = _hasSearch + ? ref.watch(articleSearchProvider(feedId)) + : ref.watch(feedArticleListProvider(feedId)); + + return articlesAsync.whenData((articles) { + if (filterTags.isNotEmpty) { + return articles.where((article) { + final tags = article.tags?.map((tag) => tag.id).toSet(); + + final authors = article.authors + ?.map((author) => author.name.whenNotEmpty) + .nonNulls + .toSet(); + + return filterTags.every( + (filter) => + (tags?.contains(filter) ?? false) || + (authors?.contains(filter) ?? false), + ); + }).toList(); + } + + return articles; + }); + } +} + +@Riverpod() +Stream feedArticle( + Ref ref, + String articleId, { + required bool updateReadDate, +}) async* { + final repository = ref.watch(feedRepositoryProvider.notifier); + + if (updateReadDate) { + await repository.touchArticleRead(articleId); + } + + yield* repository.watchArticle(articleId); +} + +@Riverpod() +Raw>> unreadArticleCount(Ref ref) { + final repository = ref.watch(feedRepositoryProvider.notifier); + return repository.watchUnreadFeedArticleCount(); +} + +@Riverpod() +Stream unreadFeedArticleCount(Ref ref, Uri feedId) { + final stream = ref.watch(unreadArticleCountProvider); + return stream.map((counts) => counts[feedId.toString()]); +} + +@Riverpod() +Future fetchWebFeed(Ref ref, Uri url) { + return ref.read(feedReaderProvider.notifier).parseFeed(url); +} diff --git a/app/lib/features/web_feed/domain/providers.g.dart b/app/lib/features/web_feed/domain/providers.g.dart new file mode 100644 index 00000000..1c7dacdb --- /dev/null +++ b/app/lib/features/web_feed/domain/providers.g.dart @@ -0,0 +1,971 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$feedListHash() => r'0076186437354768c39fb1d7c8bcfcf7b94c7dd1'; + +/// See also [feedList]. +@ProviderFor(feedList) +final feedListProvider = AutoDisposeStreamProvider>.internal( + feedList, + name: r'feedListProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedListHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FeedListRef = AutoDisposeStreamProviderRef>; +String _$feedDataHash() => r'0599a2e3d159ef3abb6c3d2c871f87da2f5e646b'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [feedData]. +@ProviderFor(feedData) +const feedDataProvider = FeedDataFamily(); + +/// See also [feedData]. +class FeedDataFamily extends Family> { + /// See also [feedData]. + const FeedDataFamily(); + + /// See also [feedData]. + FeedDataProvider call(Uri? feedId) { + return FeedDataProvider(feedId); + } + + @override + FeedDataProvider getProviderOverride(covariant FeedDataProvider provider) { + return call(provider.feedId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'feedDataProvider'; +} + +/// See also [feedData]. +class FeedDataProvider extends AutoDisposeStreamProvider { + /// See also [feedData]. + FeedDataProvider(Uri? feedId) + : this._internal( + (ref) => feedData(ref as FeedDataRef, feedId), + from: feedDataProvider, + name: r'feedDataProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedDataHash, + dependencies: FeedDataFamily._dependencies, + allTransitiveDependencies: FeedDataFamily._allTransitiveDependencies, + feedId: feedId, + ); + + FeedDataProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.feedId, + }) : super.internal(); + + final Uri? feedId; + + @override + Override overrideWith( + Stream Function(FeedDataRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FeedDataProvider._internal( + (ref) => create(ref as FeedDataRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + feedId: feedId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _FeedDataProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FeedDataProvider && other.feedId == feedId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FeedDataRef on AutoDisposeStreamProviderRef { + /// The parameter `feedId` of this provider. + Uri? get feedId; +} + +class _FeedDataProviderElement + extends AutoDisposeStreamProviderElement + with FeedDataRef { + _FeedDataProviderElement(super.provider); + + @override + Uri? get feedId => (origin as FeedDataProvider).feedId; +} + +String _$feedArticleListHash() => r'45d585cc9f59ad48a0d1d6fbcf802b1c7de7f6bc'; + +/// See also [feedArticleList]. +@ProviderFor(feedArticleList) +const feedArticleListProvider = FeedArticleListFamily(); + +/// See also [feedArticleList]. +class FeedArticleListFamily extends Family>> { + /// See also [feedArticleList]. + const FeedArticleListFamily(); + + /// See also [feedArticleList]. + FeedArticleListProvider call(Uri? feedId) { + return FeedArticleListProvider(feedId); + } + + @override + FeedArticleListProvider getProviderOverride( + covariant FeedArticleListProvider provider, + ) { + return call(provider.feedId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'feedArticleListProvider'; +} + +/// See also [feedArticleList]. +class FeedArticleListProvider + extends AutoDisposeStreamProvider> { + /// See also [feedArticleList]. + FeedArticleListProvider(Uri? feedId) + : this._internal( + (ref) => feedArticleList(ref as FeedArticleListRef, feedId), + from: feedArticleListProvider, + name: r'feedArticleListProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedArticleListHash, + dependencies: FeedArticleListFamily._dependencies, + allTransitiveDependencies: + FeedArticleListFamily._allTransitiveDependencies, + feedId: feedId, + ); + + FeedArticleListProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.feedId, + }) : super.internal(); + + final Uri? feedId; + + @override + Override overrideWith( + Stream> Function(FeedArticleListRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FeedArticleListProvider._internal( + (ref) => create(ref as FeedArticleListRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + feedId: feedId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement> createElement() { + return _FeedArticleListProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FeedArticleListProvider && other.feedId == feedId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FeedArticleListRef on AutoDisposeStreamProviderRef> { + /// The parameter `feedId` of this provider. + Uri? get feedId; +} + +class _FeedArticleListProviderElement + extends AutoDisposeStreamProviderElement> + with FeedArticleListRef { + _FeedArticleListProviderElement(super.provider); + + @override + Uri? get feedId => (origin as FeedArticleListProvider).feedId; +} + +String _$feedArticleHash() => r'18b5faf391867b95f4b3bc4f74a6083854b633a5'; + +/// See also [feedArticle]. +@ProviderFor(feedArticle) +const feedArticleProvider = FeedArticleFamily(); + +/// See also [feedArticle]. +class FeedArticleFamily extends Family> { + /// See also [feedArticle]. + const FeedArticleFamily(); + + /// See also [feedArticle]. + FeedArticleProvider call(String articleId, {required bool updateReadDate}) { + return FeedArticleProvider(articleId, updateReadDate: updateReadDate); + } + + @override + FeedArticleProvider getProviderOverride( + covariant FeedArticleProvider provider, + ) { + return call(provider.articleId, updateReadDate: provider.updateReadDate); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'feedArticleProvider'; +} + +/// See also [feedArticle]. +class FeedArticleProvider extends AutoDisposeStreamProvider { + /// See also [feedArticle]. + FeedArticleProvider(String articleId, {required bool updateReadDate}) + : this._internal( + (ref) => feedArticle( + ref as FeedArticleRef, + articleId, + updateReadDate: updateReadDate, + ), + from: feedArticleProvider, + name: r'feedArticleProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedArticleHash, + dependencies: FeedArticleFamily._dependencies, + allTransitiveDependencies: FeedArticleFamily._allTransitiveDependencies, + articleId: articleId, + updateReadDate: updateReadDate, + ); + + FeedArticleProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.articleId, + required this.updateReadDate, + }) : super.internal(); + + final String articleId; + final bool updateReadDate; + + @override + Override overrideWith( + Stream Function(FeedArticleRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FeedArticleProvider._internal( + (ref) => create(ref as FeedArticleRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + articleId: articleId, + updateReadDate: updateReadDate, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _FeedArticleProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FeedArticleProvider && + other.articleId == articleId && + other.updateReadDate == updateReadDate; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, articleId.hashCode); + hash = _SystemHash.combine(hash, updateReadDate.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FeedArticleRef on AutoDisposeStreamProviderRef { + /// The parameter `articleId` of this provider. + String get articleId; + + /// The parameter `updateReadDate` of this provider. + bool get updateReadDate; +} + +class _FeedArticleProviderElement + extends AutoDisposeStreamProviderElement + with FeedArticleRef { + _FeedArticleProviderElement(super.provider); + + @override + String get articleId => (origin as FeedArticleProvider).articleId; + @override + bool get updateReadDate => (origin as FeedArticleProvider).updateReadDate; +} + +String _$unreadArticleCountHash() => + r'709518ad229636df0f1095f47e3a6d116b3aa7e6'; + +/// See also [unreadArticleCount]. +@ProviderFor(unreadArticleCount) +final unreadArticleCountProvider = + AutoDisposeProvider>>>.internal( + unreadArticleCount, + name: r'unreadArticleCountProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$unreadArticleCountHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UnreadArticleCountRef = + AutoDisposeProviderRef>>>; +String _$unreadFeedArticleCountHash() => + r'5e0d8e58b3d1dec978dc07b22367f2cb037b1b15'; + +/// See also [unreadFeedArticleCount]. +@ProviderFor(unreadFeedArticleCount) +const unreadFeedArticleCountProvider = UnreadFeedArticleCountFamily(); + +/// See also [unreadFeedArticleCount]. +class UnreadFeedArticleCountFamily extends Family> { + /// See also [unreadFeedArticleCount]. + const UnreadFeedArticleCountFamily(); + + /// See also [unreadFeedArticleCount]. + UnreadFeedArticleCountProvider call(Uri feedId) { + return UnreadFeedArticleCountProvider(feedId); + } + + @override + UnreadFeedArticleCountProvider getProviderOverride( + covariant UnreadFeedArticleCountProvider provider, + ) { + return call(provider.feedId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'unreadFeedArticleCountProvider'; +} + +/// See also [unreadFeedArticleCount]. +class UnreadFeedArticleCountProvider extends AutoDisposeStreamProvider { + /// See also [unreadFeedArticleCount]. + UnreadFeedArticleCountProvider(Uri feedId) + : this._internal( + (ref) => + unreadFeedArticleCount(ref as UnreadFeedArticleCountRef, feedId), + from: unreadFeedArticleCountProvider, + name: r'unreadFeedArticleCountProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$unreadFeedArticleCountHash, + dependencies: UnreadFeedArticleCountFamily._dependencies, + allTransitiveDependencies: + UnreadFeedArticleCountFamily._allTransitiveDependencies, + feedId: feedId, + ); + + UnreadFeedArticleCountProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.feedId, + }) : super.internal(); + + final Uri feedId; + + @override + Override overrideWith( + Stream Function(UnreadFeedArticleCountRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: UnreadFeedArticleCountProvider._internal( + (ref) => create(ref as UnreadFeedArticleCountRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + feedId: feedId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _UnreadFeedArticleCountProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is UnreadFeedArticleCountProvider && other.feedId == feedId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin UnreadFeedArticleCountRef on AutoDisposeStreamProviderRef { + /// The parameter `feedId` of this provider. + Uri get feedId; +} + +class _UnreadFeedArticleCountProviderElement + extends AutoDisposeStreamProviderElement + with UnreadFeedArticleCountRef { + _UnreadFeedArticleCountProviderElement(super.provider); + + @override + Uri get feedId => (origin as UnreadFeedArticleCountProvider).feedId; +} + +String _$fetchWebFeedHash() => r'73bdf87ad7dbd039c7dc181d80acdf99d96fe1c6'; + +/// See also [fetchWebFeed]. +@ProviderFor(fetchWebFeed) +const fetchWebFeedProvider = FetchWebFeedFamily(); + +/// See also [fetchWebFeed]. +class FetchWebFeedFamily extends Family> { + /// See also [fetchWebFeed]. + const FetchWebFeedFamily(); + + /// See also [fetchWebFeed]. + FetchWebFeedProvider call(Uri url) { + return FetchWebFeedProvider(url); + } + + @override + FetchWebFeedProvider getProviderOverride( + covariant FetchWebFeedProvider provider, + ) { + return call(provider.url); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'fetchWebFeedProvider'; +} + +/// See also [fetchWebFeed]. +class FetchWebFeedProvider extends AutoDisposeFutureProvider { + /// See also [fetchWebFeed]. + FetchWebFeedProvider(Uri url) + : this._internal( + (ref) => fetchWebFeed(ref as FetchWebFeedRef, url), + from: fetchWebFeedProvider, + name: r'fetchWebFeedProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$fetchWebFeedHash, + dependencies: FetchWebFeedFamily._dependencies, + allTransitiveDependencies: + FetchWebFeedFamily._allTransitiveDependencies, + url: url, + ); + + FetchWebFeedProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.url, + }) : super.internal(); + + final Uri url; + + @override + Override overrideWith( + FutureOr Function(FetchWebFeedRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FetchWebFeedProvider._internal( + (ref) => create(ref as FetchWebFeedRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + url: url, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _FetchWebFeedProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FetchWebFeedProvider && other.url == url; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, url.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FetchWebFeedRef on AutoDisposeFutureProviderRef { + /// The parameter `url` of this provider. + Uri get url; +} + +class _FetchWebFeedProviderElement + extends AutoDisposeFutureProviderElement + with FetchWebFeedRef { + _FetchWebFeedProviderElement(super.provider); + + @override + Uri get url => (origin as FetchWebFeedProvider).url; +} + +String _$articleSearchHash() => r'cc59a5d3c4b926db9f03ed96db592965ff925b04'; + +abstract class _$ArticleSearch + extends BuildlessAutoDisposeStreamNotifier> { + late final Uri? feedId; + + Stream> build(Uri? feedId); +} + +/// See also [ArticleSearch]. +@ProviderFor(ArticleSearch) +const articleSearchProvider = ArticleSearchFamily(); + +/// See also [ArticleSearch]. +class ArticleSearchFamily extends Family>> { + /// See also [ArticleSearch]. + const ArticleSearchFamily(); + + /// See also [ArticleSearch]. + ArticleSearchProvider call(Uri? feedId) { + return ArticleSearchProvider(feedId); + } + + @override + ArticleSearchProvider getProviderOverride( + covariant ArticleSearchProvider provider, + ) { + return call(provider.feedId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'articleSearchProvider'; +} + +/// See also [ArticleSearch]. +class ArticleSearchProvider + extends + AutoDisposeStreamNotifierProviderImpl< + ArticleSearch, + List + > { + /// See also [ArticleSearch]. + ArticleSearchProvider(Uri? feedId) + : this._internal( + () => ArticleSearch()..feedId = feedId, + from: articleSearchProvider, + name: r'articleSearchProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$articleSearchHash, + dependencies: ArticleSearchFamily._dependencies, + allTransitiveDependencies: + ArticleSearchFamily._allTransitiveDependencies, + feedId: feedId, + ); + + ArticleSearchProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.feedId, + }) : super.internal(); + + final Uri? feedId; + + @override + Stream> runNotifierBuild(covariant ArticleSearch notifier) { + return notifier.build(feedId); + } + + @override + Override overrideWith(ArticleSearch Function() create) { + return ProviderOverride( + origin: this, + override: ArticleSearchProvider._internal( + () => create()..feedId = feedId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + feedId: feedId, + ), + ); + } + + @override + AutoDisposeStreamNotifierProviderElement> + createElement() { + return _ArticleSearchProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ArticleSearchProvider && other.feedId == feedId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ArticleSearchRef + on AutoDisposeStreamNotifierProviderRef> { + /// The parameter `feedId` of this provider. + Uri? get feedId; +} + +class _ArticleSearchProviderElement + extends + AutoDisposeStreamNotifierProviderElement< + ArticleSearch, + List + > + with ArticleSearchRef { + _ArticleSearchProviderElement(super.provider); + + @override + Uri? get feedId => (origin as ArticleSearchProvider).feedId; +} + +String _$filteredArticleListHash() => + r'a691c3c6aa722dd85ee4980e6c48721a8025f1d8'; + +abstract class _$FilteredArticleList + extends BuildlessAutoDisposeNotifier>> { + late final Uri? feedId; + + AsyncValue> build(Uri? feedId); +} + +/// See also [FilteredArticleList]. +@ProviderFor(FilteredArticleList) +const filteredArticleListProvider = FilteredArticleListFamily(); + +/// See also [FilteredArticleList]. +class FilteredArticleListFamily extends Family>> { + /// See also [FilteredArticleList]. + const FilteredArticleListFamily(); + + /// See also [FilteredArticleList]. + FilteredArticleListProvider call(Uri? feedId) { + return FilteredArticleListProvider(feedId); + } + + @override + FilteredArticleListProvider getProviderOverride( + covariant FilteredArticleListProvider provider, + ) { + return call(provider.feedId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'filteredArticleListProvider'; +} + +/// See also [FilteredArticleList]. +class FilteredArticleListProvider + extends + AutoDisposeNotifierProviderImpl< + FilteredArticleList, + AsyncValue> + > { + /// See also [FilteredArticleList]. + FilteredArticleListProvider(Uri? feedId) + : this._internal( + () => FilteredArticleList()..feedId = feedId, + from: filteredArticleListProvider, + name: r'filteredArticleListProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$filteredArticleListHash, + dependencies: FilteredArticleListFamily._dependencies, + allTransitiveDependencies: + FilteredArticleListFamily._allTransitiveDependencies, + feedId: feedId, + ); + + FilteredArticleListProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.feedId, + }) : super.internal(); + + final Uri? feedId; + + @override + AsyncValue> runNotifierBuild( + covariant FilteredArticleList notifier, + ) { + return notifier.build(feedId); + } + + @override + Override overrideWith(FilteredArticleList Function() create) { + return ProviderOverride( + origin: this, + override: FilteredArticleListProvider._internal( + () => create()..feedId = feedId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + feedId: feedId, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement< + FilteredArticleList, + AsyncValue> + > + createElement() { + return _FilteredArticleListProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FilteredArticleListProvider && other.feedId == feedId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FilteredArticleListRef + on AutoDisposeNotifierProviderRef>> { + /// The parameter `feedId` of this provider. + Uri? get feedId; +} + +class _FilteredArticleListProviderElement + extends + AutoDisposeNotifierProviderElement< + FilteredArticleList, + AsyncValue> + > + with FilteredArticleListRef { + _FilteredArticleListProviderElement(super.provider); + + @override + Uri? get feedId => (origin as FilteredArticleListProvider).feedId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/providers/add_dialog_blocking.dart b/app/lib/features/web_feed/domain/providers/add_dialog_blocking.dart new file mode 100644 index 00000000..9f3508d7 --- /dev/null +++ b/app/lib/features/web_feed/domain/providers/add_dialog_blocking.dart @@ -0,0 +1,43 @@ +import 'package:nullability/nullability.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; + +part 'add_dialog_blocking.g.dart'; + +@Riverpod(keepAlive: true) +class AddFeedDialogBlocking extends _$AddFeedDialogBlocking { + DateTime? _lastIgnore; + final _ignoredUrls = {}; + + void ignore(Uri url) { + final date = DateTime.now(); + + _lastIgnore = date; + _ignoredUrls[url] = date; + } + + bool canPush(Uri url) { + if (_lastIgnore.mapNotNull( + (last) => + DateTime.now().difference(last) <= const Duration(seconds: 30), + ) ?? + false) { + logger.i('Blocking add feed default timeout for $url'); + return false; + } + + if (_ignoredUrls[url].mapNotNull( + (last) => + DateTime.now().difference(last) <= const Duration(minutes: 5), + ) ?? + false) { + logger.i('Blocking add feed url specific for $url'); + return false; + } + + return true; + } + + @override + void build() {} +} diff --git a/app/lib/features/web_feed/domain/providers/add_dialog_blocking.g.dart b/app/lib/features/web_feed/domain/providers/add_dialog_blocking.g.dart new file mode 100644 index 00000000..590dfcef --- /dev/null +++ b/app/lib/features/web_feed/domain/providers/add_dialog_blocking.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'add_dialog_blocking.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$addFeedDialogBlockingHash() => + r'513071d5e507dd292df6b4a3ee3ef7d2217db5bb'; + +/// See also [AddFeedDialogBlocking]. +@ProviderFor(AddFeedDialogBlocking) +final addFeedDialogBlockingProvider = + NotifierProvider.internal( + AddFeedDialogBlocking.new, + name: r'addFeedDialogBlockingProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$addFeedDialogBlockingHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$AddFeedDialogBlocking = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/providers/article_filter.dart b/app/lib/features/web_feed/domain/providers/article_filter.dart new file mode 100644 index 00000000..77e61e82 --- /dev/null +++ b/app/lib/features/web_feed/domain/providers/article_filter.dart @@ -0,0 +1,21 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'article_filter.g.dart'; + +@Riverpod(keepAlive: true) +class ArticleFilter extends _$ArticleFilter { + void addTag(String tagId) { + state = {...state, tagId}; + } + + void removeTag(String tagId) { + if (state.isNotEmpty) { + state = {...state}..remove(tagId); + } + } + + @override + Set build() { + return {}; + } +} diff --git a/app/lib/features/web_feed/domain/providers/article_filter.g.dart b/app/lib/features/web_feed/domain/providers/article_filter.g.dart new file mode 100644 index 00000000..18cf1931 --- /dev/null +++ b/app/lib/features/web_feed/domain/providers/article_filter.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'article_filter.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$articleFilterHash() => r'61e4d5230e214038e753bc173158ed1d4dc57040'; + +/// See also [ArticleFilter]. +@ProviderFor(ArticleFilter) +final articleFilterProvider = + NotifierProvider>.internal( + ArticleFilter.new, + name: r'articleFilterProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$articleFilterHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ArticleFilter = Notifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/repositories/feed_repository.dart b/app/lib/features/web_feed/domain/repositories/feed_repository.dart new file mode 100644 index 00000000..496f4990 --- /dev/null +++ b/app/lib/features/web_feed/domain/repositories/feed_repository.dart @@ -0,0 +1,89 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; +import 'package:weblibre/features/web_feed/data/providers.dart'; + +part 'feed_repository.g.dart'; + +@Riverpod(keepAlive: true) +class FeedRepository extends _$FeedRepository { + Future> getAllFeeds() { + return ref.read(feedDatabaseProvider).feedDao.getFeeds().get(); + } + + Future touchFeedFetched(Uri feedId) { + return ref + .read(feedDatabaseProvider) + .feedDao + .updateFeedFetched(feedId, DateTime.now()); + } + + Future upsertFeed(FeedData feedData) { + return ref.read(feedDatabaseProvider).feedDao.upsertFeed(feedData); + } + + Future upsertArticles(List articles) { + return ref.read(feedDatabaseProvider).articleDao.upsertArticles(articles); + } + + Future deleteFeed(Uri feedId) { + return ref.read(feedDatabaseProvider).feedDao.deleteFeed(feedId); + } + + Future touchArticleRead(String articleId) { + return ref + .read(feedDatabaseProvider) + .articleDao + .updateArticleRead(articleId, DateTime.now()); + } + + Future unsetArticleRead(String articleId) { + return ref + .read(feedDatabaseProvider) + .articleDao + .updateArticleRead(articleId, null); + } + + Stream> watchFeeds() { + return ref.read(feedDatabaseProvider).feedDao.getFeeds().watch(); + } + + Stream watchFeed(Uri feedId) { + return ref + .read(feedDatabaseProvider) + .feedDao + .getFeed(feedId) + .watchSingleOrNull(); + } + + Stream> watchFeedArticles(Uri? feedId) { + return ref + .read(feedDatabaseProvider) + .articleDao + .getFeedArticles(feedId) + .watch(); + } + + Stream watchArticle(String articleId) { + return ref + .read(feedDatabaseProvider) + .articleDao + .getArticleById(articleId) + .watchSingleOrNull(); + } + + Stream> watchUnreadFeedArticleCount() { + return ref + .read(feedDatabaseProvider) + .articleDao + .getUnreadArticleCount() + .watch() + .map( + (results) => + Map.fromEntries(results.map((e) => MapEntry(e.$1, e.$2))), + ); + } + + @override + void build() {} +} diff --git a/app/lib/features/web_feed/domain/repositories/feed_repository.g.dart b/app/lib/features/web_feed/domain/repositories/feed_repository.g.dart new file mode 100644 index 00000000..f25c4f85 --- /dev/null +++ b/app/lib/features/web_feed/domain/repositories/feed_repository.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$feedRepositoryHash() => r'805cc26890b0d43576a1eab0ecb5851b6649d7b7'; + +/// See also [FeedRepository]. +@ProviderFor(FeedRepository) +final feedRepositoryProvider = NotifierProvider.internal( + FeedRepository.new, + name: r'feedRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$FeedRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/services/article_content_processor.dart b/app/lib/features/web_feed/domain/services/article_content_processor.dart new file mode 100644 index 00000000..b8cb6b16 --- /dev/null +++ b/app/lib/features/web_feed/domain/services/article_content_processor.dart @@ -0,0 +1,50 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; +import 'package:weblibre/features/web_feed/data/providers.dart'; + +part 'article_content_processor.g.dart'; + +@Riverpod(keepAlive: true) +class ArticleContentProcessorService extends _$ArticleContentProcessorService { + @override + void build() { + final db = ref.watch(feedDatabaseProvider); + + final processSub = db.articleDao.getUnprocessedArticles().watch().listen(( + articles, + ) async { + try { + final content = await GeckoBrowserExtensionService.turndownHtml( + articles.map((article) => article.contentHtml ?? '').toList(), + ); + final summary = await GeckoBrowserExtensionService.turndownHtml( + articles.map((article) => article.summaryHtml ?? '').toList(), + ); + + await db.articleDao.updateArticleContent( + articles + .mapIndexed( + (index, article) => article.copyWith( + contentMarkdown: content[index].markdown ?? '', + contentPlain: content[index].plain, + summaryMarkdown: summary[index].markdown ?? '', + summaryPlain: summary[index].plain, + ), + ) + .toList(), + ); + + logger.i('Processed ${articles.length} articles'); + } catch (e, s) { + logger.e('Error processing articles', error: e, stackTrace: s); + } + }); + + ref.onDispose(() async { + await processSub.cancel(); + }); + } +} diff --git a/app/lib/features/web_feed/domain/services/article_content_processor.g.dart b/app/lib/features/web_feed/domain/services/article_content_processor.g.dart new file mode 100644 index 00000000..4320631e --- /dev/null +++ b/app/lib/features/web_feed/domain/services/article_content_processor.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'article_content_processor.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$articleContentProcessorServiceHash() => + r'e7cc3da71f6dcf39b0c10df4dcd061f816d5c12e'; + +/// See also [ArticleContentProcessorService]. +@ProviderFor(ArticleContentProcessorService) +final articleContentProcessorServiceProvider = + NotifierProvider.internal( + ArticleContentProcessorService.new, + name: r'articleContentProcessorServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$articleContentProcessorServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ArticleContentProcessorService = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/services/feed_reader.dart b/app/lib/features/web_feed/domain/services/feed_reader.dart new file mode 100644 index 00000000..5dae22bf --- /dev/null +++ b/app/lib/features/web_feed/domain/services/feed_reader.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_parse_result.dart'; +import 'package:weblibre/features/web_feed/utils/feed_parser.dart'; + +part 'feed_reader.g.dart'; + +@Riverpod(keepAlive: true) +class FeedReader extends _$FeedReader { + Future parseFeed(Uri url) async { + final rootIsolateToken = ServicesBinding.rootIsolateToken!; + + final result = await compute((args) async { + // Initialize BackgroundIsolateBinaryMessenger with the token + BackgroundIsolateBinaryMessenger.ensureInitialized( + args['token']! as RootIsolateToken, + ); + + final client = http.Client(); + try { + final url = Uri.parse(args['url']! as String); + final response = await client + .get(url) + .timeout(const Duration(seconds: 30)); + + final parser = FeedParser.parse(url: url, xmlString: response.body); + final result = FeedParseResult( + feedData: parser.readGeneralData(), + articleData: parser.readArticles(), + ); + + return result.toJson(); + } finally { + client.close(); + } + }, {'token': rootIsolateToken, 'url': url.toString()}); + + return FeedParseResult.fromJson(result); + } + + @override + void build() {} +} diff --git a/app/lib/features/kagi/data/services/chat.g.dart b/app/lib/features/web_feed/domain/services/feed_reader.g.dart similarity index 51% rename from app/lib/features/kagi/data/services/chat.g.dart rename to app/lib/features/web_feed/domain/services/feed_reader.g.dart index aef3e736..b49b2d57 100644 --- a/app/lib/features/kagi/data/services/chat.g.dart +++ b/app/lib/features/web_feed/domain/services/feed_reader.g.dart @@ -1,26 +1,25 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'chat.dart'; +part of 'feed_reader.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** -String _$kagiChatServiceHash() => r'fda3874c955812fa62d555afaca352a3776dd692'; +String _$feedReaderHash() => r'301e01fa831c58723dc0ab118496f4fb34c38610'; -/// See also [KagiChatService]. -@ProviderFor(KagiChatService) -final kagiChatServiceProvider = - NotifierProvider.internal( - KagiChatService.new, - name: r'kagiChatServiceProvider', +/// See also [FeedReader]. +@ProviderFor(FeedReader) +final feedReaderProvider = NotifierProvider.internal( + FeedReader.new, + name: r'feedReaderProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$kagiChatServiceHash, + : _$feedReaderHash, dependencies: null, allTransitiveDependencies: null, ); -typedef _$KagiChatService = Notifier; +typedef _$FeedReader = Notifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/extensions/atom.dart b/app/lib/features/web_feed/extensions/atom.dart new file mode 100644 index 00000000..e696a98a --- /dev/null +++ b/app/lib/features/web_feed/extensions/atom.dart @@ -0,0 +1,63 @@ +import 'package:collection/collection.dart'; +import 'package:nullability/nullability.dart'; +import 'package:rss_dart/dart_rss.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_author.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_category.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; + +extension ParseAtomLink on List { + List toFeedLinks() { + return map((link) { + final uri = link.href.mapNotNull(Uri.tryParse); + if (uri == null) { + return null; + } + + final relation = link.rel.mapNotNull( + (rel) => FeedLinkRelation.values.firstWhereOrNull((e) => e.name == rel), + ); + + return FeedLink( + uri: uri, + relation: relation, + title: link.title.whenNotEmpty, + ); + }).nonNulls.toList(); + } +} + +extension SelectFeedLink on List { + FeedLink? getRelation(FeedLinkRelation? relation) { + return firstWhereOrNull((link) => link.relation == relation); + } +} + +extension ParseAtomCategory on List { + List toFeedCategories() { + return where((category) => category.term.isNotEmpty) + .map( + (category) => FeedCategory( + id: category.term!, + title: category.label.whenNotEmpty, + ), + ) + .toList(); + } +} + +extension ParseAtomPerson on List { + List toFeedAuthors() { + return where( + (author) => + author.name.whenNotEmpty != null || + author.email.whenNotEmpty != null, + ) + .map( + (author) => FeedAuthor( + name: author.name.whenNotEmpty, + email: author.email.whenNotEmpty, + ), + ) + .toList(); + } +} diff --git a/app/lib/features/web_feed/extensions/feed_article.dart b/app/lib/features/web_feed/extensions/feed_article.dart new file mode 100644 index 00000000..da97133f --- /dev/null +++ b/app/lib/features/web_feed/extensions/feed_article.dart @@ -0,0 +1,15 @@ +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; +import 'package:weblibre/features/web_feed/extensions/atom.dart'; + +extension FeedArticleX on FeedArticle { + String get displayTitle => + title ?? + links + ?.getRelation(FeedLinkRelation.alternate) + .mapNotNull( + (link) => link.title.whenNotEmpty ?? link.uri.toString(), + ) ?? + 'Unnamed Article'; +} diff --git a/app/lib/features/web_feed/extensions/rss.dart b/app/lib/features/web_feed/extensions/rss.dart new file mode 100644 index 00000000..714aacc0 --- /dev/null +++ b/app/lib/features/web_feed/extensions/rss.dart @@ -0,0 +1,25 @@ +import 'package:nullability/nullability.dart'; +import 'package:rss_dart/dart_rss.dart'; +import 'package:rss_dart/domain/dublin_core/dublin_core.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_category.dart'; + +extension ParseDublinCategory on DublinCore { + List toFeedCategories() { + return subjects + .where((subject) => subject.isNotEmpty) + .map((subject) => FeedCategory(id: subject)) + .toList(); + } +} + +extension ParseRssCategory on List { + List toFeedCategories() { + return where((category) => category.value.isNotEmpty) + .map( + (category) => FeedCategory( + id: '${category.domain ?? ''} ${category.value ?? ''}'.trim(), + ), + ) + .toList(); + } +} diff --git a/app/lib/features/web_feed/extensions/web_feed_converter.dart b/app/lib/features/web_feed/extensions/web_feed_converter.dart new file mode 100644 index 00000000..7cd03507 --- /dev/null +++ b/app/lib/features/web_feed/extensions/web_feed_converter.dart @@ -0,0 +1,45 @@ +import 'package:rss_dart/dart_rss.dart'; + +extension WebFeedJson on WebFeed { + Map toJson() { + return { + 'title': title, + 'description': description, + 'links': links.where((link) => link != null).toList(), + 'items': items.map((item) => item.toJson()).toList(), + }; + } + + static WebFeed fromJson(Map json) { + return WebFeed( + title: json['title'] as String, + description: json['description'] as String, + links: (json['links'] as List).cast(), + items: (json['items'] as List) + .map((item) => WebFeedItemJson.fromJson(item as Map)) + .toList(), + ); + } +} + +extension WebFeedItemJson on WebFeedItem { + Map toJson() { + return { + 'title': title, + 'body': body, + 'links': links.where((link) => link != null).toList(), + 'updated': updated?.toIso8601String(), + }; + } + + static WebFeedItem fromJson(Map json) { + return WebFeedItem( + title: json['title'] as String, + body: json['body'] as String, + links: (json['links'] as List).cast(), + updated: json['updated'] != null + ? DateTime.parse(json['updated'] as String) + : null, + ); + } +} diff --git a/app/lib/features/web_feed/presentation/add_feed_dialog.dart b/app/lib/features/web_feed/presentation/add_feed_dialog.dart new file mode 100644 index 00000000..f5d20007 --- /dev/null +++ b/app/lib/features/web_feed/presentation/add_feed_dialog.dart @@ -0,0 +1,71 @@ +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/core/routing/routes.dart'; +import 'package:weblibre/features/web_feed/domain/providers/add_dialog_blocking.dart'; +import 'package:weblibre/utils/form_validators.dart'; +import 'package:weblibre/utils/uri_parser.dart' as uri_parser; + +class AddFeedDialog extends HookConsumerWidget { + final Uri? initialUri; + + const AddFeedDialog({super.key, required this.initialUri}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + + final textController = useTextEditingController( + text: initialUri?.toString(), + ); + + return AlertDialog( + title: const Text('Add Feed'), + // contentPadding: const EdgeInsets.fromLTRB(24.0, 12.0, 24.0, 16.0), + content: Form( + key: formKey, + child: TextFormField( + decoration: const InputDecoration( + label: Text('URL'), + hintText: 'https://example.com/feed', + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + controller: textController, + keyboardType: TextInputType.url, + validator: (value) { + return validateUrl(value, onlyHttpProtocol: true); + }, + ), + ), + actions: [ + if (initialUri != null) + TextButton( + onPressed: () { + ref + .read(addFeedDialogBlockingProvider.notifier) + .ignore(initialUri!); + context.pop(); + }, + child: const Text('Ignore'), + ), + TextButton( + onPressed: () { + context.pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + if (formKey.currentState?.validate() == true) { + FeedCreateRoute( + feedId: uri_parser.tryParseUrl(textController.text)!, + ).pushReplacement(context); + } + }, + child: const Text('Add'), + ), + ], + ); + } +} diff --git a/app/lib/features/web_feed/presentation/controllers/fetch_articles.dart b/app/lib/features/web_feed/presentation/controllers/fetch_articles.dart new file mode 100644 index 00000000..167b245b --- /dev/null +++ b/app/lib/features/web_feed/presentation/controllers/fetch_articles.dart @@ -0,0 +1,60 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/web_feed/domain/repositories/feed_repository.dart'; +import 'package:weblibre/features/web_feed/domain/services/feed_reader.dart'; + +part 'fetch_articles.g.dart'; + +@Riverpod() +class FetchArticlesController extends _$FetchArticlesController { + Future fetchAllArticles() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final feeds = await ref + .read(feedRepositoryProvider.notifier) + .getAllFeeds(); + + await Future.wait( + feeds.map((feed) async { + try { + final result = await ref + .read(feedReaderProvider.notifier) + .parseFeed(feed.url); + + await ref + .read(feedRepositoryProvider.notifier) + .upsertArticles(result.articleData); + + await ref + .read(feedRepositoryProvider.notifier) + .touchFeedFetched(feed.url); + } catch (e, s) { + logger.e( + 'Failed fetching feed ${feed.url}', + error: e, + stackTrace: s, + ); + } + }).toList(), + ); + }); + } + + Future fetchFeedArticles(Uri uri) async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final result = await ref.read(feedReaderProvider.notifier).parseFeed(uri); + + await ref + .read(feedRepositoryProvider.notifier) + .upsertArticles(result.articleData); + + await ref.read(feedRepositoryProvider.notifier).touchFeedFetched(uri); + }); + } + + @override + AsyncValue build() { + return const AsyncData(null); + } +} diff --git a/app/lib/features/web_feed/presentation/controllers/fetch_articles.g.dart b/app/lib/features/web_feed/presentation/controllers/fetch_articles.g.dart new file mode 100644 index 00000000..8a629926 --- /dev/null +++ b/app/lib/features/web_feed/presentation/controllers/fetch_articles.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'fetch_articles.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$fetchArticlesControllerHash() => + r'7604d9352da886624c3cf6862b89c397edda1f28'; + +/// See also [FetchArticlesController]. +@ProviderFor(FetchArticlesController) +final fetchArticlesControllerProvider = + AutoDisposeNotifierProvider< + FetchArticlesController, + AsyncValue + >.internal( + FetchArticlesController.new, + name: r'fetchArticlesControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$fetchArticlesControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$FetchArticlesController = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/presentation/screens/feed_article.dart b/app/lib/features/web_feed/presentation/screens/feed_article.dart new file mode 100644 index 00000000..c4ac68b1 --- /dev/null +++ b/app/lib/features/web_feed/presentation/screens/feed_article.dart @@ -0,0 +1,263 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/providers/format.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; +import 'package:weblibre/features/web_feed/domain/providers.dart'; +import 'package:weblibre/features/web_feed/extensions/atom.dart'; +import 'package:weblibre/features/web_feed/extensions/feed_article.dart'; +import 'package:weblibre/features/web_feed/presentation/widgets/authors_horizontal_list.dart'; +import 'package:weblibre/features/web_feed/presentation/widgets/tags_horizontal_list.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/utils/markdown/image_extractor.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +enum _Pages { summary, content } + +class FeedArticleScreen extends HookConsumerWidget { + final String articleId; + + const FeedArticleScreen({super.key, required this.articleId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final articleAsync = ref.watch( + feedArticleProvider(articleId, updateReadDate: true), + ); + + return Scaffold( + body: articleAsync.when( + skipLoadingOnReload: true, + data: (article) { + if (article == null) { + return const SizedBox.shrink(); + } + + return HookBuilder( + builder: (context) { + final hasArticleCreated = article.created != null; + final hasArticleUpdated = + article.updated != null && article.updated != article.created; + final hasAuthors = article.authors.isNotEmpty; + final hasTags = article.tags.isNotEmpty; + + final tabs = useMemoized( + () => [ + if (article.summaryMarkdown.isNotEmpty) _Pages.summary, + if (article.contentMarkdown.isNotEmpty) _Pages.content, + ], + [article], + ); + + final tabController = useTabController( + initialLength: tabs.length, + ); + + final articleLink = useMemoized( + () => + article.links?.getRelation(FeedLinkRelation.alternate) ?? + article.links?.getRelation(null), + [article], + ); + + final articleImages = useMemoized( + () => (article.contentMarkdown ?? article.summaryMarkdown) + .mapNotNull(extractImagesFromMarkdown), + [article], + ); + + final bottomHeight = useMemoized(() { + var height = 0.0; + + if (hasArticleCreated) { + height += 20; + } + if (hasArticleUpdated) { + height += 20; + } + if (hasAuthors) { + height += 56; + } + if (hasTags) { + height += 56; + } + + return height; + }, [article]); + + return NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverAppBar.large( + pinned: false, + flexibleSpace: FlexibleSpaceBar( + centerTitle: false, + titlePadding: EdgeInsetsDirectional.only( + start: 72, + end: 72, + bottom: bottomHeight + 16, + ), + title: Text( + article.displayTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + background: articleImages?.firstOrNull.mapNotNull( + (img) => Image.network( + img.toString(), + fit: BoxFit.cover, + color: Theme.of( + context, + ).colorScheme.surface.withAlpha(200), + colorBlendMode: BlendMode.darken, + ), + ), + ), + bottom: PreferredSize( + preferredSize: Size(double.infinity, bottomHeight), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + Text( + 'Published: ${hasArticleCreated ? ref.read(formatProvider.notifier).fullDateTimeWithTimezone(article.created!) : 'N/A'}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + if (hasArticleUpdated) + Text( + 'Updated: ${ref.read(formatProvider.notifier).fullDateTimeWithTimezone(article.updated!)}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + if (hasAuthors) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + children: [ + const Text('Authors:'), + const SizedBox(width: 8), + Expanded( + child: AuthorsHorizontalList( + authors: article.authors!, + ), + ), + ], + ), + ), + if (hasTags) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + children: [ + const Text('Tags:'), + const SizedBox(width: 8), + Expanded( + child: TagsHorizontalList( + tags: article.tags!, + ), + ), + ], + ), + ), + ], + ), + ), + ), + actions: [ + if (articleLink != null) + IconButton( + onPressed: () async { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: articleLink.uri); + + if (context.mounted) { + BrowserRoute().go(context); + } + }, + icon: const Icon(Icons.open_in_browser), + ), + ], + ), + // SliverToBoxAdapter( + // child: Padding( + // padding: const EdgeInsets.all(8.0), + // child: TabBar( + // controller: tabController, + // tabs: [ + // ...tabs.map( + // (tab) => switch (tab) { + // _Pages.summary => const Tab(text: 'Summary'), + // _Pages.content => const Tab(text: 'Article'), + // }, + // ), + // ], + // ), + // ), + // ), + ], + body: TabBarView( + controller: tabController, + children: [ + ...tabs.map( + (tab) => Markdown( + selectable: true, + onTapLink: (text, href, title) async { + if (href.mapNotNull(Uri.tryParse) + case final Uri url) { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: url); + + if (context.mounted) { + showTabOpenedMessage( + context, + tabName: title.whenNotEmpty, + onShow: () { + BrowserRoute().go(context); + }, + ); + } + } + }, + styleSheet: + MarkdownStyleSheet.fromTheme( + Theme.of(context), + ).copyWith( + blockquoteDecoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(2.0), + ), + ), + data: switch (tab) { + _Pages.summary => article.summaryMarkdown!, + _Pages.content => article.contentMarkdown!, + }, + ), + ), + ], + ), + ); + }, + ); + }, + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed reading article', + exception: error, + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/screens/feed_article_list.dart b/app/lib/features/web_feed/presentation/screens/feed_article_list.dart new file mode 100644 index 00000000..068a416e --- /dev/null +++ b/app/lib/features/web_feed/presentation/screens/feed_article_list.dart @@ -0,0 +1,189 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/web_feed/domain/providers.dart'; +import 'package:weblibre/features/web_feed/domain/providers/article_filter.dart'; +import 'package:weblibre/features/web_feed/presentation/controllers/fetch_articles.dart'; +import 'package:weblibre/features/web_feed/presentation/widgets/feed_article_card.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/presentation/widgets/speech_to_text_button.dart'; + +class FeedArticleListScreen extends HookConsumerWidget { + final Uri? feedId; + + const FeedArticleListScreen({required this.feedId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + HookConsumer( + builder: (context, ref, child) { + final tags = ref.watch(articleFilterProvider); + final feedTitle = ref.watch( + feedDataProvider( + feedId, + ).select((value) => value.valueOrNull?.title.whenNotEmpty), + ); + + final focusNode = useFocusNode(); + final searchTextController = useTextEditingController(); + + final hasText = useListenableSelector( + searchTextController, + () => searchTextController.text.isNotEmpty, + ); + + useListenableCallback(searchTextController, () { + ref + .read(filteredArticleListProvider(feedId).notifier) + .search(searchTextController.text); + }); + + final bottomHeight = useMemoized(() { + var height = 56.0 + 4.0; + + if (tags.isNotEmpty) { + height += 48; + } + + return height; + }, [tags.isNotEmpty]); + + return SliverAppBar( + floating: true, + title: Text(feedTitle ?? 'Articles'), + bottom: PreferredSize( + preferredSize: Size(double.infinity, bottomHeight), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + TextField( + focusNode: focusNode, + controller: searchTextController, + decoration: InputDecoration( + label: const Text('Search'), + suffixIcon: hasText + ? IconButton( + onPressed: () { + searchTextController.clear(); + focusNode.requestFocus(); + }, + icon: const Icon(Icons.clear), + ) + : SpeechToTextButton( + onTextReceived: (data) { + searchTextController.text = data + .toString(); + }, + ), + ), + ), + const SizedBox(height: 4), + if (tags.isNotEmpty) + SizedBox( + width: double.infinity, + height: 48, + child: FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return ListView( + controller: controller, + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: tags + .map( + (tag) => Padding( + padding: const EdgeInsets.only( + right: 8.0, + ), + child: FilterChip( + label: Text(tag), + showCheckmark: false, + selected: true, + onSelected: (value) {}, + onDeleted: () { + ref + .read( + articleFilterProvider + .notifier, + ) + .removeTag(tag); + }, + ), + ), + ) + .toList(), + ); + }, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ]; + }, + body: Consumer( + builder: (context, ref, child) { + final articlesAsync = ref.watch( + // ignore: provider_parameters + filteredArticleListProvider(feedId), + ); + + return articlesAsync.when( + skipLoadingOnReload: true, + data: (articles) { + return RefreshIndicator( + onRefresh: () async { + if (feedId != null) { + await ref + .read(fetchArticlesControllerProvider.notifier) + .fetchFeedArticles(feedId!); + } else { + await ref + .read(fetchArticlesControllerProvider.notifier) + .fetchAllArticles(); + } + }, + child: MediaQuery.removePadding( + removeTop: true, + context: context, + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: articles.length, + itemBuilder: (context, i) { + final article = articles[i]; + return FeedArticleCard( + key: ValueKey(article.id), + article: article, + ); + }, + ), + ), + ); + }, + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load Articles', + exception: error, + ), + ), + loading: () => const SizedBox.shrink(), + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/screens/feed_edit.dart b/app/lib/features/web_feed/presentation/screens/feed_edit.dart new file mode 100644 index 00000000..a301b42e --- /dev/null +++ b/app/lib/features/web_feed/presentation/screens/feed_edit.dart @@ -0,0 +1,318 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/extensions/uri.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_category.dart'; +import 'package:weblibre/features/web_feed/domain/providers.dart'; +import 'package:weblibre/features/web_feed/domain/repositories/feed_repository.dart'; +import 'package:weblibre/features/web_feed/presentation/widgets/tag_field.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; +import 'package:weblibre/utils/form_validators.dart'; +import 'package:weblibre/utils/uri_parser.dart' as uri_parser; + +enum _DialogMode { create, edit } + +class FeedEditScreen extends HookConsumerWidget { + final _DialogMode _mode; + + final Uri feedId; + + const FeedEditScreen._({required _DialogMode mode, required this.feedId}) + : _mode = mode; + + factory FeedEditScreen.create({required Uri feedId}) { + return FeedEditScreen._(mode: _DialogMode.create, feedId: feedId); + } + + factory FeedEditScreen.edit({required Uri feedId}) { + return FeedEditScreen._(mode: _DialogMode.edit, feedId: feedId); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final initialFeedAsync = switch (_mode) { + _DialogMode.create => ref.watch( + fetchWebFeedProvider( + feedId, + ).select((value) => value.whenData((result) => result.feedData)), + ), + _DialogMode.edit => ref.watch(feedDataProvider(feedId)), + }; + + return initialFeedAsync.when( + skipLoadingOnReload: true, + data: (initialFeed) { + if (initialFeed == null) { + return Scaffold( + key: const ValueKey('data'), + appBar: AppBar(), + body: const Center( + child: FailureWidget(title: 'Failed to load feed'), + ), + ); + } + + return _FeedEditContent(mode: _mode, initialFeed: initialFeed); + }, + error: (error, stackTrace) => Scaffold( + key: const ValueKey('error'), + appBar: AppBar(), + body: Center( + child: FailureWidget(title: 'Failed to load feed', exception: error), + ), + ), + loading: () => Scaffold( + key: const ValueKey('loading'), + appBar: AppBar( + title: Text(switch (_mode) { + _DialogMode.create => 'New Feed', + _DialogMode.edit => 'Edit Feed', + }), + ), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text('Fetching feed...'), + ), + ], + ), + ), + ), + ); + } +} + +class _FeedEditContent extends HookConsumerWidget { + final _DialogMode _mode; + + final FeedData initialFeed; + + const _FeedEditContent({required _DialogMode mode, required this.initialFeed}) + : _mode = mode; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + final initialTags = useMemoized( + () => initialFeed.tags?.map((tag) => tag.id).toSet(), + [EquatableValue(initialFeed.tags)], + ); + final tags = useRef(initialTags ?? {}); + + final titleTextController = useTextEditingController( + text: initialFeed.title ?? initialFeed.url.host, + ); + final descriptionTextController = useTextEditingController( + text: initialFeed.description, + ); + final urlTextController = useTextEditingController( + text: initialFeed.url.toString(), + ); + final iconUrlTextController = useTextEditingController( + text: initialFeed.icon?.toString(), + ); + final siteLinkTextController = useTextEditingController( + text: initialFeed.siteLink?.toString(), + ); + + return Scaffold( + appBar: AppBar( + title: Text(switch (_mode) { + _DialogMode.create => 'New Feed', + _DialogMode.edit => 'Edit Feed', + }), + actions: [ + IconButton( + onPressed: () async { + if (formKey.currentState?.validate() ?? false) { + final feedData = FeedData( + url: uri_parser.tryParseUrl( + urlTextController.text, + eagerParsing: true, + )!, + authors: initialFeed.authors, + description: descriptionTextController.text.whenNotEmpty, + icon: uri_parser.tryParseUrl( + iconUrlTextController.text, + eagerParsing: true, + ), + siteLink: uri_parser.tryParseUrl( + siteLinkTextController.text, + eagerParsing: true, + ), + tags: tags.value.map((tag) => FeedCategory(id: tag)).toList(), + title: titleTextController.text.whenNotEmpty, + ); + + await ref + .read(feedRepositoryProvider.notifier) + .upsertFeed(feedData); + + if (context.mounted) { + context.pop(); + } + } + }, + icon: const Icon(Icons.check), + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + Expanded( + child: Form( + key: formKey, + child: ListView( + children: [ + TextFormField( + decoration: InputDecoration( + prefixIcon: Padding( + padding: const EdgeInsets.all(10.0), + child: UrlIcon([ + initialFeed.icon ?? + initialFeed.siteLink ?? + initialFeed.url.base, + ], iconSize: 24.0), + ), + label: const Text('Title'), + ), + controller: titleTextController, + ), + TextFormField( + decoration: const InputDecoration( + label: Text('Description'), + prefixIcon: Icon(Icons.short_text), + ), + minLines: 1, + maxLines: 3, + controller: descriptionTextController, + ), + const SizedBox(height: 32), + TextFormField( + decoration: const InputDecoration( + label: Text('Icon URL'), + prefixIcon: Icon(Icons.image), + ), + keyboardType: TextInputType.url, + controller: iconUrlTextController, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + return validateUrl( + value, + onlyHttpProtocol: true, + required: false, + ); + }, + ), + TextFormField( + decoration: const InputDecoration( + label: Text('Site Link'), + prefixIcon: Icon(Icons.link), + ), + keyboardType: TextInputType.url, + controller: siteLinkTextController, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + return validateUrl( + value, + onlyHttpProtocol: true, + required: false, + ); + }, + ), + const SizedBox(height: 32), + TagField( + initialTags: tags.value, + onTagsUpdate: (newTags) { + tags.value = newTags; + }, + ), + const SizedBox(height: 32), + TextFormField( + decoration: const InputDecoration( + label: Text('Feed URL'), + prefixIcon: Icon(MdiIcons.rss), + ), + keyboardType: TextInputType.url, + controller: urlTextController, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + return validateUrl(value, onlyHttpProtocol: true); + }, + ), + ], + ), + ), + ), + if (_mode == _DialogMode.edit) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + foregroundColor: Theme.of(context).colorScheme.error, + iconColor: Theme.of(context).colorScheme.error, + ), + label: const Text('Delete'), + icon: const Icon(Icons.delete), + onPressed: () async { + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Delete Feed'), + content: const Text( + 'Are you sure you want to delete this feed and delete all related articles?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + + if (result == true) { + await ref + .read(feedRepositoryProvider.notifier) + .deleteFeed(initialFeed.url); + + if (context.mounted) { + context.pop(); + } + } + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/screens/feed_list.dart b/app/lib/features/web_feed/presentation/screens/feed_list.dart new file mode 100644 index 00000000..ce80e875 --- /dev/null +++ b/app/lib/features/web_feed/presentation/screens/feed_list.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/web_feed/domain/providers.dart'; +import 'package:weblibre/features/web_feed/presentation/controllers/fetch_articles.dart'; +import 'package:weblibre/features/web_feed/presentation/widgets/feed_card.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; + +class FeedListScreen extends HookConsumerWidget { + const FeedListScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final feeds = ref.watch(feedListProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Feeds')), + body: feeds.when( + skipLoadingOnReload: true, + data: (feeds) { + return RefreshIndicator( + onRefresh: () async { + await ref + .read(fetchArticlesControllerProvider.notifier) + .fetchAllArticles(); + }, + child: ListView.builder( + itemCount: feeds.length, + itemBuilder: (context, i) { + return FeedCard(feed: feeds[i]); + }, + ), + ); + }, + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load Feeds', + exception: error, + onRetry: () { + // ignore: unused_result + ref.refresh(feedListProvider); + }, + ), + ), + loading: () => const SizedBox.shrink(), + ), + floatingActionButton: FloatingActionButton.extended( + label: const Text('Feed'), + icon: const Icon(Icons.add), + onPressed: () async { + await const FeedAddRoute().push(context); + }, + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/select_feed_dialog.dart b/app/lib/features/web_feed/presentation/select_feed_dialog.dart new file mode 100644 index 00000000..178d3d68 --- /dev/null +++ b/app/lib/features/web_feed/presentation/select_feed_dialog.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/web_feed/domain/providers.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; + +class SelectFeedDialog extends HookConsumerWidget { + final Set feedUris; + + const SelectFeedDialog({required this.feedUris}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SimpleDialog( + title: const Text('Add Feed'), + children: feedUris + .map( + (uri) => HookConsumer( + builder: (context, ref, child) { + final feedAsync = ref.watch(fetchWebFeedProvider(uri)); + + return feedAsync.when( + data: (data) { + return ListTile( + title: Text( + data.feedData.title.whenNotEmpty ?? 'Unnamed Feed', + ), + subtitle: Text(uri.toString()), + trailing: const Icon(Icons.add), + onTap: () { + FeedCreateRoute(feedId: uri).pushReplacement(context); + }, + ); + }, + error: (error, stackTrace) => FailureWidget( + title: 'Failed to fetch Feed', + exception: error, + onRetry: () { + // ignore: unused_result + ref.refresh(fetchWebFeedProvider(uri)); + }, + ), + loading: () => Skeletonizer( + child: ListTile( + title: Text(BoneMock.title), + subtitle: Skeleton.keep(child: Text(uri.toString())), + ), + ), + ); + }, + ), + ) + .toList(), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart b/app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart new file mode 100644 index 00000000..973108d8 --- /dev/null +++ b/app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart @@ -0,0 +1,52 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_author.dart'; + +class AuthorsHorizontalList extends StatelessWidget { + late final List _authors; + + AuthorsHorizontalList({ + required List authors, + Set selectedTags = const {}, + void Function(String tagId, bool value)? onTagSelected, + }) { + _authors = authors.map((author) { + final label = Text( + '${author.name ?? ''} ${author.email.mapNotNull((email) => '($email)') ?? ''}' + .trim(), + ); + + return onTagSelected.mapNotNull( + (onTagSelected) => FilterChip( + label: label, + selected: selectedTags.contains(author.name), + onSelected: (value) { + if (author.name.isNotEmpty) { + onTagSelected(author.name!, value); + } + }, + ), + ) ?? + Chip(label: label); + }).toList(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48, + child: FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return ListView.builder( + itemCount: _authors.length, + controller: controller, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => _authors[index], + ); + }, + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart b/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart new file mode 100644 index 00000000..8c61bccd --- /dev/null +++ b/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:timeago/timeago.dart' as timeago; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/extensions/uri.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article_query_result.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; +import 'package:weblibre/features/web_feed/domain/providers/article_filter.dart'; +import 'package:weblibre/features/web_feed/domain/repositories/feed_repository.dart'; +import 'package:weblibre/features/web_feed/extensions/atom.dart'; +import 'package:weblibre/features/web_feed/extensions/feed_article.dart'; +import 'package:weblibre/features/web_feed/presentation/widgets/authors_horizontal_list.dart'; +import 'package:weblibre/features/web_feed/presentation/widgets/tags_horizontal_list.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class FeedArticleCard extends HookConsumerWidget { + final FeedArticle article; + + const FeedArticleCard({super.key, required this.article}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final tags = ref.watch(articleFilterProvider); + + final titleHighlight = switch (article) { + final FeedArticleQueryResult result => result.titleHighlight.whenNotEmpty, + _ => null, + }; + + final searchSnippet = switch (article) { + final FeedArticleQueryResult result => + result.summarySnippet.whenNotEmpty ?? + result.contentSnippet.whenNotEmpty, + _ => null, + }; + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async { + await FeedArticleRoute(articleId: article.id).push(context); + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UrlIcon([ + article.icon ?? + article.links + ?.getRelation(FeedLinkRelation.alternate) + ?.uri ?? + article.siteLink ?? + article.feedId.base, + ], iconSize: 34.0), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (titleHighlight.isNotEmpty) + MarkdownBody( + data: titleHighlight!, + styleSheet: MarkdownStyleSheet( + p: Theme.of(context).textTheme.titleMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + ), + ), + if (titleHighlight.isEmpty) + Text( + article.displayTitle, + style: theme.textTheme.titleMedium, + ), + if (searchSnippet.isNotEmpty) + MarkdownBody( + data: searchSnippet!, + styleSheet: MarkdownStyleSheet( + p: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + a: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + decoration: TextDecoration.none, + ), + ), + ), + if (searchSnippet.isEmpty && + article.summaryPlain != null) + Text( + article.summaryPlain!, + style: theme.textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (article.lastRead != null) + IconButton( + onPressed: () async { + await ref + .read(feedRepositoryProvider.notifier) + .unsetArticleRead(article.id); + }, + icon: const Icon(Icons.visibility), + ), + ], + ), + if (article.authors.isNotEmpty || article.tags.isNotEmpty) ...[ + const SizedBox(height: 8), + if (article.authors.isNotEmpty) + AuthorsHorizontalList( + authors: article.authors!, + selectedTags: tags, + onTagSelected: (tagId, value) { + if (value) { + ref.read(articleFilterProvider.notifier).addTag(tagId); + } else { + ref + .read(articleFilterProvider.notifier) + .removeTag(tagId); + } + }, + ), + if (article.tags.isNotEmpty) + TagsHorizontalList( + tags: article.tags!, + selectedTags: tags, + onTagSelected: (tagId, value) { + if (value) { + ref.read(articleFilterProvider.notifier).addTag(tagId); + } else { + ref + .read(articleFilterProvider.notifier) + .removeTag(tagId); + } + }, + ), + ], + const Divider(), + Row( + children: [ + Text( + 'Published: ${(article.created != null) ? timeago.format(article.created!) : 'N/A'}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + if (article.updated != null && + article.updated != article.created) + Text( + 'Updated: ${timeago.format(article.updated!)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/feed_card.dart b/app/lib/features/web_feed/presentation/widgets/feed_card.dart new file mode 100644 index 00000000..5769def3 --- /dev/null +++ b/app/lib/features/web_feed/presentation/widgets/feed_card.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:timeago/timeago.dart' as timeago; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/extensions/uri.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; +import 'package:weblibre/features/web_feed/domain/providers.dart'; +import 'package:weblibre/features/web_feed/presentation/widgets/authors_horizontal_list.dart'; +import 'package:weblibre/features/web_feed/presentation/widgets/tags_horizontal_list.dart'; +import 'package:weblibre/presentation/widgets/rounded_text.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class FeedCard extends HookConsumerWidget { + final FeedData feed; + + const FeedCard({super.key, required this.feed}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async { + await FeedArticleListRoute(feedId: feed.url).push(context); + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UrlIcon([ + feed.icon ?? feed.siteLink ?? feed.url.base, + ], iconSize: 34.0), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + feed.title ?? feed.url.host, + style: theme.textTheme.titleMedium, + ), + if (feed.description != null) + Text( + feed.description!, + style: theme.textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: () async { + await FeedEditRoute(feedId: feed.url).push(context); + }, + icon: const Icon(Icons.edit), + ), + ], + ), + if (feed.authors.isNotEmpty || feed.tags.isNotEmpty) ...[ + if (feed.authors.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + + child: AuthorsHorizontalList(authors: feed.authors!), + ), + if (feed.tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + + child: TagsHorizontalList(tags: feed.tags!), + ), + const Divider(), + ], + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Last fetched: ${(feed.lastFetched != null) ? timeago.format(feed.lastFetched!) : 'N/A'}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + Consumer( + builder: (context, ref, child) { + final countAsync = ref.watch( + unreadFeedArticleCountProvider(feed.url), + ); + + return countAsync.when( + skipLoadingOnReload: true, + data: (count) { + if (count == null) { + return const SizedBox(); + } + + return RoundedBackground( + child: Row( + children: [ + Icon( + MdiIcons.newspaperVariantMultipleOutline, + size: 18, + color: Theme.of( + context, + ).colorScheme.onPrimary, + ), + const SizedBox(width: 4), + Text( + count.toString(), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onPrimary, + ), + ), + ], + ), + ); + }, + error: (error, stackTrace) => const Text('N/A'), + loading: () => const SizedBox.shrink(), + ); + }, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/tag_field.dart b/app/lib/features/web_feed/presentation/widgets/tag_field.dart new file mode 100644 index 00000000..80867adf --- /dev/null +++ b/app/lib/features/web_feed/presentation/widgets/tag_field.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; + +final _tagSplitPatter = RegExp(r'[,\s]+'); + +class TagField extends HookWidget { + final Set initialTags; + final void Function(Set tags) onTagsUpdate; + + const TagField({ + super.key, + required this.initialTags, + required this.onTagsUpdate, + }); + + @override + Widget build(BuildContext context) { + final textController = useTextEditingController(); + final tags = useState(initialTags); + + useListenableCallback(tags, () { + onTagsUpdate(tags.value); + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tags', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Wrap( + spacing: 8.0, + children: tags.value + .map( + (tag) => InputChip( + label: Text(tag), + onDeleted: () { + tags.value = {...tags.value}..remove(tag); + }, + ), + ) + .toList(), + ), + TextField( + controller: textController, + decoration: const InputDecoration( + hintText: 'tag1, tag2, ...', + prefixIcon: Icon(MdiIcons.tagMultiple), + ), + onChanged: (String value) { + if (value.isNotEmpty) { + final values = value.split(_tagSplitPatter); + + if (values.length > 1) { + final trimmed = values + .map((str) => str.trim()) + .where((str) => str.isNotEmpty); + + if (trimmed.isNotEmpty) { + textController.clear(); + + tags.value = {...tags.value, ...trimmed}; + } + } + } + }, + onSubmitted: (value) { + if (value.isNotEmpty) { + final values = value + .split(_tagSplitPatter) + .map((str) => str.trim()) + .where((str) => str.isNotEmpty) + .toList(); + + if (values.isNotEmpty) { + textController.clear(); + + tags.value = {...tags.value, ...values}; + } + } + }, + ), + ], + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart b/app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart new file mode 100644 index 00000000..a3e4f29e --- /dev/null +++ b/app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart @@ -0,0 +1,55 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_category.dart'; + +class TagsHorizontalList extends StatelessWidget { + late final List _tags; + + TagsHorizontalList({ + required List tags, + Set selectedTags = const {}, + void Function(String tagId, bool value)? onTagSelected, + }) { + _tags = tags.map((tag) { + final label = Text( + '${tag.id} ${tag.title.mapNotNull((title) => '($title)') ?? ''}'.trim(), + ); + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: + onTagSelected.mapNotNull( + (onTagSelected) => FilterChip( + label: label, + selected: selectedTags.contains(tag.id), + onSelected: (value) { + onTagSelected(tag.id, value); + }, + ), + ) ?? + Chip(label: label), + ); + }).toList(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48, + child: FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return ListView.builder( + itemCount: _tags.length, + controller: controller, + //Improve list performance by not rendering outside screen at all + cacheExtent: 0, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => _tags[index], + ); + }, + ), + ); + } +} diff --git a/app/lib/features/web_feed/utils/feed_finder.dart b/app/lib/features/web_feed/utils/feed_finder.dart new file mode 100644 index 00000000..788e1ae9 --- /dev/null +++ b/app/lib/features/web_feed/utils/feed_finder.dart @@ -0,0 +1,100 @@ +import 'package:html/dom.dart'; + +class FeedFinder { + final Uri url; + final Document document; + + late final String _base; + + FeedFinder({required this.url, required this.document}) { + final uri = url.removeFragment(); + _base = '${uri.scheme}://${uri.authority}'; + } + + // Future> _verifyCandidates(Set candidates) async { + // final results = {}; + + // final client = http.Client(); + // try { + // for (final candidate in candidates) { + // try { + // await client.get(Uri.parse(candidate)); + // } catch (e) { + // continue; + // } + + // results.add(candidate); + // } + // } finally { + // client.close(); + // } + + // return results; + // } + + void _parseBody(Set candidates) { + for (final a in document.querySelectorAll('a')) { + var href = a.attributes['href']; + if (href != null) { + if (href.contains('rss') || + href.contains('xml') || + href.contains('feed')) { + // Fix relative URLs + href = href.startsWith('/') ? _base + href : href; + href = href.endsWith('/') ? href.substring(0, href.length - 2) : href; + + // Fix naked URLs + href = !href.startsWith('http') ? '$_base/$href' : href; + + if (Uri.tryParse(href) case final Uri uri) { + candidates.add(uri); + } + } + } + } + } + + void _parseHead(Set candidates) { + for (final link in document.querySelectorAll("link[rel='alternate']")) { + final type = link.attributes['type']; + if (type != null) { + if (type.contains('rss') || type.contains('xml')) { + var href = link.attributes['href']; + if (href != null) { + // Fix relative URLs + href = href.startsWith('/') ? _base + href : href; + + if (Uri.tryParse(href) case final Uri uri) { + candidates.add(uri); + } + } + } + } + } + } + + Future> parse({ + bool parseHead = true, + bool parseBody = true, + // bool verifyCandidates = true, + }) async { + final candidates = {}; + + // Look for feed candidates in head + if (parseHead) { + _parseHead(candidates); + } + + // Look for feed candidates in body + if (parseBody) { + _parseBody(candidates); + } + + // Verify candidates + // if (verifyCandidates) { + // return _verifyCandidates(candidates); + // } + + return candidates; + } +} diff --git a/app/lib/features/web_feed/utils/feed_parser.dart b/app/lib/features/web_feed/utils/feed_parser.dart new file mode 100644 index 00000000..0a49e0b7 --- /dev/null +++ b/app/lib/features/web_feed/utils/feed_parser.dart @@ -0,0 +1,196 @@ +import 'package:collection/collection.dart'; +import 'package:nullability/nullability.dart'; +import 'package:rss_dart/dart_rss.dart'; +import 'package:rss_dart/domain/rss1_feed.dart'; +import 'package:weblibre/features/web_feed/data/database/database.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_article.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_author.dart'; +import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; +import 'package:weblibre/features/web_feed/extensions/atom.dart'; +import 'package:weblibre/features/web_feed/extensions/rss.dart'; + +class FeedParser { + final Uri url; + late Object _feed; + + FeedParser._(this.url, this._feed); + + factory FeedParser.parse({required Uri url, required String xmlString}) { + final rssVersion = WebFeed.detectRssVersion(xmlString); + final feed = switch (rssVersion) { + RssVersion.rss1 => Rss1Feed.parse(xmlString), + RssVersion.rss2 => RssFeed.parse(xmlString), + RssVersion.atom => AtomFeed.parse(xmlString), + RssVersion.unknown => throw Error.safeToString( + 'Invalid XML String? We cannot detect RSS/Atom version.', + ), + }; + + return FeedParser._(url, feed); + } + + FeedData readGeneralData() { + switch (_feed) { + case final Rss1Feed feed: + return FeedData( + url: url, + title: feed.title.whenNotEmpty ?? feed.dc?.title, + description: feed.description.whenNotEmpty ?? feed.dc?.description, + siteLink: feed.link.mapNotNull(Uri.tryParse), + authors: feed.dc?.creator.whenNotEmpty.mapNotNull( + (creator) => [FeedAuthor(name: creator)], + ), + tags: feed.dc?.toFeedCategories(), + ); + case final RssFeed feed: + final categories = feed.categories.toFeedCategories(); + + return FeedData( + url: url, + title: feed.title.whenNotEmpty ?? feed.dc?.title, + description: feed.description.whenNotEmpty ?? feed.dc?.description, + siteLink: feed.link.mapNotNull(Uri.tryParse), + authors: (feed.author.whenNotEmpty ?? feed.dc?.creator.whenNotEmpty) + .mapNotNull((creator) => [FeedAuthor(name: creator)]), + tags: categories.isNotEmpty + ? categories + : feed.dc?.toFeedCategories(), + ); + case final AtomFeed feed: + final authors = feed.authors.toFeedAuthors(); + final tags = feed.categories.toFeedCategories(); + + return FeedData( + url: url, + title: feed.title.whenNotEmpty, + icon: feed.icon.mapNotNull(Uri.tryParse), + siteLink: feed.links + .toFeedLinks() + .getRelation(FeedLinkRelation.alternate) + ?.uri, + description: feed.subtitle.whenNotEmpty, + authors: authors.isNotEmpty ? authors : null, + tags: tags, + ); + default: + throw Exception(); + } + } + + List readArticles() { + final fetchDate = DateTime.now(); + + switch (_feed) { + case final Rss1Feed feed: + return feed.items.mapIndexed((i, item) { + final title = item.title.whenNotEmpty ?? item.dc?.title.whenNotEmpty; + + final itemId = item.dc?.identifier.whenNotEmpty ?? title; + final uniqueId = + '${item.link.whenNotEmpty ?? feed.link.whenNotEmpty ?? url}#$itemId'; + final date = SafeParseDateTime.safeParse(item.dc?.date); + final link = item.link.mapNotNull(Uri.tryParse); + + return FeedArticle( + id: uniqueId, + feedId: url, + title: title, + fetched: fetchDate, + created: date, + authors: item.dc?.creator.whenNotEmpty.mapNotNull( + (creator) => [FeedAuthor(name: creator)], + ), + summaryHtml: + item.description.whenNotEmpty ?? + item.dc?.description.whenNotEmpty, + links: link.mapNotNull( + (link) => [ + FeedLink(uri: link, relation: FeedLinkRelation.alternate), + ], + ), + tags: item.dc?.toFeedCategories(), + contentHtml: item.content?.value.whenNotEmpty, + ); + }).toList(); + case final RssFeed feed: + return feed.items.mapIndexed((i, item) { + final title = item.title.whenNotEmpty ?? item.dc?.title.whenNotEmpty; + + final itemId = + item.guid.whenNotEmpty ?? + item.dc?.identifier.whenNotEmpty ?? + title; + + final uniqueId = + '${item.link.whenNotEmpty ?? feed.link.whenNotEmpty ?? url}#$itemId'; + final date = SafeParseDateTime.safeParse( + item.pubDate.whenNotEmpty ?? item.dc?.date, + ); + final link = item.link.mapNotNull(Uri.tryParse); + final author = + item.author.whenNotEmpty ?? item.dc?.creator.whenNotEmpty; + + final categories = item.categories.toFeedCategories(); + + return FeedArticle( + id: uniqueId, + feedId: url, + title: title, + fetched: fetchDate, + created: date, + authors: author.mapNotNull( + (creator) => [FeedAuthor(name: creator)], + ), + summaryHtml: + item.description.whenNotEmpty ?? + item.dc?.description.whenNotEmpty, + links: link.mapNotNull( + (link) => [ + FeedLink(uri: link, relation: FeedLinkRelation.alternate), + ], + ), + tags: categories.isNotEmpty + ? categories + : item.dc?.toFeedCategories(), + contentHtml: item.content?.value.whenNotEmpty, + ); + }).toList(); + case final AtomFeed feed: + final feedLink = feed.links.toFeedLinks().getRelation( + FeedLinkRelation.self, + ); + + return feed.items.mapIndexed((i, item) { + final authors = item.authors.toFeedAuthors(); + + final tags = item.categories.toFeedCategories(); + + final itemLinks = item.links.toFeedLinks(); + final articleLink = itemLinks.getRelation(FeedLinkRelation.alternate); + + final itemId = item.id.whenNotEmpty ?? item.title; + final uniqueId = + '${articleLink?.uri ?? feedLink?.uri ?? url}#$itemId'; + + final published = SafeParseDateTime.safeParse(item.published); + final updated = SafeParseDateTime.safeParse(item.updated); + + return FeedArticle( + id: uniqueId, + feedId: url, + title: item.title.whenNotEmpty, + summaryHtml: item.summary.whenNotEmpty, + links: itemLinks, + authors: authors.isNotEmpty ? authors : null, + tags: tags, + fetched: fetchDate, + created: published, + updated: updated, + contentHtml: item.content.whenNotEmpty, + ); + }).toList(); + default: + throw Exception(); + } + } +} diff --git a/app/lib/features/web_feed/utils/fetch_entrypoint.dart b/app/lib/features/web_feed/utils/fetch_entrypoint.dart new file mode 100644 index 00000000..0b776ac6 --- /dev/null +++ b/app/lib/features/web_feed/utils/fetch_entrypoint.dart @@ -0,0 +1,35 @@ +import 'package:background_fetch/background_fetch.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:weblibre/core/error_observer.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/web_feed/presentation/controllers/fetch_articles.dart'; + +@pragma('vm:entry-point') +Future backgroundFetch(HeadlessTask task) async { + final taskId = task.taskId; + + final isTimeout = task.timeout; + if (isTimeout) { + // This task has exceeded its allowed running-time. + // You must stop what you're doing and immediately .finish(taskId) + logger.e("[BackgroundFetch] Headless task timed-out: $taskId"); + await BackgroundFetch.finish(taskId); + return; + } + + final ref = ProviderContainer(observers: const [ErrorObserver()]); + try { + await ref.read(fetchArticlesControllerProvider.notifier).fetchAllArticles(); + + logger.i('Fetched articles in background'); + } catch (e, s) { + logger.e('Failed fetching articles', error: e, stackTrace: s); + } finally { + ref.dispose(); + + // Give everything a bit of time to properly dispose + await Future.delayed(const Duration(seconds: 1)); + + await BackgroundFetch.finish(taskId); + } +} diff --git a/app/lib/features/web_view/domain/entities/abstract/tab.dart b/app/lib/features/web_view/domain/entities/abstract/tab.dart deleted file mode 100644 index 144f703d..00000000 --- a/app/lib/features/web_view/domain/entities/abstract/tab.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; - -abstract interface class ITab { - String get id; - Uri get url; - String? get title; - String? get topicId; - Favicon? get favicon; - Uint8List? get screenshot; -} diff --git a/app/lib/features/web_view/domain/entities/consistent_controller.dart b/app/lib/features/web_view/domain/entities/consistent_controller.dart deleted file mode 100644 index eca5ed21..00000000 --- a/app/lib/features/web_view/domain/entities/consistent_controller.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; - -class ConsistentController { - final InAppWebViewController? value; - - const ConsistentController(this.value); - - @override - // ignore: avoid_dynamic_calls - int get hashCode => value?.platform.id.hashCode ?? -1; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! ConsistentController) return false; - return hashCode == other.hashCode; - } -} diff --git a/app/lib/features/web_view/domain/entities/web_view_page.dart b/app/lib/features/web_view/domain/entities/web_view_page.dart deleted file mode 100644 index 8b5ab8ec..00000000 --- a/app/lib/features/web_view/domain/entities/web_view_page.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:copy_with_extension/copy_with_extension.dart'; -import 'package:fast_equatable/fast_equatable.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:lensai/core/uuid.dart'; -import 'package:lensai/domain/entities/web_page_info.dart'; -import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; - -part 'web_view_page.g.dart'; - -typedef PageHistory = ({bool canGoBack, bool canGoForward}); - -@CopyWith() -class WebViewPage extends WebPageInfo with FastEquatable implements ITab { - @override - @CopyWithField(immutable: true) - final String id; - - final InAppWebViewController? controller; - - // ignore: missing_field_in_equatable_props - final SslError? sslError; - - @override - final String? topicId; - - @override - final Uint8List? screenshot; - - final PageHistory pageHistory; - - WebViewPage({ - required this.id, - required this.controller, - required super.url, - required this.sslError, - required super.title, - required this.topicId, - required super.favicon, - required this.screenshot, - required this.pageHistory, - }); - - WebViewPage.create({ - String? id, - this.controller, - required super.url, - this.sslError, - super.title, - this.topicId, - super.favicon, - this.screenshot, - this.pageHistory = (canGoBack: false, canGoForward: false), - }) : id = id ?? uuid.v7(); - - @override - bool get cacheHash => true; - - @override - List get hashParameters => [ - id, - controller, - super.url, - sslError?.toString(), - title, - topicId, - favicon?.toString(), - screenshot, - pageHistory, - ]; -} diff --git a/app/lib/features/web_view/domain/entities/web_view_page.g.dart b/app/lib/features/web_view/domain/entities/web_view_page.g.dart deleted file mode 100644 index 8d2799c4..00000000 --- a/app/lib/features/web_view/domain/entities/web_view_page.g.dart +++ /dev/null @@ -1,137 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'web_view_page.dart'; - -// ************************************************************************** -// CopyWithGenerator -// ************************************************************************** - -abstract class _$WebViewPageCWProxy { - WebViewPage controller(InAppWebViewController? controller); - - WebViewPage url(Uri url); - - WebViewPage sslError(SslError? sslError); - - WebViewPage title(String? title); - - WebViewPage topicId(String? topicId); - - WebViewPage favicon(Favicon? favicon); - - WebViewPage screenshot(Uint8List? screenshot); - - WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory); - - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebViewPage(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. - /// - /// Usage - /// ```dart - /// WebViewPage(...).copyWith(id: 12, name: "My name") - /// ```` - WebViewPage call({ - InAppWebViewController? controller, - Uri? url, - SslError? sslError, - String? title, - String? topicId, - Favicon? favicon, - Uint8List? screenshot, - ({bool canGoBack, bool canGoForward})? pageHistory, - }); -} - -/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfWebViewPage.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfWebViewPage.copyWith.fieldName(...)` -class _$WebViewPageCWProxyImpl implements _$WebViewPageCWProxy { - const _$WebViewPageCWProxyImpl(this._value); - - final WebViewPage _value; - - @override - WebViewPage controller(InAppWebViewController? controller) => - this(controller: controller); - - @override - WebViewPage url(Uri url) => this(url: url); - - @override - WebViewPage sslError(SslError? sslError) => this(sslError: sslError); - - @override - WebViewPage title(String? title) => this(title: title); - - @override - WebViewPage topicId(String? topicId) => this(topicId: topicId); - - @override - WebViewPage favicon(Favicon? favicon) => this(favicon: favicon); - - @override - WebViewPage screenshot(Uint8List? screenshot) => this(screenshot: screenshot); - - @override - WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory) => - this(pageHistory: pageHistory); - - @override - - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebViewPage(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. - /// - /// Usage - /// ```dart - /// WebViewPage(...).copyWith(id: 12, name: "My name") - /// ```` - WebViewPage call({ - Object? controller = const $CopyWithPlaceholder(), - Object? url = const $CopyWithPlaceholder(), - Object? sslError = const $CopyWithPlaceholder(), - Object? title = const $CopyWithPlaceholder(), - Object? topicId = const $CopyWithPlaceholder(), - Object? favicon = const $CopyWithPlaceholder(), - Object? screenshot = const $CopyWithPlaceholder(), - Object? pageHistory = const $CopyWithPlaceholder(), - }) { - return WebViewPage( - id: _value.id, - controller: controller == const $CopyWithPlaceholder() - ? _value.controller - // ignore: cast_nullable_to_non_nullable - : controller as InAppWebViewController?, - url: url == const $CopyWithPlaceholder() || url == null - ? _value.url - // ignore: cast_nullable_to_non_nullable - : url as Uri, - sslError: sslError == const $CopyWithPlaceholder() - ? _value.sslError - // ignore: cast_nullable_to_non_nullable - : sslError as SslError?, - title: title == const $CopyWithPlaceholder() - ? _value.title - // ignore: cast_nullable_to_non_nullable - : title as String?, - topicId: topicId == const $CopyWithPlaceholder() - ? _value.topicId - // ignore: cast_nullable_to_non_nullable - : topicId as String?, - favicon: favicon == const $CopyWithPlaceholder() - ? _value.favicon - // ignore: cast_nullable_to_non_nullable - : favicon as Favicon?, - screenshot: screenshot == const $CopyWithPlaceholder() - ? _value.screenshot - // ignore: cast_nullable_to_non_nullable - : screenshot as Uint8List?, - pageHistory: - pageHistory == const $CopyWithPlaceholder() || pageHistory == null - ? _value.pageHistory - // ignore: cast_nullable_to_non_nullable - : pageHistory as ({bool canGoBack, bool canGoForward}), - ); - } -} - -extension $WebViewPageCopyWith on WebViewPage { - /// Returns a callable class that can be used as follows: `instanceOfWebViewPage.copyWith(...)` or like so:`instanceOfWebViewPage.copyWith.fieldName(...)`. - // ignore: library_private_types_in_public_api - _$WebViewPageCWProxy get copyWith => _$WebViewPageCWProxyImpl(this); -} diff --git a/app/lib/features/web_view/domain/providers.dart b/app/lib/features/web_view/domain/providers.dart deleted file mode 100644 index d02d6420..00000000 --- a/app/lib/features/web_view/domain/providers.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart' show rootBundle; -import 'package:lensai/features/content_block/domain/repositories/host.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/topics/domain/providers.dart'; -import 'package:lensai/features/topics/domain/repositories/tab.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:universal_io/io.dart'; - -part 'providers.g.dart'; - -@Riverpod(keepAlive: true) -Stream?> blockContentHosts(BlockContentHostsRef ref) { - final hostRepository = ref.watch(hostRepositoryProvider.notifier); - final enableContentBlocking = ref.watch( - settingsRepositoryProvider.select( - (value) => - (value.valueOrNull ?? Settings.withDefaults()).enableContentBlocking, - ), - ); - final enableHostList = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).enableHostList, - ), - ); - - if (!enableContentBlocking || enableHostList.isEmpty) { - return Stream.value(null); - } - - return hostRepository - .watchHosts(sources: enableHostList) - .map((hosts) => hosts.toSet()); -} - -@Riverpod(keepAlive: true) -Future readerabilityScript(ReaderabilityScriptRef ref) { - return rootBundle.load('assets/scripts/readability.min.js.gz').then( - (value) => compute( - (message) => utf8.decode(gzip.decode(message)), - value.buffer.asUint8List(), - ), - ); -} - -@Riverpod() -List activeTabs(ActiveTabsRef ref) { - final topic = ref.watch(selectedTopicProvider); - return ref.watch( - topicTabRepositoryProvider(topic) - .select((value) => value.valueOrNull ?? []), - ); -} diff --git a/app/lib/features/web_view/domain/providers.g.dart b/app/lib/features/web_view/domain/providers.g.dart deleted file mode 100644 index 21429702..00000000 --- a/app/lib/features/web_view/domain/providers.g.dart +++ /dev/null @@ -1,55 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'providers.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$blockContentHostsHash() => r'41855093d8376152244ae37f98e66eea7c74aa32'; - -/// See also [blockContentHosts]. -@ProviderFor(blockContentHosts) -final blockContentHostsProvider = StreamProvider?>.internal( - blockContentHosts, - name: r'blockContentHostsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$blockContentHostsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef BlockContentHostsRef = StreamProviderRef?>; -String _$readerabilityScriptHash() => - r'6fdd1aa920bc58c35ef9888158e572e25793870a'; - -/// See also [readerabilityScript]. -@ProviderFor(readerabilityScript) -final readerabilityScriptProvider = FutureProvider.internal( - readerabilityScript, - name: r'readerabilityScriptProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$readerabilityScriptHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef ReaderabilityScriptRef = FutureProviderRef; -String _$activeTabsHash() => r'd1ef2ae32d8707f7cc0902dd031e8302a63f50d4'; - -/// See also [activeTabs]. -@ProviderFor(activeTabs) -final activeTabsProvider = AutoDisposeProvider>.internal( - activeTabs, - name: r'activeTabsProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$activeTabsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef ActiveTabsRef = AutoDisposeProviderRef>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/web_view/domain/repositories/web_view.dart b/app/lib/features/web_view/domain/repositories/web_view.dart deleted file mode 100644 index 071ab293..00000000 --- a/app/lib/features/web_view/domain/repositories/web_view.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:copy_with_extension/copy_with_extension.dart'; -import 'package:drift/drift.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/logger.dart'; -import 'package:lensai/features/topics/data/providers.dart'; -import 'package:lensai/features/topics/domain/providers.dart'; -import 'package:lensai/features/topics/domain/repositories/tab.dart'; -import 'package:lensai/features/web_view/domain/entities/consistent_controller.dart'; -import 'package:lensai/features/web_view/domain/entities/web_view_page.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'web_view.g.dart'; - -abstract class _TabStateCopyProxy { - Future copyWith({ - InAppWebViewController? controller, - Uri? url, - SslError? sslError, - String? title, - String? topicId, - Favicon? favicon, - Uint8List? screenshot, - ({bool canGoBack, bool canGoForward})? pageHistory, - }); -} - -@Riverpod() -class WebViewTabController extends _$WebViewTabController { - String? _requestedTab; - - @override - String? build() { - final webViewTabs = ref.watch( - tabRepositoryProvider.select( - (value) => value.value ?? [], - ), - ); - - if (_requestedTab != null) { - if (webViewTabs.any((tab) => tab.id == _requestedTab)) { - return _requestedTab; - } else if (webViewTabs.isNotEmpty) { - final topic = ref.read(selectedTopicProvider); - return webViewTabs.where((tab) => tab.topicId == topic).lastOrNull?.id; - } - } - - return null; - } - - void showTab(String? id) { - _requestedTab = id; - ref.invalidateSelf(); - } -} - -@Riverpod() -class TabState extends _$TabState implements _TabStateCopyProxy { - KeepAliveLink? _aliveLink; - - @override - WebViewPage? build(String tabId) { - final tabAsync = ref.watch(tabDataProvider(tabId)); - - if (tabAsync.hasValue) { - final tab = tabAsync.valueOrNull; - - final current = stateOrNull ?? - WebViewPage.create( - id: tabId, - url: tab?.url ?? Uri.https('localhost'), - ); - - if (tab != null) { - _aliveLink ??= ref.keepAlive(); - - return current.copyWith( - topicId: tab.topicId, - url: tab.url, - title: tab.title, - screenshot: tab.screenshot, - ); - } else { - _aliveLink?.close(); - _aliveLink = null; - - return current; - } - } - - return null; - } - - Future updateScreenshot() async { - final screenshot = await state?.controller - ?.takeScreenshot( - screenshotConfiguration: ScreenshotConfiguration( - compressFormat: CompressFormat.JPEG, - quality: 20, - ), - ) - .timeout( - const Duration(milliseconds: 1500), - onTimeout: () { - logger.w('Screenshot timed out'); - return null; - }, - ); - - await copyWith(screenshot: screenshot); - } - - @override - Future copyWith({ - Object? controller = const $CopyWithPlaceholder(), - Object? url = const $CopyWithPlaceholder(), - Object? sslError = const $CopyWithPlaceholder(), - Object? title = const $CopyWithPlaceholder(), - Object? topicId = const $CopyWithPlaceholder(), - Object? favicon = const $CopyWithPlaceholder(), - Object? screenshot = const $CopyWithPlaceholder(), - Object? pageHistory = const $CopyWithPlaceholder(), - }) async { - if (state != null) { - final current = state!; - - state = WebViewPage( - id: current.id, - controller: controller == const $CopyWithPlaceholder() - ? current.controller - : controller as InAppWebViewController?, - url: current.url, - sslError: sslError == const $CopyWithPlaceholder() - ? current.sslError - : sslError as SslError?, - title: current.title, - topicId: current.topicId, - favicon: favicon == const $CopyWithPlaceholder() - ? current.favicon - : favicon as Favicon?, - screenshot: current.screenshot, - pageHistory: - pageHistory == const $CopyWithPlaceholder() || pageHistory == null - ? current.pageHistory - : pageHistory as ({bool canGoBack, bool canGoForward}), - ); - - await ref.read(tabRepositoryProvider.notifier).updateTab( - current.id, - url: url == const $CopyWithPlaceholder() || url == null - ? const Value.absent() - : Value(url as Uri), - title: title == const $CopyWithPlaceholder() - ? const Value.absent() - : Value(title as String?), - topicId: topicId == const $CopyWithPlaceholder() - ? const Value.absent() - : Value(topicId as String?), - screenshot: screenshot == const $CopyWithPlaceholder() - ? const Value.absent() - : Value(screenshot as Uint8List?), - ); - } - } -} - -@Riverpod() -InAppWebViewController? webViewController( - WebViewControllerRef ref, - String tabId, -) { - return ref - .watch( - tabStateProvider(tabId) - .select((value) => ConsistentController(value?.controller)), - ) - .value; -} diff --git a/app/lib/features/web_view/domain/repositories/web_view.g.dart b/app/lib/features/web_view/domain/repositories/web_view.g.dart deleted file mode 100644 index 2d636a34..00000000 --- a/app/lib/features/web_view/domain/repositories/web_view.g.dart +++ /dev/null @@ -1,318 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'web_view.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$webViewControllerHash() => r'f35fb13d46fcb04cee5676089f0cb8d5ca21e89b'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [webViewController]. -@ProviderFor(webViewController) -const webViewControllerProvider = WebViewControllerFamily(); - -/// See also [webViewController]. -class WebViewControllerFamily extends Family { - /// See also [webViewController]. - const WebViewControllerFamily(); - - /// See also [webViewController]. - WebViewControllerProvider call( - String tabId, - ) { - return WebViewControllerProvider( - tabId, - ); - } - - @override - WebViewControllerProvider getProviderOverride( - covariant WebViewControllerProvider provider, - ) { - return call( - provider.tabId, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'webViewControllerProvider'; -} - -/// See also [webViewController]. -class WebViewControllerProvider - extends AutoDisposeProvider { - /// See also [webViewController]. - WebViewControllerProvider( - String tabId, - ) : this._internal( - (ref) => webViewController( - ref as WebViewControllerRef, - tabId, - ), - from: webViewControllerProvider, - name: r'webViewControllerProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$webViewControllerHash, - dependencies: WebViewControllerFamily._dependencies, - allTransitiveDependencies: - WebViewControllerFamily._allTransitiveDependencies, - tabId: tabId, - ); - - WebViewControllerProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.tabId, - }) : super.internal(); - - final String tabId; - - @override - Override overrideWith( - InAppWebViewController? Function(WebViewControllerRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: WebViewControllerProvider._internal( - (ref) => create(ref as WebViewControllerRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - tabId: tabId, - ), - ); - } - - @override - AutoDisposeProviderElement createElement() { - return _WebViewControllerProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is WebViewControllerProvider && other.tabId == tabId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, tabId.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin WebViewControllerRef on AutoDisposeProviderRef { - /// The parameter `tabId` of this provider. - String get tabId; -} - -class _WebViewControllerProviderElement - extends AutoDisposeProviderElement - with WebViewControllerRef { - _WebViewControllerProviderElement(super.provider); - - @override - String get tabId => (origin as WebViewControllerProvider).tabId; -} - -String _$webViewTabControllerHash() => - r'cf1b766aa84472040b56a7b4d9ce1fd1606ed5e1'; - -/// See also [WebViewTabController]. -@ProviderFor(WebViewTabController) -final webViewTabControllerProvider = - AutoDisposeNotifierProvider.internal( - WebViewTabController.new, - name: r'webViewTabControllerProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$webViewTabControllerHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$WebViewTabController = AutoDisposeNotifier; -String _$tabStateHash() => r'9ce6b0d99e5032f534c97482d6140dda4b14fb07'; - -abstract class _$TabState extends BuildlessAutoDisposeNotifier { - late final String tabId; - - WebViewPage? build( - String tabId, - ); -} - -/// See also [TabState]. -@ProviderFor(TabState) -const tabStateProvider = TabStateFamily(); - -/// See also [TabState]. -class TabStateFamily extends Family { - /// See also [TabState]. - const TabStateFamily(); - - /// See also [TabState]. - TabStateProvider call( - String tabId, - ) { - return TabStateProvider( - tabId, - ); - } - - @override - TabStateProvider getProviderOverride( - covariant TabStateProvider provider, - ) { - return call( - provider.tabId, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'tabStateProvider'; -} - -/// See also [TabState]. -class TabStateProvider - extends AutoDisposeNotifierProviderImpl { - /// See also [TabState]. - TabStateProvider( - String tabId, - ) : this._internal( - () => TabState()..tabId = tabId, - from: tabStateProvider, - name: r'tabStateProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$tabStateHash, - dependencies: TabStateFamily._dependencies, - allTransitiveDependencies: TabStateFamily._allTransitiveDependencies, - tabId: tabId, - ); - - TabStateProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.tabId, - }) : super.internal(); - - final String tabId; - - @override - WebViewPage? runNotifierBuild( - covariant TabState notifier, - ) { - return notifier.build( - tabId, - ); - } - - @override - Override overrideWith(TabState Function() create) { - return ProviderOverride( - origin: this, - override: TabStateProvider._internal( - () => create()..tabId = tabId, - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - tabId: tabId, - ), - ); - } - - @override - AutoDisposeNotifierProviderElement createElement() { - return _TabStateProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is TabStateProvider && other.tabId == tabId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, tabId.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin TabStateRef on AutoDisposeNotifierProviderRef { - /// The parameter `tabId` of this provider. - String get tabId; -} - -class _TabStateProviderElement - extends AutoDisposeNotifierProviderElement - with TabStateRef { - _TabStateProviderElement(super.provider); - - @override - String get tabId => (origin as TabStateProvider).tabId; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/web_view/presentation/controllers/readerability.dart b/app/lib/features/web_view/presentation/controllers/readerability.dart deleted file mode 100644 index d94e1749..00000000 --- a/app/lib/features/web_view/presentation/controllers/readerability.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:lensai/features/topics/data/providers.dart'; -import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; -import 'package:lensai/features/web_view/presentation/services/readerability_script.dart'; -import 'package:riverpod/riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'readerability.g.dart'; - -@Riverpod() -class ReaderabilityController extends _$ReaderabilityController { - late ReaderabilityScriptService _service; - late KeepAliveLink _aliveLink; - - @override - AsyncValue<({bool readerable, bool applied})> build( - String tabId, - ) { - _service = ref.watch(readerabilityScriptServiceProvider(tabId).notifier); - _aliveLink = ref.keepAlive(); - - ref.listen( - isTabExistingProvider(tabId), - (previous, next) { - if (next.valueOrNull == false) { - _aliveLink.close(); - } - }, - ); - - return const AsyncLoading(); - } - - Future checkReaderable() async { - final applied = state.valueOrNull?.applied ?? false; - - state = const AsyncLoading(); - final result = await AsyncValue.guard(() async { - return (readerable: await _service.isReaderable(), applied: applied); - }); - state = result; - - return result.valueOrNull?.readerable ?? false; - } - - Future toggleReaderable() async { - final applied = state.valueOrNull?.applied ?? false; - final readerable = state.valueOrNull?.readerable ?? false; - - state = const AsyncLoading(); - state = await AsyncValue.guard(() async { - if (applied) { - await ref.read(webViewControllerProvider(tabId))?.reload(); - } else { - await _service.applyReaderable(); - } - - return (readerable: readerable, applied: !applied); - }); - } - - void reset() { - state = const AsyncLoading(); - } -} diff --git a/app/lib/features/web_view/presentation/controllers/readerability.g.dart b/app/lib/features/web_view/presentation/controllers/readerability.g.dart deleted file mode 100644 index 17e86e6d..00000000 --- a/app/lib/features/web_view/presentation/controllers/readerability.g.dart +++ /dev/null @@ -1,178 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'readerability.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$readerabilityControllerHash() => - r'a1c8da3a9cf48300089e390a4fcae89862a6ec1a'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -abstract class _$ReaderabilityController extends BuildlessAutoDisposeNotifier< - AsyncValue<({bool readerable, bool applied})>> { - late final String tabId; - - AsyncValue<({bool readerable, bool applied})> build( - String tabId, - ); -} - -/// See also [ReaderabilityController]. -@ProviderFor(ReaderabilityController) -const readerabilityControllerProvider = ReaderabilityControllerFamily(); - -/// See also [ReaderabilityController]. -class ReaderabilityControllerFamily - extends Family> { - /// See also [ReaderabilityController]. - const ReaderabilityControllerFamily(); - - /// See also [ReaderabilityController]. - ReaderabilityControllerProvider call( - String tabId, - ) { - return ReaderabilityControllerProvider( - tabId, - ); - } - - @override - ReaderabilityControllerProvider getProviderOverride( - covariant ReaderabilityControllerProvider provider, - ) { - return call( - provider.tabId, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'readerabilityControllerProvider'; -} - -/// See also [ReaderabilityController]. -class ReaderabilityControllerProvider extends AutoDisposeNotifierProviderImpl< - ReaderabilityController, AsyncValue<({bool readerable, bool applied})>> { - /// See also [ReaderabilityController]. - ReaderabilityControllerProvider( - String tabId, - ) : this._internal( - () => ReaderabilityController()..tabId = tabId, - from: readerabilityControllerProvider, - name: r'readerabilityControllerProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$readerabilityControllerHash, - dependencies: ReaderabilityControllerFamily._dependencies, - allTransitiveDependencies: - ReaderabilityControllerFamily._allTransitiveDependencies, - tabId: tabId, - ); - - ReaderabilityControllerProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.tabId, - }) : super.internal(); - - final String tabId; - - @override - AsyncValue<({bool readerable, bool applied})> runNotifierBuild( - covariant ReaderabilityController notifier, - ) { - return notifier.build( - tabId, - ); - } - - @override - Override overrideWith(ReaderabilityController Function() create) { - return ProviderOverride( - origin: this, - override: ReaderabilityControllerProvider._internal( - () => create()..tabId = tabId, - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - tabId: tabId, - ), - ); - } - - @override - AutoDisposeNotifierProviderElement> createElement() { - return _ReaderabilityControllerProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is ReaderabilityControllerProvider && other.tabId == tabId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, tabId.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin ReaderabilityControllerRef on AutoDisposeNotifierProviderRef< - AsyncValue<({bool readerable, bool applied})>> { - /// The parameter `tabId` of this provider. - String get tabId; -} - -class _ReaderabilityControllerProviderElement - extends AutoDisposeNotifierProviderElement> - with ReaderabilityControllerRef { - _ReaderabilityControllerProviderElement(super.provider); - - @override - String get tabId => (origin as ReaderabilityControllerProvider).tabId; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart b/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart deleted file mode 100644 index 4838a727..00000000 --- a/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:lensai/features/topics/domain/providers.dart'; -import 'package:lensai/features/topics/domain/repositories/tab.dart'; -import 'package:lensai/features/web_view/domain/entities/web_view_page.dart'; -import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'switch_new_tab.g.dart'; - -@Riverpod(keepAlive: true) -class SwitchNewTabController extends _$SwitchNewTabController { - @override - FutureOr build() {} - - Future add(Uri url) async { - state = const AsyncLoading(); - state = await AsyncValue.guard( - () async { - final topic = ref.read(selectedTopicProvider); - final newTab = WebViewPage.create( - url: WebUri.uri(url), - topicId: topic, - ); - - await ref - .read(topicTabRepositoryProvider(topic).notifier) - .addTab(newTab); - - ref.read(webViewTabControllerProvider.notifier).showTab(newTab.id); - }, - ); - } -} diff --git a/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart b/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart deleted file mode 100644 index df257786..00000000 --- a/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'switch_new_tab.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$switchNewTabControllerHash() => - r'7ebfea953aeb81c279ea11254ff0ad558e6a949f'; - -/// See also [SwitchNewTabController]. -@ProviderFor(SwitchNewTabController) -final switchNewTabControllerProvider = - AsyncNotifierProvider.internal( - SwitchNewTabController.new, - name: r'switchNewTabControllerProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$switchNewTabControllerHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$SwitchNewTabController = AsyncNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/web_view/presentation/services/readerability_script.dart b/app/lib/features/web_view/presentation/services/readerability_script.dart deleted file mode 100644 index 79167da9..00000000 --- a/app/lib/features/web_view/presentation/services/readerability_script.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/web_view/domain/providers.dart'; -import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'readerability_script.g.dart'; - -@Riverpod() -class ReaderabilityScriptService extends _$ReaderabilityScriptService { - late InAppWebViewController? _controller; - late Future _readerabilityScript; - late bool _enableReadability; - - @override - Future build(String tabId) async { - _controller = ref.watch(webViewControllerProvider(tabId)); - _readerabilityScript = ref.watch(readerabilityScriptProvider.future); - _enableReadability = ref.watch( - settingsRepositoryProvider.select( - (value) => - (value.valueOrNull ?? Settings.withDefaults()).enableReadability, - ), - ); - } - - Future _injectScript() async { - if (_controller != null) { - await _readerabilityScript - .then((script) => _controller!.evaluateJavascript(source: script)); - } - } - - Future _ensureScriptInjected() async { - if (_controller != null) { - final injected = await _controller!.evaluateJavascript( - source: - "(typeof window !== 'undefined' && typeof window.isReaderable === 'function')", - ) as bool; - - if (!injected) { - await _injectScript(); - } - } - } - - Future isReaderable() async { - if (_enableReadability) { - await _ensureScriptInjected(); - - if (_controller != null) { - return await _controller!.evaluateJavascript( - source: 'isReaderable();', - ) as bool; - } - } - - return false; - } - - Future applyReaderable() async { - if (_enableReadability) { - await _ensureScriptInjected(); - - if (_controller != null) { - await _controller!.evaluateJavascript(source: 'applyReaderable();'); - } - } - } -} diff --git a/app/lib/features/web_view/presentation/services/readerability_script.g.dart b/app/lib/features/web_view/presentation/services/readerability_script.g.dart deleted file mode 100644 index dc540b77..00000000 --- a/app/lib/features/web_view/presentation/services/readerability_script.g.dart +++ /dev/null @@ -1,177 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'readerability_script.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$readerabilityScriptServiceHash() => - r'85f0de50516cadb1f2d1efa8161a8cf45868b9d9'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -abstract class _$ReaderabilityScriptService - extends BuildlessAutoDisposeAsyncNotifier { - late final String tabId; - - FutureOr build( - String tabId, - ); -} - -/// See also [ReaderabilityScriptService]. -@ProviderFor(ReaderabilityScriptService) -const readerabilityScriptServiceProvider = ReaderabilityScriptServiceFamily(); - -/// See also [ReaderabilityScriptService]. -class ReaderabilityScriptServiceFamily extends Family> { - /// See also [ReaderabilityScriptService]. - const ReaderabilityScriptServiceFamily(); - - /// See also [ReaderabilityScriptService]. - ReaderabilityScriptServiceProvider call( - String tabId, - ) { - return ReaderabilityScriptServiceProvider( - tabId, - ); - } - - @override - ReaderabilityScriptServiceProvider getProviderOverride( - covariant ReaderabilityScriptServiceProvider provider, - ) { - return call( - provider.tabId, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'readerabilityScriptServiceProvider'; -} - -/// See also [ReaderabilityScriptService]. -class ReaderabilityScriptServiceProvider - extends AutoDisposeAsyncNotifierProviderImpl { - /// See also [ReaderabilityScriptService]. - ReaderabilityScriptServiceProvider( - String tabId, - ) : this._internal( - () => ReaderabilityScriptService()..tabId = tabId, - from: readerabilityScriptServiceProvider, - name: r'readerabilityScriptServiceProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$readerabilityScriptServiceHash, - dependencies: ReaderabilityScriptServiceFamily._dependencies, - allTransitiveDependencies: - ReaderabilityScriptServiceFamily._allTransitiveDependencies, - tabId: tabId, - ); - - ReaderabilityScriptServiceProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.tabId, - }) : super.internal(); - - final String tabId; - - @override - FutureOr runNotifierBuild( - covariant ReaderabilityScriptService notifier, - ) { - return notifier.build( - tabId, - ); - } - - @override - Override overrideWith(ReaderabilityScriptService Function() create) { - return ProviderOverride( - origin: this, - override: ReaderabilityScriptServiceProvider._internal( - () => create()..tabId = tabId, - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - tabId: tabId, - ), - ); - } - - @override - AutoDisposeAsyncNotifierProviderElement - createElement() { - return _ReaderabilityScriptServiceProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is ReaderabilityScriptServiceProvider && other.tabId == tabId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, tabId.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin ReaderabilityScriptServiceRef - on AutoDisposeAsyncNotifierProviderRef { - /// The parameter `tabId` of this provider. - String get tabId; -} - -class _ReaderabilityScriptServiceProviderElement - extends AutoDisposeAsyncNotifierProviderElement with ReaderabilityScriptServiceRef { - _ReaderabilityScriptServiceProviderElement(super.provider); - - @override - String get tabId => (origin as ReaderabilityScriptServiceProvider).tabId; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/web_view/presentation/widgets/favicon.dart b/app/lib/features/web_view/presentation/widgets/favicon.dart deleted file mode 100644 index 2a449dfc..00000000 --- a/app/lib/features/web_view/presentation/widgets/favicon.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:lensai/extensions/web_uri_favicon.dart'; - -class FaviconImage extends StatelessWidget { - final double size; - final Icon _iconPlaceholder; - - final Favicon? favicon; - final Uri url; - - FaviconImage({ - required this.url, - required this.favicon, - this.size = 16, - super.key, - }) : _iconPlaceholder = Icon( - MdiIcons.webBox, - size: size, - ); - - @override - Widget build(BuildContext context) { - if (favicon != null) { - return FadeInImage( - placeholder: NetworkImage( - url.genericFavicon().toString(), - ), - image: NetworkImage(favicon!.url.toString()), - placeholderErrorBuilder: (_, __, ___) => _iconPlaceholder, - imageErrorBuilder: (_, __, ___) => Image.network( - url.genericFavicon().toString(), - errorBuilder: (_, __, ___) => _iconPlaceholder, - height: size, - width: size, - ), - height: size, - width: size, - ); - } else { - return Image.network( - url.genericFavicon().toString(), - errorBuilder: (_, __, ___) => _iconPlaceholder, - height: size, - width: size, - ); - } - } -} diff --git a/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart b/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart deleted file mode 100644 index 22f9b231..00000000 --- a/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/domain/entities/web_page_info.dart'; -import 'package:lensai/features/bangs/data/models/bang_data.dart'; -import 'package:lensai/features/bangs/domain/providers.dart'; -import 'package:lensai/features/bangs/presentation/widgets/site_search.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/search_browser/utils/url_builder.dart' - as uri_builder; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; -import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; -import 'package:lensai/presentation/widgets/failure_widget.dart'; -import 'package:lensai/presentation/widgets/website_title_tile.dart'; -import 'package:lensai/utils/ui_helper.dart' as ui_helper; -import 'package:lensai/utils/uri_parser.dart' as uri_parser; -import 'package:share_plus/share_plus.dart'; - -class WebPageDialog extends HookConsumerWidget { - final Uri url; - final WebPageInfo? precachedInfo; - final InAppWebViewController? webViewController; - - final void Function()? onDismiss; - - const WebPageDialog({ - required this.url, - this.precachedInfo, - this.webViewController, - this.onDismiss, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final incognitoEnabled = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode, - ), - ); - - final availableBangsAsync = ref.watch( - bangDataListProvider( - filter: ( - domain: url.host, - groups: null, - categoryFilter: null, - orderMostFrequentFirst: true, - ), - ), - ); - final availableBangCount = availableBangsAsync.valueOrNull?.length; - - final formKey = useMemoized(() => GlobalKey()); - final urlTextController = useTextEditingController(text: url.toString()); - - return Stack( - children: [ - ModalBarrier( - color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54, - onDismiss: onDismiss, - ), - SimpleDialog( - titlePadding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0), - contentPadding: EdgeInsets.zero, - insetPadding: const EdgeInsets.symmetric( - horizontal: 20.0, - vertical: 24.0, - ), - title: WebsiteTitleTile(url, precachedInfo: precachedInfo), - children: [ - SizedBox( - //We need this to stretch the dialog, then padding from dialog is applied - width: double.maxFinite, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Form( - key: formKey, - child: TextFormField( - controller: urlTextController, - enableIMEPersonalizedLearning: !incognitoEnabled, - decoration: InputDecoration( - labelText: 'URL', - suffixIcon: (webViewController != null) - ? IconButton( - onPressed: () async { - if (formKey.currentState?.validate() ?? false) { - await webViewController!.loadUrl( - urlRequest: URLRequest( - url: WebUri.uri( - uri_parser.tryParseUrl( - urlTextController.text, - eagerParsing: true, - )!, - ), - ), - ); - - onDismiss?.call(); - } - }, - icon: const Icon(Icons.send), - ) - : null, - ), - validator: (value) { - if (uri_parser.tryParseUrl(value, eagerParsing: true) != - null) { - return null; - } - - return 'Invalid URL'; - }, - ), - ), - ), - ), - const Divider(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: availableBangsAsync.when( - data: (availableBangs) { - if (availableBangs.isEmpty) { - return const SizedBox.shrink(); - } - - return SiteSearch( - domain: url.host, - availableBangs: availableBangs, - ); - }, - error: (error, stackTrace) => FailureWidget( - title: 'Could not load bangs', - exception: error, - ), - loading: () => SiteSearch( - domain: url.host, - availableBangs: [ - BangData( - websiteName: 'websiteName', - domain: 'domain', - trigger: 'trigger', - urlTemplate: 'urlTemplate', - ), - ], - ), - ), - ), - if (availableBangsAsync.isLoading || - availableBangCount == null || - availableBangCount > 0) - const Divider(), - ListTile( - leading: const Icon(MdiIcons.contentCopy), - title: const Text('Copy address'), - onTap: () async { - await Clipboard.setData( - ClipboardData(text: url.toString()), - ); - onDismiss?.call(); - }, - ), - ListTile( - onTap: () async { - await ui_helper.launchUrlFeedback(context, url); - }, - leading: const Icon(Icons.open_in_browser), - title: const Text('Launch External'), - ), - ListTile( - leading: const Icon(MdiIcons.tabPlus), - title: const Text('Open in new tab'), - onTap: () async { - await ref - .read(switchNewTabControllerProvider.notifier) - .add(url); - - onDismiss?.call(); - }, - ), - ListTile( - leading: const Icon(Icons.share), - title: const Text('Share link'), - onTap: () async { - await Share.shareUri(url); - - onDismiss?.call(); - }, - ), - const Divider(), - ListTile( - leading: Icon(KagiTool.summarizer.icon), - title: const Text('Summarize'), - onTap: () async { - final summarizerUrl = uri_builder.summarizerUri( - document: SharedUrl(url), - mode: SummarizerMode.keyMoments, - ); - - await ref - .read(switchNewTabControllerProvider.notifier) - .add(summarizerUrl); - - onDismiss?.call(); - }, - ), - ], - ), - ], - ); - } -} diff --git a/app/lib/features/web_view/presentation/widgets/web_view.dart b/app/lib/features/web_view/presentation/widgets/web_view.dart deleted file mode 100644 index 7fc1d775..00000000 --- a/app/lib/features/web_view/presentation/widgets/web_view.dart +++ /dev/null @@ -1,612 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/logger.dart'; -import 'package:lensai/domain/services/generic_website.dart'; -import 'package:lensai/features/bangs/domain/providers.dart'; -import 'package:lensai/features/chat_archive/domain/entities/chat_entity.dart'; -import 'package:lensai/features/chat_archive/domain/repositories/archive.dart'; -import 'package:lensai/features/search_browser/domain/entities/modes.dart'; -import 'package:lensai/features/search_browser/domain/entities/sheet.dart'; -import 'package:lensai/features/search_browser/domain/providers.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/features/web_view/domain/providers.dart'; -import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; -import 'package:lensai/features/web_view/presentation/controllers/readerability.dart'; -import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; -import 'package:lensai/features/web_view/presentation/widgets/web_page_dialog.dart'; -import 'package:lensai/features/web_view/utils/download_helper.dart'; -import 'package:lensai/utils/platform_util.dart' as platform_util; -import 'package:lensai/utils/ui_helper.dart' as ui_helper; -import 'package:url_launcher/url_launcher.dart'; - -const _webViewSupportedSchemes = [ - "http", - "https", - "file", - "chrome", - "data", - "javascript", - "about", -]; - -class WebView extends StatefulHookConsumerWidget { - final String tabId; - final URLRequest? initialUrlRequest; - - const WebView({ - required this.tabId, - required super.key, - this.initialUrlRequest, - }); - - @override - ConsumerState createState() => _WebViewState(); -} - -class _WebViewState extends ConsumerState { - Timer? _onLoadStopDebounce; - Timer? _periodicScreenshotUpdate; - - Future _downloadChat( - DownloadStartRequest downloadStartRequest, - BuildContext context, - ) async { - final fileName = getDispositionFileName( - downloadStartRequest.contentDisposition!, - ); - - if (fileName != null) { - final entity = ChatEntity.fromFileName(fileName); - if (entity.name != null) { - final fileWrite = await ref - .read(chatArchiveRepositoryProvider.notifier) - .archiveChat(fileName, downloadStartRequest.url); - - return fileWrite.fold( - (_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: Colors.green, - content: Text( - 'Conversation "$entity" saved successfully!', - style: const TextStyle(color: Colors.white), - ), - ), - ); - - return true; - }, - onFailure: (errorMessage) { - ui_helper.showErrorMessage( - context, - errorMessage.toString(), - ); - - return false; - }, - ); - } - } - - return false; - } - - @override - Future dispose() async { - super.dispose(); - - _onLoadStopDebounce?.cancel(); - _periodicScreenshotUpdate?.cancel(); - - logger.i('Disposed ${widget.key} ${widget.tabId}'); - } - - @override - Widget build(BuildContext context) { - final initialSettings = useMemoized( - () => InAppWebViewSettings( - // isInspectable: kDebugMode, - useOnDownloadStart: true, - allowsLinkPreview: false, - disableLongPressContextMenuOnLinks: true, - useShouldOverrideUrlLoading: true, - javaScriptEnabled: (ref.read(settingsRepositoryProvider).valueOrNull ?? - Settings.withDefaults()) - .enableJavascript, - saveFormData: false, - disabledActionModeMenuItems: ActionModeMenuItem.MENU_ITEM_WEB_SEARCH, - ), - ); - - final showEarlyAccessFeatures = ref.watch( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .showEarlyAccessFeatures, - ), - ); - - // We keep listening to changes and cache them in this ref. - // This is important, since our WebView will not have the main scope anymore - // to react to updates from ref.watch - // - // With this solution we dont need to read on every interception request - // and can just obtain the value from here - final blockContentHosts = useRef?>(null); - ref.listen( - blockContentHostsProvider, - (previous, next) { - blockContentHosts.value = next.valueOrNull; - }, - ); - - ref.listen( - settingsRepositoryProvider.select( - (value) => - (value.valueOrNull ?? Settings.withDefaults()).enableJavascript, - ), - (previous, next) async { - final controller = ref.read(webViewControllerProvider(widget.tabId)); - await controller?.setSettings( - settings: initialSettings.copy()..javaScriptEnabled = next, - ); - }, - ); - - final webViewProgress = useValueNotifier(100); - final inPageSearchResult = - useValueNotifier<({int activeMatchOrdinal, int numberOfMatches})?>( - null, - ); - - final findInteractionController = useMemoized( - () => FindInteractionController( - onFindResultReceived: ( - controller, - activeMatchOrdinal, - numberOfMatches, - isDoneCounting, - ) { - if (isDoneCounting) { - inPageSearchResult.value = ( - activeMatchOrdinal: activeMatchOrdinal, - numberOfMatches: numberOfMatches, - ); - } - }, - ), - ); - - useOnAppLifecycleStateChange((previous, current) async { - final controller = ref.read(webViewControllerProvider(widget.tabId)); - - switch (current) { - case AppLifecycleState.paused: - if (platform_util.isAndroid()) { - await controller?.pause(); - } - if (platform_util.isAndroid() || platform_util.isIOS()) { - await controller?.pauseTimers(); - } - case AppLifecycleState.resumed: - if (platform_util.isAndroid()) { - await controller?.resume(); - } - if (platform_util.isAndroid() || platform_util.isIOS()) { - await controller?.resumeTimers(); - } - default: - } - }); - - return Stack( - children: [ - InAppWebView( - initialUrlRequest: widget.initialUrlRequest, - initialSettings: initialSettings, - findInteractionController: findInteractionController, - contextMenu: ContextMenu( - menuItems: [ - ContextMenuItem( - id: 1, - title: "Search", - action: () async { - final controller = - ref.read(webViewControllerProvider(widget.tabId)); - - final selectedText = await controller?.getSelectedText(); - - if (selectedText != null && selectedText.isNotEmpty) { - final searchBang = - await ref.read(kagiSearchBangDataProvider.future); - - await ref - .read(switchNewTabControllerProvider.notifier) - .add(searchBang!.getUrl(selectedText)); - } - }, - ), - if (showEarlyAccessFeatures) - ContextMenuItem( - id: 2, - title: "Assistant", - action: () async { - final controller = - ref.read(webViewControllerProvider(widget.tabId)); - - final selectedText = await controller?.getSelectedText(); - - if (selectedText != null && selectedText.isNotEmpty) { - ref.read(bottomSheetProvider.notifier).show( - CreateTab( - content: selectedText, - preferredTool: KagiTool.assistant, - ), - ); - } - }, - ), - ], - ), - onWebViewCreated: (controller) async { - if (platform_util.isAndroid()) { - await controller.startSafeBrowsing(); - } - - await ref - .read(tabStateProvider(widget.tabId).notifier) - .copyWith(controller: controller); - }, - onReceivedServerTrustAuthRequest: (controller, challenge) async { - final sslError = challenge.protectionSpace.sslError; - - if (sslError != null && sslError.code != null) { - final url = await controller.getUrl(); - if (challenge.protectionSpace.host == url?.host) { - await ref - .read(tabStateProvider(widget.tabId).notifier) - .copyWith(sslError: sslError); - - if (context.mounted) { - ui_helper.showErrorMessage( - context, - 'We detected an security issue and did not continue to ${url?.authority}: ${sslError.message}', - ); - } - } - - return ServerTrustAuthResponse( - // ignore: avoid_redundant_argument_values - action: ServerTrustAuthResponseAction.CANCEL, - ); - } - - await ref - .read(tabStateProvider(widget.tabId).notifier) - .copyWith(sslError: null); - - return ServerTrustAuthResponse( - action: ServerTrustAuthResponseAction.PROCEED, - ); - }, - shouldInterceptRequest: (controller, request) async { - if (blockContentHosts.value?.contains(request.url.host) == true) { - return WebResourceResponse( - contentType: "text/plain", - data: utf8.encode("Blocked"), - statusCode: 403, - reasonPhrase: "Forbidden", - ); - } - - //Check if main page aka main frame is upgradeable - if (request.url.isScheme('http') && - request.isForMainFrame == true) { - final upgradedUri = await ref - .read(genericWebsiteServiceProvider.notifier) - .tryUpgradeToHttps(request.url); - - if (upgradedUri != null) { - unawaited( - controller.loadUrl( - urlRequest: URLRequest(url: WebUri.uri(upgradedUri)), - ), - ); - - return WebResourceResponse( - statusCode: 200, - reasonPhrase: "Redirecting...", - ); - } - } - - if (request.url.isScheme('http')) { - if ((ref.read(settingsRepositoryProvider).valueOrNull ?? - Settings.withDefaults()) - .blockHttpProtocol) { - return WebResourceResponse( - contentType: "text/plain", - data: utf8.encode("HTTP protocol is blocked"), - statusCode: 403, - reasonPhrase: "Forbidden", - ); - } - } - - return null; - }, - onProgressChanged: (controller, progress) { - webViewProgress.value = progress; - }, - onLoadStart: (controller, url) async { - final readabilityNotifier = ref.read( - readerabilityControllerProvider(widget.tabId).notifier, - ); - - if (url != null) { - await ref.read(tabStateProvider(widget.tabId).notifier).copyWith( - url: url, - // ignore: avoid_redundant_argument_values - sslError: null, - ); - } - - readabilityNotifier.reset(); - }, - onLoadStop: (controller, url) async { - if (url != null) { - await ref - .read(tabStateProvider(widget.tabId).notifier) - .copyWith(url: url); - } - - _onLoadStopDebounce?.cancel(); - _onLoadStopDebounce = - Timer(const Duration(milliseconds: 150), () async { - final readabilityNotifier = ref.read( - readerabilityControllerProvider(widget.tabId).notifier, - ); - - await readabilityNotifier.checkReaderable(); - - await ref - .read(tabStateProvider(widget.tabId).notifier) - .updateScreenshot() - .whenComplete(() { - _periodicScreenshotUpdate?.cancel(); - _periodicScreenshotUpdate = - Timer.periodic(const Duration(seconds: 5), (timer) async { - await ref - .read(tabStateProvider(widget.tabId).notifier) - .updateScreenshot() - .onError((error, stackTrace) { - logger.e(error, stackTrace: stackTrace); - timer.cancel(); - }); - }); - }); - }); - }, - onUpdateVisitedHistory: (controller, url, isReload) async { - if (isReload != true) { - final history = ( - canGoBack: await controller.canGoBack(), - canGoForward: await controller.canGoForward() - ); - - await ref - .read(tabStateProvider(widget.tabId).notifier) - .copyWith(pageHistory: history); - } - }, - shouldOverrideUrlLoading: (controller, navigationAction) async { - final url = navigationAction.request.url; - if (url != null) { - final launchExternal = ref.read( - settingsRepositoryProvider.select( - (value) => (value.valueOrNull ?? Settings.withDefaults()) - .launchUrlExternal, - ), - ); - - final unhandledScheme = - !_webViewSupportedSchemes.contains(url.scheme); - - if (unhandledScheme || - (launchExternal && url.host != 'kagi.com')) { - if (await canLaunchUrl(url)) { - var success = false; - if (unhandledScheme) { - success = await launchUrl(url); - } else if (launchExternal) { - success = await launchUrl( - url, - mode: LaunchMode.externalApplication, - ); - } - - if (!success) { - if (context.mounted) { - ui_helper.showErrorMessage( - context, - 'Could not launch URL ($url)', - ); - } - } - // and cancel the request - return NavigationActionPolicy.CANCEL; - } - } - } - - return NavigationActionPolicy.ALLOW; - }, - onLongPressHitTestResult: (controller, hitTestResult) async { - if (switch (hitTestResult.type) { - InAppWebViewHitTestResultType.SRC_IMAGE_ANCHOR_TYPE || - InAppWebViewHitTestResultType.SRC_ANCHOR_TYPE || - InAppWebViewHitTestResultType.IMAGE_TYPE => - true, - _ => false, - }) { - final requestFocusNodeHrefResult = - await controller.requestFocusNodeHref(); - - final url = requestFocusNodeHrefResult?.url ?? - Uri.tryParse(requestFocusNodeHrefResult?.src ?? ''); - if (url?.hasScheme == true && url?.hasAuthority == true) { - ref.read(overlayDialogProvider.notifier).show( - WebPageDialog( - url: url!, - onDismiss: () { - ref.read(overlayDialogProvider.notifier).dismiss(); - }, - ), - ); - } - } - }, - onTitleChanged: (controller, title) async { - await ref - .read(tabStateProvider(widget.tabId).notifier) - .copyWith(title: title); - }, - onDownloadStartRequest: (controller, downloadStartRequest) async { - final handled = switch (downloadStartRequest.mimeType) { - 'text/markdown' => - await _downloadChat(downloadStartRequest, context), - _ => false - }; - - if (!handled) { - if (context.mounted) { - await ui_helper.launchUrlFeedback( - context, - downloadStartRequest.url, - mode: LaunchMode.platformDefault, - ); - } - } - - webViewProgress.value = 100; - }, - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: HookBuilder( - builder: (context) { - final value = useValueListenable(webViewProgress); - - return Visibility( - visible: value < 100, - child: LinearProgressIndicator( - value: value / 100, - ), - ); - }, - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: HookConsumer( - builder: (context, ref, child) { - final showFindInPage = ref.watch(showFindInPageProvider); - - final textController = useTextEditingController(); - final progress = useValueListenable(webViewProgress); - - return Visibility( - visible: showFindInPage, - child: Padding( - padding: - EdgeInsets.only(bottom: (progress < 100) ? 4.0 : 0.0), - child: Material( - child: Row( - children: [ - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: textController, - autofocus: true, - autocorrect: false, - decoration: const InputDecoration.collapsed( - hintText: 'Find in page', - ), - keyboardType: TextInputType.text, - onSubmitted: (value) async { - if (value == '') { - inPageSearchResult.value = null; - await findInteractionController.clearMatches(); - } else { - await findInteractionController.findAll( - find: value, - ); - } - }, - ), - ), - HookConsumer( - builder: (context, ref, child) { - final searchResult = - useValueListenable(inPageSearchResult); - - if (searchResult != null) { - if (searchResult.numberOfMatches == 0) { - return const Text('Not found'); - } - - return Text( - '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}', - ); - } - - return const SizedBox.shrink(); - }, - ), - IconButton( - icon: const Icon(Icons.arrow_upward), - onPressed: () async { - await findInteractionController.findNext( - forward: false, - ); - }, - ), - IconButton( - icon: const Icon(Icons.arrow_downward), - onPressed: () async { - await findInteractionController.findNext(); - }, - ), - IconButton( - icon: const Icon(Icons.clear), - onPressed: () async { - ref - .read(showFindInPageProvider.notifier) - .update(false); - - inPageSearchResult.value = null; - await findInteractionController.clearMatches(); - textController.clear(); - }, - ), - ], - ), - ), - ), - ); - }, - ), - ), - ], - ); - } -} diff --git a/app/lib/features/web_view/presentation/widgets/web_view_tab.dart b/app/lib/features/web_view/presentation/widgets/web_view_tab.dart deleted file mode 100644 index 49a4b977..00000000 --- a/app/lib/features/web_view/presentation/widgets/web_view_tab.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; -import 'package:lensai/features/web_view/presentation/widgets/favicon.dart'; - -class WebViewTab extends StatelessWidget { - final ITab tab; - final bool isActive; - - final VoidCallback? onTap; - final VoidCallback? onLongPress; - final VoidCallback? onDelete; - - const WebViewTab({ - required this.tab, - required this.isActive, - this.onTap, - this.onLongPress, - this.onDelete, - super.key, - }); - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - decoration: BoxDecoration( - border: Border.all( - color: isActive ? colorScheme.primary : colorScheme.outline, - width: isActive ? 2.0 : 1.0, - ), - borderRadius: const BorderRadius.all(Radius.circular(16.0)), - ), - child: Material( - color: colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.all(Radius.circular(16.0)), - child: InkWell( - borderRadius: const BorderRadius.all(Radius.circular(16.0)), - onTap: onTap, - onLongPress: onLongPress, - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 6.0), - child: Text( - overflow: TextOverflow.ellipsis, - tab.title ?? 'New Tab', - maxLines: 2, - ), - ), - ), - IconButton( - visualDensity: - const VisualDensity(horizontal: -4.0, vertical: -4.0), - onPressed: onDelete, - icon: const Icon(Icons.close), - ), - ], - ), - Row( - children: [ - const SizedBox( - width: 6.0, - ), - FaviconImage( - url: tab.url, - favicon: tab.favicon, - ), - const SizedBox( - width: 6.0, - ), - Expanded( - child: Text( - tab.url.authority, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - const SizedBox( - height: 6, - ), - if (tab.screenshot != null) - Expanded( - child: ClipRRect( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(16.0), - bottomRight: Radius.circular(16.0), - ), - child: SizedBox( - width: double.infinity, - child: Image.memory( - fit: BoxFit.fitWidth, - tab.screenshot!, - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/app/lib/features/web_view/utils/download_helper.dart b/app/lib/features/web_view/utils/download_helper.dart deleted file mode 100644 index b417cd30..00000000 --- a/app/lib/features/web_view/utils/download_helper.dart +++ /dev/null @@ -1,39 +0,0 @@ -//https://stackoverflow.com/a/67994693/20878798 - -import 'package:universal_io/io.dart'; - -final _utf8FilenameRegex = RegExp( - r"filename\*=UTF-8''([\w%\-\.]+)(?:; ?|$)", - caseSensitive: false, -); - -final _asciiFilenameRegex = RegExp( - r"""^filename=(["']?)(.*?[^\\])\1(?:; ?|$)""", - caseSensitive: false, -); - -String? getDispositionFileName(String contentDisposition) { - if (_utf8FilenameRegex.hasMatch(contentDisposition)) { - final file = Uri.decodeComponent( - _utf8FilenameRegex.firstMatch(contentDisposition)!.group(1)!, - ); - - return File(file).uri.pathSegments.last; - } else { - // Prevent ReDos attacks by anchoring the ascii regex to string start and - // slicing off everything before 'filename=' - final filenameStart = contentDisposition.toLowerCase().indexOf('filename='); - if (filenameStart >= 0) { - final partialDisposition = contentDisposition.substring(filenameStart); - final matches = _asciiFilenameRegex.firstMatch(partialDisposition); - if (matches != null && matches.group(2) != null) { - final file = matches.group(2); - if (file != null) { - return File(file).uri.pathSegments.last; - } - } - } - } - - return null; -} diff --git a/app/lib/features/web_view/utils/favicon_helper.dart b/app/lib/features/web_view/utils/favicon_helper.dart deleted file mode 100644 index 7c5781fb..00000000 --- a/app/lib/features/web_view/utils/favicon_helper.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; - -Favicon? choseFavicon(Iterable? favicons, [Favicon? currentIcon]) { - var selectedIcon = currentIcon; - - if (favicons != null) { - for (final icon in favicons) { - if (selectedIcon == null) { - selectedIcon = icon; - } else { - if ((selectedIcon.width == null && - !selectedIcon.url.toString().endsWith("favicon.ico")) || - (icon.width != null && - selectedIcon.width != null && - icon.width! > selectedIcon.width!)) { - selectedIcon = icon; - } - } - } - } - - return selectedIcon; -} diff --git a/app/lib/main.dart b/app/lib/main.dart index 7d119745..123324e5 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,22 +1,47 @@ +import 'dart:developer'; + +import 'package:background_fetch/background_fetch.dart'; import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + show GeckoBrowserService; import 'package:home_widget/home_widget.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/error_observer.dart'; -import 'package:lensai/domain/services/app_initialization.dart'; -import 'package:lensai/features/settings/data/models/settings.dart'; -import 'package:lensai/features/settings/data/repositories/settings_repository.dart'; -import 'package:lensai/presentation/hooks/on_initialization.dart'; -import 'package:lensai/presentation/widgets/main_app.dart'; +import 'package:weblibre/core/error_observer.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/core/providers/defaults.dart'; +import 'package:weblibre/domain/services/app_initialization.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/web_feed/presentation/controllers/fetch_articles.dart'; +import 'package:weblibre/features/web_feed/utils/fetch_entrypoint.dart'; +import 'package:weblibre/presentation/hooks/on_initialization.dart'; +import 'package:weblibre/presentation/main_app.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await HomeWidget.setAppGroupId('bang_navigator'); + FlutterError.onError = (e) { + logger.e(e.toString(), error: e.exception, stackTrace: e.stack); + }; - // if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { - // await InAppWebViewController.setWebContentsDebuggingEnabled(kDebugMode); - // } + PlatformDispatcher.instance.onError = (error, stack) { + logger.e('Unhandled Error', error: error, stackTrace: stack); + return true; + }; + + if (kDebugMode) { + final serviceProtocolInfo = await Service.getInfo(); + logger.d('VM: ${serviceProtocolInfo.serverUri}'); + } + + //Ensure everything is ready + await Future.delayed(Duration.zero); + await GeckoBrowserService().initialize(); + + await BackgroundFetch.registerHeadlessTask(backgroundFetch); + + await HomeWidget.setAppGroupId('weblibre'); runApp( ProviderScope( @@ -24,30 +49,71 @@ void main() async { child: HookConsumer( builder: (context, ref, child) { final themeMode = ref.watch( - settingsRepositoryProvider.select( - (value) => - (value.valueOrNull ?? Settings.withDefaults()).themeMode, + generalSettingsRepositoryProvider.select( + (value) => value.themeMode, ), ); - useOnInitialization( - () async { - await ref - .read(appInitializationServiceProvider.notifier) - .initialize(); - }, - ); + useOnInitialization(() async { + await ref + .read(appInitializationServiceProvider.notifier) + .initialize(); + + await BackgroundFetch.configure( + BackgroundFetchConfig( + minimumFetchInterval: 15, + enableHeadless: true, + stopOnTerminate: false, + requiredNetworkType: NetworkType.ANY, + startOnBoot: true, + ), + (String taskId) async { + try { + await ref + .read(fetchArticlesControllerProvider.notifier) + .fetchAllArticles(); + + logger.i('Fetched articles in foreground'); + } catch (e, s) { + logger.e('Failed fetching articles', error: e, stackTrace: s); + } finally { + await BackgroundFetch.finish(taskId); + } + }, + ); + }); return DynamicColorBuilder( builder: (lightDynamic, darkDynamic) { + ColorScheme lightColorScheme; + ColorScheme darkColorScheme; + + if (lightDynamic != null && darkDynamic != null) { + // On Android S+ devices, use the provided dynamic color scheme. + // (Recommended) Harmonize the dynamic color scheme' built-in semantic colors. + lightColorScheme = lightDynamic.harmonized(); + + // Repeat for the dark color scheme. + darkColorScheme = darkDynamic.harmonized(); + } else { + // Otherwise, use fallback schemes. + lightColorScheme = ColorScheme.fromSeed( + seedColor: ref.read(lightSeedColorFallbackProvider), + ); + darkColorScheme = ColorScheme.fromSeed( + seedColor: ref.read(darkSeedColorFallbackProvider), + brightness: Brightness.dark, + ); + } + return MainApp( theme: ThemeData( useMaterial3: true, - colorScheme: lightDynamic?.harmonized(), + colorScheme: lightColorScheme, ), darkTheme: ThemeData( useMaterial3: true, - colorScheme: darkDynamic?.harmonized(), + colorScheme: darkColorScheme, ), themeMode: themeMode, ); diff --git a/app/lib/presentation/controllers/website_title.dart b/app/lib/presentation/controllers/website_title.dart index ff28478e..bf8b5eee 100644 --- a/app/lib/presentation/controllers/website_title.dart +++ b/app/lib/presentation/controllers/website_title.dart @@ -1,11 +1,98 @@ -import 'package:lensai/domain/entities/web_page_info.dart'; -import 'package:lensai/domain/services/generic_website.dart'; +import 'package:nullability/nullability.dart'; +import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/data/models/web_page_info.dart'; +import 'package:weblibre/domain/services/generic_website.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; part 'website_title.g.dart'; -@Riverpod(keepAlive: true) -Future pageInfo(PageInfoRef ref, Uri url) { - final websiteService = ref.watch(genericWebsiteServiceProvider.notifier); - return websiteService.getInfo(url).then((value) => value.value); +const _supportedFetchSchemes = {'http', 'https'}; + +@Riverpod() +class CompletePageInfo extends _$CompletePageInfo { + @override + AsyncValue build(Uri url, WebPageInfo? cached) { + if (cached?.isPageInfoComplete == true || + !_supportedFetchSchemes.contains(url.scheme)) { + return AsyncData(cached!); + } + + ref.listen( + fireImmediately: true, + pageInfoProvider(url, isImageRequest: false), + (previous, next) { + if (cached != null && next.hasValue) { + state = AsyncData( + WebPageInfo( + url: url, + //Cached is preferred as this comes from gecko and is more likely to be correct compared to manual request + favicon: cached.favicon ?? next.value!.favicon, + feeds: cached.feeds ?? next.value!.feeds, + title: cached.title ?? next.value!.title, + ), + ); + } else { + state = next; + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to pageInfoProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + return (cached != null) ? AsyncData(cached) : const AsyncLoading(); + } +} + +@Riverpod() +Future pageInfo( + Ref ref, + Uri url, { + required bool isImageRequest, +}) async { + final tabId = ref.read(selectedTabProvider); + + int? proxyPort; + if (tabId != null) { + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .containerTabId(tabId); + + final containerData = await containerId.mapNotNull( + (containerId) => ref + .read(containerRepositoryProvider.notifier) + .getContainerData(containerId), + ); + + if (containerData?.metadata.useProxy == true) { + proxyPort = await ref.read(torProxyServiceProvider.future); + + if (proxyPort == null) { + throw Exception('Could not proxy request'); + } + } + } + + final result = await ref + .watch(genericWebsiteServiceProvider.notifier) + .fetchPageInfo( + url: url, + isImageRequest: isImageRequest, + proxyPort: proxyPort, + ); + + if (result.isSuccess) { + ref.keepAlive(); + } + + return result.value; } diff --git a/app/lib/presentation/controllers/website_title.g.dart b/app/lib/presentation/controllers/website_title.g.dart index 792f8ce4..069dcf74 100644 --- a/app/lib/presentation/controllers/website_title.g.dart +++ b/app/lib/presentation/controllers/website_title.g.dart @@ -6,7 +6,7 @@ part of 'website_title.dart'; // RiverpodGenerator // ************************************************************************** -String _$pageInfoHash() => r'66800fc7e4863750fc9a3717a25e94ee95ec52a3'; +String _$pageInfoHash() => r'32527f7803e3eb33f74ce58d0773a9386df2546d'; /// Copied from Dart SDK class _SystemHash { @@ -39,21 +39,13 @@ class PageInfoFamily extends Family> { const PageInfoFamily(); /// See also [pageInfo]. - PageInfoProvider call( - Uri url, - ) { - return PageInfoProvider( - url, - ); + PageInfoProvider call(Uri url, {required bool isImageRequest}) { + return PageInfoProvider(url, isImageRequest: isImageRequest); } @override - PageInfoProvider getProviderOverride( - covariant PageInfoProvider provider, - ) { - return call( - provider.url, - ); + PageInfoProvider getProviderOverride(covariant PageInfoProvider provider) { + return call(provider.url, isImageRequest: provider.isImageRequest); } static const Iterable? _dependencies = null; @@ -72,25 +64,22 @@ class PageInfoFamily extends Family> { } /// See also [pageInfo]. -class PageInfoProvider extends FutureProvider { +class PageInfoProvider extends AutoDisposeFutureProvider { /// See also [pageInfo]. - PageInfoProvider( - Uri url, - ) : this._internal( - (ref) => pageInfo( - ref as PageInfoRef, - url, - ), - from: pageInfoProvider, - name: r'pageInfoProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$pageInfoHash, - dependencies: PageInfoFamily._dependencies, - allTransitiveDependencies: PageInfoFamily._allTransitiveDependencies, - url: url, - ); + PageInfoProvider(Uri url, {required bool isImageRequest}) + : this._internal( + (ref) => + pageInfo(ref as PageInfoRef, url, isImageRequest: isImageRequest), + from: pageInfoProvider, + name: r'pageInfoProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$pageInfoHash, + dependencies: PageInfoFamily._dependencies, + allTransitiveDependencies: PageInfoFamily._allTransitiveDependencies, + url: url, + isImageRequest: isImageRequest, + ); PageInfoProvider._internal( super._createNotifier, { @@ -100,9 +89,11 @@ class PageInfoProvider extends FutureProvider { required super.debugGetCreateSourceHash, required super.from, required this.url, + required this.isImageRequest, }) : super.internal(); final Uri url; + final bool isImageRequest; @override Override overrideWith( @@ -118,40 +109,213 @@ class PageInfoProvider extends FutureProvider { allTransitiveDependencies: null, debugGetCreateSourceHash: null, url: url, + isImageRequest: isImageRequest, ), ); } @override - FutureProviderElement createElement() { + AutoDisposeFutureProviderElement createElement() { return _PageInfoProviderElement(this); } @override bool operator ==(Object other) { - return other is PageInfoProvider && other.url == url; + return other is PageInfoProvider && + other.url == url && + other.isImageRequest == isImageRequest; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); hash = _SystemHash.combine(hash, url.hashCode); + hash = _SystemHash.combine(hash, isImageRequest.hashCode); return _SystemHash.finish(hash); } } -mixin PageInfoRef on FutureProviderRef { +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin PageInfoRef on AutoDisposeFutureProviderRef { /// The parameter `url` of this provider. Uri get url; + + /// The parameter `isImageRequest` of this provider. + bool get isImageRequest; } -class _PageInfoProviderElement extends FutureProviderElement +class _PageInfoProviderElement + extends AutoDisposeFutureProviderElement with PageInfoRef { _PageInfoProviderElement(super.provider); @override Uri get url => (origin as PageInfoProvider).url; + @override + bool get isImageRequest => (origin as PageInfoProvider).isImageRequest; } + +String _$completePageInfoHash() => r'6a258150d2bb72b8f21de973ddcd23734f79ace0'; + +abstract class _$CompletePageInfo + extends BuildlessAutoDisposeNotifier> { + late final Uri url; + late final WebPageInfo? cached; + + AsyncValue build(Uri url, WebPageInfo? cached); +} + +/// See also [CompletePageInfo]. +@ProviderFor(CompletePageInfo) +const completePageInfoProvider = CompletePageInfoFamily(); + +/// See also [CompletePageInfo]. +class CompletePageInfoFamily extends Family> { + /// See also [CompletePageInfo]. + const CompletePageInfoFamily(); + + /// See also [CompletePageInfo]. + CompletePageInfoProvider call(Uri url, WebPageInfo? cached) { + return CompletePageInfoProvider(url, cached); + } + + @override + CompletePageInfoProvider getProviderOverride( + covariant CompletePageInfoProvider provider, + ) { + return call(provider.url, provider.cached); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'completePageInfoProvider'; +} + +/// See also [CompletePageInfo]. +class CompletePageInfoProvider + extends + AutoDisposeNotifierProviderImpl< + CompletePageInfo, + AsyncValue + > { + /// See also [CompletePageInfo]. + CompletePageInfoProvider(Uri url, WebPageInfo? cached) + : this._internal( + () => CompletePageInfo() + ..url = url + ..cached = cached, + from: completePageInfoProvider, + name: r'completePageInfoProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$completePageInfoHash, + dependencies: CompletePageInfoFamily._dependencies, + allTransitiveDependencies: + CompletePageInfoFamily._allTransitiveDependencies, + url: url, + cached: cached, + ); + + CompletePageInfoProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.url, + required this.cached, + }) : super.internal(); + + final Uri url; + final WebPageInfo? cached; + + @override + AsyncValue runNotifierBuild( + covariant CompletePageInfo notifier, + ) { + return notifier.build(url, cached); + } + + @override + Override overrideWith(CompletePageInfo Function() create) { + return ProviderOverride( + origin: this, + override: CompletePageInfoProvider._internal( + () => create() + ..url = url + ..cached = cached, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + url: url, + cached: cached, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement> + createElement() { + return _CompletePageInfoProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is CompletePageInfoProvider && + other.url == url && + other.cached == cached; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, url.hashCode); + hash = _SystemHash.combine(hash, cached.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin CompletePageInfoRef + on AutoDisposeNotifierProviderRef> { + /// The parameter `url` of this provider. + Uri get url; + + /// The parameter `cached` of this provider. + WebPageInfo? get cached; +} + +class _CompletePageInfoProviderElement + extends + AutoDisposeNotifierProviderElement< + CompletePageInfo, + AsyncValue + > + with CompletePageInfoRef { + _CompletePageInfoProviderElement(super.provider); + + @override + Uri get url => (origin as CompletePageInfoProvider).url; + @override + WebPageInfo? get cached => (origin as CompletePageInfoProvider).cached; +} + // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/presentation/hooks/cached_future.dart b/app/lib/presentation/hooks/cached_future.dart index 1375fb07..5d96d3cd 100644 --- a/app/lib/presentation/hooks/cached_future.dart +++ b/app/lib/presentation/hooks/cached_future.dart @@ -2,11 +2,10 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; AsyncSnapshot useCachedFuture( - Future Function() valueBuilder, -) { - // ignore: discarded_futures - final cachedFuture = useMemoized( - valueBuilder, - ); + Future Function() valueBuilder, [ + List keys = const [], +]) { + // ignore: discarded_futures is used + final cachedFuture = useMemoized(valueBuilder, keys); return useFuture(cachedFuture); } diff --git a/app/lib/presentation/hooks/listenable_callback.dart b/app/lib/presentation/hooks/listenable_callback.dart index f3067a2b..5d223489 100644 --- a/app/lib/presentation/hooks/listenable_callback.dart +++ b/app/lib/presentation/hooks/listenable_callback.dart @@ -1,12 +1,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -void useListenableCallback(Listenable? listenable, void Function() callback) { - useEffect( - () { - listenable?.addListener(callback); - return () => listenable?.removeListener(callback); - }, - [listenable], - ); +void useListenableCallback( + Listenable? listenable, + void Function() callback, [ + List? keys, +]) { + useEffect(() { + listenable?.addListener(callback); + return () => listenable?.removeListener(callback); + }, keys ?? [listenable]); } diff --git a/app/lib/presentation/hooks/menu_controller.dart b/app/lib/presentation/hooks/menu_controller.dart new file mode 100644 index 00000000..87d7ad50 --- /dev/null +++ b/app/lib/presentation/hooks/menu_controller.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +MenuController useMenuController() { + return useMemoized(() => MenuController()); +} diff --git a/app/lib/presentation/hooks/on_dispose.dart b/app/lib/presentation/hooks/on_dispose.dart index 5ec5f4ec..762ee548 100644 --- a/app/lib/presentation/hooks/on_dispose.dart +++ b/app/lib/presentation/hooks/on_dispose.dart @@ -2,10 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; void useOnDispose(VoidCallback onDispose) { - useEffect( - () { - return onDispose; - }, - const [], - ); + useEffect(() { + return onDispose; + }, const []); } diff --git a/app/lib/presentation/hooks/on_initialization.dart b/app/lib/presentation/hooks/on_initialization.dart index 6f4de469..2a2a5f46 100644 --- a/app/lib/presentation/hooks/on_initialization.dart +++ b/app/lib/presentation/hooks/on_initialization.dart @@ -3,11 +3,8 @@ import 'dart:async'; import 'package:flutter_hooks/flutter_hooks.dart'; void useOnInitialization(FutureOr Function() callback) { - useEffect( - () { - Future.microtask(callback); - return null; - }, - [], - ); + useEffect(() { + unawaited(Future.microtask(callback)); + return null; + }, []); } diff --git a/app/lib/presentation/hooks/overlay_portal_controller.dart b/app/lib/presentation/hooks/overlay_portal_controller.dart deleted file mode 100644 index c97e18c6..00000000 --- a/app/lib/presentation/hooks/overlay_portal_controller.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -OverlayPortalController useOverlayPortalController() { - return use(const _OverlayPortalControllerHook()); -} - -class _OverlayPortalControllerHook extends Hook { - const _OverlayPortalControllerHook(); - - @override - HookState> - createState() { - return _OverlayPortalControllerHookState(); - } -} - -class _OverlayPortalControllerHookState - extends HookState { - late final controller = OverlayPortalController(); - - @override - OverlayPortalController build(BuildContext context) => controller; - - @override - String get debugLabel => 'useOverlayPortalController'; -} diff --git a/app/lib/presentation/hooks/sampled_value_notifier.dart b/app/lib/presentation/hooks/sampled_value_notifier.dart new file mode 100644 index 00000000..988ed0ac --- /dev/null +++ b/app/lib/presentation/hooks/sampled_value_notifier.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:weblibre/utils/sampled_value_notifier.dart'; + +/// A custom hook that creates a sampled ValueNotifier from another ValueNotifier +ValueNotifier useSampledValueNotifier({ + required ValueNotifier source, + required Duration sampleDuration, +}) { + return use( + _SampledValueNotifierHook( + source: source, + sampleDuration: sampleDuration, + ), + ); +} + +/// Hook implementation for sampled ValueNotifier +class _SampledValueNotifierHook extends Hook> { + final ValueNotifier source; + final Duration sampleDuration; + + const _SampledValueNotifierHook({ + required this.source, + required this.sampleDuration, + }); + + @override + _SampledValueNotifierHookState createState() => + _SampledValueNotifierHookState(); +} + +class _SampledValueNotifierHookState + extends HookState, _SampledValueNotifierHook> { + late SampledValueNotifier _sampledNotifier; + + @override + void initHook() { + super.initHook(); + _sampledNotifier = SampledValueNotifier( + source: hook.source, + sampleDuration: hook.sampleDuration, + ); + } + + @override + ValueNotifier build(BuildContext context) { + return _sampledNotifier; + } + + @override + void dispose() { + _sampledNotifier.dispose(); + super.dispose(); + } + + @override + String get debugLabel => 'useSampledValueNotifier<$T>'; +} diff --git a/app/lib/presentation/hooks/sync_page_tab.dart b/app/lib/presentation/hooks/sync_page_tab.dart index 4d0162d6..f7de17f3 100644 --- a/app/lib/presentation/hooks/sync_page_tab.dart +++ b/app/lib/presentation/hooks/sync_page_tab.dart @@ -6,34 +6,31 @@ void useSyncPageWithTab( PageController pageController, { void Function(int index)? onIndexChanged, }) { - useEffect( - () { - Future syncPage() async { - await pageController.animateToPage( - tabController.index, - curve: Curves.linear, - duration: const Duration(milliseconds: 300), - ); - onIndexChanged?.call(tabController.index); + useEffect(() { + Future syncPage() async { + await pageController.animateToPage( + tabController.index, + curve: Curves.linear, + duration: const Duration(milliseconds: 300), + ); + onIndexChanged?.call(tabController.index); + } + + void syncTab() { + if (!tabController.indexIsChanging) { + final index = pageController.page!.round(); + + tabController.animateTo(index); + onIndexChanged?.call(index); } + } - void syncTab() { - if (!tabController.indexIsChanging) { - final index = pageController.page!.round(); + tabController.addListener(syncPage); + pageController.addListener(syncTab); - tabController.animateTo(index); - onIndexChanged?.call(index); - } - } - - tabController.addListener(syncPage); - pageController.addListener(syncTab); - - return () { - tabController.removeListener(syncPage); - pageController.removeListener(syncTab); - }; - }, - [tabController, pageController], - ); + return () { + tabController.removeListener(syncPage); + pageController.removeListener(syncTab); + }; + }, [tabController, pageController]); } diff --git a/app/lib/presentation/icons/tor_icons.dart b/app/lib/presentation/icons/tor_icons.dart new file mode 100644 index 00000000..e9480799 --- /dev/null +++ b/app/lib/presentation/icons/tor_icons.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; + +enum TorIcons implements IconData { + authority(0xf101), + badexit(0xf102), + bridge(0xf103), + country(0xf104), + directory(0xf105), + exit(0xf106), + experimental(0xf107), + fallbackdir(0xf108), + fast(0xf109), + fingerprint(0xf10a), + guard(0xf10b), + hibernating(0xf10c), + hsdir(0xf10d), + ipv4(0xf10e), + ipv6exit(0xf10f), + ipv6(0xf110), + noedconsensus(0xf111), + notrecommended(0xf112), + onionAlt(0xf113), + onion(0xf114), + outdated(0xf115), + reachableipv4(0xf116), + reachableipv6(0xf117), + relay(0xf118), + running(0xf119), + stable(0xf11a), + tshirt(0xf11b), + unmeasured(0xf11c), + unreachableipv4(0xf11d), + unreachableipv6(0xf11e), + v2dir(0xf11f), + valid(0xf120); + + const TorIcons(this.codePoint); + + @override + final int codePoint; + + @override + String get fontFamily => 'TorIcons'; + + @override + List? get fontFamilyFallback => null; + + @override + String? get fontPackage => null; + + @override + bool get matchTextDirection => false; + + @override + String toString() => 'TorIcons.$name'; +} diff --git a/app/lib/presentation/widgets/main_app.dart b/app/lib/presentation/main_app.dart similarity index 87% rename from app/lib/presentation/widgets/main_app.dart rename to app/lib/presentation/main_app.dart index 92f57348..b45dedb1 100644 --- a/app/lib/presentation/widgets/main_app.dart +++ b/app/lib/presentation/main_app.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/core/providers.dart'; -import 'package:lensai/domain/services/app_initialization.dart'; -import 'package:lensai/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/core/providers/router.dart'; +import 'package:weblibre/domain/services/app_initialization.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; class MainApp extends HookConsumerWidget { final ThemeData? theme; @@ -52,7 +52,7 @@ class MainApp extends HookConsumerWidget { theme: theme, darkTheme: darkTheme, themeMode: themeMode, - routerConfig: router, + routerConfig: router.valueOrNull, ); }, onFailure: (errorMessage) { @@ -62,9 +62,7 @@ class MainApp extends HookConsumerWidget { darkTheme: darkTheme, themeMode: themeMode, home: Scaffold( - appBar: AppBar( - title: const Text('Initiallization Error'), - ), + appBar: AppBar(title: const Text('Initiallization Error')), body: Center( child: FailureWidget( title: 'Could not initialize App', diff --git a/app/lib/presentation/widgets/animate_gradient_shader.dart b/app/lib/presentation/widgets/animate_gradient_shader.dart index 2b4b0726..13ec3536 100644 --- a/app/lib/presentation/widgets/animate_gradient_shader.dart +++ b/app/lib/presentation/widgets/animate_gradient_shader.dart @@ -19,8 +19,8 @@ class AnimateGradientShader extends StatefulWidget { this.duration = const Duration(seconds: 4), this.animateAlignments = true, this.reverse = true, - }) : assert(primaryColors.length >= 2), - assert(primaryColors.length == secondaryColors.length); + }) : assert(primaryColors.length >= 2), + assert(primaryColors.length == secondaryColors.length); /// [controller]: pass this to have a fine control over the [Animation] final AnimationController? controller; @@ -142,8 +142,9 @@ class _AnimateGradientShaderState extends State return ShaderMask( shaderCallback: (Rect bounds) { - return gradient - .createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)); + return gradient.createShader( + Rect.fromLTWH(0, 0, bounds.width, bounds.height), + ); }, child: widget.child, ); @@ -160,10 +161,7 @@ class _AnimateGradientShaderState extends State for (int i = 0; i < primaryColors.length; i++) { colorTweens.add( - ColorTween( - begin: primaryColors[i], - end: secondaryColors[i], - ), + ColorTween(begin: primaryColors[i], end: secondaryColors[i]), ); } @@ -204,17 +202,12 @@ class _AnimateGradientShaderState extends State void _setAnimations() { _controller?.dispose(); - _controller = (widget.controller ?? - AnimationController( - vsync: this, - duration: widget.duration, - )) - ..repeat(reverse: widget.reverse); + _controller = + (widget.controller ?? + AnimationController(vsync: this, duration: widget.duration)) + ..repeat(reverse: widget.reverse); - _animation = CurvedAnimation( - parent: _controller!, - curve: Curves.easeInOut, - ); + _animation = CurvedAnimation(parent: _controller!, curve: Curves.easeInOut); } @override diff --git a/app/lib/presentation/widgets/animated_indexed_stack.dart b/app/lib/presentation/widgets/animated_indexed_stack.dart index 920533dc..f3198565 100644 --- a/app/lib/presentation/widgets/animated_indexed_stack.dart +++ b/app/lib/presentation/widgets/animated_indexed_stack.dart @@ -77,7 +77,8 @@ class AnimatedIndexedStack extends StatefulWidget { Widget child, Animation primaryAnimation, Animation secondaryAnimation, - ) transitionBuilder; + ) + transitionBuilder; /// A function that lays out all the children in this IndexedStack. /// This defaults to [PageTransitionSwitcher.defaultLayoutBuilder]. @@ -91,10 +92,7 @@ class AnimatedIndexedStack extends StatefulWidget { /// The default layout builder for [AnimatedIndexedStack]. /// Contains all the children in a [Stack]. static Widget defaultLayoutBuilder(List entries) { - return Stack( - alignment: Alignment.center, - children: entries, - ); + return Stack(alignment: Alignment.center, children: entries); } @override @@ -173,8 +171,9 @@ class _AnimatedIndexedStackState extends State for (final child in widget.children) { // We find the previous entry by looking for an identical child widget. // If the children of this Stack share widget types, they must be given unique keys. - final int existingIndex = - _entries.indexWhere((entry) => Widget.canUpdate(entry.child, child)); + final int existingIndex = _entries.indexWhere( + (entry) => Widget.canUpdate(entry.child, child), + ); _ChildEntry? existingEntry; if (existingIndex != -1) { @@ -276,17 +275,17 @@ class _AnimatedIndexedStackState extends State } _ChildEntry _newEntry(Widget child) => _ChildEntry( - key: GlobalKey(), - child: child, - primaryController: AnimationController( - duration: widget.duration, - vsync: this, - ), - secondaryController: AnimationController( - duration: widget.duration, - vsync: this, - ), - ); + key: GlobalKey(), + child: child, + primaryController: AnimationController( + duration: widget.duration, + vsync: this, + ), + secondaryController: AnimationController( + duration: widget.duration, + vsync: this, + ), + ); @override void dispose() { @@ -297,30 +296,28 @@ class _AnimatedIndexedStackState extends State } Widget _buildChild(_ChildEntry entry) => AnimatedBuilder( - animation: Listenable.merge([ + animation: Listenable.merge([ + entry.primaryController, + entry.secondaryController, + ]), + builder: (context, child) { + final bool isVisible = + entry.primaryController.isAnimating || + entry.secondaryController.isAnimating || + entry == _currentEntry; + + return Visibility( + visible: isVisible, + maintainState: true, + child: widget.transitionBuilder( + KeyedSubtree(key: entry.key, child: child!), entry.primaryController, entry.secondaryController, - ]), - builder: (context, child) { - final bool isVisible = entry.primaryController.isAnimating || - entry.secondaryController.isAnimating || - entry == _currentEntry; - - return Visibility( - visible: isVisible, - maintainState: true, - child: widget.transitionBuilder( - KeyedSubtree( - key: entry.key, - child: child!, - ), - entry.primaryController, - entry.secondaryController, - ), - ); - }, - child: entry.child, + ), ); + }, + child: entry.child, + ); @override Widget build(BuildContext context) { diff --git a/app/lib/presentation/widgets/auto_suggest_text_field.dart b/app/lib/presentation/widgets/auto_suggest_text_field.dart new file mode 100644 index 00000000..19badb11 --- /dev/null +++ b/app/lib/presentation/widgets/auto_suggest_text_field.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:nullability/nullability.dart'; + +class AutoSuggestTextField extends HookWidget { + final TextEditingController controller; + final String? suggestion; + final TextStyle? style; + final TextStyle? labelStyle; + final InputDecoration? decoration; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final TextCapitalization textCapitalization; + final bool autofocus; + final bool obscureText; + final int? maxLines; + final int? minLines; + final int? maxLength; + final ValueChanged? onChanged; + final VoidCallback? onEditingComplete; + final FormFieldValidator? validator; + final ValueChanged? onSubmitted; + final List? inputFormatters; + final bool? enabled; + final FocusNode? focusNode; + final Color? cursorColor; + final Color? suggestionHighlightColor; + final bool? enableIMEPersonalizedLearning; + final TapRegionCallback? onTapOutside; + final VoidCallback? onTap; + + const AutoSuggestTextField({ + super.key, + required this.controller, + this.suggestion, + this.style, + this.labelStyle, + this.decoration, + this.keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.autofocus = false, + this.obscureText = false, + this.maxLines = 1, + this.minLines, + this.maxLength, + this.onChanged, + this.onEditingComplete, + this.validator, + this.onSubmitted, + this.inputFormatters, + this.enabled, + this.focusNode, + this.cursorColor, + this.suggestionHighlightColor, + this.enableIMEPersonalizedLearning = true, + this.onTapOutside, + this.onTap, + }); + + bool _suggestionHasMatch() => + suggestion != null && + controller.text.isNotEmpty && + suggestion!.startsWith(controller.text); + + static int _getLineCountUsingBoxes( + String text, + TextStyle style, + double maxWidth, + ) { + final textSpan = TextSpan(text: text, style: style); + + final textPainter = TextPainter( + text: textSpan, + textDirection: TextDirection.ltr, + ); + + textPainter.layout(maxWidth: maxWidth); + + // Select all text + final selection = TextSelection(baseOffset: 0, extentOffset: text.length); + + // Each box represents one line + final lines = textPainter.getBoxesForSelection(selection); + return lines.length; + } + + @override + Widget build(BuildContext context) { + final textFieldKey = useMemoized(() => GlobalKey()); + + final showSuggestion = useListenableSelector(controller, () { + if (maxLines != 1) { + final box = textFieldKey.currentContext?.findRenderObject(); + if (box case final RenderBox box) { + final width = box.size.width; + + final lines = _getLineCountUsingBoxes( + controller.text, + style ?? Theme.of(context).textTheme.bodyLarge!, + width, + ); + + final suggestionLines = suggestion.mapNotNull( + (suggestion) => _getLineCountUsingBoxes( + suggestion, + style ?? Theme.of(context).textTheme.bodyLarge!, + width, + ), + ); + + return lines == 1 && suggestionLines == 1; + } + } + + return true; + }); + + final baseDecoration = decoration ?? const InputDecoration(); + + return Stack( + children: [ + if (showSuggestion && suggestion != null) + AbsorbPointer( + child: TextField( + minLines: minLines, + maxLines: maxLines, + maxLength: maxLength, + decoration: baseDecoration.copyWith( + floatingLabelBehavior: FloatingLabelBehavior.never, + border: InputBorder.none, + label: HookBuilder( + builder: (context) { + final text = useListenableSelector( + controller, + () => controller.text, + ); + + useEffect(() { + TextSelection? lastSelection; + + void handleSelectionChange() { + if (lastSelection != controller.selection) { + lastSelection = controller.selection; + if (lastSelection!.start != lastSelection!.end) { + if (_suggestionHasMatch()) { + controller.value = controller.value.copyWith( + text: suggestion, + selection: lastSelection!.expandTo( + TextPosition(offset: suggestion!.length), + ), + ); + } + } + } + } + + controller.addListener(handleSelectionChange); + return () => + controller.removeListener(handleSelectionChange); + }); + + if (!_suggestionHasMatch()) { + return const SizedBox.shrink(); + } + + return RichText( + maxLines: maxLines, + text: TextSpan( + text: text, + style: (style ?? Theme.of(context).textTheme.bodyLarge) + ?.copyWith(color: Colors.transparent), + children: [ + TextSpan( + text: suggestion!.substring(text.length), + style: TextStyle( + height: 1.2, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + backgroundColor: + suggestionHighlightColor ?? + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.40), + ), + ), + ], + ), + ); + }, + ), + alignLabelWithHint: true, + ), + ), + ), + TextFormField( + key: textFieldKey, + controller: controller, + focusNode: focusNode, + decoration: baseDecoration.copyWith( + label: baseDecoration.label ?? const Text(''), + floatingLabelBehavior: + baseDecoration.floatingLabelBehavior ?? + FloatingLabelBehavior.never, + ), + style: style, + keyboardType: keyboardType, + textInputAction: textInputAction, + textCapitalization: textCapitalization, + autofocus: autofocus, + obscureText: obscureText, + maxLines: maxLines, + minLines: minLines, + maxLength: maxLength, + onChanged: onChanged, + onEditingComplete: onEditingComplete, + validator: validator, + onFieldSubmitted: onSubmitted.mapNotNull( + (onSubmitted) => (value) { + if (_suggestionHasMatch()) { + onSubmitted(suggestion!); + } else { + onSubmitted(value); + } + }, + ), + inputFormatters: inputFormatters, + enabled: enabled, + cursorColor: cursorColor, + enableIMEPersonalizedLearning: enableIMEPersonalizedLearning ?? true, + onTapOutside: onTapOutside, + onTap: onTap, + ), + ], + ); + } +} diff --git a/app/lib/presentation/widgets/autocomplete.dart b/app/lib/presentation/widgets/autocomplete.dart deleted file mode 100644 index 7777f658..00000000 --- a/app/lib/presentation/widgets/autocomplete.dart +++ /dev/null @@ -1,436 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -class _AutocompleteCallbackAction extends CallbackAction { - _AutocompleteCallbackAction({ - required super.onInvoke, - required this.isEnabledCallback, - }); - - // The enabled state determines whether the action will consume the - // key shortcut or let it continue on to the underlying text field. - // They should only be enabled when the options are showing so shortcuts - // can be used to navigate them. - final bool Function() isEnabledCallback; - - @override - bool isEnabled(covariant T intent) => isEnabledCallback(); - - @override - bool consumesKey(covariant T intent) => isEnabled(intent); -} - -class ExternalResultsAutocomplete extends StatefulWidget { - /// Create an instance of RawAutocomplete. - /// - /// [displayStringForOption], [onTextChanged] and [optionsViewBuilder] must - /// not be null. - const ExternalResultsAutocomplete({ - super.key, - required this.optionsViewBuilder, - required this.onTextChanged, - required this.optionsStream, - this.optionsViewOpenDirection = OptionsViewOpenDirection.down, - this.displayStringForOption = defaultStringForOption, - this.fieldViewBuilder, - this.focusNode, - this.onSelected, - this.textEditingController, - this.initialValue, - }) : assert( - fieldViewBuilder != null || - (key != null && - focusNode != null && - textEditingController != null), - 'Pass in a fieldViewBuilder, or otherwise create a separate field and pass in the FocusNode, TextEditingController, and a key. Use the key with RawAutocomplete.onFieldSubmitted.', - ), - assert((focusNode == null) == (textEditingController == null)), - assert( - !(textEditingController != null && initialValue != null), - 'textEditingController and initialValue cannot be simultaneously defined.', - ); - - /// {@template flutter.widgets.RawAutocomplete.fieldViewBuilder} - /// Builds the field whose input is used to get the options. - /// - /// Pass the provided [TextEditingController] to the field built here so that - /// RawAutocomplete can listen for changes. - /// {@endtemplate} - /// - /// If this parameter is null, then a [SizedBox.shrink] is built instead. - /// For how that pattern can be useful, see [textEditingController]. - final AutocompleteFieldViewBuilder? fieldViewBuilder; - - /// The [FocusNode] that is used for the text field. - /// - /// {@template flutter.widgets.RawAutocomplete.split} - /// The main purpose of this parameter is to allow the use of a separate text - /// field located in another part of the widget tree instead of the text - /// field built by [fieldViewBuilder]. For example, it may be desirable to - /// place the text field in the AppBar and the options below in the main body. - /// - /// When following this pattern, [fieldViewBuilder] can be omitted, - /// so that a text field is not drawn where it would normally be. - /// A separate text field can be created elsewhere, and a - /// FocusNode and TextEditingController can be passed both to that text field - /// and to RawAutocomplete. - /// - /// {@tool dartpad} - /// This examples shows how to create an autocomplete widget with the text - /// field in the AppBar and the results in the main body of the app. - /// - /// ** See code in examples/api/lib/widgets/autocomplete/raw_autocomplete.focus_node.0.dart ** - /// {@end-tool} - /// {@endtemplate} - /// - /// If this parameter is not null, then [textEditingController] must also be - /// not null. - final FocusNode? focusNode; - - /// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder} - /// Builds the selectable options widgets from a list of options objects. - /// - /// The options are displayed floating below or above the field using a - /// [CompositedTransformFollower] inside of an [Overlay], not at the same - /// place in the widget tree as [ExternalResultsAutocomplete]. To control whether it opens - /// upward or downward, use [optionsViewOpenDirection]. - /// - /// In order to track which item is highlighted by keyboard navigation, the - /// resulting options will be wrapped in an inherited - /// [AutocompleteHighlightedOption] widget. - /// Inside this callback, the index of the highlighted option can be obtained - /// from [AutocompleteHighlightedOption.of] to display the highlighted option - /// with a visual highlight to indicate it will be the option selected from - /// the keyboard. - /// - /// {@endtemplate} - final AutocompleteOptionsViewBuilder optionsViewBuilder; - - /// {@template flutter.widgets.RawAutocomplete.optionsViewOpenDirection} - /// The direction in which to open the options-view overlay. - /// - /// Defaults to [OptionsViewOpenDirection.down]. - /// {@endtemplate} - final OptionsViewOpenDirection optionsViewOpenDirection; - - /// {@template flutter.widgets.RawAutocomplete.displayStringForOption} - /// Returns the string to display in the field when the option is selected. - /// - /// This is useful when using a custom T type and the string to display is - /// different than the string to search by. - /// - /// If not provided, will use `option.toString()`. - /// {@endtemplate} - final AutocompleteOptionToString displayStringForOption; - - /// {@template flutter.widgets.RawAutocomplete.onSelected} - /// Called when an option is selected by the user. - /// {@endtemplate} - final AutocompleteOnSelected? onSelected; - - final FutureOr Function(TextEditingValue textEditingValue) - onTextChanged; - - /// The [TextEditingController] that is used for the text field. - /// - /// {@macro flutter.widgets.RawAutocomplete.split} - /// - /// If this parameter is not null, then [focusNode] must also be not null. - final TextEditingController? textEditingController; - - /// {@template flutter.widgets.RawAutocomplete.initialValue} - /// The initial value to use for the text field. - /// {@endtemplate} - /// - /// Setting the initial value does not notify [textEditingController]'s - /// listeners, and thus will not cause the options UI to appear. - /// - /// This parameter is ignored if [textEditingController] is defined. - final TextEditingValue? initialValue; - - final Stream> optionsStream; - - /// Calls [AutocompleteFieldViewBuilder]'s onFieldSubmitted callback for the - /// RawAutocomplete widget indicated by the given [GlobalKey]. - /// - /// This is not typically used unless a custom field is implemented instead of - /// using [fieldViewBuilder]. In the typical case, the onFieldSubmitted - /// callback is passed via the [AutocompleteFieldViewBuilder] signature. When - /// not using fieldViewBuilder, the same callback can be called by using this - /// static method. - /// - /// See also: - /// - /// * [focusNode] and [textEditingController], which contain a code example - /// showing how to create a separate field outside of fieldViewBuilder. - static void onFieldSubmitted(GlobalKey key) { - final _RawAutocompleteState rawAutocomplete = - key.currentState! as _RawAutocompleteState; - rawAutocomplete._onFieldSubmitted(); - } - - /// The default way to convert an option to a string in - /// [displayStringForOption]. - /// - /// Uses the `toString` method of the given `option`. - static String defaultStringForOption(Object? option) { - return option.toString(); - } - - @override - State> createState() => - _RawAutocompleteState(); -} - -class _RawAutocompleteState - extends State> { - final GlobalKey _fieldKey = GlobalKey(); - final LayerLink _optionsLayerLink = LayerLink(); - final OverlayPortalController _optionsViewController = - OverlayPortalController(debugLabel: '_RawAutocompleteState'); - - TextEditingController? _internalTextEditingController; - TextEditingController get _textEditingController { - return widget.textEditingController ?? - (_internalTextEditingController ??= TextEditingController() - ..addListener(_onChangedField)); - } - - FocusNode? _internalFocusNode; - FocusNode get _focusNode { - return widget.focusNode ?? - (_internalFocusNode ??= FocusNode() - ..addListener(_updateOptionsViewVisibility)); - } - - late final Map> _actionMap = - >{ - AutocompletePreviousOptionIntent: - _AutocompleteCallbackAction( - onInvoke: _highlightPreviousOption, - isEnabledCallback: () => _canShowOptionsView, - ), - AutocompleteNextOptionIntent: - _AutocompleteCallbackAction( - onInvoke: _highlightNextOption, - isEnabledCallback: () => _canShowOptionsView, - ), - DismissIntent: CallbackAction(onInvoke: _hideOptions), - }; - - late StreamSubscription> _optionsSubscription; - Iterable _options = Iterable.empty(); - T? _selection; - // Set the initial value to null so when this widget gets focused for the first - // time it will try to run the options view builder. - String? _lastFieldText; - final ValueNotifier _highlightedOptionIndex = ValueNotifier(0); - - static const Map _shortcuts = - { - SingleActivator(LogicalKeyboardKey.arrowUp): - AutocompletePreviousOptionIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown): - AutocompleteNextOptionIntent(), - }; - - bool get _canShowOptionsView => - _focusNode.hasFocus && _selection == null && _options.isNotEmpty; - - void _updateOptionsViewVisibility() { - if (_canShowOptionsView) { - _optionsViewController.show(); - } else { - _optionsViewController.hide(); - } - } - - void _onUpateOptions(Iterable options) { - final TextEditingValue value = _textEditingController.value; - - _options = options; - _updateHighlight(_highlightedOptionIndex.value); - final T? selection = _selection; - if (selection != null && - value.text != widget.displayStringForOption(selection)) { - _selection = null; - } - - // Make sure the options are no longer hidden if the content of the field - // changes (ignore selection changes). - if (value.text != _lastFieldText) { - _lastFieldText = value.text; - _updateOptionsViewVisibility(); - } - } - - // Called when _textEditingController changes. - Future _onChangedField() async { - final TextEditingValue value = _textEditingController.value; - await widget.onTextChanged(value); - } - - // Called from fieldViewBuilder when the user submits the field. - void _onFieldSubmitted() { - if (_optionsViewController.isShowing) { - _select(_options.elementAt(_highlightedOptionIndex.value)); - } - } - - // Select the given option and update the widget. - void _select(T nextSelection) { - if (nextSelection == _selection) { - return; - } - _selection = nextSelection; - final String selectionString = widget.displayStringForOption(nextSelection); - _textEditingController.value = TextEditingValue( - selection: TextSelection.collapsed(offset: selectionString.length), - text: selectionString, - ); - widget.onSelected?.call(nextSelection); - _updateOptionsViewVisibility(); - } - - void _updateHighlight(int newIndex) { - _highlightedOptionIndex.value = - _options.isEmpty ? 0 : newIndex % _options.length; - } - - void _highlightPreviousOption(AutocompletePreviousOptionIntent intent) { - assert(_canShowOptionsView); - _updateOptionsViewVisibility(); - assert(_optionsViewController.isShowing); - _updateHighlight(_highlightedOptionIndex.value - 1); - } - - void _highlightNextOption(AutocompleteNextOptionIntent intent) { - assert(_canShowOptionsView); - _updateOptionsViewVisibility(); - assert(_optionsViewController.isShowing); - _updateHighlight(_highlightedOptionIndex.value + 1); - } - - Object? _hideOptions(DismissIntent intent) { - if (_optionsViewController.isShowing) { - _optionsViewController.hide(); - return null; - } else { - return Actions.invoke(context, intent); - } - } - - Widget _buildOptionsView(BuildContext context) { - final TextDirection textDirection = Directionality.of(context); - final Alignment followerAlignment = - switch (widget.optionsViewOpenDirection) { - OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart, - OptionsViewOpenDirection.down => AlignmentDirectional.topStart, - } - .resolve(textDirection); - final Alignment targetAnchor = switch (widget.optionsViewOpenDirection) { - OptionsViewOpenDirection.up => AlignmentDirectional.topStart, - OptionsViewOpenDirection.down => AlignmentDirectional.bottomStart, - } - .resolve(textDirection); - - return CompositedTransformFollower( - link: _optionsLayerLink, - showWhenUnlinked: false, - targetAnchor: targetAnchor, - followerAnchor: followerAlignment, - child: TextFieldTapRegion( - child: AutocompleteHighlightedOption( - highlightIndexNotifier: _highlightedOptionIndex, - child: Builder( - builder: (BuildContext context) => - widget.optionsViewBuilder(context, _select, _options), - ), - ), - ), - ); - } - - @override - void initState() { - super.initState(); - final TextEditingController initialController = - widget.textEditingController ?? - (_internalTextEditingController = - TextEditingController.fromValue(widget.initialValue)); - initialController.addListener(_onChangedField); - widget.focusNode?.addListener(_updateOptionsViewVisibility); - _optionsSubscription = widget.optionsStream.listen(_onUpateOptions); - } - - @override - void didUpdateWidget(ExternalResultsAutocomplete oldWidget) { - super.didUpdateWidget(oldWidget); - if (!identical( - oldWidget.textEditingController, - widget.textEditingController, - )) { - oldWidget.textEditingController?.removeListener(_onChangedField); - if (oldWidget.textEditingController == null) { - _internalTextEditingController?.dispose(); - _internalTextEditingController = null; - } - widget.textEditingController?.addListener(_onChangedField); - } - if (!identical(oldWidget.focusNode, widget.focusNode)) { - oldWidget.focusNode?.removeListener(_updateOptionsViewVisibility); - if (oldWidget.focusNode == null) { - _internalFocusNode?.dispose(); - _internalFocusNode = null; - } - widget.focusNode?.addListener(_updateOptionsViewVisibility); - } - if (!identical(oldWidget.optionsStream, widget.optionsStream)) { - unawaited(_optionsSubscription.cancel()); - _optionsSubscription = widget.optionsStream.listen(_onUpateOptions); - } - } - - @override - void dispose() { - widget.textEditingController?.removeListener(_onChangedField); - _internalTextEditingController?.dispose(); - widget.focusNode?.removeListener(_updateOptionsViewVisibility); - _internalFocusNode?.dispose(); - _highlightedOptionIndex.dispose(); - unawaited(_optionsSubscription.cancel()); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final Widget fieldView = widget.fieldViewBuilder?.call( - context, - _textEditingController, - _focusNode, - _onFieldSubmitted, - ) ?? - const SizedBox.shrink(); - return OverlayPortal.targetsRootOverlay( - controller: _optionsViewController, - overlayChildBuilder: _buildOptionsView, - child: TextFieldTapRegion( - child: Container( - key: _fieldKey, - child: Shortcuts( - shortcuts: _shortcuts, - child: Actions( - actions: _actionMap, - child: CompositedTransformTarget( - link: _optionsLayerLink, - child: fieldView, - ), - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/presentation/widgets/failure_widget.dart b/app/lib/presentation/widgets/failure_widget.dart index 26056164..4475fc8e 100644 --- a/app/lib/presentation/widgets/failure_widget.dart +++ b/app/lib/presentation/widgets/failure_widget.dart @@ -25,7 +25,7 @@ class FailureWidget extends StatelessWidget { subtitle: exception != null ? switch (exception) { final String string => Text(string), - _ => Text(exception.runtimeType.toString()) + _ => Text(exception.runtimeType.toString()), } : null, trailing: compact && onRetry != null @@ -41,8 +41,10 @@ class FailureWidget extends StatelessWidget { ), if (!compact && onRetry != null) Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), child: SizedBox( width: double.infinity, child: OutlinedButton.icon( diff --git a/app/lib/presentation/widgets/rounded_text.dart b/app/lib/presentation/widgets/rounded_text.dart new file mode 100644 index 00000000..0b6a5ab2 --- /dev/null +++ b/app/lib/presentation/widgets/rounded_text.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class RoundedBackground extends StatelessWidget { + final Widget child; + final Color? backgroundColor; + + const RoundedBackground({ + super.key, + required this.child, + this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25), + color: backgroundColor ?? Theme.of(context).colorScheme.primary, + ), + child: child, + ); + } +} diff --git a/app/lib/presentation/widgets/selectable_chips.dart b/app/lib/presentation/widgets/selectable_chips.dart index f97b7498..22c83bca 100644 --- a/app/lib/presentation/widgets/selectable_chips.dart +++ b/app/lib/presentation/widgets/selectable_chips.dart @@ -12,6 +12,8 @@ class _BadgeWrapper extends StatelessWidget { return count != null ? Badge.count( count: count!, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + textColor: Theme.of(context).colorScheme.onPrimaryContainer, child: child, ) : child; @@ -27,8 +29,11 @@ class SelectableChips extends StatelessWidget { final K Function(S item) itemId; final Widget Function(T item) itemLabel; final Widget? Function(T item)? itemAvatar; + final String? Function(T item)? itemTooltip; final int? Function(T item)? itemBadgeCount; + final Widget Function(Widget child, S item)? itemWrap; + final void Function(T item)? onSelected; final void Function(T item)? onDeleted; @@ -37,6 +42,8 @@ class SelectableChips extends StatelessWidget { required this.itemLabel, this.itemAvatar, this.itemBadgeCount, + this.itemWrap, + this.itemTooltip, required this.availableItems, this.selectedItem, this.maxCount = 25, @@ -54,15 +61,9 @@ class SelectableChips extends StatelessWidget { (item) => itemId(item) == itemId(selectedItem), ); if (selectedIndex < 0) { - items = [ - selectedItem, - ...items, - ]; + items = [selectedItem, ...items]; } else { - items = [ - items.removeAt(selectedIndex), - ...items, - ]; + items = [items.removeAt(selectedIndex), ...items]; } } @@ -71,17 +72,19 @@ class SelectableChips extends StatelessWidget { builder: (context, controller) { return ListView.builder( controller: controller, + //Improve list performance by not rendering outside screen at all + cacheExtent: 0, scrollDirection: Axis.horizontal, - shrinkWrap: true, itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; - return Padding( + final child = Padding( padding: const EdgeInsets.only(right: 8.0), child: _BadgeWrapper( count: itemBadgeCount?.call(item), child: FilterChip( - selected: selectedItem != null && + selected: + selectedItem != null && itemId(item) == itemId(selectedItem as S), showCheckmark: false, onSelected: (value) { @@ -98,9 +101,12 @@ class SelectableChips extends StatelessWidget { : null, label: itemLabel.call(item), avatar: itemAvatar?.call(item), + tooltip: itemTooltip?.call(item), ), ), ); + + return (itemWrap != null) ? itemWrap!(child, item) : child; }, ); }, diff --git a/app/lib/presentation/widgets/share_tile.dart b/app/lib/presentation/widgets/share_tile.dart new file mode 100644 index 00000000..9ba247d4 --- /dev/null +++ b/app/lib/presentation/widgets/share_tile.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class ShareTile extends StatelessWidget { + final void Function()? onTap; + final void Function()? onTapQr; + + const ShareTile({super.key, this.onTap, this.onTapQr}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.share), + title: const Text('Share link'), + onTap: onTap, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const VerticalDivider(indent: 4, endIndent: 4), + IconButton(icon: const Icon(Icons.qr_code), onPressed: onTapQr), + ], + ), + ); + } +} diff --git a/app/lib/features/search_browser/presentation/widgets/speech_to_text_button.dart b/app/lib/presentation/widgets/speech_to_text_button.dart similarity index 60% rename from app/lib/features/search_browser/presentation/widgets/speech_to_text_button.dart rename to app/lib/presentation/widgets/speech_to_text_button.dart index 7f80b491..0dc19e1c 100644 --- a/app/lib/features/search_browser/presentation/widgets/speech_to_text_button.dart +++ b/app/lib/presentation/widgets/speech_to_text_button.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:lensai/utils/ui_helper.dart' as ui_helper; import 'package:speech_to_text_google_dialog/speech_to_text_google_dialog.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; class SpeechToTextButton extends StatelessWidget { final Function(dynamic data) onTextReceived; @@ -11,18 +11,15 @@ class SpeechToTextButton extends StatelessWidget { Widget build(BuildContext context) { return IconButton( onPressed: () async { - final isServiceAvailable = - await SpeechToTextGoogleDialog.getInstance().showGoogleDialog( - onTextReceived: onTextReceived, - // locale: "en-US", - ); + final isServiceAvailable = await SpeechToTextGoogleDialog.getInstance() + .showGoogleDialog( + onTextReceived: onTextReceived, + // locale: "en-US", + ); if (!isServiceAvailable) { if (context.mounted) { - ui_helper.showErrorMessage( - context, - 'Service is not available', - ); + ui_helper.showErrorMessage(context, 'Service is not available'); } } }, diff --git a/app/lib/presentation/widgets/url_icon.dart b/app/lib/presentation/widgets/url_icon.dart new file mode 100644 index 00000000..02c6ff83 --- /dev/null +++ b/app/lib/presentation/widgets/url_icon.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/domain/services/generic_website.dart'; +import 'package:weblibre/presentation/hooks/cached_future.dart'; + +class UrlIcon extends HookConsumerWidget { + final double iconSize; + final List urlList; + + const UrlIcon(this.urlList, {required this.iconSize, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final icon = useCachedFuture( + () => + // ignore: discarded_futures + ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(urlList), + [urlList], + ); + + return Skeletonizer( + enabled: !icon.hasData, + child: SizedBox.square( + dimension: iconSize, + child: (icon.data != null) + ? RepaintBoundary( + child: RawImage( + image: icon.data?.image.value, + height: iconSize, + width: iconSize, + fit: BoxFit.fill, + ), + ) + : Icon(MdiIcons.web, size: iconSize), + ), + ); + } +} diff --git a/app/lib/presentation/widgets/website_feed_tile.dart b/app/lib/presentation/widgets/website_feed_tile.dart new file mode 100644 index 00000000..cb70fab4 --- /dev/null +++ b/app/lib/presentation/widgets/website_feed_tile.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/data/models/web_page_info.dart'; +import 'package:weblibre/presentation/controllers/website_title.dart'; +import 'package:weblibre/presentation/widgets/rounded_text.dart'; + +class WebsiteFeedTile extends HookConsumerWidget { + final Uri url; + final WebPageInfo? precachedInfo; + + const WebsiteFeedTile(this.url, {this.precachedInfo, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pageInfoAsync = ref.watch( + completePageInfoProvider(url, precachedInfo), + ); + + return Skeletonizer( + enabled: pageInfoAsync.isLoading && precachedInfo?.feeds == null, + child: pageInfoAsync.when( + skipLoadingOnReload: true, + data: (info) { + if (info.feeds.isEmpty) { + return const SizedBox.shrink(); + } + + return ListTile( + leading: const Icon(Icons.rss_feed), + title: const Text('Available Web Feeds'), + trailing: RoundedBackground( + child: Text( + info.feeds!.length.toString(), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + onTap: () async { + await SelectFeedDialogRoute( + feedsJson: jsonEncode( + info.feeds!.map((feed) => feed.toString()).toList(), + ), + ).push(context); + }, + ); + }, + error: (error, stackTrace) { + return const SizedBox.shrink(); + + //Will be dispalyed on title already + + // return FailureWidget( + // title: error.toString(), + // onRetry: () => ref.refresh(pageInfoProvider(url)), + // ); + }, + loading: () => const ListTile( + leading: Icon(Icons.rss_feed), + contentPadding: EdgeInsets.zero, + title: Text('Available Web Feeds'), + trailing: Bone.icon(), + ), + ), + ); + } +} diff --git a/app/lib/presentation/widgets/website_title_tile.dart b/app/lib/presentation/widgets/website_title_tile.dart index f69cb9cc..315862f2 100644 --- a/app/lib/presentation/widgets/website_title_tile.dart +++ b/app/lib/presentation/widgets/website_title_tile.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lensai/domain/entities/web_page_info.dart'; -import 'package:lensai/features/web_view/presentation/widgets/favicon.dart'; -import 'package:lensai/presentation/controllers/website_title.dart'; -import 'package:lensai/presentation/widgets/failure_widget.dart'; import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/data/models/web_page_info.dart'; +import 'package:weblibre/presentation/controllers/website_title.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; class WebsiteTitleTile extends HookConsumerWidget { final Uri url; @@ -14,17 +13,20 @@ class WebsiteTitleTile extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final pageInfoAsync = ref.watch(pageInfoProvider(url)); + final pageInfoAsync = ref.watch( + completePageInfoProvider(url, precachedInfo), + ); return Skeletonizer( enabled: pageInfoAsync.isLoading && precachedInfo == null, child: pageInfoAsync.when( + skipLoadingOnReload: true, data: (info) { return ListTile( - leading: FaviconImage( - favicon: info.favicon, - url: info.url, - size: 24, + leading: RawImage( + image: info.favicon?.image.value, + height: 24, + width: 24, ), contentPadding: EdgeInsets.zero, title: Text(info.title ?? 'Unknown Title'), @@ -34,15 +36,16 @@ class WebsiteTitleTile extends HookConsumerWidget { error: (error, stackTrace) { return FailureWidget( title: error.toString(), - onRetry: () => ref.refresh(pageInfoProvider(url)), + onRetry: () => + ref.refresh(pageInfoProvider(url, isImageRequest: false)), ); }, loading: () => (precachedInfo != null) ? ListTile( - leading: FaviconImage( - favicon: precachedInfo!.favicon, - url: precachedInfo!.url, - size: 24, + leading: RawImage( + image: precachedInfo!.favicon?.image.value, + height: 24, + width: 24, ), contentPadding: EdgeInsets.zero, title: Text(precachedInfo!.title ?? 'Unknown Title'), diff --git a/app/lib/utils/clipboard.dart b/app/lib/utils/clipboard.dart new file mode 100644 index 00000000..e76a7323 --- /dev/null +++ b/app/lib/utils/clipboard.dart @@ -0,0 +1,7 @@ +import 'package:flutter/services.dart'; +import 'package:weblibre/utils/uri_parser.dart'; + +Future tryGetUriFromClipboard({bool eagerParsing = true}) async { + final data = await Clipboard.getData('text/plain'); + return tryParseUrl(data?.text, eagerParsing: eagerParsing); +} diff --git a/app/lib/utils/debouncer.dart b/app/lib/utils/debouncer.dart new file mode 100644 index 00000000..0c6ae20d --- /dev/null +++ b/app/lib/utils/debouncer.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +class Debouncer { + final Duration debounce; + + Timer? _timer; + + Debouncer(this.debounce); + + bool get isDebouncing => _timer?.isActive ?? false; + + void eventOccured(void Function() callback) { + _timer?.cancel(); + _timer = Timer(debounce, callback); + } + + void dispose() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/app/lib/utils/form_validators.dart b/app/lib/utils/form_validators.dart new file mode 100644 index 00000000..39dc257f --- /dev/null +++ b/app/lib/utils/form_validators.dart @@ -0,0 +1,30 @@ +import 'package:nullability/nullability.dart'; +import 'package:weblibre/utils/uri_parser.dart' as uri_parser; + +String? validateUrl( + String? value, { + bool requireAuthority = true, + bool eagerParsing = true, + bool onlyHttpProtocol = false, + bool required = true, +}) { + if (value.isEmpty) { + if (required) { + return 'URL must be provided'; + } else { + return null; + } + } + + if (uri_parser.tryParseUrl(value, eagerParsing: eagerParsing) + case final Uri url) { + if (!requireAuthority || url.authority.isNotEmpty) { + if (!onlyHttpProtocol || + (url.isScheme('https') || url.isScheme('http'))) { + return null; + } + } + } + + return 'Inavlid URL'; +} diff --git a/app/lib/utils/image_helper.dart b/app/lib/utils/image_helper.dart deleted file mode 100644 index 2a9582e8..00000000 --- a/app/lib/utils/image_helper.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'dart:typed_data'; -import 'dart:ui'; - -Future isImageValid(Uint8List bytes) async { - try { - final codec = await instantiateImageCodec(bytes, targetWidth: 32); - final frameInfo = await codec.getNextFrame(); - return frameInfo.image.width > 0; - } catch (e) { - return false; - } -} diff --git a/app/lib/utils/lru_cache.dart b/app/lib/utils/lru_cache.dart index e1bfb88d..8a52a012 100644 --- a/app/lib/utils/lru_cache.dart +++ b/app/lib/utils/lru_cache.dart @@ -10,10 +10,10 @@ class LRUCache { int Function(K)? hashCode, bool Function(dynamic)? isValidKey, }) : _cache = LinkedHashMap( - equals: equals, - hashCode: hashCode, - isValidKey: isValidKey, - ); + equals: equals, + hashCode: hashCode, + isValidKey: isValidKey, + ); void resize(int capacity) { if (_capacity > capacity) { @@ -23,6 +23,10 @@ class LRUCache { _capacity = capacity; } + bool contains(K key) { + return _cache.containsKey(key); + } + V? get(K key) { final value = _cache.remove(key); // Temporarily remove the item. @@ -34,7 +38,7 @@ class LRUCache { return value; } - void set(K key, V value) { + V set(K key, V value) { if (_cache.containsKey(key)) { _cache.remove(key); // Remove the existing item before updating. } else if (_cache.length == _capacity) { @@ -43,6 +47,6 @@ class LRUCache { ); // Explicitly remove the least recently used item if at capacity. } - _cache[key] = value; // Inserting or updating the item. + return _cache[key] = value; // Inserting or updating the item. } } diff --git a/app/lib/utils/markdown/image_extractor.dart b/app/lib/utils/markdown/image_extractor.dart new file mode 100644 index 00000000..eaeedbd9 --- /dev/null +++ b/app/lib/utils/markdown/image_extractor.dart @@ -0,0 +1,32 @@ +import 'package:markdown/markdown.dart'; +import 'package:nullability/nullability.dart'; +import 'package:path/path.dart' as p; + +List extractImagesFromMarkdown(String markdownText) { + final imageUrls = []; + + final nodes = Document().parse(markdownText); + + void findImages(List nodes) { + for (final node in nodes) { + if (node is Element && node.tag == 'img') { + final url = node.attributes['src']; + + if (url.mapNotNull((url) => Uri.tryParse(url)) case final Uri url) { + if (p.extension(url.path) + case '.png' || '.jpg' || '.jpeg' || '.webp') { + imageUrls.add(url); + } + } + } + + if (node is Element && node.children != null) { + findImages(node.children!); + } + } + } + + findImages(nodes); + + return imageUrls; +} diff --git a/app/lib/utils/sampled_value_notifier.dart b/app/lib/utils/sampled_value_notifier.dart new file mode 100644 index 00000000..d909e466 --- /dev/null +++ b/app/lib/utils/sampled_value_notifier.dart @@ -0,0 +1,75 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; + +/// A ValueNotifier that acts as a proxy for another ValueNotifier, +/// sampling its values based on a given duration. +class SampledValueNotifier extends ValueNotifier { + /// The source ValueNotifier to sample from + final ValueNotifier _source; + + /// The duration to sample at + final Duration _sampleDuration; + + /// Timer for sampling + Timer? _timer; + + /// Whether a value has changed since the last sample + bool _hasNewValue = false; + + /// Subscription to the source ValueNotifier + late final VoidCallback _sourceListener; + + /// Creates a SampledValueNotifier that samples values from [source] + /// at the specified [sampleDuration]. + SampledValueNotifier({ + required ValueNotifier source, + required Duration sampleDuration, + }) : _source = source, + _sampleDuration = sampleDuration, + super(source.value) { + // Set up listener for source changes + _sourceListener = () { + _hasNewValue = true; + + // Start timer if not already running + if (_timer == null || !_timer!.isActive) { + _startTimer(); + } + }; + + _source.addListener(_sourceListener); + _startTimer(); + } + + /// Starts the sampling timer + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(_sampleDuration, _onSampleTime); + } + + /// Called when it's time to sample + void _onSampleTime(Timer timer) { + if (_hasNewValue) { + value = _source.value; + _hasNewValue = false; + } + } + + @override + void dispose() { + _timer?.cancel(); + _source.removeListener(_sourceListener); + + super.dispose(); + } +} + +extension ValueNotifierSampleExtension on ValueNotifier { + /// Creates a new ValueNotifier that samples this ValueNotifier's values + /// at the specified [duration]. + /// + /// The returned ValueNotifier must be disposed when no longer needed. + ValueNotifier sampleTime(Duration duration) { + return SampledValueNotifier(source: this, sampleDuration: duration); + } +} diff --git a/app/lib/utils/ui_helper.dart b/app/lib/utils/ui_helper.dart index 136970d8..ef338d3f 100644 --- a/app/lib/utils/ui_helper.dart +++ b/app/lib/utils/ui_helper.dart @@ -1,16 +1,114 @@ import 'package:flutter/material.dart'; +import 'package:nullability/nullability.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:weblibre/utils/clipboard.dart'; void showErrorMessage(BuildContext context, String message) { final snackBar = SnackBar( content: Text( message, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), + style: TextStyle(color: Theme.of(context).colorScheme.error), ), backgroundColor: Theme.of(context).colorScheme.onError, ); + + ScaffoldMessenger.of(context).showSnackBar(snackBar); +} + +void showInfoMessage(BuildContext context, String message) { + final snackBar = SnackBar(content: Text(message)); + + ScaffoldMessenger.of(context).showSnackBar(snackBar); +} + +void showTabBackButtonMessage( + BuildContext context, + int tabCount, + Duration duration, +) { + final snackbar = SnackBar( + content: (tabCount > 1) + ? const Text('Navigate BACK again to close current tab') + : const Text('Navigate BACK again to exit app'), + duration: duration, + ); + + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(snackbar); +} + +void showTabOpenedMessage( + BuildContext context, { + String? tabName, + void Function()? onShow, +}) { + final message = switch (tabName.whenNotEmpty) { + String() => "New tab '$tabName' opened in background", + null => 'New tab opened in background', + }; + + final snackBar = SnackBar( + content: Text(message), + action: onShow.mapNotNull( + (onPressed) => SnackBarAction(label: 'Show', onPressed: onPressed), + ), + ); + + ScaffoldMessenger.of(context).showSnackBar(snackBar); +} + +Future showSuggestNewTabMessage( + BuildContext context, { + required void Function(String? searchText) onAdd, + Duration duration = const Duration(seconds: 2), +}) async { + final clipboardUrl = await tryGetUriFromClipboard(); + + final snackBar = (clipboardUrl != null) + ? SnackBar( + content: const Text('Want to open link from clipboard?'), + action: SnackBarAction( + label: 'Open', + onPressed: () { + onAdd(clipboardUrl.toString()); + }, + ), + duration: duration, + ) + : SnackBar( + content: const Text('Want to open a new tab?'), + action: SnackBarAction( + label: 'New Tab', + onPressed: () { + onAdd(null); + }, + ), + duration: duration, + ); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } +} + +void showTabSwitchMessage( + BuildContext context, { + String? tabName, + void Function()? onSwitch, +}) { + final message = switch (tabName.whenNotEmpty) { + String() => "New tab '$tabName' opened", + null => 'New tab opened', + }; + + final snackBar = SnackBar( + content: Text(message), + action: onSwitch.mapNotNull( + (onPressed) => SnackBarAction(label: 'Switch', onPressed: onPressed), + ), + ); + ScaffoldMessenger.of(context).showSnackBar(snackBar); } @@ -19,15 +117,15 @@ Future launchUrlFeedback( Uri url, { LaunchMode mode = LaunchMode.externalApplication, }) async { - if (!await launchUrl( - url, - mode: mode, - )) { + if (await canLaunchUrl(url)) { + if (!await launchUrl(url, mode: mode)) { + if (context.mounted) { + showErrorMessage(context, 'Could not launch URL ($url)'); + } + } + } else { if (context.mounted) { - showErrorMessage( - context, - 'Could not launch URL ($url)', - ); + showErrorMessage(context, 'Can not handle "${url.scheme}"'); } } } diff --git a/app/lib/utils/uri_parser.dart b/app/lib/utils/uri_parser.dart index e710b5d2..06026127 100644 --- a/app/lib/utils/uri_parser.dart +++ b/app/lib/utils/uri_parser.dart @@ -1,20 +1,32 @@ +import 'package:universal_io/io.dart'; + +final _domainRegex = RegExp(r'\.[a-zA-Z]{2,}$'); +const _supportedSchemes = {'https', 'http', 'ftp', 'file', 'content', 'about'}; + Uri? tryParseUrl(String? input, {bool eagerParsing = false}) { if (input != null) { var uri = Uri.tryParse(input); if (uri != null) { if (uri.authority.isEmpty && eagerParsing) { - //When there is no scheme, there will be no authority/host and all becomes a path - //so we make sure there are at least 2 segments where the first one looks like a domain - if (uri.pathSegments.length > 1 && - RegExp(r'.[a-z]{2,}$').hasMatch(uri.pathSegments.first)) { - uri = Uri.tryParse('https://$input'); + if (uri.pathSegments.isNotEmpty) { + if (_domainRegex.hasMatch(uri.pathSegments.first)) { + uri = Uri.tryParse('https://$input'); + } else if (InternetAddress.tryParse(uri.pathSegments.first) != null) { + uri = Uri.tryParse('https://$input'); + } } } - if (uri != null && - uri.authority.isNotEmpty && - (uri.isScheme('http') || uri.isScheme('https') || !uri.hasScheme)) { - return uri; + if (uri != null) { + if (uri.isScheme('about')) { + return uri; + } + + if (uri.authority.isNotEmpty) { + if (_supportedSchemes.contains(uri.scheme) || !uri.hasScheme) { + return uri; + } + } } } } diff --git a/app/linux/flutter/ephemeral/.plugin_symlinks/dynamic_color b/app/linux/flutter/ephemeral/.plugin_symlinks/dynamic_color deleted file mode 120000 index 5869768b..00000000 --- a/app/linux/flutter/ephemeral/.plugin_symlinks/dynamic_color +++ /dev/null @@ -1 +0,0 @@ -/home/fafre/.pub-cache/hosted/pub.dev/dynamic_color-1.7.0/ \ No newline at end of file diff --git a/app/linux/flutter/ephemeral/.plugin_symlinks/file_picker b/app/linux/flutter/ephemeral/.plugin_symlinks/file_picker deleted file mode 120000 index 2609a00e..00000000 --- a/app/linux/flutter/ephemeral/.plugin_symlinks/file_picker +++ /dev/null @@ -1 +0,0 @@ -/home/fafre/.pub-cache/hosted/pub.dev/file_picker-8.1.2/ \ No newline at end of file diff --git a/app/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux b/app/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux deleted file mode 120000 index c72b7b3b..00000000 --- a/app/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux +++ /dev/null @@ -1 +0,0 @@ -/home/fafre/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.1/ \ No newline at end of file diff --git a/app/linux/flutter/ephemeral/.plugin_symlinks/package_info_plus b/app/linux/flutter/ephemeral/.plugin_symlinks/package_info_plus deleted file mode 120000 index e2873900..00000000 --- a/app/linux/flutter/ephemeral/.plugin_symlinks/package_info_plus +++ /dev/null @@ -1 +0,0 @@ -/home/fafre/.pub-cache/hosted/pub.dev/package_info_plus-8.0.2/ \ No newline at end of file diff --git a/app/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux b/app/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux deleted file mode 120000 index a3fb9b6e..00000000 --- a/app/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux +++ /dev/null @@ -1 +0,0 @@ -/home/fafre/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/ \ No newline at end of file diff --git a/app/linux/flutter/ephemeral/.plugin_symlinks/share_plus b/app/linux/flutter/ephemeral/.plugin_symlinks/share_plus deleted file mode 120000 index 44a0fca4..00000000 --- a/app/linux/flutter/ephemeral/.plugin_symlinks/share_plus +++ /dev/null @@ -1 +0,0 @@ -/home/fafre/.pub-cache/hosted/pub.dev/share_plus-10.0.2/ \ No newline at end of file diff --git a/app/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux b/app/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux deleted file mode 120000 index 04cb4c4e..00000000 --- a/app/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux +++ /dev/null @@ -1 +0,0 @@ -/home/fafre/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/ \ No newline at end of file diff --git a/app/linux/flutter/ephemeral/.plugin_symlinks/sqlite3_flutter_libs b/app/linux/flutter/ephemeral/.plugin_symlinks/sqlite3_flutter_libs deleted file mode 120000 index 03aad085..00000000 --- a/app/linux/flutter/ephemeral/.plugin_symlinks/sqlite3_flutter_libs +++ /dev/null @@ -1 +0,0 @@ -/home/fafre/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.24/ \ No newline at end of file diff --git a/app/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux b/app/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux deleted file mode 120000 index 3747cd02..00000000 --- a/app/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux +++ /dev/null @@ -1 +0,0 @@ -/home/fafre/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.0/ \ No newline at end of file diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 18a72331..00000000 --- a/app/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,27 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) dynamic_color_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); - dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); - g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); - flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); - g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); - sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); - g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); - url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); -} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47b..00000000 --- a/app/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake deleted file mode 100644 index f9162541..00000000 --- a/app/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,27 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - dynamic_color - flutter_secure_storage_linux - sqlite3_flutter_libs - url_launcher_linux -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 019aabe4..00000000 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import dynamic_color -import flutter_inappwebview_macos -import flutter_secure_storage_macos -import package_info_plus -import path_provider_foundation -import share_plus -import shared_preferences_foundation -import sqlite3_flutter_libs -import url_launcher_macos - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) - InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) -} diff --git a/app/macos/Flutter/ephemeral/Flutter-Generated.xcconfig b/app/macos/Flutter/ephemeral/Flutter-Generated.xcconfig deleted file mode 100644 index 8c880c4d..00000000 --- a/app/macos/Flutter/ephemeral/Flutter-Generated.xcconfig +++ /dev/null @@ -1,11 +0,0 @@ -// This is a generated file; do not edit or check into version control. -FLUTTER_ROOT=/home/fafre/Software/flutter -FLUTTER_APPLICATION_PATH=/home/fafre/Repos/kagi_bang_bang/app -COCOAPODS_PARALLEL_CODE_SIGN=true -FLUTTER_BUILD_DIR=build -FLUTTER_BUILD_NAME=0.6.2 -FLUTTER_BUILD_NUMBER=14 -DART_OBFUSCATION=false -TRACK_WIDGET_CREATION=true -TREE_SHAKE_ICONS=false -PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/app/macos/Flutter/ephemeral/flutter_export_environment.sh b/app/macos/Flutter/ephemeral/flutter_export_environment.sh deleted file mode 100755 index 1a43578c..00000000 --- a/app/macos/Flutter/ephemeral/flutter_export_environment.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -# This is a generated file; do not edit or check into version control. -export "FLUTTER_ROOT=/home/fafre/Software/flutter" -export "FLUTTER_APPLICATION_PATH=/home/fafre/Repos/kagi_bang_bang/app" -export "COCOAPODS_PARALLEL_CODE_SIGN=true" -export "FLUTTER_BUILD_DIR=build" -export "FLUTTER_BUILD_NAME=0.6.2" -export "FLUTTER_BUILD_NUMBER=14" -export "DART_OBFUSCATION=false" -export "TRACK_WIDGET_CREATION=true" -export "TREE_SHAKE_ICONS=false" -export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/app/pubspec.lock b/app/pubspec.lock deleted file mode 100644 index 9478de5d..00000000 --- a/app/pubspec.lock +++ /dev/null @@ -1,1439 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 - url: "https://pub.dev" - source: hosted - version: "72.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.2" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 - url: "https://pub.dev" - source: hosted - version: "6.7.0" - analyzer_plugin: - dependency: transitive - description: - name: analyzer_plugin - sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" - url: "https://pub.dev" - source: hosted - version: "0.11.3" - animations: - dependency: "direct main" - description: - name: animations - sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb - url: "https://pub.dev" - source: hosted - version: "2.0.11" - archive: - dependency: transitive - description: - name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d - url: "https://pub.dev" - source: hosted - version: "3.6.1" - args: - dependency: transitive - description: - name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" - url: "https://pub.dev" - source: hosted - version: "2.5.0" - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.dev" - source: hosted - version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - build: - dependency: transitive - description: - name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - build_config: - dependency: transitive - description: - name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 - url: "https://pub.dev" - source: hosted - version: "1.1.1" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: dd09dd4e2b078992f42aac7f1a622f01882a8492fef08486b27ddde929c19f04 - url: "https://pub.dev" - source: hosted - version: "2.4.12" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 - url: "https://pub.dev" - source: hosted - version: "7.3.2" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb - url: "https://pub.dev" - source: hosted - version: "8.9.2" - characters: - dependency: transitive - description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 - url: "https://pub.dev" - source: hosted - version: "1.3.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.dev" - source: hosted - version: "2.0.3" - ci: - dependency: transitive - description: - name: ci - sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" - url: "https://pub.dev" - source: hosted - version: "0.1.0" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 - url: "https://pub.dev" - source: hosted - version: "0.4.1" - clock: - dependency: transitive - description: - name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.dev" - source: hosted - version: "1.1.1" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 - url: "https://pub.dev" - source: hosted - version: "4.10.0" - collection: - dependency: "direct main" - description: - name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a - url: "https://pub.dev" - source: hosted - version: "1.18.0" - convert: - dependency: transitive - description: - name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" - url: "https://pub.dev" - source: hosted - version: "3.1.1" - copy_with_extension: - dependency: "direct main" - description: - name: copy_with_extension - sha256: fbcf890b0c34aedf0894f91a11a579994b61b4e04080204656b582708b5b1125 - url: "https://pub.dev" - source: hosted - version: "5.0.4" - copy_with_extension_gen: - dependency: "direct dev" - description: - name: copy_with_extension_gen - sha256: "51cd11094096d40824c8da629ca7f16f3b7cea5fc44132b679617483d43346b0" - url: "https://pub.dev" - source: hosted - version: "5.0.4" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" - url: "https://pub.dev" - source: hosted - version: "0.3.4+2" - crypto: - dependency: transitive - description: - name: crypto - sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 - url: "https://pub.dev" - source: hosted - version: "3.0.5" - csslib: - dependency: transitive - description: - name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - custom_lint: - dependency: "direct dev" - description: - name: custom_lint - sha256: "4939d89e580c36215e48a7de8fd92f22c79dcc3eb11fda84f3402b3b45aec663" - url: "https://pub.dev" - source: hosted - version: "0.6.5" - custom_lint_builder: - dependency: transitive - description: - name: custom_lint_builder - sha256: d9e5bb63ed52c1d006f5a1828992ba6de124c27a531e8fba0a31afffa81621b3 - url: "https://pub.dev" - source: hosted - version: "0.6.5" - custom_lint_core: - dependency: transitive - description: - name: custom_lint_core - sha256: "4ddbbdaa774265de44c97054dcec058a83d9081d071785ece601e348c18c267d" - url: "https://pub.dev" - source: hosted - version: "0.6.5" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" - url: "https://pub.dev" - source: hosted - version: "2.3.6" - dependency_validator: - dependency: "direct dev" - description: - name: dependency_validator - sha256: "16906f2137a312d0ffe754aee156436563a63d6ab7faaded83d401b5385d5d35" - url: "https://pub.dev" - source: hosted - version: "4.1.0" - drift: - dependency: "direct main" - description: - name: drift - sha256: "15b51e0ee1970455c0c3f7e560f3ac02ecb9c04711a9657586e470b234659dba" - url: "https://pub.dev" - source: hosted - version: "2.20.0" - drift_dev: - dependency: "direct dev" - description: - name: drift_dev - sha256: b9ec6159a731288e805a44225ccbebad507dd84d52ab71352c52584f13199d2d - url: "https://pub.dev" - source: hosted - version: "2.20.1" - dynamic_color: - dependency: "direct main" - description: - name: dynamic_color - sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d - url: "https://pub.dev" - source: hosted - version: "1.7.0" - exceptions: - dependency: "direct main" - description: - name: exceptions - sha256: f4f5ea4f074a6c1cee96b718f7ee37afe70a0dc788cd3c3aba96c29b8c94d231 - url: "https://pub.dev" - source: hosted - version: "0.6.1" - expandable_page_view: - dependency: "direct main" - description: - name: expandable_page_view - sha256: "210dc6961cfc29f7ed42867824eb699c9a4b9b198a7c04b8bdc1c05844969dc6" - url: "https://pub.dev" - source: hosted - version: "1.0.17" - fading_scroll: - dependency: "direct main" - description: - name: fading_scroll - sha256: "70d13b031dc075d2cf395d599f381be7ea6759577cdfd88daa923d375522a9a2" - url: "https://pub.dev" - source: hosted - version: "0.9.1" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - fast_equatable: - dependency: "direct main" - description: - name: fast_equatable - sha256: "2d73c8a33200411df6c9c625f9a71b49f0c4a3740869f9efac7952b3fa8fe1b1" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - fast_equatable_lint: - dependency: "direct dev" - description: - name: fast_equatable_lint - sha256: "303b706fd77410d3c88a33fc0cddd43d28db5c44c62ab5c7b7e0f27579fd609d" - url: "https://pub.dev" - source: hosted - version: "0.3.0" - ffi: - dependency: transitive - description: - name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - file: - dependency: transitive - description: - name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - file_picker: - dependency: "direct main" - description: - name: file_picker - sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12" - url: "https://pub.dev" - source: hosted - version: "8.1.2" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_hooks: - dependency: "direct main" - description: - name: flutter_hooks - sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 - url: "https://pub.dev" - source: hosted - version: "0.20.5" - flutter_inappwebview: - dependency: "direct main" - description: - name: flutter_inappwebview - sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_inappwebview_android: - dependency: transitive - description: - name: flutter_inappwebview_android - sha256: d247f6ed417f1f8c364612fa05a2ecba7f775c8d0c044c1d3b9ee33a6515c421 - url: "https://pub.dev" - source: hosted - version: "1.0.13" - flutter_inappwebview_internal_annotations: - dependency: transitive - description: - name: flutter_inappwebview_internal_annotations - sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter_inappwebview_ios: - dependency: transitive - description: - name: flutter_inappwebview_ios - sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f - url: "https://pub.dev" - source: hosted - version: "1.0.13" - flutter_inappwebview_macos: - dependency: transitive - description: - name: flutter_inappwebview_macos - sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636 - url: "https://pub.dev" - source: hosted - version: "1.0.11" - flutter_inappwebview_platform_interface: - dependency: transitive - description: - name: flutter_inappwebview_platform_interface - sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187" - url: "https://pub.dev" - source: hosted - version: "1.0.10" - flutter_inappwebview_web: - dependency: transitive - description: - name: flutter_inappwebview_web - sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" - url: "https://pub.dev" - source: hosted - version: "0.13.1" - flutter_markdown: - dependency: "direct main" - description: - name: flutter_markdown - sha256: a23c41ee57573e62fc2190a1f36a0480c4d90bde3a8a8d7126e5d5992fb53fb7 - url: "https://pub.dev" - source: hosted - version: "0.7.3+1" - flutter_material_design_icons: - dependency: "direct main" - description: - name: flutter_material_design_icons - sha256: ffb0be14d206cb4cd8501ce738bdfc5654130cb5f70911905694a2c1ee9f05b1 - url: "https://pub.dev" - source: hosted - version: "1.1.7447" - flutter_pdf_text: - dependency: "direct main" - description: - name: flutter_pdf_text - sha256: "97a49ecba6cf711a607642785758bf74fe0aaa3a2223524fa0c142e3966c56a0" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "9ee02950848f61c4129af3d6ec84a1cfc0e47931abc746b03e7a3bc3e8ff6eda" - url: "https://pub.dev" - source: hosted - version: "2.0.22" - flutter_riverpod: - dependency: transitive - description: - name: flutter_riverpod - sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d" - url: "https://pub.dev" - source: hosted - version: "2.5.1" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" - url: "https://pub.dev" - source: hosted - version: "9.2.2" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" - url: "https://pub.dev" - source: hosted - version: "3.1.2" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 - url: "https://pub.dev" - source: hosted - version: "1.1.2" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - flutter_sharing_intent: - dependency: "direct main" - description: - name: flutter_sharing_intent - sha256: "785ffc391822641457f930eb477c91c2f598a888f50b8fbb40d481ee01c7e719" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter_test: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 - url: "https://pub.dev" - source: hosted - version: "2.4.4" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - go_router: - dependency: "direct main" - description: - name: go_router - sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459" - url: "https://pub.dev" - source: hosted - version: "14.2.7" - go_router_builder: - dependency: "direct dev" - description: - name: go_router_builder - sha256: "3425b72dea69209754ac6b71b4da34165dcd4d4a2934713029945709a246427a" - url: "https://pub.dev" - source: hosted - version: "2.7.1" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 - url: "https://pub.dev" - source: hosted - version: "6.2.1" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - home_widget: - dependency: "direct main" - description: - name: home_widget - sha256: "2a0fdd6267ff975bd07bedf74686bd5577200f504f5de36527ac1b56bdbe68e3" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - hooks_riverpod: - dependency: "direct main" - description: - name: hooks_riverpod - sha256: "97266a91c994951a06ef0ff3a1c7fb261e52ec7f74e87f0614ea0b7411b859b2" - url: "https://pub.dev" - source: hosted - version: "2.5.2" - hotreloader: - dependency: transitive - description: - name: hotreloader - sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e - url: "https://pub.dev" - source: hosted - version: "4.2.0" - html: - dependency: "direct main" - description: - name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" - url: "https://pub.dev" - source: hosted - version: "0.15.4" - http: - dependency: "direct main" - description: - name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 - url: "https://pub.dev" - source: hosted - version: "1.2.2" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - image: - dependency: transitive - description: - name: image - sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" - url: "https://pub.dev" - source: hosted - version: "4.2.0" - intl: - dependency: transitive - description: - name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf - url: "https://pub.dev" - source: hosted - version: "0.19.0" - io: - dependency: transitive - description: - name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: "direct main" - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - json_serializable: - dependency: "direct dev" - description: - name: json_serializable - sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b - url: "https://pub.dev" - source: hosted - version: "6.8.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" - url: "https://pub.dev" - source: hosted - version: "10.0.5" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" - url: "https://pub.dev" - source: hosted - version: "3.0.5" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - lint: - dependency: "direct dev" - description: - name: lint - sha256: d758a5211fce7fd3f5e316f804daefecdc34c7e53559716125e6da7388ae8565 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - logger: - dependency: "direct main" - description: - name: logger - sha256: "697d067c60c20999686a0add96cf6aba723b3aa1f83ecf806a8097231529ec32" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - logging: - dependency: transitive - description: - name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - macros: - dependency: transitive - description: - name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" - url: "https://pub.dev" - source: hosted - version: "0.1.2-main.4" - markdown: - dependency: "direct main" - description: - name: markdown - sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 - url: "https://pub.dev" - source: hosted - version: "7.2.2" - matcher: - dependency: transitive - description: - name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb - url: "https://pub.dev" - source: hosted - version: "0.12.16+1" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 - url: "https://pub.dev" - source: hosted - version: "1.15.0" - mime: - dependency: "direct main" - description: - name: mime - sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" - url: "https://pub.dev" - source: hosted - version: "1.0.6" - package_config: - dependency: transitive - description: - name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - sha256: a75164ade98cb7d24cfd0a13c6408927c6b217fa60dee5a7ff5c116a58f28918 - url: "https://pub.dev" - source: hosted - version: "8.0.2" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 - url: "https://pub.dev" - source: hosted - version: "3.0.1" - path: - dependency: "direct main" - description: - name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" - url: "https://pub.dev" - source: hosted - version: "1.9.0" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 - url: "https://pub.dev" - source: hosted - version: "2.1.4" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7" - url: "https://pub.dev" - source: hosted - version: "2.2.10" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 - url: "https://pub.dev" - source: hosted - version: "2.4.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 - url: "https://pub.dev" - source: hosted - version: "6.0.2" - platform: - dependency: transitive - description: - name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" - url: "https://pub.dev" - source: hosted - version: "3.1.5" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - recase: - dependency: transitive - description: - name: recase - sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 - url: "https://pub.dev" - source: hosted - version: "4.1.0" - riverpod: - dependency: "direct main" - description: - name: riverpod - sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d - url: "https://pub.dev" - source: hosted - version: "2.5.1" - riverpod_analyzer_utils: - dependency: transitive - description: - name: riverpod_analyzer_utils - sha256: ac28d7bc678471ec986b42d88e5a0893513382ff7542c7ac9634463b044ac72c - url: "https://pub.dev" - source: hosted - version: "0.5.4" - riverpod_annotation: - dependency: "direct main" - description: - name: riverpod_annotation - sha256: e5e796c0eba4030c704e9dae1b834a6541814963292839dcf9638d53eba84f5c - url: "https://pub.dev" - source: hosted - version: "2.3.5" - riverpod_generator: - dependency: "direct dev" - description: - name: riverpod_generator - sha256: "63311e361ffc578d655dfc31b48dfa4ed3bc76fd06f9be845e9bf97c5c11a429" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - riverpod_lint: - dependency: "direct dev" - description: - name: riverpod_lint - sha256: a35a92f2c2a4b7a5d95671c96c5432b42c20f26bb3e985e83d0b186471b61a85 - url: "https://pub.dev" - source: hosted - version: "2.3.13" - rxdart: - dependency: "direct main" - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - share_plus: - dependency: "direct main" - description: - name: share_plus - sha256: "468c43f285207c84bcabf5737f33b914ceb8eb38398b91e5e3ad1698d1b72a52" - url: "https://pub.dev" - source: hosted - version: "10.0.2" - share_plus_platform_interface: - dependency: transitive - description: - name: share_plus_platform_interface - sha256: "6ababf341050edff57da8b6990f11f4e99eaba837865e2e6defe16d039619db5" - url: "https://pub.dev" - source: hosted - version: "5.0.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: c4b35f6cb8f63c147312c054ce7c2254c8066745125264f0c88739c417fc9d9f - url: "https://pub.dev" - source: hosted - version: "2.5.2" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e - url: "https://pub.dev" - source: hosted - version: "2.4.2" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shelf: - dependency: transitive - description: - name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 - url: "https://pub.dev" - source: hosted - version: "1.4.1" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - skeletonizer: - dependency: "direct main" - description: - name: skeletonizer - sha256: "3b202e4fa9c49b017d368fb0e570d4952bcd19972b67b2face071bdd68abbfae" - url: "https://pub.dev" - source: hosted - version: "1.4.2" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" - url: "https://pub.dev" - source: hosted - version: "1.3.4" - source_span: - dependency: transitive - description: - name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - speech_to_text_google_dialog: - dependency: "direct main" - description: - name: speech_to_text_google_dialog - sha256: f156aaf3b39416161310fb27a33a4e4da1b4bfeecd643635f28d5cac405a00da - url: "https://pub.dev" - source: hosted - version: "0.0.6" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - sqlite3: - dependency: "direct main" - description: - name: sqlite3 - sha256: "45f168ae2213201b54e09429ed0c593dc2c88c924a1488d6f9c523a255d567cb" - url: "https://pub.dev" - source: hosted - version: "2.4.6" - sqlite3_flutter_libs: - dependency: "direct main" - description: - name: sqlite3_flutter_libs - sha256: "62bbb4073edbcdf53f40c80775f33eea01d301b7b81417e5b3fb7395416258c1" - url: "https://pub.dev" - source: hosted - version: "0.5.24" - sqlparser: - dependency: transitive - description: - name: sqlparser - sha256: "3be52b4968fc2f098ba735863404756d2fe3ea0729cf006a5b5612618f74ca04" - url: "https://pub.dev" - source: hosted - version: "0.37.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" - url: "https://pub.dev" - source: hosted - version: "1.11.1" - state_notifier: - dependency: transitive - description: - name: state_notifier - sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.dev" - source: hosted - version: "1.0.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.dev" - source: hosted - version: "2.1.2" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" - url: "https://pub.dev" - source: hosted - version: "0.7.2" - text_scroll: - dependency: "direct main" - description: - name: text_scroll - sha256: "7869d86a6fdd725dee56bdd150216a99f0372b82fbfcac319214dbd5f36e1908" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - timeago: - dependency: "direct main" - description: - name: timeago - sha256: "054cedf68706bb142839ba0ae6b135f6b68039f0b8301cbe8784ae653d5ff8de" - url: "https://pub.dev" - source: hosted - version: "3.7.0" - timing: - dependency: transitive - description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c - url: "https://pub.dev" - source: hosted - version: "1.3.2" - universal_io: - dependency: "direct main" - description: - name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - uri_to_file: - dependency: "direct main" - description: - name: uri_to_file - sha256: cbbb38f975d22311efacc9a53cdcb806708606666d8092314b81c5cb7983b700 - url: "https://pub.dev" - source: hosted - version: "1.0.0" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" - url: "https://pub.dev" - source: hosted - version: "6.3.0" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: e35a698ac302dd68e41f73250bd9517fe3ab5fa4f18fe4647a0872db61bacbab - url: "https://pub.dev" - source: hosted - version: "6.3.10" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e - url: "https://pub.dev" - source: hosted - version: "6.3.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af - url: "https://pub.dev" - source: hosted - version: "3.2.0" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" - url: "https://pub.dev" - source: hosted - version: "3.2.0" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" - url: "https://pub.dev" - source: hosted - version: "2.3.3" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" - url: "https://pub.dev" - source: hosted - version: "3.1.2" - uuid: - dependency: "direct main" - description: - name: uuid - sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" - url: "https://pub.dev" - source: hosted - version: "4.4.2" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc - url: "https://pub.dev" - source: hosted - version: "14.2.4" - watcher: - dependency: "direct main" - description: - name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - web: - dependency: transitive - description: - name: web - sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 - url: "https://pub.dev" - source: hosted - version: "1.0.0" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" - url: "https://pub.dev" - source: hosted - version: "0.1.6" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - win32: - dependency: transitive - description: - name: win32 - sha256: "68d1e89a91ed61ad9c370f9f8b6effed9ae5e0ede22a270bdfa6daf79fc2290a" - url: "https://pub.dev" - source: hosted - version: "5.5.4" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d - url: "https://pub.dev" - source: hosted - version: "1.0.4" - xml: - dependency: transitive - description: - name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 - url: "https://pub.dev" - source: hosted - version: "6.5.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" - url: "https://pub.dev" - source: hosted - version: "3.1.2" -sdks: - dart: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 80d610d8..090abf7c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,83 +1,118 @@ -name: lensai +name: weblibre description: "The Privacy-Focused & AI-Powered Research Browser" publish_to: 'none' -version: 0.7.0+15 +version: 0.9.10+13 environment: - sdk: '>=3.4.1 <4.0.0' + sdk: '>=3.8.0 <4.0.0' dependencies: - animations: ^2.0.11 - collection: ^1.18.0 - copy_with_extension: ^5.0.4 - drift: ^2.20.0 + background_fetch: ^1.3.8 + collection: ^1.19.1 + copy_with_extension: ^6.0.1 + drift: ^2.27.0 dynamic_color: ^1.7.0 exceptions: ^0.6.1 - expandable_page_view: ^1.0.17 - fading_scroll: ^0.9.0 - fast_equatable: ^1.1.0 - file_picker: ^8.1.2 + fading_scroll: ^0.9.1 + fast_equatable: ^1.3.1 flutter: sdk: flutter - flutter_hooks: ^0.20.5 - flutter_inappwebview: ^6.0.0 - flutter_markdown: ^0.7.3+1 + flutter_auto_size_text: ^4.1.0 + flutter_background_service: ^5.1.0 + flutter_hooks: ^0.21.2 + flutter_markdown: ^0.7.7+1 flutter_material_design_icons: ^1.1.7447 - flutter_pdf_text: ^0.6.0 - flutter_secure_storage: ^9.2.2 - flutter_sharing_intent: ^1.1.1 - go_router: ^14.2.3 + flutter_mozilla_components: + path: ../packages/flutter_mozilla_components + flutter_reorderable_grid_view: ^5.5.0 + flutter_secure_storage: ^10.0.0-beta.4 + flutter_slidable: ^4.0.0 + flutter_svg: ^2.2.0 + go_router: ^15.2.4 google_fonts: ^6.2.1 - home_widget: ^0.6.0 - hooks_riverpod: ^2.5.2 - html: ^0.15.4 - http: ^1.2.2 + graphview: ^1.2.0 + home_widget: ^0.8.0 + hooks_riverpod: ^2.6.1 + html: ^0.15.6 + http: ^1.4.0 + intl: ^0.20.2 json_annotation: ^4.9.0 - logger: ^2.4.0 - markdown: ^7.2.2 - mime: ^1.0.5 - package_info_plus: ^8.0.2 - path: ^1.9.0 - path_provider: ^2.1.4 - riverpod: ^2.5.1 - riverpod_annotation: ^2.3.5 + lexo_rank: + git: + url: https://github.com/FaFre/lexo_rank.git + local_auth: ^2.3.0 + logger: ^2.6.0 + markdown: ^7.3.0 + mime: ^2.0.0 + nullability: ^1.0.0 + package_info_plus: ^8.3.0 + path: ^1.9.1 + path_provider: ^2.1.5 + permission_handler: ^12.0.0+1 + pretty_qr_code: ^3.4.0 + riverpod: ^2.6.1 + riverpod_annotation: ^2.6.1 + rss_dart: ^1.0.13 rxdart: ^0.28.0 - share_plus: ^10.0.2 - shared_preferences: ^2.3.2 - skeletonizer: ^1.4.2 - speech_to_text_google_dialog: ^0.0.6 - sqlite3: ^2.4.6 - sqlite3_flutter_libs: ^0.5.24 + share_plus: ^11.0.0 + simple_intent_receiver: + path: ../packages/simple_intent_receiver + skeletonizer: ^2.0.1 + sliver_tools: ^0.2.12 + smooth_page_indicator: ^1.2.1 + socks5_proxy: + git: + url: https://github.com/sylvieon/socks_dart.git + ref: 04550bc08f42cc1a35f1e870f651b93a4f9aceab + speech_to_text_google_dialog: + git: + url: https://github.com/FaFre/speech_to_text_google_dialog.git + sqlite3: ^2.7.6 + sqlite3_flutter_libs: ^0.5.34 text_scroll: ^0.2.0 - timeago: ^3.7.0 + timeago: ^3.7.1 + tor: + git: + url: https://github.com/FaFre/tor.git + ref: update universal_io: ^2.2.2 - uri_to_file: ^1.0.0 - url_launcher: ^6.3.0 - uuid: ^4.4.2 - watcher: ^1.1.0 + uri_to_file: + git: + url: https://github.com/FaFre/uri-to-file.git + url_launcher: ^6.3.1 + uuid: ^4.5.1 + vector_math: ^2.1.4 dev_dependencies: - build_runner: ^2.4.12 - copy_with_extension_gen: ^5.0.4 - custom_lint: ^0.6.5 - dependency_validator: ^4.1.0 - drift_dev: ^2.20.1 - fast_equatable_lint: ^0.3.0 - flutter_launcher_icons: ^0.13.1 - go_router_builder: ^2.7.1 - json_serializable: ^6.8.0 - lint: ^2.3.0 - riverpod_generator: ^2.4.3 - riverpod_lint: ^2.3.13 + analyzer_plugin: ^0.13.1 + build_runner: ^2.4.15 + copy_with_extension_gen: ^6.0.1 + custom_lint: ^0.7.5 + dependency_validator: ^5.0.2 + drift_dev: ^2.27.0 + fast_equatable_lint: ^0.3.2 + flutter_launcher_icons: ^0.14.4 + go_router_builder: ^3.0.1 + json_serializable: ^6.9.5 + lint: ^2.8.0 + riverpod_generator: ^2.6.5 + riverpod_lint: ^2.6.5 flutter: uses-material-design: true assets: - - assets/icon/ - - assets/landing/ - assets/fonts/ - - assets/scripts/ + - assets/icon/ + - assets/images/ + - assets/landing/ + - assets/preferences/ + fonts: + - family: TorIcons + fonts: + - asset: assets/fonts/tor-icons.ttf flutter_launcher_icons: android: "launcher_icon" image_path: "assets/icon/icon.png" + adaptive_icon_foreground: "assets/icon/icon.png" + adaptive_icon_background: "#FF000000" diff --git a/app/pubspec_overrides.yaml b/app/pubspec_overrides.yaml new file mode 100644 index 00000000..d1cf0013 --- /dev/null +++ b/app/pubspec_overrides.yaml @@ -0,0 +1,6 @@ +# melos_managed_dependency_overrides: flutter_mozilla_components,simple_intent_receiver +dependency_overrides: + flutter_mozilla_components: + path: ../packages/flutter_mozilla_components + simple_intent_receiver: + path: ../packages/simple_intent_receiver diff --git a/assets/screenshots/archive_conversation.png b/assets/screenshots/archive_conversation.png deleted file mode 100644 index beb1f2b5..00000000 Binary files a/assets/screenshots/archive_conversation.png and /dev/null differ diff --git a/assets/screenshots/archive_search.png b/assets/screenshots/archive_search.png deleted file mode 100644 index f7bdc8db..00000000 Binary files a/assets/screenshots/archive_search.png and /dev/null differ diff --git a/assets/screenshots/archived_chat.png b/assets/screenshots/archived_chat.png deleted file mode 100644 index 0d3852bf..00000000 Binary files a/assets/screenshots/archived_chat.png and /dev/null differ diff --git a/assets/screenshots/bang_category.png b/assets/screenshots/bang_category.png deleted file mode 100644 index b9799c4f..00000000 Binary files a/assets/screenshots/bang_category.png and /dev/null differ diff --git a/assets/screenshots/bang_search.png b/assets/screenshots/bang_search.png deleted file mode 100644 index 6884a62f..00000000 Binary files a/assets/screenshots/bang_search.png and /dev/null differ diff --git a/assets/screenshots/bang_settings.png b/assets/screenshots/bang_settings.png deleted file mode 100644 index efe4b915..00000000 Binary files a/assets/screenshots/bang_settings.png and /dev/null differ diff --git a/assets/screenshots/content_blocking_settings.png b/assets/screenshots/content_blocking_settings.png deleted file mode 100644 index dec84680..00000000 Binary files a/assets/screenshots/content_blocking_settings.png and /dev/null differ diff --git a/assets/screenshots/highlight_actions.png b/assets/screenshots/highlight_actions.png deleted file mode 100644 index d6216dc6..00000000 Binary files a/assets/screenshots/highlight_actions.png and /dev/null differ diff --git a/assets/screenshots/home_widget.png b/assets/screenshots/home_widget.png deleted file mode 100644 index 9eff9031..00000000 Binary files a/assets/screenshots/home_widget.png and /dev/null differ diff --git a/assets/screenshots/kagi_search.png b/assets/screenshots/kagi_search.png deleted file mode 100644 index 517e793a..00000000 Binary files a/assets/screenshots/kagi_search.png and /dev/null differ diff --git a/assets/screenshots/kagi_summarize.png b/assets/screenshots/kagi_summarize.png deleted file mode 100644 index cea71b4f..00000000 Binary files a/assets/screenshots/kagi_summarize.png and /dev/null differ diff --git a/assets/screenshots/kagi_tools.png b/assets/screenshots/kagi_tools.png deleted file mode 100644 index d5b64a44..00000000 Binary files a/assets/screenshots/kagi_tools.png and /dev/null differ diff --git a/assets/screenshots/landing.png b/assets/screenshots/landing.png deleted file mode 100644 index cb12a6e7..00000000 Binary files a/assets/screenshots/landing.png and /dev/null differ diff --git a/assets/screenshots/settings.png b/assets/screenshots/settings.png deleted file mode 100644 index 00bb380e..00000000 Binary files a/assets/screenshots/settings.png and /dev/null differ diff --git a/assets/screenshots/tabs.png b/assets/screenshots/tabs.png deleted file mode 100644 index caa4b5d5..00000000 Binary files a/assets/screenshots/tabs.png and /dev/null differ diff --git a/assets/screenshots/title_actions.png b/assets/screenshots/title_actions.png deleted file mode 100644 index 93390ba7..00000000 Binary files a/assets/screenshots/title_actions.png and /dev/null differ diff --git a/melos.yaml b/melos.yaml new file mode 100644 index 00000000..75b83f8a --- /dev/null +++ b/melos.yaml @@ -0,0 +1,50 @@ +name: weblibre_project + +command: + clean: + hooks: + pre: melos exec -c 1 -- "flutter clean" + +scripts: + upgrade-major-tighten: + run: | + dart pub upgrade --major-versions --tighten + exec: + concurrency: 1 + validate-dependencies: + description: Validates dependcies in the workspace + packageFilters: + noDependsOn: melos + exec: | + dart pub global run dependency_validator + build-watch: + description: Generate and watch files with build_runner in the workspace + packageFilters: + dependsOn: build_runner + exec: | + dart run build_runner watch --delete-conflicting-outputs + build: + description: Generate files with build_runner in the workspace + packageFilters: + dependsOn: build_runner + run: | + dart run build_runner build --delete-conflicting-outputs + exec: + failFast: true + concurrency: 1 + build-pigeons: + description: Running pigeon for files in pigeons directory + exec: | + echo "Running pigeon for files in pigeons directory:" + for file in ./pigeons/*; do + if [ -f "\$file" ]; then + echo "Processing \$file" + flutter pub run pigeon --input "\$file" + fi + done + packageFilters: + dirExists: 'pigeons' + +packages: + - app/ + - packages/** diff --git a/metadata/en-US/changelogs/10.txt b/metadata/en-US/changelogs/10.txt deleted file mode 100644 index 8386232f..00000000 --- a/metadata/en-US/changelogs/10.txt +++ /dev/null @@ -1,6 +0,0 @@ -* Added automatic upgrade of HTTP URLs to HTTPS (when available) -* Added support for using the HTTP protocol app-wide -* Introduced a setting to block all HTTP (insecure) content -* Enhanced user URL parsing for URLs without a specified scheme -* Added support for launching documents and downloads through third-party apps -* Improved widget layout \ No newline at end of file diff --git a/metadata/en-US/changelogs/11.txt b/metadata/en-US/changelogs/11.txt deleted file mode 100644 index 827abd06..00000000 --- a/metadata/en-US/changelogs/11.txt +++ /dev/null @@ -1,3 +0,0 @@ -* Enhanced bottom sheet behavior for better UI and UX -* Browser tabs now sort with the most recently added first -* Browser tab sheet automatically scrolls to the active tab \ No newline at end of file diff --git a/metadata/en-US/changelogs/12.txt b/metadata/en-US/changelogs/12.txt deleted file mode 100644 index afa5347f..00000000 --- a/metadata/en-US/changelogs/12.txt +++ /dev/null @@ -1,5 +0,0 @@ -* Renamed BangNavigator to Lensai. -* Implemented Reader Mode to extract and simplify web pages, enhancing readability by removing ads, sidebars, and other non-essential elements. -* Improved the layout and style of the browser app bar. -* Added a setting to show a selectable quick action that appears in the browser app bar between the website title and tab count. -* Added an accessibility feature to directly use speech-to-text for quick actions. \ No newline at end of file diff --git a/metadata/en-US/changelogs/13.txt b/metadata/en-US/changelogs/13.txt deleted file mode 100644 index d3dad617..00000000 --- a/metadata/en-US/changelogs/13.txt +++ /dev/null @@ -1,3 +0,0 @@ -* Changed ABI versioning -* Updated dependencies -* Updated landing pages \ No newline at end of file diff --git a/metadata/en-US/changelogs/14.txt b/metadata/en-US/changelogs/14.txt deleted file mode 100644 index fc337b90..00000000 --- a/metadata/en-US/changelogs/14.txt +++ /dev/null @@ -1,3 +0,0 @@ -* Implemented "Find in page" for web content -* Fixed Bang & List synchronization timeout issue -* Updated build files \ No newline at end of file diff --git a/metadata/en-US/changelogs/15.txt b/metadata/en-US/changelogs/15.txt deleted file mode 100644 index e6629e8c..00000000 --- a/metadata/en-US/changelogs/15.txt +++ /dev/null @@ -1,2 +0,0 @@ -* Enhanced user interface for improved usability -* Introduced "Topics," a new feature to save and organize browser tabs by subject \ No newline at end of file diff --git a/metadata/en-US/changelogs/2.txt b/metadata/en-US/changelogs/2.txt deleted file mode 100644 index 4cae8561..00000000 --- a/metadata/en-US/changelogs/2.txt +++ /dev/null @@ -1 +0,0 @@ -* Added the ability to open and directly summarize/prompt PDF documents. \ No newline at end of file diff --git a/metadata/en-US/changelogs/3.txt b/metadata/en-US/changelogs/3.txt deleted file mode 100644 index e6a8cf9b..00000000 --- a/metadata/en-US/changelogs/3.txt +++ /dev/null @@ -1,3 +0,0 @@ -* Added "About"-Dialog -* Added new GPT 4o model as default -* Prepare Google Play release \ No newline at end of file diff --git a/metadata/en-US/changelogs/4.txt b/metadata/en-US/changelogs/4.txt deleted file mode 100644 index 0224e15f..00000000 --- a/metadata/en-US/changelogs/4.txt +++ /dev/null @@ -1,4 +0,0 @@ -* Added landing page -* Adjusted routing for back navigation and landing page -* Added link to Kagi Acoount in Settings -* Improved input usability for Kagi Tools \ No newline at end of file diff --git a/metadata/en-US/changelogs/5.txt b/metadata/en-US/changelogs/5.txt deleted file mode 100644 index f130eb9d..00000000 --- a/metadata/en-US/changelogs/5.txt +++ /dev/null @@ -1,4 +0,0 @@ -* Added Chat Archive for conversations with Assistant -* Moved the browser app bar to the bottom of the screen for easier operation -* Fixed back button navigation -* Added setting to disable early access Kagi tools \ No newline at end of file diff --git a/metadata/en-US/changelogs/7.txt b/metadata/en-US/changelogs/7.txt deleted file mode 100644 index 711b4060..00000000 --- a/metadata/en-US/changelogs/7.txt +++ /dev/null @@ -1,10 +0,0 @@ -* Added full-text search for archived Chats -* Added synchronized Bang repository from the official Kagi source -* Added same-site search with Bangs via Page title -* Added categorized Bang browser with full-text search -* Added Bang quick select for searches based on frequency -* Added cache for favicons -* Applied incognito mode to keyboard input -* Refactored app initialization -* Enabled voice input for assistant -* Fixed search suggestion overflow \ No newline at end of file diff --git a/metadata/en-US/changelogs/8.txt b/metadata/en-US/changelogs/8.txt deleted file mode 100644 index 0ffcf1a7..00000000 --- a/metadata/en-US/changelogs/8.txt +++ /dev/null @@ -1,4 +0,0 @@ -* Implemented Material You dynamic color scheme -* Fixed back button navigation for dialogs -* Fixed bang search displayed without available bangs -* Updated landing page \ No newline at end of file diff --git a/metadata/en-US/changelogs/9.txt b/metadata/en-US/changelogs/9.txt deleted file mode 100644 index 31ac075d..00000000 --- a/metadata/en-US/changelogs/9.txt +++ /dev/null @@ -1,6 +0,0 @@ -* Reworked home widgets, now available in three sizes -* Implemented content blocker for unwanted websites and ads -* Added possibilty to remove Bangs from quick select -* Added setting to switch theme (system/light/dark) -* Remember assistant UI state -* Added Gemini 1.5 Pro \ No newline at end of file diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt index 087198ca..31cccbfd 100644 --- a/metadata/en-US/full_description.txt +++ b/metadata/en-US/full_description.txt @@ -1,40 +1,29 @@ -Lensai: The Privacy-Focused & AI-Powered Research Browser with Kagi integration. +WebLibre is a privacy-first browser that puts you in control. -Lensai enhances your mobile research experience while keeping your personal data and identity secure. -Powered by advanced AI tools and browser extensions, Lensai offers modern features designed to elevate your browsing experience. +Built on Mozilla’s Gecko engine, WebLibre combines powerful privacy features with advanced tab management and a personal local search engine—so your data stays on your device, not in the cloud. -• Privacy by Default -Incognito mode is enabled by default to keep your searches and browsing history private. +- Personal Local Search Engine + Instantly search across open tabs, browsing history, and your favorite web feeds—all indexed privately on your device. No third-party search engine required. -• Kagi Integration -Seamlessly integrates Kagi's search, assistant, and summarizer tools within a user-friendly interface. +- Bangs: Direct Site Search + Use thousands of “bangs” to search directly on popular websites, bypassing external search engines for faster, more private results. -• Easy Sharing -Share URLs or text from any app directly into Lensai without the need to copy and paste, for direct use with summarizer/assistant. +- Tab Containers for Privacy & Organization + Separate your browsing into containers for work, shopping, or personal use. Each container has its own cookies, settings, and can be locked with biometric authentication. -• Quick Summarization -Long press any link on a website to get a summary via Kagi's summarizer. +- Integrated Tor Proxy + Route selected tabs or containers through the Tor network for enhanced anonymity and to bypass censorship—without leaving your browser. -• Content Blocking -A powerful content blocker is enabled by default to filter out ads, malware, and tracking sites. +- Tree View Tab Navigation + Visualize and manage complex browsing sessions with hierarchical tab organization. Never lose track of your research or workflow. -• Home Widgets -Includes home screen widgets in three different sizes for quick actions, enhancing productivity. +- No Tracking, No Cloud Storage + WebLibre never tracks you. All your data—searches, history, and feeds—remains on your device for maximum privacy. -• PDF processing -Open and directly summarize/prompt PDF documents. +Additional Highlights -• Reader Mode -Extract and simplify web pages, enhancing readability by removing ads, sidebars, and other non-essential elements. +- Biometric Authentication: Lock sensitive tabs or containers with your fingerprint or face. +- Efficient Resource Use: Lightweight tabs and persistent sessions for seamless multitasking. +- Open Source Foundations: Built using trusted Mozilla components. -• Chat Archive -Easily archive conversations with the Kagi assistant on your device and search through them locally. - -• Bang Repository -Automatically synchronizes all official Kagi Bangs from the official Repository and makes them available through a convenient interface. - -• Enhanced Text Interaction -Custom context menu allows for easy passage of marked text to Kagi's search or assistant tools. - -• Speech Input -Supports voice commands for hands-free operation and accessibility. \ No newline at end of file +Take back control of your browsing. Download WebLibre and experience privacy, speed, and organization—your way. diff --git a/metadata/en-US/images/icon.png b/metadata/en-US/images/icon.png deleted file mode 100644 index 816cc0c9..00000000 Binary files a/metadata/en-US/images/icon.png and /dev/null differ diff --git a/metadata/en-US/images/icon.png b/metadata/en-US/images/icon.png new file mode 120000 index 00000000..9a2170ed --- /dev/null +++ b/metadata/en-US/images/icon.png @@ -0,0 +1 @@ +../../../app/assets/icon/icon.png \ No newline at end of file diff --git a/metadata/en-US/images/phoneScreenshots/1.png b/metadata/en-US/images/phoneScreenshots/1.png deleted file mode 100644 index eee944d0..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/1.png and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/2.png b/metadata/en-US/images/phoneScreenshots/2.png deleted file mode 100644 index 94eeaee0..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/2.png and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/3.png b/metadata/en-US/images/phoneScreenshots/3.png deleted file mode 100644 index b03453b3..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/3.png and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/4.png b/metadata/en-US/images/phoneScreenshots/4.png deleted file mode 100644 index 145a4280..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/4.png and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/5.png b/metadata/en-US/images/phoneScreenshots/5.png deleted file mode 100644 index fe5af0b0..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/5.png and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/6.png b/metadata/en-US/images/phoneScreenshots/6.png deleted file mode 100644 index 4411046d..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/6.png and /dev/null differ diff --git a/metadata/en-US/short_description.txt b/metadata/en-US/short_description.txt index d32b48b4..fba3a44d 100644 --- a/metadata/en-US/short_description.txt +++ b/metadata/en-US/short_description.txt @@ -1 +1 @@ -The Privacy-Focused & AI-Powered Research Browser with Kagi integration \ No newline at end of file +Browse privately, search locally. Organize tabs, block tracking, and use Tor. \ No newline at end of file diff --git a/metadata/en-US/title.txt b/metadata/en-US/title.txt index da62ea8e..b23334cd 100644 --- a/metadata/en-US/title.txt +++ b/metadata/en-US/title.txt @@ -1 +1 @@ -Lensai: Secure AI Browser \ No newline at end of file +WebLibre: Private Browser \ No newline at end of file diff --git a/packages/flutter_mozilla_components/.gitignore b/packages/flutter_mozilla_components/.gitignore new file mode 100644 index 00000000..ea810a52 --- /dev/null +++ b/packages/flutter_mozilla_components/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/packages/flutter_mozilla_components/.metadata b/packages/flutter_mozilla_components/.metadata new file mode 100644 index 00000000..d19de3eb --- /dev/null +++ b/packages/flutter_mozilla_components/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "6fe09872b12acb5747d6e01e84987120cabf31df" + channel: "master" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6fe09872b12acb5747d6e01e84987120cabf31df + base_revision: 6fe09872b12acb5747d6e01e84987120cabf31df + - platform: android + create_revision: 6fe09872b12acb5747d6e01e84987120cabf31df + base_revision: 6fe09872b12acb5747d6e01e84987120cabf31df + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/flutter_mozilla_components/CHANGELOG.md b/packages/flutter_mozilla_components/CHANGELOG.md new file mode 100644 index 00000000..41cc7d81 --- /dev/null +++ b/packages/flutter_mozilla_components/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/packages/flutter_mozilla_components/LICENSE b/packages/flutter_mozilla_components/LICENSE new file mode 100644 index 00000000..d0a1fa14 --- /dev/null +++ b/packages/flutter_mozilla_components/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/packages/flutter_mozilla_components/README.md b/packages/flutter_mozilla_components/README.md new file mode 100644 index 00000000..82734742 --- /dev/null +++ b/packages/flutter_mozilla_components/README.md @@ -0,0 +1,15 @@ +# flutter_mozilla_components + +A new Flutter plugin project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/packages/flutter_mozilla_components/analysis_options.yaml b/packages/flutter_mozilla_components/analysis_options.yaml new file mode 100644 index 00000000..9357549d --- /dev/null +++ b/packages/flutter_mozilla_components/analysis_options.yaml @@ -0,0 +1,35 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lint/package.yaml +# Uncomment the following section to specify additional rules. + +linter: + rules: + unawaited_futures: true + discarded_futures: true + collection_methods_unrelated_type: true + +analyzer: + plugins: + - custom_lint + exclude: + - "**.g.dart" + - "**.swagger.dart" + - "**.freezed.dart" + - "**.chopper.dart" +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/flutter_mozilla_components/android/.gitignore b/packages/flutter_mozilla_components/android/.gitignore new file mode 100644 index 00000000..161bdcda --- /dev/null +++ b/packages/flutter_mozilla_components/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/packages/flutter_mozilla_components/android/build.gradle b/packages/flutter_mozilla_components/android/build.gradle new file mode 100644 index 00000000..1e403b6c --- /dev/null +++ b/packages/flutter_mozilla_components/android/build.gradle @@ -0,0 +1,148 @@ +group = "eu.weblibre.flutter_mozilla_components" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "2.1.20" + ext.mozillaComponentsVersion = '141.0b3' + + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.7.3") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + + // maven { + // name "Mozilla Nightly" + // url "https://nightly.maven.mozilla.org/maven2" + + // content { + // // Always fetch components from the snapshots repository + // includeGroup "org.mozilla.components" + // } + // } + + maven { + name "Mozilla" + url "https://maven.mozilla.org/maven2" + + content { + // Never fetch components from here. We always want to use snapshots when using nightly + // excludeGroup "org.mozilla.components" + } + } + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + if (project.android.hasProperty("namespace")) { + namespace = "eu.weblibre.flutter_mozilla_components" + } + + compileSdk = 35 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + } + + defaultConfig { + minSdk = 21 + } + + buildFeatures { + viewBinding true + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} + +configurations.configureEach { + exclude group: "org.mozilla.telemetry", module: "glean-native" +} + +// Select the Glean from GeckoView. +// `service-sync-logins` requires Glean, which pulls in glean-native, +// but that's also provided by geckoview-omni, so now we need to select which one to use. +project.configurations.configureEach { + resolutionStrategy.capabilitiesResolution.withCapability("org.mozilla.telemetry:glean-native") { + def toBeSelected = candidates.find { it.id instanceof ModuleComponentIdentifier && it.id.module.contains('geckoview') } + if (toBeSelected != null) { + select(toBeSelected) + } + because 'use GeckoView Glean instead of standalone Glean' + } +} + +dependencies { + implementation "org.mozilla.components:support-utils:$mozillaComponentsVersion" + implementation "org.mozilla.components:support-ktx:$mozillaComponentsVersion" + implementation "org.mozilla.components:support-webextensions:$mozillaComponentsVersion" + implementation "org.mozilla.components:support-locale:$mozillaComponentsVersion" + implementation "org.mozilla.components:concept-engine:$mozillaComponentsVersion" + implementation "org.mozilla.components:concept-fetch:$mozillaComponentsVersion" + implementation "org.mozilla.components:concept-toolbar:$mozillaComponentsVersion" + implementation "org.mozilla.components:concept-awesomebar:$mozillaComponentsVersion" + implementation "org.mozilla.components:browser-engine-gecko:$mozillaComponentsVersion" + implementation "org.mozilla.components:browser-state:$mozillaComponentsVersion" + implementation "org.mozilla.components:browser-session-storage:$mozillaComponentsVersion" + implementation "org.mozilla.components:browser-storage-sync:$mozillaComponentsVersion" + implementation "org.mozilla.components:browser-icons:$mozillaComponentsVersion" + implementation "org.mozilla.components:browser-thumbnails:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-addons:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-app-links:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-awesomebar:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-customtabs:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-downloads:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-media:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-tabs:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-prompts:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-session:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-readerview:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-privatemode:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-sitepermissions:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-webcompat:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-webnotifications:$mozillaComponentsVersion" + implementation "org.mozilla.components:ui-widgets:$mozillaComponentsVersion" + + implementation 'androidx.coordinatorlayout:coordinatorlayout:1.3.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation 'androidx.preference:preference-ktx:1.2.1' + implementation 'com.google.android.material:material:1.12.0' + //https://stackoverflow.com/questions/73782320/onbackinvokedcallback-is-not-enabled-for-the-application-in-set-androidenableo + implementation 'androidx.activity:activity-ktx:1.10.1' + implementation 'androidx.paging:paging-runtime-ktx:3.3.6' + + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.15.2") +} diff --git a/packages/flutter_mozilla_components/android/settings.gradle b/packages/flutter_mozilla_components/android/settings.gradle new file mode 100644 index 00000000..4e797632 --- /dev/null +++ b/packages/flutter_mozilla_components/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'flutter_mozilla_components' diff --git a/packages/flutter_mozilla_components/android/src/main/AndroidManifest.xml b/packages/flutter_mozilla_components/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..feb3da3c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/feed.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/feed.js new file mode 100644 index 00000000..15cf8d38 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/feed.js @@ -0,0 +1,40 @@ +const FEED_MIME_TYPES = [ + "application/atom", + "application/rss" +]; + +// Listen for any webRequest that might be a feed +browser.webRequest.onHeadersReceived.addListener( + function (details) { + let isFeed = false; + + for (let header of details.responseHeaders) { + if (header.name.toLowerCase() === "content-type") { + const contentType = header.value.toLowerCase(); + + for (const mimeType of FEED_MIME_TYPES) { + if (contentType.includes(mimeType)) { + isFeed = true; + break; + } + } + + if (isFeed) break; + } + } + + if (isFeed) { + port.postMessage({ + "type": "feedRequest", + "url": details.url + }); + + return { cancel: true }; + } + + // Not a feed, let the browser handle it normally + return { responseHeaders: details.responseHeaders }; + }, + { urls: [""] }, + ["blocking", "responseHeaders"] +); \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/manifest.json b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/manifest.json new file mode 100644 index 00000000..790abd01 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 2, + "browser_specific_settings": { + "gecko": { + "id": "browser_extension@weblibre.eu" + } + }, + "name": "Misc extensions", + "version": "1.0", + "background": { + "scripts": [ + "readability.min.js", + "port.js", + "turndown.js", + "feed.js" + ] + }, + "permissions": [ + "geckoViewAddons", + "nativeMessaging", + "webRequest", + "webRequestBlocking", + "" + ] +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/port.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/port.js new file mode 100644 index 00000000..33a7aa93 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/port.js @@ -0,0 +1 @@ +const port = browser.runtime.connectNative("mozacBrowserExtension"); \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/readability.min.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/readability.min.js new file mode 120000 index 00000000..1eada1aa --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/readability.min.js @@ -0,0 +1 @@ +../../../../../../javascript/readability/dist/readability.min.js \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/turndown.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/turndown.js new file mode 100644 index 00000000..6c11afe0 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/browser_extension/turndown.js @@ -0,0 +1,32 @@ +const parser = new DOMParser(); + +port.onMessage.addListener(message => { + let requestId = message["id"]; + + switch (message["action"]) { + case "turndown": + console.log(message.args) + // Handle array of HTML strings + if (Array.isArray(message.args)) { + const results = message.args.map(htmlString => { + const document = parser.parseFromString(htmlString, 'text/html'); + return parseFullMarkdown(document); + }); + + port.postMessage({ + "type": "turndown", + "id": requestId, + "status": "success", + "result": results + }); + } else { + // Handle error case for invalid input + port.postMessage({ + "id": requestId, + "status": "error", + "error": "Expected args to be an array of HTML strings" + }); + } + break; + } +}); diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/container_proxy b/packages/flutter_mozilla_components/android/src/main/assets/extensions/container_proxy new file mode 120000 index 00000000..5a97e492 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/container_proxy @@ -0,0 +1 @@ +../../../../../javascript/container_proxy/dist/ \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/cookie-manager/background.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/cookie-manager/background.js new file mode 100644 index 00000000..e5dd5776 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/cookie-manager/background.js @@ -0,0 +1,85 @@ +'use strict'; + +console.log("Connecting to native port...") +const port = browser.runtime.connectNative("cookieManager"); +console.log("Native port connected!") + +function cookieToMap(cookie) { + let partitionKey = {} + if (cookie.partitionKey) { + partitionKey["topLevelSite"] = cookie.partitionKey.topLevelSite + } + + return { + "domain": cookie.domain, + "expirationDate": cookie.expirationDate, + "firstPartyDomain": cookie.firstPartyDomain, + "hostOnly": cookie.hostOnly, + "httpOnly": cookie.httpOnly, + "name": cookie.name, + "partitionKey": partitionKey, + "path": cookie.path, + "secure": cookie.secure, + "session": cookie.session, + "sameSite": cookie.sameSite, + "storeId": cookie.storeId, + "value": cookie.value + } +} + +function sendCookieResultForRequest(id) { + return function (cookie) { + port.postMessage({ + "id": id, + "status": "success", + "result": cookieToMap(cookie) + }) + } +} + +function sendCookieListResultForRequest(id) { + return function (cookies) { + port.postMessage({ + "id": id, + "status": "success", + "result": cookies.map((cookie) => cookieToMap(cookie)) + }) + } +} + +function sendErrorForRequest(id) { + return function (error) { + console.error(error); + port.postMessage({ + "id": id, + "status": "error", + "error": error + }); + } +} + +port.onMessage.addListener(message => { + let requestId = message["id"] + switch (message["action"]) { + case "get": + browser.cookies.get(message["args"]) + .then(sendCookieResultForRequest(requestId)) + .catch(sendErrorForRequest(requestId)) + break + case "getAll": + browser.cookies.getAll(message["args"]) + .then(sendCookieListResultForRequest(requestId)) + .catch(sendErrorForRequest(requestId)) + break + case "remove": + browser.cookies.remove(message["args"]) + .then(sendCookieResultForRequest(requestId)) + .catch(sendErrorForRequest(requestId)) + break + case "set": + browser.cookies.set(message["args"]) + .then(sendCookieResultForRequest(requestId)) + .catch(sendErrorForRequest(requestId)) + break + } +}); diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/cookie-manager/manifest.json b/packages/flutter_mozilla_components/android/src/main/assets/extensions/cookie-manager/manifest.json new file mode 100644 index 00000000..ac1dc85c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/cookie-manager/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 2, + "name": "cookie-manager", + "version": "1.0", + "description": "Cookie manager", + "browser_specific_settings": { + "gecko": { + "id": "cookie-manager@weblibre.eu" + } + }, + "background": { + "scripts": [ + "background.js" + ] + }, + "permissions": [ + "nativeMessaging", + "nativeMessagingFromContent", + "geckoViewAddons", + "cookies", + "" + ] +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/api.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/api.js new file mode 100644 index 00000000..295f5de6 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/api.js @@ -0,0 +1,119 @@ +"use strict"; + +const { BackgroundTasksUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BackgroundTasksUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + // tip: set maxLogLevel to "debug" and use log.debug() to create detailed + // messages during development. See LOG_LEVELS in Console.sys.mjs for details. + maxLogLevel: "error", + maxLogLevelPref: "app.update.background.loglevel", + prefix: "PrefsManager", + }; + return new ConsoleAPI(consoleOptions); +}); + +var prefmanager = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + prefmanager: { + async resetPrefs(prefNames) { + if (prefNames && prefNames.length > 0) { + for (const prefName of prefNames) { + Services.prefs.clearUserPref(prefName); + } + } else { + Services.prefs.resetPrefs(); + } + }, + async getPrefs(prefNames) { + const prefs = (prefNames && prefNames.length > 0) + ? prefNames + : Services.prefs.getChildList(""); + + const result = {}; + + for (const prefName of prefs) { + try { + switch (Services.prefs.getPrefType(prefName)) { + case Services.prefs.PREF_BOOL: + result[prefName] = Services.prefs.getBoolPref(prefName); + break; + case Services.prefs.PREF_INT: + result[prefName] = Services.prefs.getIntPref(prefName); + break; + case Services.prefs.PREF_STRING: + result[prefName] = Services.prefs.getCharPref(prefName); + break; + default: + // Skip complex values or invalid preferences + continue; + } + } catch (e) { + lazy.log.error(`Error reading preference ${prefName}: ${e}`); + continue; + } + } + + lazy.log.debug(`getAll: retrieved ${Object.keys(result).length} preferences`); + return result; + }, + async parsePrefsAndApply(prefsFileContent, predicate = null) { + let prefs = {}; + let addPref = (kind, name, value) => { + if (predicate && !predicate(name)) { + return; + } + prefs[name] = value; + }; + + Services.prefs.parsePrefsFromBuffer( + prefsFileContent, + { + onStringPref: addPref, + onIntPref: addPref, + onBoolPref: addPref, + onError(message) { + throw new Error( + `Error parsing preferences "${message}"` + ); + }, + }, + ); + + await BackgroundTasksUtils.withProfileLock(profileLock => { + for (let [name, value] of Object.entries(prefs)) { + switch (typeof value) { + case "boolean": + Services.prefs.setBoolPref(name, value); + break; + case "number": + Services.prefs.setIntPref(name, value); + break; + case "string": + Services.prefs.setCharPref(name, value); + break; + default: + throw new Error( + `Pref from default profile with name "${name}" has unrecognized type` + ); + } + } + }); + + lazy.log.debug(`applyPreferences: parsed prefs from buffer`, prefs); + return prefs; + } + } + } + }; + } +}; diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/background.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/background.js new file mode 100644 index 00000000..a8cf37e2 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/background.js @@ -0,0 +1,47 @@ +'use strict'; + +const encoder = new TextEncoder(); + +const port = browser.runtime.connectNative("prefManager"); + +function sendJsonResultForRequest(id) { + return function (result) { + port.postMessage({ + "id": id, + "status": "success", + "result": result + }) + } +} + +function sendErrorForRequest(id) { + return function (error) { + console.error(error); + port.postMessage({ + "id": id, + "status": "error", + "error": error + }); + } +} + +port.onMessage.addListener(message => { + let requestId = message["id"] + switch (message["action"]) { + case "parsePrefsAndApply": + browser.experiments.prefmanager.parsePrefsAndApply(encoder.encode(message["args"]), null) + .then(sendJsonResultForRequest(requestId)) + .catch(sendErrorForRequest(requestId)) + break + case "getPrefs": + browser.experiments.prefmanager.getPrefs(message["args"]) + .then(sendJsonResultForRequest(requestId)) + .catch(sendErrorForRequest(requestId)) + break + case "resetPrefs": + browser.experiments.prefmanager.resetPrefs(message["args"]) + .then(sendJsonResultForRequest(requestId)) + .catch(sendErrorForRequest(requestId)) + break + } +}); diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/manifest.json b/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/manifest.json new file mode 100644 index 00000000..f722709a --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/manifest.json @@ -0,0 +1,39 @@ +{ + "manifest_version": 2, + "name": "Pref Manager", + "version": "1.0", + "description": "Manipulate GeckoView preferences", + "browser_specific_settings": { + "gecko": { + "id": "pref-manager@weblibre.eu" + } + }, + "background": { + "scripts": [ + "background.js" + ] + }, + "experiment_apis": { + "prefmanager": { + "schema": "schema.json", + "parent": { + "scopes": [ + "addon_parent" + ], + "paths": [ + [ + "experiments", + "prefmanager" + ] + ], + "script": "api.js" + } + } + }, + "permissions": [ + "nativeMessaging", + "nativeMessagingFromContent", + "geckoViewAddons", + "" + ] +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/schema.json b/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/schema.json new file mode 100644 index 00000000..6b2a279f --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/pref_manager/schema.json @@ -0,0 +1,78 @@ +[ + { + "namespace": "experiments.prefmanager", + "description": "Experimental API for updating Geckoview preferences", + "functions": [ + { + "name": "getPrefs", + "type": "function", + "description": "Retrieves preferences and their values", + "async": true, + "parameters": [ + { + "name": "prefNames", + "type": "array", + "optional": true, + "description": "Array of preference names to retrieve. If empty or null, retrieves all preferences", + "items": { + "type": "string" + } + } + ], + "returns": { + "type": "object", + "description": "Object containing the preferences as key-value pairs", + "additionalProperties": { + "type": "any", + "description": "Preference values can be boolean, number, or string" + } + } + }, + { + "name": "parsePrefsAndApply", + "type": "function", + "description": "Reads and sets preferences from an encoded preferences file content", + "async": true, + "parameters": [ + { + "name": "prefsFileContent", + "type": "object", + "isInstanceOf": "Uint8Array", + "description": "Encoded content of the preferences file to parse (Uint8Array)" + }, + { + "name": "predicate", + "type": "function", + "optional": true, + "description": "Optional filter function to determine which preferences to include" + } + ], + "returns": { + "type": "object", + "description": "Object containing the parsed preferences as key-value pairs", + "additionalProperties": { + "type": "any", + "description": "Preference values can be boolean, number, or string" + } + } + }, + { + "name": "resetPrefs", + "type": "function", + "description": "Resets multiple or all preferences to their default values", + "async": true, + "parameters": [ + { + "name": "prefNames", + "type": "array", + "optional": true, + "description": "Array of preference names to reset. If empty or null, resets all preferences", + "items": { + "type": "string" + } + } + ] + } + ] + } +] \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/readability_extract/content.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readability_extract/content.js new file mode 100644 index 00000000..57b85b24 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readability_extract/content.js @@ -0,0 +1,47 @@ +const port = browser.runtime.connectNative("mozacReaderExtract"); + +let domChanged = false; +let lastContent = ''; + +function parseContent() { + const article = window.parseReaderable(document); + + if (article && lastContent !== article.textContent) { + port.postMessage(article); + + lastContent = article.textContent; + } + + domChanged = false; +} + +port.onMessage.addListener((message) => { + if (message.action === "parseContent") { + if (domChanged) { + parseContent(); + } + } +}); + +const observer = new MutationObserver(() => { + domChanged = true; +}); + +const observerConfig = { + childList: true, + subtree: true, + characterData: true, + attributes: false +}; + +// Wait for page to be fully loaded +if (document.readyState === 'complete') { + parseContent(); + observer.observe(document.body, observerConfig); +} else { + window.addEventListener('load', parseContent); +} + +window.addEventListener('unload', () => { + observer.disconnect(); +}); diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/readability_extract/manifest.json b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readability_extract/manifest.json new file mode 100644 index 00000000..6cebe278 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readability_extract/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 2, + "browser_specific_settings": { + "gecko": { + "id": "readability-extract@weblibre.eu" + } + }, + "name": "Extract text of Websites", + "version": "1.0", + "content_scripts": [ + { + "matches": [ + "" + ], + "js": [ + "readability.min.js", + "content.js" + ] + } + ], + "permissions": [ + "mozillaAddons", + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent", + "storage", + "tabs", + "webNavigation", + "" + ] +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/readability_extract/readability.min.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readability_extract/readability.min.js new file mode 120000 index 00000000..1eada1aa --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readability_extract/readability.min.js @@ -0,0 +1 @@ +../../../../../../javascript/readability/dist/readability.min.js \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt new file mode 100644 index 00000000..b12bf044 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt @@ -0,0 +1,400 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.CallSuper +import androidx.fragment.app.Fragment +import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature +import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding +import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey +import eu.weblibre.flutter_mozilla_components.pip.PictureInPictureIntegration +import eu.weblibre.flutter_mozilla_components.services.DownloadService +import io.flutter.Log +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.concept.engine.EngineView +import mozilla.components.feature.app.links.AppLinksFeature +import mozilla.components.feature.downloads.DownloadsFeature +import mozilla.components.feature.downloads.manager.FetchDownloadManager +import mozilla.components.feature.downloads.temporary.ShareResourceFeature +import mozilla.components.feature.media.fullscreen.MediaSessionFullscreenFeature +import mozilla.components.feature.privatemode.feature.SecureWindowFeature +import mozilla.components.feature.prompts.PromptFeature +import mozilla.components.feature.session.FullScreenFeature +import mozilla.components.feature.session.SessionFeature +import mozilla.components.feature.session.SwipeRefreshFeature +import mozilla.components.feature.sitepermissions.SitePermissionsFeature +import mozilla.components.feature.sitepermissions.SitePermissionsRules +import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction +import mozilla.components.support.base.feature.ActivityResultHandler +import mozilla.components.support.base.feature.UserInteractionHandler +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.android.view.enterImmersiveMode +import mozilla.components.support.ktx.android.view.exitImmersiveMode +import mozilla.components.support.locale.ActivityContextWrapper + +/** + * Base fragment extended by [BrowserFragment] and [ExternalAppBrowserFragment]. + * This class only contains shared code focused on the main browsing content. + * UI code specific to the app or to custom tabs can be found in the subclasses. + */ +@SuppressWarnings("LargeClass") +abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, ActivityResultHandler { + private val sessionFeature = ViewBoundFeatureWrapper() + private val shareResourceFeature = ViewBoundFeatureWrapper() + private val downloadsFeature = ViewBoundFeatureWrapper() + private val appLinksFeature = ViewBoundFeatureWrapper() + private val promptFeature = ViewBoundFeatureWrapper() + private val webExtensionPromptFeature = ViewBoundFeatureWrapper() + private val pictureInPictureIntegration = ViewBoundFeatureWrapper() + private val sitePermissionsFeature = ViewBoundFeatureWrapper() + private val swipeRefreshFeature = ViewBoundFeatureWrapper() + private val secureWindowFeature = ViewBoundFeatureWrapper() + private val fullScreenFeature = ViewBoundFeatureWrapper() + private val mediaSessionFullscreenFeature = + ViewBoundFeatureWrapper() + + private val sessionId: String? + get() = arguments?.getString(SESSION_ID_KEY) + + private var _binding: FragmentBrowserBinding? = null + val binding get() = _binding!! + + protected val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private val backButtonHandler: List> = listOf( + fullScreenFeature, + sessionFeature, + ) + + private val activityResultHandler: List> = listOf( + promptFeature, + ) + + protected abstract fun createEngine(components: Components) : EngineView + + private lateinit var requestDownloadPermissionsLauncher: ActivityResultLauncher> + private lateinit var requestSitePermissionsLauncher: ActivityResultLauncher> + private lateinit var requestPromptsPermissionsLauncher: ActivityResultLauncher> + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requestDownloadPermissionsLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results -> + val permissions = results.keys.toTypedArray() + val grantResults = + results.values.map { + if (it) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED + }.toIntArray() + downloadsFeature.withFeature { + it.onPermissionsResult(permissions, grantResults) + } + } + + requestSitePermissionsLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results -> + val permissions = results.keys.toTypedArray() + val grantResults = + results.values.map { + if (it) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED + }.toIntArray() + sitePermissionsFeature.withFeature { + it.onPermissionsResult(permissions, grantResults) + } + } + + requestPromptsPermissionsLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results -> + val permissions = results.keys.toTypedArray() + val grantResults = + results.values.map { + if (it) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED + }.toIntArray() + promptFeature.withFeature { + it.onPermissionsResult(permissions, grantResults) + } + } + } + + @CallSuper + @Suppress("LongMethod") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + sessionFeature.set( + feature = SessionFeature( + components.core.store, + components.useCases.sessionUseCases.goBack, + components.useCases.sessionUseCases.goForward, + components.engineView!!, + sessionId, + ), + owner = this, + view = view, + ) + + swipeRefreshFeature.set( + feature = SwipeRefreshFeature( + components.core.store, + components.useCases.sessionUseCases.reload, + binding.swipeToRefresh, + ), + owner = this, + view = view, + ) + + shareResourceFeature.set( + ShareResourceFeature( + context = requireContext().applicationContext, + httpClient = components.core.client, + store = components.core.store, + tabId = sessionId, + ), + owner = this, + view = view, + ) + + downloadsFeature.set( + feature = DownloadsFeature( + requireContext().applicationContext, + store = components.core.store, + useCases = components.useCases.downloadsUseCases, + fragmentManager = childFragmentManager, + onDownloadStopped = { download, id, status -> + Logger.debug("Download done. ID#$id $download with status $status") + }, + downloadManager = FetchDownloadManager( + requireContext().applicationContext, + components.core.store, + DownloadService::class, + notificationsDelegate = components.notificationsDelegate, + ), + tabId = sessionId, + onNeedToRequestPermissions = { permissions -> + requestDownloadPermissionsLauncher.launch(permissions) + }, + ), + owner = this, + view = view, + ) + + appLinksFeature.set( + feature = AppLinksFeature( + context = requireContext(), + store = components.core.store, + sessionId = sessionId, + fragmentManager = parentFragmentManager, + launchInApp = { components.core.prefs.getBoolean(context?.getPreferenceKey(R.string.pref_key_launch_external_app), false) }, + loadUrlUseCase = components.useCases.sessionUseCases.loadUrl, + ), + owner = this, + view = view, + ) + + promptFeature.set( + feature = PromptFeature( + fragment = this, + store = components.core.store, + customTabId = sessionId, + tabsUseCases = components.useCases.tabsUseCases, + fragmentManager = parentFragmentManager, + fileUploadsDirCleaner = components.core.fileUploadsDirCleaner, + onNeedToRequestPermissions = { permissions -> + requestPromptsPermissionsLauncher.launch(permissions) + }, + ), + owner = this, + view = view, + ) + + sitePermissionsFeature.set( + feature = SitePermissionsFeature( + context = requireContext(), + sessionId = sessionId, + storage = components.core.geckoSitePermissionsStorage, + fragmentManager = parentFragmentManager, + sitePermissionsRules = SitePermissionsRules( + autoplayAudible = AutoplayAction.BLOCKED, + autoplayInaudible = AutoplayAction.BLOCKED, + camera = SitePermissionsRules.Action.ASK_TO_ALLOW, + location = SitePermissionsRules.Action.ASK_TO_ALLOW, + notification = SitePermissionsRules.Action.ASK_TO_ALLOW, + microphone = SitePermissionsRules.Action.ASK_TO_ALLOW, + persistentStorage = SitePermissionsRules.Action.ASK_TO_ALLOW, + mediaKeySystemAccess = SitePermissionsRules.Action.ASK_TO_ALLOW, + crossOriginStorageAccess = SitePermissionsRules.Action.ASK_TO_ALLOW, + ), + onNeedToRequestPermissions = { permissions -> + requestSitePermissionsLauncher.launch(permissions) + }, + onShouldShowRequestPermissionRationale = { shouldShowRequestPermissionRationale(it) }, + store = components.core.store, + ), + owner = this, + view = view, + ) + + webExtensionPromptFeature.set( + feature = WebExtensionPromptFeature( + store = components.core.store, + context = requireContext(), + fragmentManager = parentFragmentManager, + ), + owner = this, + view = view + ) + + fullScreenFeature.set( + feature = FullScreenFeature( + store = components.core.store, + sessionUseCases = components.useCases.sessionUseCases, + tabId = sessionId, + fullScreenChanged = ::fullScreenChanged, + ), + owner = this, + view = binding.root, + ) + + mediaSessionFullscreenFeature.set( + feature = MediaSessionFullscreenFeature( + requireActivity(), + components.core.store, + sessionId, + ), + owner = this, + view = binding.root, + ) + + pictureInPictureIntegration.set( + feature = PictureInPictureIntegration( + components.core.store, + requireActivity(), + sessionId, + ), + owner = this, + view = view, + ) + + secureWindowFeature.set( + feature = SecureWindowFeature( + window = requireActivity().window, + store = components.core.store, + customTabId = sessionId, + ), + owner = this, + view = binding.root, + ) + } + + private fun restartApp(context: Context) { + val packageManager = context.packageManager + val intent = packageManager.getLaunchIntentForPackage(context.packageName) + intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + + if (context is Activity) { + context.finish() + } + + Runtime.getRuntime().exit(0) + } + + private fun createAndSetupEngine() { + try { + // Set layout parameters + val layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + + val engineView = createEngine(components) + val originalContext = ActivityContextWrapper.getOriginalContext(requireActivity()) + val engineNativeView = engineView.asView() + engineNativeView.layoutParams = layoutParams + + engineView.setActivityContext(originalContext) + + binding.swipeToRefresh.addView(engineNativeView) + + components.engineView = engineView + } catch (e: Exception) { + Log.e("EngineCreation", "Failed to create engine: ${e.message}", e) + context?.let { restartApp(it) } + } + } + + @CallSuper + @Suppress("LongMethod") + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentBrowserBinding.inflate(inflater, container, false) + + createAndSetupEngine() + + return binding.root + } + + private fun fullScreenChanged(enabled: Boolean) { + if (enabled) { + activity?.enterImmersiveMode() + } else { + activity?.exitImmersiveMode() + } + } + + @CallSuper + override fun onBackPressed(): Boolean { + return backButtonHandler.any { it.onBackPressed() } + } + + final override fun onPause() { + pictureInPictureIntegration.get()?.onHomePressed() ?: false + super.onPause() + } + + final override fun onPictureInPictureModeChanged(enabled: Boolean) { + val session = components.core.store.state.selectedTab + val fullScreenMode = session?.content?.fullScreen ?: false + // If we're exiting PIP mode and we're in fullscreen mode, then we should exit fullscreen mode as well. + if (!enabled && fullScreenMode) { + onBackPressed() + fullScreenChanged(false) + } + } + + @CallSuper + override fun onActivityResult(requestCode: Int, data: Intent?, resultCode: Int): Boolean { + return activityResultHandler.any { it.onActivityResult(requestCode, data, resultCode) } + } + + companion object { + private const val SESSION_ID_KEY = "session_id" + + @JvmStatic + protected fun Bundle.putSessionId(sessionId: String?) { + putString(SESSION_ID_KEY, sessionId) + } + } + override fun onDestroyView() { + super.onDestroyView() + + components.engineView?.setActivityContext(null) + _binding = null + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BrowserFragment.kt new file mode 100644 index 00000000..d06ed558 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BrowserFragment.kt @@ -0,0 +1,110 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity +import eu.weblibre.flutter_mozilla_components.feature.ReadabilityExtractFeature +import eu.weblibre.flutter_mozilla_components.feature.WebExtensionToolbarFeature +import eu.weblibre.flutter_mozilla_components.integration.ReaderViewIntegration +import mozilla.components.browser.state.state.WebExtensionState +import mozilla.components.browser.thumbnails.BrowserThumbnails +import mozilla.components.concept.engine.EngineView +import mozilla.components.feature.tabs.WindowFeature +import mozilla.components.support.base.feature.UserInteractionHandler +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import mozilla.components.support.webextensions.WebExtensionPopupObserver + +/** + * Fragment used for browsing the web within the main app. + */ +class BrowserFragment() : BaseBrowserFragment(), UserInteractionHandler { + private val windowFeature = ViewBoundFeatureWrapper() + private val thumbnailsFeature = ViewBoundFeatureWrapper() + private val readerViewFeature = ViewBoundFeatureWrapper() + private val readabilityExtractFeature = ViewBoundFeatureWrapper() + private val webExtensionPopupObserver = ViewBoundFeatureWrapper() + private val webExtToolbarFeature = ViewBoundFeatureWrapper() + + override fun createEngine(components: Components): EngineView { + return components.core.engine.createView(requireContext()).apply { + selectionActionDelegate = components.selectionAction + } + } + + @Suppress("LongMethod") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + readerViewFeature.set( + feature = ReaderViewIntegration( + requireContext(), + components.core.engine, + components.core.store, + binding.readerViewBar, + components.events.readerViewEvents, + components.readerViewController, + ), + owner = this, + view = view, + ) + + readabilityExtractFeature.set( + feature = components.features.readabilityExtractFeature, + owner = this, + view = view, + ) + + windowFeature.set( + feature = WindowFeature(components.core.store, components.useCases.tabsUseCases), + owner = this, + view = view, + ) + + thumbnailsFeature.set( + feature = BrowserThumbnails(requireContext(), components.engineView!!, components.core.store), + owner = this, + view = view, + ) + + webExtensionPopupObserver.set( + feature = WebExtensionPopupObserver(components.core.store, ::openPopup), + owner = this, + view = view, + ) + + webExtToolbarFeature.set( + feature = components.features.webExtensionToolbarFeature, + owner = this, + view = view, + ) + + components.core.historyStorage.registerStorageMaintenanceWorker() + } + + private fun openPopup(webExtensionState: WebExtensionState) { + val intent = Intent(requireContext().applicationContext, WebExtensionActionPopupActivity::class.java) + intent.putExtra("web_extension_id", webExtensionState.id) + intent.putExtra("web_extension_name", webExtensionState.name) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } + + override fun onBackPressed(): Boolean = + readerViewFeature.onBackPressed() || super.onBackPressed() + + companion object { + fun create(sessionId: String? = null) = BrowserFragment().apply { + arguments = Bundle().apply { + putSessionId(sessionId) + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt new file mode 100644 index 00000000..3a181b22 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt @@ -0,0 +1,62 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import eu.weblibre.flutter_mozilla_components.components.Core +import eu.weblibre.flutter_mozilla_components.components.Events +import eu.weblibre.flutter_mozilla_components.components.Features +import eu.weblibre.flutter_mozilla_components.components.Search +import eu.weblibre.flutter_mozilla_components.components.Services +import eu.weblibre.flutter_mozilla_components.components.UseCases +import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents +import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController +import mozilla.components.concept.engine.EngineView +import mozilla.components.concept.engine.selection.SelectionActionDelegate +import mozilla.components.feature.downloads.DateTimeProvider +import mozilla.components.feature.downloads.DefaultDateTimeProvider +import mozilla.components.feature.downloads.DefaultFileSizeFormatter +import mozilla.components.feature.downloads.DownloadEstimator +import mozilla.components.feature.downloads.FileSizeFormatter +import mozilla.components.support.base.android.NotificationsDelegate + +class Components(private val context: Context, + val flutterEvents: GeckoStateEvents, + val readerViewController: ReaderViewController, + val selectionAction: SelectionActionDelegate, + private val addonEvents: GeckoAddonEvents, + private val tabContentEvents: GeckoTabContentEvents, + private val extensionEvents: BrowserExtensionEvents +) { + val core by lazy { Core(context, this, flutterEvents, extensionEvents) } + val events by lazy { Events(flutterEvents) } + val useCases by lazy { UseCases(context, core.engine, core.store) } + val services by lazy { Services(context, useCases.tabsUseCases) } + val features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) } + val search by lazy { Search(context, core, useCases) } + + var engineView: EngineView? = null + var engineReportedInitialized = false + + private val notificationManagerCompat = NotificationManagerCompat.from(context) + val notificationsDelegate: NotificationsDelegate by lazy { + NotificationsDelegate( + notificationManagerCompat, + ) + } + + val fileSizeFormatter: FileSizeFormatter by lazy { DefaultFileSizeFormatter(context) } + + val dateTimeProvider: DateTimeProvider by lazy { DefaultDateTimeProvider() } + + val downloadEstimator: DownloadEstimator by lazy { DownloadEstimator(dateTimeProvider = dateTimeProvider) } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt new file mode 100644 index 00000000..cc1a2348 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components + +import android.content.Context +import eu.weblibre.flutter_mozilla_components.feature.ContainerProxyFeature +import eu.weblibre.flutter_mozilla_components.feature.CookieManagerFeature +import eu.weblibre.flutter_mozilla_components.feature.PrefManagerFeature +import eu.weblibre.flutter_mozilla_components.feature.BrowserExtensionFeature +import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents +import mozilla.components.browser.engine.gecko.GeckoEngine +import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient +import mozilla.components.concept.engine.DefaultSettings +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.fetch.Client +import mozilla.components.feature.webcompat.WebCompatFeature +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoRuntimeSettings + +object EngineProvider { + private var runtime: GeckoRuntime? = null + + @Synchronized + fun getOrCreateRuntime(context: Context): GeckoRuntime { + if (runtime == null) { + Logger.debug("Creating Runtime") + val builder = GeckoRuntimeSettings.Builder() + +// if (isCrashReportActive) { +// builder.crashHandler(CrashHandlerService::class.java) +// } + + // About config it's no longer enabled by default + builder.aboutConfigEnabled(true) + builder.extensionsProcessEnabled(true) + builder.extensionsWebAPIEnabled(true) + builder.consoleOutput(true) + + runtime = GeckoRuntime.create(context, builder.build()) + } + + return runtime!! + } + + fun createEngine(context: Context, defaultSettings: DefaultSettings, extensionEvents: BrowserExtensionEvents): Engine { + Logger.debug("Creating Engine") + val runtime = getOrCreateRuntime(context) + + return GeckoEngine(context, defaultSettings, runtime).also { + WebCompatFeature.install(it) + CookieManagerFeature.install(it) + PrefManagerFeature.install(it) + ContainerProxyFeature.install(it) + BrowserExtensionFeature.install(it, extensionEvents) + } + } + + fun createClient(context: Context): Client { + Logger.debug("Fetching Client") + val runtime = getOrCreateRuntime(context) + return GeckoViewFetchClient(context, runtime) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt new file mode 100644 index 00000000..891cf422 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components + +import eu.weblibre.flutter_mozilla_components.api.GeckoBrowserApiImpl +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding + + +/** FlutterMozillaComponentsPlugin */ +class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { + private val browserApi = GeckoBrowserApiImpl() + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + browserApi.attachBinding(flutterPluginBinding) + GeckoBrowserApi.setUp(flutterPluginBinding.binaryMessenger, browserApi) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + browserApi.attachActivity(binding.activity) + } + + override fun onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + + override fun onDetachedFromActivity() { + browserApi.detachActivity() + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt new file mode 100644 index 00000000..84d1ee74 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt @@ -0,0 +1,71 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components + +import android.view.View +import android.app.Activity +import android.content.Context +import android.view.ViewGroup +import android.widget.FrameLayout +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + +class GeckoViewFactory( + private val activity: Activity, + private val containerId: Int, + private val flutterEvents: GeckoStateEvents + ) : PlatformViewFactory( + StandardMessageCodec.INSTANCE) { + override fun create(context: Context?, id: Int, args: Any?): PlatformView { + return NativeFragmentView(this.activity, this.containerId, this.flutterEvents) + } +} + +private class NativeFragmentView( + activity: Activity?, + containerId: Int, + private val flutterEvents: GeckoStateEvents +) : PlatformView { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private val container: View + + init { + val vParams: ViewGroup.LayoutParams = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + ) + + // Ensure activity is not null before creating the container + if (activity == null) { + throw IllegalStateException("Activity cannot be null when creating NativeFragmentView") + } + + container = FrameLayout(activity) + container.layoutParams = vParams + container.id = containerId + } + + override fun onFlutterViewAttached(flutterView: View) { + super.onFlutterViewAttached(flutterView) + + components.engineReportedInitialized = false + flutterEvents.onViewReadyStateChange(System.currentTimeMillis(), true) { _ -> } + } + + override fun getView(): View { + return container + } + + override fun dispose() { + // Clean up if needed + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt new file mode 100644 index 00000000..0fcac5d7 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -0,0 +1,120 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components + +import android.content.Context +import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents +import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import mozilla.components.browser.storage.sync.GlobalPlacesDependencyProvider +import mozilla.components.concept.engine.selection.SelectionActionDelegate +import mozilla.components.feature.addons.update.GlobalAddonDependencyProvider +import mozilla.components.support.base.facts.Facts +import mozilla.components.support.base.facts.processor.LogFactProcessor +import mozilla.components.support.base.log.Log +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.log.sink.AndroidLogSink +import mozilla.components.support.webextensions.WebExtensionSupport +import java.util.concurrent.TimeUnit + +object GlobalComponents { + private var _components: Components? = null + + val components: Components? + get() = _components + + @DelicateCoroutinesApi + private fun restoreBrowserState(newComponents: Components) = GlobalScope.launch(Dispatchers.Main) { + newComponents.useCases.tabsUseCases.restore(newComponents.core.sessionStorage) + + newComponents.core.sessionStorage.autoSave(newComponents.core.store) + .periodicallyInForeground(interval = 30, unit = TimeUnit.SECONDS) + .whenGoingToBackground() + .whenSessionsChange() + } + + @OptIn(DelicateCoroutinesApi::class) + fun setUp( + applicationContext: Context, + flutterEvents: GeckoStateEvents, + readerViewController: ReaderViewController, + selectionAction: SelectionActionDelegate, + addonEvents: GeckoAddonEvents, + tabContentEvents: GeckoTabContentEvents, + extensionEvents: BrowserExtensionEvents + ) { + Logger.debug("Creating new components") + + val newComponents = Components( + applicationContext, + flutterEvents, + readerViewController, + selectionAction, + addonEvents, + tabContentEvents, + extensionEvents + ) + _components = newComponents + + //newComponents.crashReporter.install(applicationContext) + + //Facts.registerProcessor(LogFactProcessor()) + + //RustHttpConfig.setClient(lazy { newComponents.core.client }) + + newComponents.core.engine.warmUp() + + restoreBrowserState(newComponents) + + //newComponents.useCases.downloadsUseCases.restoreDownloads() + + try { + GlobalPlacesDependencyProvider.initialize(newComponents.core.historyStorage) + + GlobalAddonDependencyProvider.initialize( + newComponents.core.addonManager, + newComponents.core.addonUpdater, + ) + + WebExtensionSupport.initialize( + newComponents.core.engine, + newComponents.core.store, + onNewTabOverride = { + _, engineSession, url -> + newComponents.useCases.tabsUseCases.addTab(url, selectTab = true, engineSession = engineSession) + }, + onCloseTabOverride = { + _, sessionId -> + newComponents.useCases.tabsUseCases.removeTab(sessionId) + }, + onSelectTabOverride = { + _, sessionId -> + newComponents.useCases.tabsUseCases.selectTab(sessionId) + }, + onUpdatePermissionRequest = newComponents.core.addonUpdater::onUpdatePermissionRequest, + onExtensionsLoaded = { extensions -> + newComponents.core.addonUpdater.registerForFutureUpdates(extensions) + newComponents.core.supportedAddonsChecker.registerForChecks() + }, + ) + } catch (e: UnsupportedOperationException) { + // Web extension support is only available for engine gecko + Logger.error("Failed to initialize web extension support", e) + } + + GlobalScope.launch(Dispatchers.IO) { + newComponents.core.fileUploadsDirCleaner.cleanUploadsDirectory() + } + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PermissionStorage.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PermissionStorage.kt new file mode 100644 index 00000000..c0ade629 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PermissionStorage.kt @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components + +import androidx.paging.DataSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.concept.engine.permission.SitePermissions +import mozilla.components.concept.engine.permission.SitePermissionsStorage +import kotlin.coroutines.CoroutineContext + +class PermissionStorage( + private val permissionsStorage: SitePermissionsStorage, + private val dispatcher: CoroutineContext = Dispatchers.IO, +) { + + /** + * Persists the [sitePermissions] provided as a parameter. + * + * @param sitePermissions The [SitePermissions] to be stored. + * @param private Indicates if the [SitePermissions] belongs to a private session. + */ + suspend fun add(sitePermissions: SitePermissions, private: Boolean) = withContext(dispatcher) { + permissionsStorage.save(sitePermissions, private = private) + } + + /** + * Finds all [SitePermissions] that match the [origin]. + * + * @param origin The site to be used as filter in the search. + * @param private Indicates if the [origin] belongs to a private session. + */ + suspend fun findSitePermissionsBy(origin: String, private: Boolean): SitePermissions? = + withContext(dispatcher) { + permissionsStorage.findSitePermissionsBy(origin, private = private) + } + + /** + * Replaces an existing SitePermissions with the values of provided [sitePermissions]. + * + * @param sitePermissions The [SitePermissions] to be updated. + * @param private Indicates if the [SitePermissions] belongs to a private session. + */ + suspend fun updateSitePermissions(sitePermissions: SitePermissions, private: Boolean) = + withContext(dispatcher) { + permissionsStorage.update(sitePermissions, private = private) + } + + /** + * Returns all saved [SitePermissions] instances as a [DataSource.Factory]. + * + * A consuming app can transform the data source into a `LiveData` of when using RxJava2 into a + * `Flowable` or `Observable`, that can be observed. + * + * - https://developer.android.com/topic/libraries/architecture/paging/data + * - https://developer.android.com/topic/libraries/architecture/paging/ui + */ + suspend fun getSitePermissionsPaged(): DataSource.Factory { + return permissionsStorage.getSitePermissionsPaged() + } + + /** + * Deletes all sitePermissions that match the sitePermissions provided as a parameter. + * @param sitePermissions The [SitePermissions] to be deleted from the storage. + */ + suspend fun deleteSitePermissions(sitePermissions: SitePermissions) = withContext(dispatcher) { + permissionsStorage.remove(sitePermissions, private = false) + } + + /** + * Deletes all sitePermissions sitePermissions. + */ + suspend fun deleteAllSitePermissions() = withContext(dispatcher) { + permissionsStorage.removeAll() + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/NotificationActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/NotificationActivity.kt new file mode 100644 index 00000000..f7cf9a65 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/NotificationActivity.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.activities + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import eu.weblibre.flutter_mozilla_components.GlobalComponents + +class NotificationActivity: AppCompatActivity() { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + components.notificationsDelegate.bindToActivity(this) + + finish() + } + + override fun onDestroy() { + super.onDestroy() + components.notificationsDelegate.unBindActivity(this) + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonDetailsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonDetailsActivity.kt new file mode 100644 index 00000000..781e68a3 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonDetailsActivity.kt @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.addons + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.View +import android.widget.RatingBar +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.text.HtmlCompat +import mozilla.components.feature.addons.R as MozComp +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.translateDescription +import mozilla.components.feature.addons.ui.translateName +import mozilla.components.support.utils.ext.getParcelableExtraCompat +import eu.weblibre.flutter_mozilla_components.R +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * An activity to show the details of an add-on. + */ +class AddonDetailsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_add_on_details) + val addon = requireNotNull( + intent.getParcelableExtraCompat("add_on", Addon::class.java), + ) + bind(addon) + } + + private fun bind(addon: Addon) { + title = addon.translateName(this) + + bindDetails(addon) + + bindAuthor(addon) + + bindVersion(addon) + + bindLastUpdated(addon) + + bindWebsite(addon) + + bindRating(addon) + } + + private fun bindRating(addon: Addon) { + addon.rating?.let { + val ratingView = findViewById(R.id.rating_view) + val userCountView = findViewById(R.id.users_count) + + val ratingContentDescription = getString(MozComp.string.mozac_feature_addons_rating_content_description_2) + ratingView.contentDescription = String.format(ratingContentDescription, it.average) + ratingView.rating = it.average + + userCountView.text = getFormattedAmount(it.reviews) + } + } + + private fun bindWebsite(addon: Addon) { + findViewById(R.id.home_page_text).setOnClickListener { + val intent = + Intent(Intent.ACTION_VIEW).setData(Uri.parse(addon.homepageUrl)) + startActivity(intent) + } + } + + private fun bindLastUpdated(addon: Addon) { + val lastUpdatedView = findViewById(R.id.last_updated_text) + lastUpdatedView.text = formatDate(addon.updatedAt) + } + + private fun bindVersion(addon: Addon) { + val versionView = findViewById(R.id.version_text) + versionView.text = addon.version + } + + private fun bindAuthor(addon: Addon) { + val authorsView = findViewById(R.id.author_text) + + authorsView.text = addon.author?.name.orEmpty() + } + + private fun bindDetails(addon: Addon) { + val detailsView = findViewById(R.id.details) + val detailsText = addon.translateDescription(this) + + val parsedText = detailsText.replace("\n", "
") + val text = HtmlCompat.fromHtml(parsedText, HtmlCompat.FROM_HTML_MODE_COMPACT) + + detailsView.text = text + detailsView.movementMethod = LinkMovementMethod.getInstance() + } + + private fun formatDate(text: String): String { + val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()) + return DateFormat.getDateInstance().format(formatter.parse(text)!!) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonSettingsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonSettingsActivity.kt new file mode 100644 index 00000000..795f7d32 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonSettingsActivity.kt @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.addons + +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import eu.weblibre.flutter_mozilla_components.Components +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineView +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.translateName +import mozilla.components.support.utils.ext.getParcelableCompat +import mozilla.components.support.utils.ext.getParcelableExtraCompat +import eu.weblibre.flutter_mozilla_components.R + +/** + * An activity to show the settings of an add-on. + */ +class AddonSettingsActivity : AppCompatActivity() { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_add_on_settings) + + val addon = requireNotNull( + intent.getParcelableExtraCompat("add_on", Addon::class.java), + ) + + title = addon.translateName(this) + + supportFragmentManager + .beginTransaction() + .replace(R.id.addonSettingsContainer, AddonSettingsFragment.create(addon)) + .commit() + } + + override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? = + when (name) { + EngineView::class.java.name -> components.core.engine.createView(context, attrs).asView() + else -> super.onCreateView(parent, name, context, attrs) + } + + /** + * A fragment to show the settings of an add-on with [EngineView]. + */ + class AddonSettingsFragment : Fragment() { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private lateinit var optionsPageUrl: String + private lateinit var engineSession: EngineSession + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + optionsPageUrl = requireNotNull( + arguments?.getParcelableCompat( + "add_on", + Addon::class.java, + )?.installedState?.optionsPageUrl, + ) + + engineSession = components.core.engine.createSession() + + return inflater.inflate(R.layout.fragment_add_on_settings, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val addonSettingsEngineView = view.findViewById(R.id.addonSettingsEngineView) as EngineView + addonSettingsEngineView.render(engineSession) + engineSession.loadUrl(optionsPageUrl) + } + + override fun onDestroyView() { + engineSession.close() + super.onDestroyView() + } + + companion object { + /** + * Create an [AddonSettingsFragment] with add_on as a required parameter. + */ + fun create(addon: Addon) = AddonSettingsFragment().apply { + arguments = Bundle().apply { + putParcelable("add_on", addon) + } + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsActivity.kt new file mode 100644 index 00000000..70c2ff9c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsActivity.kt @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.addons + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import eu.weblibre.flutter_mozilla_components.R + +/** + * An activity to manage add-ons. + */ +class AddonsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_add_on_main) + + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction().apply { + replace(R.id.container, AddonsFragment()) + commit() + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsFragment.kt new file mode 100644 index 00000000..e663d0f5 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonsFragment.kt @@ -0,0 +1,151 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.addons + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import eu.weblibre.flutter_mozilla_components.Components +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.AddonManagerException +import mozilla.components.feature.addons.ui.AddonsManagerAdapter +import mozilla.components.feature.addons.ui.AddonsManagerAdapterDelegate +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import eu.weblibre.flutter_mozilla_components.R +import mozilla.components.feature.addons.R as MozComp + +/** + * Fragment use for managing add-ons. + */ +class AddonsFragment : Fragment(), AddonsManagerAdapterDelegate { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private val webExtensionPromptFeature = ViewBoundFeatureWrapper() + private lateinit var recyclerView: RecyclerView + private val scope = CoroutineScope(Dispatchers.IO) + private lateinit var addons: List + private var adapter: AddonsManagerAdapter? = null + + private val addonProgressOverlay: View + get() = requireView().findViewById(R.id.addonProgressOverlay) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return inflater.inflate(R.layout.fragment_add_ons, container, false) + } + + override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { + super.onViewCreated(rootView, savedInstanceState) + bindRecyclerView(rootView) + webExtensionPromptFeature.set( + feature = WebExtensionPromptFeature( + store = components.core.store, + context = requireContext(), + fragmentManager = parentFragmentManager, + ), + owner = this, + view = rootView, + ) + } + + override fun onStart() { + super.onStart() + + this@AddonsFragment.view?.let { view -> + bindRecyclerView(view) + } + + addonProgressOverlay.visibility = View.GONE + } + + private fun bindRecyclerView(rootView: View) { + recyclerView = rootView.findViewById(R.id.add_ons_list) + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + scope.launch { + try { + addons = components.core.addonManager.getAddons() + + scope.launch(Dispatchers.Main) { + adapter = AddonsManagerAdapter( + this@AddonsFragment, + addons, + store = components.core.store, + ) + recyclerView.adapter = adapter + } + } catch (e: AddonManagerException) { + scope.launch(Dispatchers.Main) { + Toast.makeText( + activity, + MozComp.string.mozac_feature_addons_failed_to_query_extensions, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + + override fun onAddonItemClicked(addon: Addon) { + if (addon.isInstalled()) { + val intent = Intent(context, InstalledAddonDetailsActivity::class.java) + intent.putExtra("add_on", addon) + startActivity(intent) + } else { + val intent = Intent(context, AddonDetailsActivity::class.java) + intent.putExtra("add_on", addon) + startActivity(intent) + } + } + + override fun onInstallAddonButtonClicked(addon: Addon) { + if (isInstallationInProgress) { + return + } + installAddon(addon) + } + + private val installAddon: ((Addon) -> Unit) = { addon -> + addonProgressOverlay.visibility = View.VISIBLE + isInstallationInProgress = true + components.core.addonManager.installAddon( + url = addon.downloadUrl, + onSuccess = { + runIfFragmentIsAttached { + isInstallationInProgress = false + this@AddonsFragment.view?.let { view -> + bindRecyclerView(view) + } + addonProgressOverlay.visibility = View.GONE + } + }, + onError = { _ -> + runIfFragmentIsAttached { + addonProgressOverlay.visibility = View.GONE + isInstallationInProgress = false + } + }, + ) + } + + /** + * Whether or not an add-on installation is in progress. + */ + private var isInstallationInProgress = false +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/Extensions.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/Extensions.kt new file mode 100644 index 00000000..69fa7d40 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/Extensions.kt @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.addons + +import androidx.fragment.app.Fragment +import java.text.NumberFormat +import java.util.Locale + +internal fun getFormattedAmount(amount: Int): String { + return NumberFormat.getNumberInstance(Locale.getDefault()).format(amount) +} + +/** + * Run the [block] only if the [Fragment] is attached. + * + * @param block A callback to be executed if the container [Fragment] is attached. + */ +internal inline fun Fragment.runIfFragmentIsAttached(block: () -> Unit) { + context?.let { + block() + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/InstalledAddonDetailsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/InstalledAddonDetailsActivity.kt new file mode 100644 index 00000000..4056e0db --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/InstalledAddonDetailsActivity.kt @@ -0,0 +1,204 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.addons + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SwitchCompat +import androidx.core.view.isVisible +import eu.weblibre.flutter_mozilla_components.Components +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.AddonManagerException +import mozilla.components.feature.addons.ui.translateName +import mozilla.components.support.utils.ext.getParcelableExtraCompat +import eu.weblibre.flutter_mozilla_components.R +import mozilla.components.feature.addons.R as MozComp + +/** + * An activity to show the details of a installed add-on. + */ +class InstalledAddonDetailsActivity : AppCompatActivity() { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private val scope = CoroutineScope(Dispatchers.IO) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_installed_add_on_details) + val addon = requireNotNull( + intent.getParcelableExtraCompat("add_on", Addon::class.java), + ).also { + bindUI(it) + } + + bindAddon(addon) + } + + private fun bindAddon(addon: Addon) { + scope.launch { + try { + val addons = components.core.addonManager.getAddons() + scope.launch(Dispatchers.Main) { + addons.find { addon.id == it.id }.let { + if (it == null) { + throw AddonManagerException(Exception("Addon ${addon.id} not found")) + } else { + bindUI(it) + } + } + } + } catch (e: AddonManagerException) { + scope.launch(Dispatchers.Main) { + Toast.makeText( + baseContext, + MozComp.string.mozac_feature_addons_failed_to_query_extensions, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + + private fun bindUI(addon: Addon) { + title = addon.translateName(this) + + bindEnableSwitch(addon) + + bindSettings(addon) + + bindDetails(addon) + + bindPermissions(addon) + + bindAllowInPrivateBrowsingSwitch(addon) + + bindRemoveButton(addon) + } + + private fun bindEnableSwitch(addon: Addon) { + val switch = findViewById(R.id.enable_switch) + switch.setState(addon.isEnabled()) + switch.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + components.core.addonManager.enableAddon( + addon, + onSuccess = { + switch.setState(true) + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_successfully_enabled, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + }, + onError = { + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_failed_to_enable, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + }, + ) + } else { + components.core.addonManager.disableAddon( + addon, + onSuccess = { + switch.setState(false) + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_successfully_disabled, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + }, + onError = { + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_failed_to_disable, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + }, + ) + } + } + } + + private fun bindSettings(addon: Addon) { + val view = findViewById(R.id.settings) + view.isVisible = shouldSettingsBeVisible(addon) + view.isEnabled = shouldSettingsBeVisible(addon) + view.setOnClickListener { + val intent = Intent(this, AddonSettingsActivity::class.java) + intent.putExtra("add_on", addon) + this.startActivity(intent) + } + } + + private fun bindDetails(addon: Addon) { + findViewById(R.id.details).setOnClickListener { + val intent = Intent(this, AddonDetailsActivity::class.java) + intent.putExtra("add_on", addon) + this.startActivity(intent) + } + } + + private fun bindPermissions(addon: Addon) { + findViewById(R.id.permissions).setOnClickListener { + val intent = Intent(this, PermissionsDetailsActivity::class.java) + intent.putExtra("add_on", addon) + this.startActivity(intent) + } + } + + private fun bindAllowInPrivateBrowsingSwitch(addon: Addon) { + val switch = findViewById(R.id.allow_in_private_browsing_switch) + switch.isChecked = addon.isAllowedInPrivateBrowsing() + switch.setOnCheckedChangeListener { _, isChecked -> + components.core.addonManager.setAddonAllowedInPrivateBrowsing( + addon, + isChecked, + onSuccess = { + switch.isChecked = isChecked + }, + ) + } + } + + private fun bindRemoveButton(addon: Addon) { + findViewById(R.id.remove_add_on).setOnClickListener { + components.core.addonManager.uninstallAddon( + addon, + onSuccess = { + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_successfully_uninstalled, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + finish() + }, + onError = { _, _ -> + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_failed_to_uninstall, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + }, + ) + } + } + + private fun SwitchCompat.setState(checked: Boolean) { + isChecked = checked + } + + private fun shouldSettingsBeVisible(addon: Addon) = !addon.installedState?.optionsPageUrl.isNullOrEmpty() +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/PermissionsDetailsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/PermissionsDetailsActivity.kt new file mode 100644 index 00000000..092e6523 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/PermissionsDetailsActivity.kt @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.addons + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.translateName +import mozilla.components.support.utils.ext.getParcelableExtraCompat +import eu.weblibre.flutter_mozilla_components.R + +private const val LEARN_MORE_URL = + "https://support.mozilla.org/kb/permission-request-messages-firefox-extensions" + +/** + * An activity to show the permissions of an add-on. + */ +class PermissionsDetailsActivity : AppCompatActivity(), View.OnClickListener { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_add_on_permissions) + val addon = requireNotNull( + intent.getParcelableExtraCompat("add_on", Addon::class.java), + ) + + title = addon.translateName(this) + + bindPermissions(addon) + + bindLearnMore() + } + + private fun bindPermissions(addon: Addon) { + val recyclerView = findViewById(R.id.add_ons_permissions) + recyclerView.layoutManager = LinearLayoutManager(this) + val sortedPermissions = addon.translatePermissions(this).sorted() + recyclerView.adapter = PermissionsAdapter(sortedPermissions) + } + + private fun bindLearnMore() { + findViewById(R.id.learn_more_label).setOnClickListener(this) + } + + /** + * An adapter for displaying the permissions of an add-on. + */ + class PermissionsAdapter( + private val permissions: List, + ) : + RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PermissionViewHolder { + val context = parent.context + val inflater = LayoutInflater.from(context) + val view = inflater.inflate(R.layout.add_ons_permission_item, parent, false) + val titleView = view.findViewById(R.id.permission) + return PermissionViewHolder( + view, + titleView, + ) + } + + override fun getItemCount() = permissions.size + + override fun onBindViewHolder(holder: PermissionViewHolder, position: Int) { + val permission = permissions[position] + holder.textView.text = permission + } + } + + /** + * A view holder for displaying the permissions of an add-on. + */ + class PermissionViewHolder( + val view: View, + val textView: TextView, + ) : RecyclerView.ViewHolder(view) + + override fun onClick(v: View?) { + val intent = + Intent(Intent.ACTION_VIEW).setData(Uri.parse(LEARN_MORE_URL)) + startActivity(intent) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/WebExtensionActionPopupActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/WebExtensionActionPopupActivity.kt new file mode 100644 index 00000000..73ff3375 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/WebExtensionActionPopupActivity.kt @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.addons + +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import eu.weblibre.flutter_mozilla_components.Components +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import mozilla.components.browser.state.action.WebExtensionAction +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineView +import mozilla.components.concept.engine.window.WindowRequest +import mozilla.components.lib.state.ext.consumeFrom +import eu.weblibre.flutter_mozilla_components.R + +/** + * An activity to show the pop up action of a web extension. + */ +class WebExtensionActionPopupActivity : AppCompatActivity() { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private lateinit var webExtensionId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_add_on_settings) + + webExtensionId = requireNotNull(intent.getStringExtra("web_extension_id")) + intent.getStringExtra("web_extension_name")?.let { + title = it + } + + supportFragmentManager + .beginTransaction() + .replace(R.id.addonSettingsContainer, WebExtensionActionPopupFragment.create(webExtensionId)) + .commit() + } + + override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? = + when (name) { + EngineView::class.java.name -> components.core.engine.createView(context, attrs).asView() + else -> super.onCreateView(parent, name, context, attrs) + } + + /** + * A fragment to show the web extension action popup with [EngineView]. + */ + class WebExtensionActionPopupFragment : Fragment(), EngineSession.Observer { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private var engineSession: EngineSession? = null + private lateinit var webExtensionId: String + + private val addonSettingsEngineView: EngineView + get() = requireView().findViewById(R.id.addonSettingsEngineView) as EngineView + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + webExtensionId = requireNotNull(arguments?.getString("web_extension_id")) + engineSession = components.core.store.state.extensions[webExtensionId]?.popupSession + + return inflater.inflate(R.layout.fragment_add_on_settings, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val session = engineSession + if (session != null) { + addonSettingsEngineView.render(session) + consumePopupSession() + } else { + consumeFrom(components.core.store) { state -> + state.extensions[webExtensionId]?.let { extState -> + extState.popupSession?.let { + if (engineSession == null) { + addonSettingsEngineView.render(it) + consumePopupSession() + engineSession = it + } + } + } + } + } + } + + override fun onStart() { + super.onStart() + engineSession?.register(this) + } + + override fun onStop() { + super.onStop() + engineSession?.unregister(this) + } + + override fun onWindowRequest(windowRequest: WindowRequest) { + if (windowRequest.type == WindowRequest.Type.CLOSE) { + activity?.onBackPressedDispatcher?.onBackPressed() + } + } + + private fun consumePopupSession() { + components.core.store.dispatch( + WebExtensionAction.UpdatePopupSessionAction(webExtensionId, popupSession = null), + ) + } + + companion object { + /** + * Create an [WebExtensionActionPopupFragment] with webExtensionId as a required parameter. + */ + fun create(webExtensionId: String) = WebExtensionActionPopupFragment().apply { + arguments = Bundle().apply { + putString("web_extension_id", webExtensionId) + } + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/WebExtensionPromptFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/WebExtensionPromptFeature.kt new file mode 100644 index 00000000..deffaafb --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/WebExtensionPromptFeature.kt @@ -0,0 +1,369 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.addons + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapNotNull +import mozilla.components.browser.state.action.WebExtensionAction +import mozilla.components.browser.state.state.extension.WebExtensionPromptRequest +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.webextension.PermissionPromptResponse +import mozilla.components.concept.engine.webextension.WebExtensionInstallException +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.AddonInstallationDialogFragment +import mozilla.components.feature.addons.ui.PermissionsDialogFragment +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.ktx.android.content.appVersionName +import mozilla.components.ui.widgets.withCenterAlignedButtons +import eu.weblibre.flutter_mozilla_components.R +import mozilla.components.feature.addons.R as MozComp + +/** + * Feature implementation for handling [WebExtensionPromptRequest] and showing the respective UI. + */ +class WebExtensionPromptFeature( + private val store: BrowserStore, + private val context: Context, + private val fragmentManager: FragmentManager, +) : LifecycleAwareFeature { + + /** + * Whether or not an add-on installation is in progress. + */ + private var isInstallationInProgress = false + private var scope: CoroutineScope? = null + + /** + * Starts observing the selected session to listen for window requests + * and opens / closes tabs as needed. + */ + override fun start() { + scope = store.flowScoped { flow -> + flow.mapNotNull { state -> + state.webExtensionPromptRequest + }.distinctUntilChanged().collect { promptRequest -> + when (promptRequest) { + is WebExtensionPromptRequest.AfterInstallation -> { + handleAfterInstallationRequest(promptRequest) + } + + is WebExtensionPromptRequest.BeforeInstallation.InstallationFailed -> { + handleBeforeInstallationRequest(promptRequest) + consumePromptRequest() + } + } + } + } + tryToReAttachButtonHandlersToPreviousDialog() + } + + private fun handleAfterInstallationRequest(promptRequest: WebExtensionPromptRequest.AfterInstallation) { + // The install flow in Fenix relies on an [Addon] object so let's convert the (GeckoView) + // extension into a minimal add-on. The missing metadata will be fetched when the user + // opens the add-ons manager. + val addon = Addon.newFromWebExtension(promptRequest.extension) + when (promptRequest) { + is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> handlePermissionRequest( + addon, + promptRequest, + ) + + is WebExtensionPromptRequest.AfterInstallation.PostInstallation -> handlePostInstallationRequest( + addon, + ) + + is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> handleOptionalPermissionsRequest( + addon, + promptRequest, + ) + } + } + + private fun handlePostInstallationRequest( + addon: Addon, + ) { + showPostInstallationDialog(addon) + } + + private fun handlePermissionRequest( + addon: Addon, + promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions.Required, + ) { + if (hasExistingPermissionDialogFragment()) return + showPermissionDialog( + addon = addon, + promptRequest = promptRequest, + permissions = promptRequest.permissions, + origins = promptRequest.origins, + ) + } + + private fun handleOptionalPermissionsRequest( + addon: Addon, + promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions.Optional, + ) { + val shouldGrantWithoutPrompt = Addon.localizePermissions(promptRequest.permissions, context).isEmpty() + + // If we don't have any promptable permissions, just proceed. + if (shouldGrantWithoutPrompt) { + promptRequest.onConfirm(true) + consumePromptRequest() + return + } + + showPermissionDialog( + // This is a bit of a hack so that the permission prompt only lists + // the optional permissions that are requested. + addon = addon.copy(permissions = promptRequest.permissions), + promptRequest = promptRequest, + permissions = promptRequest.permissions, + forOptionalPermissions = true, + ) + } + + private fun showPostInstallationDialog(addon: Addon) { + if (!isInstallationInProgress && !hasExistingAddonPostInstallationDialogFragment()) { + val dialog = AddonInstallationDialogFragment.newInstance( + addon = addon, + onDismissed = { + consumePromptRequest() + }, + onConfirmButtonClicked = { _ -> + consumePromptRequest() + }, + ) + dialog.show(fragmentManager, POST_INSTALLATION_DIALOG_FRAGMENT_TAG) + } + } + + /** + * Stops observing the selected session for incoming window requests. + */ + override fun stop() { + scope?.cancel() + } + + @VisibleForTesting + internal fun showPermissionDialog( + addon: Addon, + promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions, + permissions: List = emptyList(), + origins: List = emptyList(), + forOptionalPermissions: Boolean = false, + ) { + if (!isInstallationInProgress && !hasExistingPermissionDialogFragment()) { + val dialog = PermissionsDialogFragment.newInstance( + addon = addon, + permissions = permissions, + origins = origins, + dataCollectionPermissions = emptyList(), + forOptionalPermissions = forOptionalPermissions, + onPositiveButtonClicked = { _, privateBrowsingAllowed, _ -> + handlePermissions( + promptRequest, + granted = true, + privateBrowsingAllowed = privateBrowsingAllowed, + ) + }, + onNegativeButtonClicked = { + when (promptRequest) { + is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> { + promptRequest.onConfirm(false) + } + + is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> { + promptRequest.onConfirm(PermissionPromptResponse(isPermissionsGranted = false)) + } + } + consumePromptRequest() + }, + ) + dialog.show( + fragmentManager, + PERMISSIONS_DIALOG_FRAGMENT_TAG, + ) + } + } + + private fun tryToReAttachButtonHandlersToPreviousDialog() { + findPreviousDialogFragment()?.let { dialog -> + dialog.onPositiveButtonClicked = { addon, privateBrowsingAllowed, _ -> + store.state.webExtensionPromptRequest?.let { promptRequest -> + if (promptRequest is WebExtensionPromptRequest.AfterInstallation.Permissions && + addon.id == promptRequest.extension.id + ) { + handlePermissions( + promptRequest, + granted = true, + privateBrowsingAllowed = privateBrowsingAllowed, + ) + } + } + } + dialog.onNegativeButtonClicked = { + store.state.webExtensionPromptRequest?.let { promptRequest -> + handlePermissions( + promptRequest, + granted = false, + privateBrowsingAllowed = false, + ) + } + } + } + } + + private fun handlePermissions( + promptRequest: WebExtensionPromptRequest, + granted: Boolean, + privateBrowsingAllowed: Boolean, + ) { + when (promptRequest) { + is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> { + promptRequest.onConfirm(granted) + } + + is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> { + val response = PermissionPromptResponse( + isPermissionsGranted = granted, + isPrivateModeGranted = privateBrowsingAllowed, + ) + promptRequest.onConfirm(response) + } + + is WebExtensionPromptRequest.AfterInstallation.PostInstallation -> { + // opt-out + } + + is WebExtensionPromptRequest.BeforeInstallation.InstallationFailed -> { + // opt-out + } + } + consumePromptRequest() + } + + private fun consumePromptRequest() { + store.dispatch(WebExtensionAction.ConsumePromptRequestWebExtensionAction) + } + + private fun hasExistingPermissionDialogFragment(): Boolean { + return findPreviousDialogFragment() != null + } + + private fun findPreviousDialogFragment(): PermissionsDialogFragment? { + return fragmentManager.findFragmentByTag(PERMISSIONS_DIALOG_FRAGMENT_TAG) as? PermissionsDialogFragment + } + + private fun hasExistingAddonPostInstallationDialogFragment(): Boolean { + return fragmentManager.findFragmentByTag(POST_INSTALLATION_DIALOG_FRAGMENT_TAG) + as? AddonInstallationDialogFragment != null + } + + private fun handleBeforeInstallationRequest(promptRequest: WebExtensionPromptRequest.BeforeInstallation) { + when (promptRequest) { + is WebExtensionPromptRequest.BeforeInstallation.InstallationFailed -> { + handleInstallationFailedRequest( + exception = promptRequest.exception, + ) + consumePromptRequest() + } + } + } + + @VisibleForTesting + internal fun handleInstallationFailedRequest( + exception: WebExtensionInstallException, + ) { + val addonName = exception.extensionName ?: "" + var title = context.getString(MozComp.string.mozac_feature_addons_cant_install_extension, "") + val message = when (exception) { + is WebExtensionInstallException.Blocklisted -> { + context.getString(MozComp.string.mozac_feature_addons_blocklisted_2, addonName) + } + + is WebExtensionInstallException.SoftBlocked -> { + context.getString(MozComp.string.mozac_feature_addons_soft_blocked_1, addonName) + } + + is WebExtensionInstallException.UserCancelled -> { + // We don't want to show an error message when users cancel installation. + return + } + + is WebExtensionInstallException.UnsupportedAddonType, + is WebExtensionInstallException.Unknown, + -> { + // Making sure we don't have a + // Title = Can't install extension + // Message = Failed to install $addonName + title = "" + if (addonName.isNotEmpty()) { + context.getString(MozComp.string.mozac_feature_addons_failed_to_install, addonName) + } else { + context.getString(MozComp.string.mozac_feature_addons_extension_failed_to_install) + } + } + + is WebExtensionInstallException.NetworkFailure -> { + context.getString(MozComp.string.mozac_feature_addons_extension_failed_to_install_network_error) + } + + is WebExtensionInstallException.CorruptFile -> { + context.getString(MozComp.string.mozac_feature_addons_extension_failed_to_install_corrupt_error) + } + + is WebExtensionInstallException.NotSigned -> { + context.getString( + MozComp.string.mozac_feature_addons_extension_failed_to_install_not_signed_error, + ) + } + + is WebExtensionInstallException.Incompatible -> { + val appName = "WebLibre" + val version = context.appVersionName + context.getString( + MozComp.string.mozac_feature_addons_failed_to_install_incompatible_error, + addonName, + appName, + version, + ) + } + + is WebExtensionInstallException.AdminInstallOnly -> { + context.getString(MozComp.string.mozac_feature_addons_admin_install_only, addonName) + } + } + + showDialog( + title = title, + message = message, + ) + } + + @VisibleForTesting + internal fun showDialog( + title: String, + message: String, + ) { + context.let { + AlertDialog.Builder(it).setTitle(title) + .setPositiveButton(android.R.string.ok) { _, _ -> }.setCancelable(false).setMessage( + message, + ).show().withCenterAlignedButtons() + } + } + + companion object { + private const val PERMISSIONS_DIALOG_FRAGMENT_TAG = "ADDONS_PERMISSIONS_DIALOG_FRAGMENT" + private const val POST_INSTALLATION_DIALOG_FRAGMENT_TAG = + "ADDONS_INSTALLATION_DIALOG_FRAGMENT" + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt new file mode 100644 index 00000000..b18e9353 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import android.content.Context +import android.content.Intent +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.addons.AddonsActivity +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi +import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType + +class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun startAddonManagerActivity() { + val intent = Intent(context, AddonsActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + } + + override fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) { + when(actionType) { + WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId) + WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId) + } + } + + override fun installAddon(url: String, callback: (Result) -> Unit) { + components.core.addonManager.installAddon( + url = url, + onSuccess = { _ -> + callback(Result.success(Unit)) + }, + onError = { e -> + callback(Result.failure(e)) + } + ) + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt new file mode 100644 index 00000000..6f3dd7f1 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -0,0 +1,216 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import android.app.Activity +import android.content.Intent +import android.view.View +import androidx.fragment.app.FragmentActivity +import eu.weblibre.flutter_mozilla_components.BrowserFragment +import eu.weblibre.flutter_mozilla_components.GeckoViewFactory +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity +import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate +import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserExtensionApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoContainerProxyApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoCookieApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoDeleteBrowsingDataController +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoDownloadsApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoFindApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoIconsApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabsApi +import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController +import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewEvents +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding +import mozilla.components.browser.state.action.SystemAction +import mozilla.components.feature.addons.logger +import mozilla.components.support.base.log.Log +import mozilla.components.support.base.log.sink.AndroidLogSink + +/** + * Implementation of GeckoBrowserApi that handles browser-related operations + * @param showFragmentCallback Callback function to show native fragment + */ +class GeckoBrowserApiImpl : GeckoBrowserApi { + companion object { + private const val TAG = "GeckoBrowserApiImpl" + private const val FRAGMENT_CONTAINER_ID = 0xBEEF + + private var isGeckoInitialized = false + } + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private var activity: Activity? = null + private var isPlatformViewRegistered = false + + private lateinit var _flutterPluginBinding: FlutterPlugin.FlutterPluginBinding + private lateinit var _flutterEvents : GeckoStateEvents + + fun attachBinding(flutterPluginBinding: FlutterPluginBinding) { + _flutterPluginBinding = flutterPluginBinding + _flutterEvents = GeckoStateEvents(_flutterPluginBinding.binaryMessenger) + + isGeckoInitialized = false + } + + fun attachActivity(activity: Activity) { + this.activity = activity + + _flutterPluginBinding.platformViewRegistry.registerViewFactory( + "eu.weblibre/gecko", GeckoViewFactory( + activity, + FRAGMENT_CONTAINER_ID, + _flutterEvents + ) + ) + + isPlatformViewRegistered = true + } + + fun detachActivity() { + this.activity = null + isPlatformViewRegistered = false + } + + override fun initialize() { + synchronized(this) { + if(!isGeckoInitialized) { + Log.addSink(AndroidLogSink()) + + setupGeckoEngine() + isGeckoInitialized = true + } + } + } + + override fun showNativeFragment(): Boolean { + try { + return showFragmentCallback() + } catch (e: Exception) { + logger.error("Failed to show native fragment", e) + } + + return false + } + + private fun setupGeckoEngine() { + val selectionActionEvents = GeckoSelectionActionEvents(_flutterPluginBinding.binaryMessenger) + + val selectionActionDelegate = DefaultSelectionActionDelegate(selectionActionEvents) { actions -> + val processTextAction = "android.intent.action.PROCESS_TEXT" + val withoutProcessText = actions.filter { it != processTextAction }.toTypedArray() + val processTextActions = actions.filter { it == processTextAction }.toTypedArray() + + withoutProcessText + processTextActions + } + + val readerViewController = + ReaderViewController(_flutterPluginBinding.binaryMessenger) + + val extensionEvents = BrowserExtensionEvents(_flutterPluginBinding.binaryMessenger) + + val addonEvents = GeckoAddonEvents(_flutterPluginBinding.binaryMessenger) + val tabContentEvents = GeckoTabContentEvents(_flutterPluginBinding.binaryMessenger) + + val suggestionEvents = GeckoSuggestionEvents(_flutterPluginBinding.binaryMessenger) + GeckoSuggestionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoSuggestionApiImpl(suggestionEvents)) + + GlobalComponents.setUp( + _flutterPluginBinding.applicationContext, + _flutterEvents, + readerViewController, + selectionActionDelegate, + addonEvents, + tabContentEvents, + extensionEvents + ) + + GeckoEngineSettingsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoEngineSettingsApiImpl()) + GeckoAddonsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoAddonsApiImpl(_flutterPluginBinding.applicationContext)) + GeckoSessionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoSessionApiImpl()) + GeckoTabsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTabsApiImpl()) + GeckoIconsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoIconsApiImpl()) + GeckoCookieApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoCookieApiImpl()) + GeckoPrefApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPrefApiImpl()) + GeckoContainerProxyApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoContainerProxyApiImpl()) + GeckoFindApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoFindApiImpl()) + GeckoSelectionActionController.setUp(_flutterPluginBinding.binaryMessenger, GeckoSelectionActionControllerImpl( + selectionActionDelegate + )) + GeckoDeleteBrowsingDataController.setUp(_flutterPluginBinding.binaryMessenger, GeckoDeleteBrowsingDataControllerImpl()) + GeckoDownloadsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoDownloadsApiImpl()) + GeckoBrowserExtensionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoBrowserExtensionApiImpl()) + + ReaderViewEvents.setUp( + _flutterPluginBinding.binaryMessenger, + components.events.readerViewEvents + ) + + val intent = Intent(_flutterPluginBinding.applicationContext, NotificationActivity::class.java) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + _flutterPluginBinding.applicationContext.startActivity(intent) + } + + private fun showFragmentCallback(): Boolean { + if (!isPlatformViewRegistered) { + return false + } + + if (activity == null || activity !is FragmentActivity) { + return false + } + + val fragmentActivity = activity as FragmentActivity + + // Check if the container view exists in the view hierarchy + val container = fragmentActivity.findViewById(FRAGMENT_CONTAINER_ID) + if (container == null) { + // Container doesn't exist yet, retry later + return false + } + + val nativeFragment = BrowserFragment.create() + val fm = fragmentActivity.supportFragmentManager + fm.beginTransaction() + .replace(FRAGMENT_CONTAINER_ID, nativeFragment) + .commitAllowingStateLoss() + + return true + } + + override fun onTrimMemory(level: Long) { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + + logger.debug("$TAG: onTrimMemory called with level: $level") + + with(GlobalComponents.components!!) { + try { + core.store.dispatch(SystemAction.LowMemoryAction(level.toInt())) + core.icons.onTrimMemory(level.toInt()) + } catch (e: Exception) { + logger.error("$TAG: Failed to handle memory trim", e) + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserExtensionApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserExtensionApiImpl.kt new file mode 100644 index 00000000..828e14db --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserExtensionApiImpl.kt @@ -0,0 +1,59 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.feature.ResultConsumer +import eu.weblibre.flutter_mozilla_components.feature.BrowserExtensionFeature +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserExtensionApi +import org.json.JSONArray +import org.json.JSONObject + +class GeckoBrowserExtensionApiImpl : GeckoBrowserExtensionApi { + private fun JSONObject.toMap(): Map { + val map = mutableMapOf() + val keys = this.keys() + + while (keys.hasNext()) { + val key = keys.next() + when (val value = this.get(key)) { + is JSONObject -> map[key] = value.toMap() + is JSONArray -> map[key] = value.toList() +// JSONObject.NULL -> map[key] = null + else -> map[key] = value + } + } + + return map + } + + private fun JSONArray.toList(): List { + val list = mutableListOf() + for (i in 0 until this.length()) { + when (val value = this.get(i)) { + is JSONObject -> list.add(value.toMap()) + is JSONArray -> list.add(value.toList()) + JSONObject.NULL -> list.add(null as Any) + else -> list.add(value) + } + } + return list + } + + override fun getMarkdown(htmlList: List, callback: (Result>) -> Unit) { + BrowserExtensionFeature.scheduleRequest("turndown", htmlList, object : + ResultConsumer { + override fun success(result: JSONObject) { + val resultArray = result.getJSONArray("result") + callback(Result.success(resultArray.toList())) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt new file mode 100644 index 00000000..477a343c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt @@ -0,0 +1,41 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.feature.BrowserExtensionFeature +import eu.weblibre.flutter_mozilla_components.feature.ContainerProxyFeature +import eu.weblibre.flutter_mozilla_components.feature.ResultConsumer +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoContainerProxyApi +import org.json.JSONObject + +class GeckoContainerProxyApiImpl : GeckoContainerProxyApi { + override fun setProxyPort(port: Long) { + ContainerProxyFeature.scheduleRequest("setProxyPort", port.toInt()) + } + + override fun addContainerProxy(contextId: String) { + ContainerProxyFeature.scheduleRequest("addContainerProxy", contextId) + } + + override fun removeContainerProxy(contextId: String) { + ContainerProxyFeature.scheduleRequest("removeContainerProxy", contextId) + } + + override fun healthcheck(callback: (Result) -> Unit) { + ContainerProxyFeature.scheduleRequestWithResponse("healthcheck", Unit, object : + ResultConsumer { + override fun success(result: JSONObject) { + val resultStatus = result.getBoolean("result") + callback(Result.success(resultStatus)) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoCookieApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoCookieApiImpl.kt new file mode 100644 index 00000000..15d592db --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoCookieApiImpl.kt @@ -0,0 +1,195 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.feature.CookieManagerFeature +import eu.weblibre.flutter_mozilla_components.feature.ResultConsumer +import eu.weblibre.flutter_mozilla_components.pigeons.* +import org.json.JSONObject + +class GeckoCookieApiImpl : GeckoCookieApi { + private companion object { + const val ERROR_INVALID_KEY = "Invalid map key" + } + + private fun JSONObject.putNullable(key: String, value: Any?) { + put(key, value ?: JSONObject.NULL) + } + + private fun JSONObject.getValueOrNull(key: String): Any? { + if (!has(key)) throw RuntimeException(ERROR_INVALID_KEY) + return if (!isNull(key)) get(key) else null + } + + private fun CookiePartitionKey.toJSON() = JSONObject().apply { + put("topLevelSite", topLevelSite) + } + + private fun cookiePartitionKeyFromJSON(json: JSONObject) = CookiePartitionKey( + topLevelSite = json.getString("topLevelSite") + ) + + private fun cookieFromJSON(json: JSONObject): Cookie { + val partitionKeyJson = json.getValueOrNull("partitionKey") as JSONObject? + val partitionKey = partitionKeyJson?.takeUnless { it.length() == 0 }?.let { + cookiePartitionKeyFromJSON(it) + } + + return Cookie( + domain = json.getString("domain"), + expirationDate = (json.getValueOrNull("expirationDate") as Int?)?.toLong(), + firstPartyDomain = json.getString("firstPartyDomain"), + hostOnly = json.getBoolean("hostOnly"), + httpOnly = json.getBoolean("httpOnly"), + name = json.getString("name"), + partitionKey = partitionKey, + path = json.getString("path"), + secure = json.getBoolean("secure"), + session = json.getBoolean("session"), + sameSite = parseSameSiteStatus(json.getString("sameSite")), + storeId = json.getString("storeId"), + value = json.getString("value") + ) + } + + private fun parseSameSiteStatus(status: String) = when(status) { + "no_restriction" -> CookieSameSiteStatus.NO_RESTRICTION + "lax" -> CookieSameSiteStatus.LAX + "strict" -> CookieSameSiteStatus.STRICT + else -> CookieSameSiteStatus.UNSPECIFIED + } + + private fun sameSiteToString(status: CookieSameSiteStatus) = when(status) { + CookieSameSiteStatus.NO_RESTRICTION -> "no_restriction" + CookieSameSiteStatus.LAX -> "lax" + CookieSameSiteStatus.STRICT -> "strict" + CookieSameSiteStatus.UNSPECIFIED -> "" + } + + private fun createBaseArgs( + firstPartyDomain: String?, + partitionKey: CookiePartitionKey?, + storeId: String?, + url: String + ) = JSONObject().apply { + putNullable("firstPartyDomain", firstPartyDomain) + putNullable("partitionKey", partitionKey?.toJSON()) + putNullable("storeId", storeId) + put("url", url) + } + + private fun handleRequest( + action: String, + args: JSONObject, + callback: (Result) -> Unit + ) { + CookieManagerFeature.scheduleRequest(action, args, object : ResultConsumer { + override fun success(result: JSONObject) { + callback(Result.success(Unit)) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } + + override fun getCookie( + firstPartyDomain: String?, + name: String, + partitionKey: CookiePartitionKey?, + storeId: String?, + url: String, + callback: (Result) -> Unit + ) { + val args = createBaseArgs(firstPartyDomain, partitionKey, storeId, url).apply { + put("name", name) + } + + CookieManagerFeature.scheduleRequest("get", args, object : ResultConsumer { + override fun success(result: JSONObject) { + callback(Result.success(cookieFromJSON(result.getJSONObject("result")))) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } + + override fun getAllCookies( + domain: String?, + firstPartyDomain: String?, + name: String?, + partitionKey: CookiePartitionKey?, + storeId: String?, + url: String, + callback: (Result>) -> Unit + ) { + val args = createBaseArgs(firstPartyDomain, partitionKey, storeId, url).apply { + putNullable("domain", domain) + putNullable("name", name) + } + + CookieManagerFeature.scheduleRequest("getAll", args, object : ResultConsumer { + override fun success(result: JSONObject) { + val cookies = result.getJSONArray("result").let { jsonArray -> + List(jsonArray.length()) { cookieFromJSON(jsonArray.getJSONObject(it)) } + } + callback(Result.success(cookies)) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } + + override fun setCookie( + domain: String?, + expirationDate: Long?, + firstPartyDomain: String?, + httpOnly: Boolean?, + name: String?, + partitionKey: CookiePartitionKey?, + path: String?, + sameSite: CookieSameSiteStatus?, + secure: Boolean?, + storeId: String?, + url: String, + value: String?, + callback: (Result) -> Unit + ) { + val args = createBaseArgs(firstPartyDomain, partitionKey, storeId, url).apply { + putNullable("domain", domain) + putNullable("expirationDate", expirationDate) + putNullable("httpOnly", httpOnly) + putNullable("name", name) + putNullable("path", path) + putNullable("sameSite", sameSite?.let { sameSiteToString(it) }) + putNullable("secure", secure) + putNullable("value", value) + } + + handleRequest("set", args, callback) + } + + override fun removeCookie( + firstPartyDomain: String?, + name: String, + partitionKey: CookiePartitionKey?, + storeId: String?, + url: String, + callback: (Result) -> Unit + ) { + val args = createBaseArgs(firstPartyDomain, partitionKey, storeId, url).apply { + put("name", name) + } + + handleRequest("remove", args, callback) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoDeleteBrowsingDataControllerImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoDeleteBrowsingDataControllerImpl.kt new file mode 100644 index 00000000..91e59f88 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoDeleteBrowsingDataControllerImpl.kt @@ -0,0 +1,113 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoDeleteBrowsingDataController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.browser.state.action.EngineAction +import mozilla.components.browser.state.action.RecentlyClosedAction +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.translate.ModelManagementOptions +import mozilla.components.concept.engine.translate.ModelOperation +import mozilla.components.concept.engine.translate.OperationLevel + +class GeckoDeleteBrowsingDataControllerImpl : GeckoDeleteBrowsingDataController { + companion object { + private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + } + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun deleteTabs(callback: (Result) -> Unit) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.useCases.tabsUseCases.removeAllTabs.invoke(false) + + callback(Result.success(Unit)) + } + } + } + + override fun deleteBrowsingHistory(callback: (Result) -> Unit) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.historyStorage.deleteEverything() + components.core.store.dispatch(EngineAction.PurgeHistoryAction) + components.core.icons.clear() + components.core.store.dispatch(RecentlyClosedAction.RemoveAllClosedTabAction) + + callback(Result.success(Unit)) + } + } + } + + override fun deleteCookiesAndSiteData(callback: (Result) -> Unit) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.engine.clearData( + Engine.BrowsingData.select( + Engine.BrowsingData.COOKIES, + Engine.BrowsingData.AUTH_SESSIONS, + ), + ) + components.core.engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.DOM_STORAGES)) + + callback(Result.success(Unit)) + } + } + } + + override fun deleteCachedFiles(callback: (Result) -> Unit) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.engine.manageTranslationsLanguageModel( + options = ModelManagementOptions( + operation = ModelOperation.DELETE, + operationLevel = OperationLevel.CACHE, + ), + onSuccess = { }, + onError = { }, + ) + components.core.engine.clearData( + Engine.BrowsingData.select(Engine.BrowsingData.ALL_CACHES), + ) + + callback(Result.success(Unit)) + } + } + } + + override fun deleteSitePermissions(callback: (Result) -> Unit) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.core.engine.clearData( + Engine.BrowsingData.select(Engine.BrowsingData.ALL_SITE_SETTINGS), + ) + components.core.permissionStorage.deleteAllSitePermissions() + + callback(Result.success(Unit)) + } + } + } + + override fun deleteDownloads(callback: (Result) -> Unit) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + components.useCases.downloadsUseCases.removeAllDownloads.invoke() + + callback(Result.success(Unit)) + } + } + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoDownloadsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoDownloadsApiImpl.kt new file mode 100644 index 00000000..83dbcc71 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoDownloadsApiImpl.kt @@ -0,0 +1,88 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import android.os.Environment +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.pigeons.DownloadState +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoDownloadsApi +import eu.weblibre.flutter_mozilla_components.pigeons.ShareInternetResourceState +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.action.CopyInternetResourceAction +import mozilla.components.browser.state.action.ShareResourceAction +import mozilla.components.browser.state.state.content.ShareResourceState +import java.util.UUID + +class GeckoDownloadsApiImpl : GeckoDownloadsApi { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun requestDownload(tabId: String, state: DownloadState) { + components.core.store.dispatch( + ContentAction.UpdateDownloadAction( + tabId, + state.toMozillaDownloadState(), + ), + ) + } + + override fun copyInternetResource(tabId: String, state: ShareInternetResourceState) { + components.core.store.dispatch(CopyInternetResourceAction.AddCopyAction(tabId, state.toMozillaShareInternetResourceState())) + } + + override fun shareInternetResource(tabId: String, state: ShareInternetResourceState) { + components.core.store.dispatch(ShareResourceAction.AddShareAction(tabId, state.toMozillaShareInternetResourceState())) + } + + private fun ShareInternetResourceState.toMozillaShareInternetResourceState(): ShareResourceState.InternetResource { + return ShareResourceState.InternetResource( + url = url, + contentType = contentType, + private = private, + response = null, // Since Pigeon class doesn't have this field + referrerUrl = referrerUrl + ) + } + + private fun DownloadState.toMozillaDownloadState(): mozilla.components.browser.state.state.content.DownloadState { + return mozilla.components.browser.state.state.content.DownloadState( + url = url, + fileName = fileName, + contentType = contentType, + contentLength = contentLength, + currentBytesCopied = currentBytesCopied ?: 0L, + status = status?.toMozillaStatus() ?: mozilla.components.browser.state.state.content.DownloadState.Status.INITIATED, + userAgent = userAgent, + destinationDirectory = destinationDirectory ?: Environment.DIRECTORY_DOWNLOADS, + directoryPath = directoryPath ?: Environment.getExternalStoragePublicDirectory( + destinationDirectory ?: Environment.DIRECTORY_DOWNLOADS + ).path, + referrerUrl = referrerUrl, + skipConfirmation = skipConfirmation ?: false, + openInApp = openInApp ?: false, + id = id ?: UUID.randomUUID().toString(), + sessionId = sessionId, + private = private ?: false, + createdTime = createdTime ?: System.currentTimeMillis(), + response = null, // Cannot be mapped from Pigeon model + notificationId = notificationId?.toInt() + ) + } + + private fun eu.weblibre.flutter_mozilla_components.pigeons.DownloadStatus.toMozillaStatus(): mozilla.components.browser.state.state.content.DownloadState.Status { + return when (this) { + eu.weblibre.flutter_mozilla_components.pigeons.DownloadStatus.INITIATED -> mozilla.components.browser.state.state.content.DownloadState.Status.INITIATED + eu.weblibre.flutter_mozilla_components.pigeons.DownloadStatus.DOWNLOADING -> mozilla.components.browser.state.state.content.DownloadState.Status.DOWNLOADING + eu.weblibre.flutter_mozilla_components.pigeons.DownloadStatus.PAUSED -> mozilla.components.browser.state.state.content.DownloadState.Status.PAUSED + eu.weblibre.flutter_mozilla_components.pigeons.DownloadStatus.CANCELLED -> mozilla.components.browser.state.state.content.DownloadState.Status.CANCELLED + eu.weblibre.flutter_mozilla_components.pigeons.DownloadStatus.FAILED -> mozilla.components.browser.state.state.content.DownloadState.Status.FAILED + eu.weblibre.flutter_mozilla_components.pigeons.DownloadStatus.COMPLETED -> mozilla.components.browser.state.state.content.DownloadState.Status.COMPLETED + } + } + +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt new file mode 100644 index 00000000..2bdb72a9 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt @@ -0,0 +1,170 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.pigeons.ColorScheme +import eu.weblibre.flutter_mozilla_components.pigeons.CookieBannerHandlingMode +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi +import eu.weblibre.flutter_mozilla_components.pigeons.HttpsOnlyMode +import eu.weblibre.flutter_mozilla_components.pigeons.WebContentIsolationStrategy +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy +import mozilla.components.concept.engine.mediaquery.PreferredColorScheme +import mozilla.components.feature.addons.logger +import mozilla.components.feature.session.SettingsUseCases +import mozilla.components.feature.session.TrackingProtectionUseCases + +/** + * Implementation of GeckoEngineSettingsApi that manages engine-specific settings + */ +class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi { + companion object { + private const val TAG = "GeckoEngineSettingsApi" + } + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private fun updateFingerprintingProtection(trackingProtectionPolicy: eu.weblibre.flutter_mozilla_components.pigeons.TrackingProtectionPolicy) { + when(trackingProtectionPolicy) { + eu.weblibre.flutter_mozilla_components.pigeons.TrackingProtectionPolicy.STRICT -> { + components.core.engineSettings.fingerprintingProtection = true + components.core.engineSettings.fingerprintingProtectionPrivateBrowsing = true + } + eu.weblibre.flutter_mozilla_components.pigeons.TrackingProtectionPolicy.RECOMMENDED -> { + components.core.engineSettings.fingerprintingProtection = false + components.core.engineSettings.fingerprintingProtectionPrivateBrowsing = true + } + eu.weblibre.flutter_mozilla_components.pigeons.TrackingProtectionPolicy.CUSTOM -> TODO() + eu.weblibre.flutter_mozilla_components.pigeons.TrackingProtectionPolicy.NONE -> { + components.core.engineSettings.fingerprintingProtection = false + components.core.engineSettings.fingerprintingProtectionPrivateBrowsing = true + } + } + } + + override fun setDefaultSettings(settings: GeckoEngineSettings) { + if(settings.javascriptEnabled != null) { + components.core.engineSettings.javascriptEnabled = settings.javascriptEnabled; + } + if(settings.trackingProtectionPolicy != null) { + components.core.engineSettings.trackingProtectionPolicy = when(settings.trackingProtectionPolicy) { + eu.weblibre.flutter_mozilla_components.pigeons.TrackingProtectionPolicy.NONE -> TrackingProtectionPolicy.none() + eu.weblibre.flutter_mozilla_components.pigeons.TrackingProtectionPolicy.RECOMMENDED -> TrackingProtectionPolicy.recommended() + eu.weblibre.flutter_mozilla_components.pigeons.TrackingProtectionPolicy.STRICT -> TrackingProtectionPolicy.strict() + eu.weblibre.flutter_mozilla_components.pigeons.TrackingProtectionPolicy.CUSTOM -> TODO() + } + + updateFingerprintingProtection(settings.trackingProtectionPolicy) + } + if(settings.httpsOnlyMode != null) { + components.core.engineSettings.httpsOnlyMode = when(settings.httpsOnlyMode) { + HttpsOnlyMode.DISABLED -> Engine.HttpsOnlyMode.DISABLED + HttpsOnlyMode.PRIVATE_ONLY -> Engine.HttpsOnlyMode.ENABLED_PRIVATE_ONLY + HttpsOnlyMode.ENABLED -> Engine.HttpsOnlyMode.ENABLED + } + } + if(settings.globalPrivacyControlEnabled != null) { + components.core.engineSettings.globalPrivacyControlEnabled = settings.globalPrivacyControlEnabled; + } + if(settings.preferredColorScheme != null) { + components.core.engineSettings.preferredColorScheme = when(settings.preferredColorScheme) { + ColorScheme.SYSTEM -> PreferredColorScheme.System + ColorScheme.LIGHT -> PreferredColorScheme.Light + ColorScheme.DARK -> PreferredColorScheme.Dark + } + } + if(settings.cookieBannerHandlingMode != null) { + components.core.engineSettings.cookieBannerHandlingMode = when(settings.cookieBannerHandlingMode) { + CookieBannerHandlingMode.DISABLED -> EngineSession.CookieBannerHandlingMode.DISABLED + CookieBannerHandlingMode.REJECT_ALL -> EngineSession.CookieBannerHandlingMode.REJECT_ALL + CookieBannerHandlingMode.REJECT_OR_ACCEPT_ALL -> EngineSession.CookieBannerHandlingMode.REJECT_OR_ACCEPT_ALL + } + } + if(settings.cookieBannerHandlingModePrivateBrowsing != null) { + components.core.engineSettings.cookieBannerHandlingModePrivateBrowsing = when(settings.cookieBannerHandlingModePrivateBrowsing) { + CookieBannerHandlingMode.DISABLED -> EngineSession.CookieBannerHandlingMode.DISABLED + CookieBannerHandlingMode.REJECT_ALL -> EngineSession.CookieBannerHandlingMode.REJECT_ALL + CookieBannerHandlingMode.REJECT_OR_ACCEPT_ALL -> EngineSession.CookieBannerHandlingMode.REJECT_OR_ACCEPT_ALL + } + } + if(settings.cookieBannerHandlingGlobalRules != null) { + components.core.engineSettings.cookieBannerHandlingGlobalRules = settings.cookieBannerHandlingGlobalRules; + } + if(settings.cookieBannerHandlingGlobalRulesSubFrames != null) { + components.core.engineSettings.cookieBannerHandlingGlobalRulesSubFrames = settings.cookieBannerHandlingGlobalRulesSubFrames; + } + if(settings.webContentIsolationStrategy != null) { + components.core.engineSettings.webContentIsolationStrategy = when(settings.webContentIsolationStrategy) { + WebContentIsolationStrategy.ISOLATE_NOTHING -> mozilla.components.concept.engine.fission.WebContentIsolationStrategy.ISOLATE_NOTHING + WebContentIsolationStrategy.ISOLATE_EVERYTHING -> mozilla.components.concept.engine.fission.WebContentIsolationStrategy.ISOLATE_EVERYTHING + WebContentIsolationStrategy.ISOLATE_HIGH_VALUE -> mozilla.components.concept.engine.fission.WebContentIsolationStrategy.ISOLATE_HIGH_VALUE + } + } + if(settings.userAgent != null) { + components.core.engineSettings.userAgentString = settings.userAgent; + } + } + + override fun updateRuntimeSettings(settings: GeckoEngineSettings) { + //First parse and set default values + setDefaultSettings(settings); + + var reloadSession = false + + //Then copy default settings into runtime + if(settings.javascriptEnabled != null) { + components.core.engine.settings.javascriptEnabled = components.core.engineSettings.javascriptEnabled + reloadSession = true + } + if(settings.trackingProtectionPolicy != null) { + components.useCases.settingsUseCases.updateTrackingProtection(components.core.engineSettings.trackingProtectionPolicy!!) + components.core.engine.settings.fingerprintingProtection = components.core.engineSettings.fingerprintingProtection + components.core.engine.settings.fingerprintingProtectionPrivateBrowsing = components.core.engineSettings.fingerprintingProtectionPrivateBrowsing + reloadSession = true + } + if(settings.httpsOnlyMode != null) { + components.core.engine.settings.httpsOnlyMode = components.core.engineSettings.httpsOnlyMode + } + if(settings.globalPrivacyControlEnabled != null) { + components.core.engine.settings.globalPrivacyControlEnabled = components.core.engineSettings.globalPrivacyControlEnabled + } + if(settings.preferredColorScheme != null) { + components.core.engine.settings.preferredColorScheme = components.core.engineSettings.preferredColorScheme + reloadSession = true + } + if(settings.cookieBannerHandlingMode != null) { + components.core.engine.settings.cookieBannerHandlingMode = components.core.engineSettings.cookieBannerHandlingMode + reloadSession = true + } + if(settings.cookieBannerHandlingModePrivateBrowsing != null) { + components.core.engine.settings.cookieBannerHandlingModePrivateBrowsing = components.core.engineSettings.cookieBannerHandlingModePrivateBrowsing + reloadSession = true + } + if(settings.cookieBannerHandlingGlobalRules != null) { + components.core.engine.settings.cookieBannerHandlingGlobalRules = components.core.engineSettings.cookieBannerHandlingGlobalRules + } + if(settings.cookieBannerHandlingGlobalRulesSubFrames != null) { + components.core.engine.settings.cookieBannerHandlingGlobalRulesSubFrames = components.core.engineSettings.cookieBannerHandlingGlobalRulesSubFrames + } + if(settings.webContentIsolationStrategy != null) { + components.core.engine.settings.webContentIsolationStrategy = components.core.engineSettings.webContentIsolationStrategy + } + if(settings.userAgent != null) { + components.core.engine.settings.userAgentString = components.core.engineSettings.userAgentString + reloadSession = true + } + + if(reloadSession) { + components.useCases.sessionUseCases.reload() + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoFindApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoFindApiImpl.kt new file mode 100644 index 00000000..684e9106 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoFindApiImpl.kt @@ -0,0 +1,55 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoFindApi +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.selector.findTabOrCustomTab +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.concept.engine.EngineSession + +class GeckoFindApiImpl : GeckoFindApi { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private fun sessionByTabId(tabId: String?) : EngineSession? { + val loadSessionId = tabId + ?: components.core.store.state.selectedTabId + + if (loadSessionId == null) { + return null + } + + val tab = components.core.store.state.findTabOrCustomTab(loadSessionId) + return tab?.engineState?.engineSession + } + + override fun findAll(tabId: String?, text: String) { + val engineSession = sessionByTabId(tabId) + engineSession?.findAll(text) + } + + override fun findNext(tabId: String?, forward: Boolean) { + val engineSession = sessionByTabId(tabId) + engineSession?.findNext(forward) + } + + override fun clearMatches(tabId: String?) { + val loadSessionId = tabId + ?: components.core.store.state.selectedTabId + + if (loadSessionId == null) { + return + } + + val engineSession = sessionByTabId(loadSessionId) + engineSession?.clearFindMatches() + components.core.store.dispatch(ContentAction.ClearFindResultsAction(loadSessionId)) + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoIconsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoIconsApiImpl.kt new file mode 100644 index 00000000..8f1c2bd5 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoIconsApiImpl.kt @@ -0,0 +1,119 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes +import eu.weblibre.flutter_mozilla_components.pigeons.* +import kotlinx.coroutines.* +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.icons.Icon +import mozilla.components.concept.engine.manifest.Size as HtmlSize +import mozilla.components.feature.addons.logger + +private typealias MozillaIconRequest = mozilla.components.browser.icons.IconRequest +private typealias MozillaIconSize = mozilla.components.browser.icons.IconRequest.Size +private typealias MozillaIconResource = mozilla.components.browser.icons.IconRequest.Resource +private typealias MozillaIconResourceType = mozilla.components.browser.icons.IconRequest.Resource.Type + +/** + * Implementation of GeckoIconsApi that handles icon loading and processing + */ +class GeckoIconsApiImpl : GeckoIconsApi { + companion object { + private const val TAG = "GeckoIconsApi" + private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + } + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun loadIcon(request: IconRequest, callback: (Result) -> Unit) { + coroutineScope.launch { + try { + val mozillaRequest = request.toMozillaIconRequest() + logger.debug("$TAG: Loading icon for URL: ${request.url}") + + val result = loadIconAsync(mozillaRequest) + withContext(Dispatchers.Main) { + callback(Result.success(result)) + } + } catch (e: Exception) { + logger.error("$TAG: Failed to load icon", e) + withContext(Dispatchers.Main) { + callback(Result.failure(e)) + } + } + } + } + + private suspend fun loadIconAsync(request: MozillaIconRequest): IconResult { + return try { + val result = components.core.icons.loadIcon(request).await() + val imageBytes = result.bitmap.toWebPBytes() + IconResult( + image = imageBytes, + maskable = result.maskable, + color = result.color?.toLong(), + source = result.source.toApiSource() + ) + } catch (e: Exception) { + logger.error("$TAG: Error in loadIconAsync", e) + throw e + } + } + + private fun IconRequest.toMozillaIconRequest(): MozillaIconRequest { + return MozillaIconRequest( + url = url, + size = size.toMozillaSize(), + color = color?.toInt(), + waitOnNetworkLoad = waitOnNetworkLoad, + isPrivate = isPrivate, + resources = resources.filterNotNull().map { it.toMozillaResource() } + ) + } + + private fun IconSize.toMozillaSize(): MozillaIconSize = when (this) { + IconSize.DEFAULT_SIZE -> MozillaIconSize.DEFAULT + IconSize.LAUNCHER -> MozillaIconSize.LAUNCHER + IconSize.LAUNCHER_ADAPTIVE -> MozillaIconSize.LAUNCHER_ADAPTIVE + } + + private fun Resource.toMozillaResource(): MozillaIconResource { + return MozillaIconResource( + url = url, + mimeType = mimeType, + maskable = maskable, + type = type.toMozillaType(), + sizes = sizes.filterNotNull().map { + HtmlSize(it.height.toInt(), it.width.toInt()) + } + ) + } + + private fun IconType.toMozillaType(): MozillaIconResourceType = when (this) { + IconType.FAVICON -> MozillaIconResourceType.FAVICON + IconType.APPLE_TOUCH_ICON -> MozillaIconResourceType.APPLE_TOUCH_ICON + IconType.FLUID_ICON -> MozillaIconResourceType.FLUID_ICON + IconType.IMAGE_SRC -> MozillaIconResourceType.IMAGE_SRC + IconType.OPEN_GRAPH -> MozillaIconResourceType.OPENGRAPH + IconType.TWITTER -> MozillaIconResourceType.TWITTER + IconType.MICROSOFT_TILE -> MozillaIconResourceType.MICROSOFT_TILE + IconType.TIPPY_TOP -> MozillaIconResourceType.TIPPY_TOP + IconType.MANIFEST_ICON -> MozillaIconResourceType.MANIFEST_ICON + } + + private fun Icon.Source.toApiSource(): IconSource = when (this) { + Icon.Source.GENERATOR -> IconSource.GENERATOR + Icon.Source.DOWNLOAD -> IconSource.DOWNLOAD + Icon.Source.INLINE -> IconSource.INLINE + Icon.Source.MEMORY -> IconSource.MEMORY + Icon.Source.DISK -> IconSource.DISK + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPrefApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPrefApiImpl.kt new file mode 100644 index 00000000..b692ed6e --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPrefApiImpl.kt @@ -0,0 +1,79 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.feature.PrefManagerFeature +import eu.weblibre.flutter_mozilla_components.feature.ResultConsumer +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi +import org.json.JSONObject +import org.json.JSONArray + +class GeckoPrefApiImpl : GeckoPrefApi { + private fun List?.toJson(): JSONArray { + return JSONArray().apply { + this@toJson?.forEach { put(it) } + } + } + + fun JSONObject.toMap(): Map { + val map = mutableMapOf() + val keys = this.keys() + + while (keys.hasNext()) { + val key = keys.next() + when (val value = this.get(key)) { + is JSONObject -> map[key] = value.toMap() +// is JSONArray -> map[key] = value.toList() +// JSONObject.NULL -> map[key] = null + else -> map[key] = value + } + } + + return map + } + + override fun getPrefs( + preferenceFilter: List?, + callback: (Result>) -> Unit + ) { + PrefManagerFeature.scheduleRequest("getPrefs", preferenceFilter.toJson(), object : ResultConsumer { + override fun success(result: JSONObject) { + callback(Result.success(result.getJSONObject("result").toMap())) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } + + override fun applyPrefs(prefBuffer: String, callback: (Result>) -> Unit) { + PrefManagerFeature.scheduleRequest("parsePrefsAndApply", prefBuffer, object : ResultConsumer { + override fun success(result: JSONObject) { + callback(Result.success(result.getJSONObject("result").toMap())) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } + + override fun resetPrefs(preferenceNames: List?, callback: (Result) -> Unit) { + PrefManagerFeature.scheduleRequest("resetPrefs", preferenceNames.toJson(), object : ResultConsumer { + override fun success(result: JSONObject) { + callback(Result.success(Unit)) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } + + +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSelectionActionControllerImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSelectionActionControllerImpl.kt new file mode 100644 index 00000000..e481f5f5 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSelectionActionControllerImpl.kt @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate +import eu.weblibre.flutter_mozilla_components.pigeons.CustomSelectionAction +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController +import mozilla.components.feature.addons.logger + +/** + * Implementation of GeckoSelectionActionController that manages custom text selection actions + * @property selectionActionDelegate Delegate handling selection actions + */ +class GeckoSelectionActionControllerImpl( + private val selectionActionDelegate: DefaultSelectionActionDelegate +) : GeckoSelectionActionController { + + companion object { + private const val TAG = "GeckoSelectionActionController" + } + + /** + * Sets the available custom selection actions + * @param actions List of custom selection actions to be registered + */ + override fun setActions(actions: List) { + try { + require(actions.distinctBy { it.id }.size == actions.size) { + "Duplicate action IDs found in selection actions" + } + + logger.debug("$TAG: Setting ${actions.size} custom selection actions") + selectionActionDelegate.actions = actions.associateBy { it.id }.also { actionMap -> + logger.debug("$TAG: Registered actions: ${actionMap.keys.joinToString()}") + } + } catch (e: Exception) { + logger.error("$TAG: Failed to set selection actions", e) + throw IllegalArgumentException("Failed to set selection actions", e) + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt new file mode 100644 index 00000000..df84c9de --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt @@ -0,0 +1,285 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes +import eu.weblibre.flutter_mozilla_components.pigeons.* +import kotlinx.coroutines.* +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.browser.thumbnails.storage.ThumbnailStorage +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineView +import mozilla.components.concept.engine.translate.TranslationOptions +import mozilla.components.feature.session.SessionUseCases +import mozilla.components.feature.addons.logger +import eu.weblibre.flutter_mozilla_components.pigeons.TranslationOptions as PigeonTranslationOptions + +/** + * Implementation of GeckoSessionApi that manages browser session operations + */ +class GeckoSessionApiImpl : GeckoSessionApi { + companion object { + private const val TAG = "GeckoSessionApi" + } + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private fun getTabId(tabId: String?) = tabId ?: components.core.store.state.selectedTabId + ?: throw IllegalStateException("No tab ID provided and no selected tab") + + override fun loadUrl( + tabId: String?, + url: String, + flags: LoadUrlFlagsValue, + additionalHeaders: Map? + ) { + try { + logger.debug("$TAG: Loading URL: $url for tab: $tabId") + components.useCases.sessionUseCases.loadUrl( + url = url, + sessionId = getTabId(tabId), + flags = EngineSession.LoadUrlFlags.select(flags.value.toInt()), + additionalHeaders = additionalHeaders + ) + } catch (e: Exception) { + logger.error("$TAG: Failed to load URL", e) + throw e + } + } + + override fun loadData(tabId: String?, data: String, mimeType: String, encoding: String) { + try { + logger.debug("$TAG: Loading data with mimeType: $mimeType for tab: $tabId") + components.useCases.sessionUseCases.loadData( + data = data, + tabId = getTabId(tabId), + mimeType = mimeType, + encoding = encoding + ) + } catch (e: Exception) { + logger.error("$TAG: Failed to load data", e) + throw e + } + } + + override fun reload(tabId: String?, flags: LoadUrlFlagsValue) { + try { + logger.debug("$TAG: Reloading tab: $tabId") + components.useCases.sessionUseCases.reload( + tabId = getTabId(tabId), + flags = EngineSession.LoadUrlFlags.select(flags.value.toInt()) + ) + } catch (e: Exception) { + logger.error("$TAG: Failed to reload", e) + throw e + } + } + + override fun stopLoading(tabId: String?) { + try { + logger.debug("$TAG: Stopping loading for tab: $tabId") + components.useCases.sessionUseCases.stopLoading(tabId = getTabId(tabId)) + } catch (e: Exception) { + logger.error("$TAG: Failed to stop loading", e) + throw e + } + } + + override fun goBack(tabId: String?, userInteraction: Boolean) { + try { + logger.debug("$TAG: Going back in tab: $tabId") + components.useCases.sessionUseCases.goBack( + tabId = getTabId(tabId), + userInteraction = userInteraction + ) + } catch (e: Exception) { + logger.error("$TAG: Failed to go back", e) + throw e + } + } + + override fun goForward(tabId: String?, userInteraction: Boolean) { + try { + logger.debug("$TAG: Going forward in tab: $tabId") + components.useCases.sessionUseCases.goForward( + tabId = getTabId(tabId), + userInteraction = userInteraction + ) + } catch (e: Exception) { + logger.error("$TAG: Failed to go forward", e) + throw e + } + } + + override fun goToHistoryIndex(index: Long, tabId: String?) { + try { + logger.debug("$TAG: Going to history index $index in tab: $tabId") + components.useCases.sessionUseCases.goToHistoryIndex( + tabId = getTabId(tabId), + index = index.toInt() + ) + } catch (e: Exception) { + logger.error("$TAG: Failed to go to history index", e) + throw e + } + } + + override fun requestDesktopSite(tabId: String?, enable: Boolean) { + try { + logger.debug("$TAG: Setting desktop site to $enable for tab: $tabId") + components.useCases.sessionUseCases.requestDesktopSite( + tabId = getTabId(tabId), + enable = enable + ) + } catch (e: Exception) { + logger.error("$TAG: Failed to request desktop site", e) + throw e + } + } + + override fun exitFullscreen(tabId: String?) { + try { + logger.debug("$TAG: Exiting fullscreen for tab: $tabId") + components.useCases.sessionUseCases.exitFullscreen(tabId = getTabId(tabId)) + } catch (e: Exception) { + logger.error("$TAG: Failed to exit fullscreen", e) + throw e + } + } + + override fun saveToPdf(tabId: String?) { + try { + logger.debug("$TAG: Saving to PDF for tab: $tabId") + components.useCases.sessionUseCases.saveToPdf(tabId = getTabId(tabId)) + } catch (e: Exception) { + logger.error("$TAG: Failed to save to PDF", e) + throw e + } + } + + override fun printContent(tabId: String?) { + try { + logger.debug("$TAG: Printing content for tab: $tabId") + components.useCases.sessionUseCases.printContent(tabId = getTabId(tabId)) + } catch (e: Exception) { + logger.error("$TAG: Failed to print content", e) + throw e + } + } + + override fun translate( + tabId: String?, + fromLanguage: String, + toLanguage: String, + options: PigeonTranslationOptions? + ) { + try { + logger.debug("$TAG: Translating from $fromLanguage to $toLanguage for tab: $tabId") + components.useCases.sessionUseCases.translate( + tabId = getTabId(tabId), + fromLanguage = fromLanguage, + toLanguage = toLanguage, + options = options?.let { TranslationOptions(downloadModel = it.downloadModel) } + ) + } catch (e: Exception) { + logger.error("$TAG: Failed to translate", e) + throw e + } + } + + override fun translateRestore(tabId: String?) { + try { + logger.debug("$TAG: Restoring translation for tab: $tabId") + components.useCases.sessionUseCases.translateRestore(tabId = getTabId(tabId)) + } catch (e: Exception) { + logger.error("$TAG: Failed to restore translation", e) + throw e + } + } + + override fun crashRecovery(tabIds: List?) { + try { + logger.debug("$TAG: Performing crash recovery for tabs: $tabIds") + if (tabIds != null) { + components.useCases.sessionUseCases.crashRecovery.invoke(tabIds = tabIds) + } else { + components.useCases.sessionUseCases.crashRecovery.invoke() + } + } catch (e: Exception) { + logger.error("$TAG: Failed to perform crash recovery", e) + throw e + } + } + + override fun purgeHistory() { + try { + logger.debug("$TAG: Purging history") + components.useCases.sessionUseCases.purgeHistory() + } catch (e: Exception) { + logger.error("$TAG: Failed to purge history", e) + throw e + } + } + + override fun updateLastAccess(tabId: String?, lastAccess: Long?) { + try { + val timestamp = lastAccess ?: System.currentTimeMillis() + logger.debug("$TAG: Updating last access time to $timestamp for tab: $tabId") + components.useCases.sessionUseCases.updateLastAccess( + tabId = getTabId(tabId), + lastAccess = timestamp + ) + } catch (e: Exception) { + logger.error("$TAG: Failed to update last access", e) + throw e + } + } + + override fun requestScreenshot(sendBack: Boolean, callback: (Result) -> Unit) { + try { + val tab = components.core.store.state.selectedTab + if (tab == null) { + logger.warn("$TAG: No selected tab for screenshot") + callback(Result.failure(IllegalStateException("No selected tab for screenshot"))) + return + } + + components.engineView?.captureThumbnail { bitmap -> + try { + if (bitmap != null) { + components.core.store.dispatch(ContentAction.UpdateThumbnailAction(tab.id, bitmap)) + if (sendBack) { + val compressed = bitmap.toWebPBytes() + logger.debug("$TAG: Screenshot captured successfully") + callback(Result.success(compressed)) + } else { + callback(Result.success(null)) + } + } else { + logger.warn("$TAG: Failed to capture screenshot - null bitmap") + callback(Result.success(null)) + } + } catch (e: Exception) { + logger.error("$TAG: Failed to process screenshot", e) + callback(Result.failure(e)) + } + } ?: run { + logger.warn("$TAG: No engine view available for screenshot") + callback(Result.failure(IllegalStateException("No engine view available"))) + } + } catch (e: Exception) { + logger.error("$TAG: Failed to request screenshot", e) + callback(Result.failure(e)) + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSuggestionApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSuggestionApiImpl.kt new file mode 100644 index 00000000..1ff2352c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSuggestionApiImpl.kt @@ -0,0 +1,88 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.api.GeckoDeleteBrowsingDataControllerImpl.Companion +import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes +import eu.weblibre.flutter_mozilla_components.pigeons.AutocompleteResult +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestion +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.concept.engine.Engine +import org.mozilla.gecko.util.ThreadUtils.runOnUiThread + +class GeckoSuggestionApiImpl( + private val suggestionEvents: GeckoSuggestionEvents +) : GeckoSuggestionApi { + companion object { + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + } + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun getAutocompleteSuggestion( + query: String, + callback: (Result) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + val suggestion = components.core.historyStorage.getAutocompleteSuggestion(query) + + callback(Result.success( suggestion?.let { AutocompleteResult( + input = it.input, + url = it.url, + text = it.text, + totalItems = it.totalItems.toLong(), + source = it.source + )} + + )) + } + } + } + + override fun querySuggestions(text: String, providers: List) { + for(provider in providers) { + coroutineScope.launch { + val results = when(provider) { + GeckoSuggestionType.SESSION -> components.search.sessionSuggestions.onInputChanged(text) + GeckoSuggestionType.CLIPBOARD -> components.search.clipboardSuggestions.onInputChanged(text) + GeckoSuggestionType.HISTORY -> components.search.historySuggestions.onInputChanged(text) + } + + val mappedResults = results.map { result -> + GeckoSuggestion( + id = result.id, + title = result.title, + score = result.score.toLong(), + description = result.description, + type = provider, + editSuggestion = result.editSuggestion, + icon = result.icon?.toWebPBytes() + ) + } + + runOnUiThread { + suggestionEvents.onSuggestionResult( + System.currentTimeMillis(), + provider, + mappedResults + ) { } + } + } + } + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoTabsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoTabsApiImpl.kt new file mode 100644 index 00000000..2be9b2af --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoTabsApiImpl.kt @@ -0,0 +1,521 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabsApi +import eu.weblibre.flutter_mozilla_components.pigeons.HistoryMetadataKey as PigeonHistoryMetadataKey +import eu.weblibre.flutter_mozilla_components.pigeons.LoadUrlFlagsValue +import eu.weblibre.flutter_mozilla_components.pigeons.RecoverableBrowserState as PigeonRecoverableBrowserState +import eu.weblibre.flutter_mozilla_components.pigeons.RestoreLocation as PigeonRestoreLocation +import eu.weblibre.flutter_mozilla_components.pigeons.RecoverableTab as PigeonRecoverableTab +import eu.weblibre.flutter_mozilla_components.pigeons.SourceValue +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes +import eu.weblibre.flutter_mozilla_components.pigeons.FindResultState +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.weblibre.flutter_mozilla_components.pigeons.HistoryItem +import eu.weblibre.flutter_mozilla_components.pigeons.HistoryState +import eu.weblibre.flutter_mozilla_components.pigeons.ReaderableState +import eu.weblibre.flutter_mozilla_components.pigeons.RestoreLocation +import eu.weblibre.flutter_mozilla_components.pigeons.SecurityInfoState +import eu.weblibre.flutter_mozilla_components.pigeons.TabContentState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.session.storage.RecoverableBrowserState +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.selector.findTab +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.LastMediaAccessState +import mozilla.components.browser.state.state.ReaderState +import mozilla.components.browser.state.state.SessionState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.state.recover.RecoverableTab +import mozilla.components.browser.state.state.recover.TabState +import mozilla.components.browser.thumbnails.storage.ThumbnailStorage +import mozilla.components.concept.base.images.ImageLoadRequest +import mozilla.components.concept.storage.HistoryMetadataKey +import mozilla.components.feature.tabs.TabsUseCases +import org.json.JSONObject +import org.mozilla.gecko.util.ThreadUtils.runOnUiThread +import mozilla.components.feature.addons.logger +import mozilla.components.concept.engine.EngineSession + +class GeckoTabsApiImpl : GeckoTabsApi { + companion object { + private const val TAG = "GeckoTabsApi" + private val coroutineScope = CoroutineScope(Dispatchers.Default) + } + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private fun restoreSource(source: SourceValue): SessionState.Source { + return SessionState.Source.restore( + source.id.toInt(), + source.caller?.packageId, + source.caller?.category?.value?.toInt() + ) + } + + private fun mapTab(tab: PigeonRecoverableTab): RecoverableTab { + return RecoverableTab( + engineSessionState = tab.engineSessionStateJson?.let { json -> + components.core.engine.createSessionState(JSONObject(json)) + }, + state = TabState( + id = tab.state.id, + url = tab.state.url, + parentId = tab.state.parentId, + title = tab.state.title, + searchTerm = tab.state.searchTerm, + contextId = tab.state.contextId, + readerState = ReaderState( + readerable = tab.state.readerState.readerable, + active = tab.state.readerState.active, + checkRequired = tab.state.readerState.checkRequired, + connectRequired = tab.state.readerState.connectRequired, + baseUrl = tab.state.readerState.baseUrl, + activeUrl = tab.state.readerState.activeUrl, + scrollY = tab.state.readerState.scrollY?.toInt() + ), + lastAccess = tab.state.lastAccess, + createdAt = tab.state.createdAt, + lastMediaAccessState = LastMediaAccessState( + lastMediaUrl = tab.state.lastMediaAccessState.lastMediaUrl, + lastMediaAccess = tab.state.lastMediaAccessState.lastMediaAccess, + mediaSessionActive = tab.state.lastMediaAccessState.mediaSessionActive + ), + private = tab.state.private, + historyMetadata = tab.state.historyMetadata?.let { metadata -> + HistoryMetadataKey( + url = metadata.url, + searchTerm = metadata.searchTerm, + referrerUrl = metadata.referrerUrl + ) + }, + source = restoreSource(tab.state.source), + index = tab.state.index.toInt(), + hasFormData = tab.state.hasFormData + ) + ) + } + + private fun mapRestoreLocation(location: PigeonRestoreLocation): TabListAction.RestoreAction.RestoreLocation { + return when(location) { + RestoreLocation.BEGINNING -> TabListAction.RestoreAction.RestoreLocation.BEGINNING + RestoreLocation.END -> TabListAction.RestoreAction.RestoreLocation.END + RestoreLocation.AT_INDEX -> TabListAction.RestoreAction.RestoreLocation.AT_INDEX + } + } + + private suspend fun handleIconChange(tab: SessionState) { + try { + val iconBytes: ByteArray? + if (tab.content.icon != null) { + iconBytes = tab.content.icon?.toWebPBytes() + } else { + iconBytes = null + } + + withContext(Dispatchers.Main) { + components.flutterEvents.onIconChange( + System.currentTimeMillis(), + tab.id, + iconBytes + ) { } + } + } catch (e: Exception) { + logger.error("$TAG: Failed to handle icon change for tab ${tab.id}", e) + } + } + + private suspend fun handleThumbnailChange(tab: SessionState) { + try { + val bitmap = components.core.thumbnailStorage.loadThumbnail( + ImageLoadRequest( + id = tab.id, + size = 1024, + isPrivate = tab.content.private + ) + ).await() + + bitmap?.let { + val bytes = it.toWebPBytes() + withContext(Dispatchers.Main) { + components.flutterEvents.onThumbnailChange(System.currentTimeMillis(), tab.id, bytes) { } + } + } + } catch (e: Exception) { + logger.error("$TAG: Failed to handle thumbnail change for tab ${tab.id}", e) + } + } + + override fun syncEvents( + onSelectedTabChange: Boolean, + onTabListChange: Boolean, + onTabContentStateChange: Boolean, + onIconChange: Boolean, + onSecurityInfoStateChange: Boolean, + onReaderableStateChange: Boolean, + onHistoryStateChange: Boolean, + onFindResults: Boolean, + onThumbnailChange: Boolean, + ) { + try { + val tabs = components.core.store.state.tabs.map { it.copy() } + val selectedTab = components.core.store.state.selectedTabId + + if (onSelectedTabChange) { + components.flutterEvents.onSelectedTabChange(System.currentTimeMillis(), selectedTab) { } + } + + if (onTabListChange) { + components.flutterEvents.onTabListChange(System.currentTimeMillis(), tabs.map { it.id }) { } + } + + tabs.forEach { tab -> + if (onTabContentStateChange) { + components.flutterEvents.onTabContentStateChange( + System.currentTimeMillis(), + TabContentState( + id = tab.id, + parentId = tab.parentId, + contextId = tab.contextId, + url = tab.content.url, + title = tab.content.title, + progress = tab.content.progress.toLong(), + isPrivate = tab.content.private, + isFullScreen = tab.content.fullScreen, + isLoading = tab.content.loading + ) + ) { } + } + + if (onIconChange) { + coroutineScope.launch { handleIconChange(tab) } + } + + if (onSecurityInfoStateChange) { + components.flutterEvents.onSecurityInfoStateChange( + System.currentTimeMillis(), + tab.id, + SecurityInfoState( + tab.content.securityInfo.secure, + tab.content.securityInfo.host, + tab.content.securityInfo.issuer + ) + ) { } + } + + if (onReaderableStateChange) { + components.flutterEvents.onReaderableStateChange( + System.currentTimeMillis(), + tab.id, + ReaderableState( + tab.readerState.readerable, + tab.readerState.active + ) + ) { } + } + + if (onHistoryStateChange) { + components.flutterEvents.onHistoryStateChange( + System.currentTimeMillis(), + tab.id, + HistoryState( + items = tab.content.history.items.map { item -> + HistoryItem(url = item.uri, title = item.title) + }, + currentIndex = tab.content.history.currentIndex.toLong(), + canGoBack = tab.content.canGoBack, + canGoForward = tab.content.canGoForward + ) + ) { } + } + + if (onFindResults) { + components.flutterEvents.onFindResults( + System.currentTimeMillis(), + tab.id, + tab.content.findResults.map { result -> + FindResultState( + activeMatchOrdinal = result.activeMatchOrdinal.toLong(), + numberOfMatches = result.numberOfMatches.toLong(), + isDoneCounting = result.isDoneCounting + ) + } + ) { } + } + + if (onThumbnailChange) { + coroutineScope.launch { handleThumbnailChange(tab) } + } + } + } catch (e: Exception) { + logger.error("$TAG: Failed to sync events", e) + } + } + + override fun selectTab(tabId: String) { + try { + components.useCases.tabsUseCases.selectTab(tabId = tabId) + logger.debug("$TAG: Selected tab $tabId") + } catch (e: Exception) { + logger.error("$TAG: Failed to select tab $tabId", e) + throw e + } + } + + override fun removeTab(tabId: String) { + try { + components.useCases.tabsUseCases.removeTab(tabId = tabId) + logger.debug("$TAG: Removed tab $tabId") + } catch (e: Exception) { + logger.error("$TAG: Failed to remove tab $tabId", e) + throw e + } + } + + override fun addTab( + url: String, + selectTab: Boolean, + startLoading: Boolean, + parentId: String?, + flags: LoadUrlFlagsValue, + contextId: String?, + source: SourceValue, + private: Boolean, + historyMetadata: PigeonHistoryMetadataKey?, + additionalHeaders: Map? + ): String { + try { + return components.useCases.tabsUseCases.addTab( + url = url, + selectTab = selectTab, + startLoading = startLoading, + parentId = parentId, + flags = EngineSession.LoadUrlFlags.select(flags.value.toInt()), + contextId = contextId, + source = restoreSource(source), + private = private, + historyMetadata = historyMetadata?.let { metadata -> + HistoryMetadataKey( + url = metadata.url, + searchTerm = metadata.searchTerm, + referrerUrl = metadata.referrerUrl + ) + }, + additionalHeaders = additionalHeaders + ).also { + logger.debug("$TAG: Added new tab with ID $it") + } + } catch (e: Exception) { + logger.error("$TAG: Failed to add tab", e) + throw e + } + } + + override fun removeAllTabs(recoverable: Boolean) { + try { + components.useCases.tabsUseCases.removeAllTabs(recoverable = recoverable) + logger.debug("$TAG: Removed all tabs, recoverable: $recoverable") + } catch (e: Exception) { + logger.error("$TAG: Failed to remove all tabs", e) + throw e + } + } + + override fun removeTabs(ids: List) { + try { + components.useCases.tabsUseCases.removeTabs(ids = ids) + logger.debug("$TAG: Removed tabs: ${ids.joinToString()}") + } catch (e: Exception) { + logger.error("$TAG: Failed to remove tabs", e) + throw e + } + } + + override fun removeNormalTabs() { + try { + components.useCases.tabsUseCases.removeNormalTabs() + logger.debug("$TAG: Removed all normal tabs") + } catch (e: Exception) { + logger.error("$TAG: Failed to remove normal tabs", e) + throw e + } + } + + override fun removePrivateTabs() { + try { + components.useCases.tabsUseCases.removePrivateTabs() + logger.debug("$TAG: Removed all private tabs") + } catch (e: Exception) { + logger.error("$TAG: Failed to remove private tabs", e) + throw e + } + } + + override fun undo() { + try { + components.useCases.tabsUseCases.undo() + logger.debug("$TAG: Performed undo operation") + } catch (e: Exception) { + logger.error("$TAG: Failed to perform undo", e) + throw e + } + } + + override fun restoreTabsByList( + tabs: List, + selectTabId: String?, + restoreLocation: PigeonRestoreLocation + ) { + try { + components.useCases.tabsUseCases.restore( + tabs = tabs.map { mapTab(it) }, + restoreLocation = mapRestoreLocation(restoreLocation), + selectTabId = selectTabId + ) + logger.debug("$TAG: Restored ${tabs.size} tabs") + } catch (e: Exception) { + logger.error("$TAG: Failed to restore tabs", e) + throw e + } + } + + override fun restoreTabsByBrowserState( + state: PigeonRecoverableBrowserState, + restoreLocation: PigeonRestoreLocation + ) { + try { + components.useCases.tabsUseCases.restore( + state = RecoverableBrowserState( + selectedTabId = state.selectedTabId, + tabs = state.tabs.filterNotNull().map { mapTab(it) } + ), + restoreLocation = mapRestoreLocation(restoreLocation) + ) + logger.debug("$TAG: Restored browser state") + } catch (e: Exception) { + logger.error("$TAG: Failed to restore browser state", e) + throw e + } + } + + override fun selectOrAddTabByHistory( + url: String, + historyMetadata: PigeonHistoryMetadataKey + ): String { + try { + return components.useCases.tabsUseCases.selectOrAddTab( + url = url, + historyMetadata = HistoryMetadataKey( + url = historyMetadata.url, + searchTerm = historyMetadata.searchTerm, + referrerUrl = historyMetadata.referrerUrl + ) + ).also { + logger.debug("$TAG: Selected or added tab by history with ID $it") + } + } catch (e: Exception) { + logger.error("$TAG: Failed to select or add tab by history", e) + throw e + } + } + + override fun selectOrAddTabByUrl( + url: String, + private: Boolean, + source: SourceValue, + flags: LoadUrlFlagsValue, + ignoreFragment: Boolean + ): String { + try { + return components.useCases.tabsUseCases.selectOrAddTab( + url = url, + private = private, + source = restoreSource(source), + flags = EngineSession.LoadUrlFlags.select(flags.value.toInt()), + ignoreFragment = ignoreFragment + ).also { + logger.debug("$TAG: Selected or added tab by URL with ID $it") + } + } catch (e: Exception) { + logger.error("$TAG: Failed to select or add tab by URL", e) + throw e + } + } + + override fun duplicateTab( + selectTabId: String?, + selectNewTab: Boolean, + newContextId: String? + ): String { + try { + val tabState = selectTabId?.let { components.core.store.state.findTab(it) } + ?: throw IllegalArgumentException("Tab not found") + + val duplicate = createTab( + url = tabState.content.url, + private = tabState.content.private, + contextId = newContextId, + parent = tabState, + engineSessionState = tabState.engineState.engineSessionState, + ) + + components.core.store.dispatch( + TabListAction.AddTabAction( + duplicate, + select = selectNewTab, + ), + ) + + logger.debug("$TAG: Duplicated tab $selectTabId to new tab ${duplicate.id}") + + return duplicate.id + } catch (e: Exception) { + logger.error("$TAG: Failed to duplicate tab", e) + throw e + } + } + + override fun moveTabs(tabIds: List, targetTabId: String, placeAfter: Boolean) { + try { + components.useCases.tabsUseCases.moveTabs( + tabIds = tabIds, + targetTabId = targetTabId, + placeAfter = placeAfter + ) + logger.debug("$TAG: Moved tabs ${tabIds.joinToString()} to $targetTabId") + } catch (e: Exception) { + logger.error("$TAG: Failed to move tabs", e) + throw e + } + } + + override fun migratePrivateTabUseCase(tabId: String, alternativeUrl: String?): String { + try { + return components.useCases.tabsUseCases.migratePrivateTabUseCase( + tabId = tabId, + alternativeUrl = alternativeUrl + ).also { + logger.debug("$TAG: Migrated private tab $tabId") + } + } catch (e: Exception) { + logger.error("$TAG: Failed to migrate private tab", e) + throw e + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/ReaderViewEventsImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/ReaderViewEventsImpl.kt new file mode 100644 index 00000000..73301e55 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/ReaderViewEventsImpl.kt @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewEvents + +// Interface for the listener +interface ReaderViewControllerListener { + fun onReaderViewToggled(enabled: Boolean) + fun onAppearanceButtonTap() +} + +class ReaderViewEventsImpl : ReaderViewEvents { + + private val listeners = mutableSetOf() + + override fun onToggleReaderView(enable: Boolean) { + listeners.forEach { it.onReaderViewToggled(enable) } + } + + override fun onAppearanceButtonTap() { + listeners.forEach { it.onAppearanceButtonTap() } + } + + // Method to add a listener + fun addListener(listener: ReaderViewControllerListener) { + listeners.add(listener) + } + + // Method to remove a listener + fun removeListener(listener: ReaderViewControllerListener) { + listeners.remove(listener) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt new file mode 100644 index 00000000..093288e3 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt @@ -0,0 +1,250 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.components + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import eu.weblibre.flutter_mozilla_components.Components +import eu.weblibre.flutter_mozilla_components.interceptor.AppRequestInterceptor +import eu.weblibre.flutter_mozilla_components.services.DownloadService +import eu.weblibre.flutter_mozilla_components.EngineProvider +import eu.weblibre.flutter_mozilla_components.PermissionStorage +import eu.weblibre.flutter_mozilla_components.services.MediaSessionService +import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity +import eu.weblibre.flutter_mozilla_components.R +import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey +import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware +import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import kotlinx.coroutines.FlowPreview +import mozilla.components.browser.engine.gecko.permission.GeckoSitePermissionsStorage +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.session.storage.SessionStorage +import mozilla.components.browser.state.engine.EngineMiddleware +import mozilla.components.browser.state.engine.middleware.SessionPrioritizationMiddleware +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.browser.storage.sync.PlacesHistoryStorage +import mozilla.components.browser.thumbnails.ThumbnailsMiddleware +import mozilla.components.browser.thumbnails.storage.ThumbnailStorage +import mozilla.components.concept.engine.DefaultSettings +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy +import mozilla.components.concept.engine.fission.WebContentIsolationStrategy +import mozilla.components.concept.engine.mediaquery.PreferredColorScheme +import mozilla.components.concept.fetch.Client +import mozilla.components.feature.addons.AddonManager +import mozilla.components.feature.addons.amo.AMOAddonsProvider +import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker +import mozilla.components.feature.addons.update.DefaultAddonUpdater +import mozilla.components.feature.customtabs.store.CustomTabsServiceStore +import mozilla.components.feature.downloads.DownloadMiddleware +import mozilla.components.feature.media.MediaSessionFeature +import mozilla.components.feature.media.middleware.RecordingDevicesMiddleware +import mozilla.components.feature.prompts.file.FileUploadsDirCleaner +import mozilla.components.feature.readerview.ReaderViewMiddleware +import mozilla.components.feature.session.HistoryDelegate +import mozilla.components.feature.session.middleware.LastAccessMiddleware +import mozilla.components.feature.sitepermissions.OnDiskSitePermissionsStorage +import mozilla.components.feature.webnotifications.WebNotificationFeature +import mozilla.components.support.base.worker.Frequency +import java.util.concurrent.TimeUnit + +private const val DAY_IN_MINUTES = 24 * 60L + +class Core(private val context: Context, + private val components: Components, + private val flutterEvents: GeckoStateEvents, + private val extensionEvents: BrowserExtensionEvents +) { + val prefs by lazy { + PreferenceManager.getDefaultSharedPreferences(context) + } + + val engineSettings by lazy { + DefaultSettings( + //historyTrackingDelegate = HistoryDelegate(lazyHistoryStorage) + requestInterceptor = AppRequestInterceptor(context), + historyTrackingDelegate = HistoryDelegate(lazyHistoryStorage), + testingModeEnabled = false, + remoteDebuggingEnabled = false, + automaticFontSizeAdjustment = true, + fontInflationEnabled = true, + suspendMediaWhenInactive = false, + getDesktopMode = { + store.state.desktopMode + }, + enterpriseRootsEnabled = false, + emailTrackerBlockingPrivateBrowsing = true, +// clearColor = ContextCompat.getColor( +// context, +// R.color.fx_mobile_layer_color_1, +// ), + + trackingProtectionPolicy = createTrackingProtectionPolicy(TrackingProtectionPolicy.strict()), + //FP Protection is handled by trackingPolicy + //fingerprintingProtection + //fingerprintingProtectionPrivateBrowsing + httpsOnlyMode = Engine.HttpsOnlyMode.ENABLED, + globalPrivacyControlEnabled = true, + preferredColorScheme = PreferredColorScheme.Dark, + cookieBannerHandlingMode = EngineSession.CookieBannerHandlingMode.REJECT_ALL, + cookieBannerHandlingModePrivateBrowsing = EngineSession.CookieBannerHandlingMode.REJECT_ALL, + cookieBannerHandlingGlobalRules = true, + cookieBannerHandlingGlobalRulesSubFrames = true, + webContentIsolationStrategy = WebContentIsolationStrategy.ISOLATE_HIGH_VALUE + ) + } + + val engine: Engine by lazy { + EngineProvider.createEngine(context, engineSettings, extensionEvents) + } + + /** + * The [Client] implementation (`concept-fetch`) used for HTTP requests. + */ + val client: Client by lazy { + EngineProvider.createClient(context) + } + + val thumbnailStorage by lazy { ThumbnailStorage(context) } + + val icons by lazy { BrowserIcons(context, client) } + + /** + * A storage component for site permissions. + */ + val geckoSitePermissionsStorage by lazy { + val geckoRuntime = EngineProvider.getOrCreateRuntime(context) + GeckoSitePermissionsStorage(geckoRuntime, OnDiskSitePermissionsStorage(context)) + } + + // Addons + val addonManager by lazy { + AddonManager(store, engine, addonsProvider, addonUpdater) + } + + val addonUpdater by lazy { + DefaultAddonUpdater( + context, + Frequency(1, TimeUnit.DAYS), + components.notificationsDelegate + ) + } + + val addonsProvider by lazy { + AMOAddonsProvider( + context, + client, + collectionName = "7dfae8669acc4312a65e8ba5553036", + maxCacheAgeInMinutes = DAY_IN_MINUTES, + ) + } + + val supportedAddonsChecker by lazy { + DefaultSupportedAddonsChecker(context, Frequency(1, TimeUnit.DAYS)) + } + + val fileUploadsDirCleaner: FileUploadsDirCleaner by lazy { + FileUploadsDirCleaner { context.cacheDir } + } + + @OptIn(FlowPreview::class) + val store by lazy { + BrowserStore( + middleware = listOf( + FlutterEventMiddleware(flutterEvents), + DownloadMiddleware(context, DownloadService::class.java), + ThumbnailsMiddleware(thumbnailStorage), + ReaderViewMiddleware(), +// UndoMiddleware(), + LastAccessMiddleware(), +// PromptMiddleware(), + SessionPrioritizationMiddleware(), + RecordingDevicesMiddleware(context, components.notificationsDelegate), + ) + EngineMiddleware.create( + engine, + // We are disabling automatic suspending of engine sessions under memory pressure. + // Instead we solely rely on GeckoView and the Android system to reclaim memory + // when needed. For details, see: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1752594 + // https://github.com/mozilla-mobile/fenix/issues/12731 + // https://github.com/mozilla-mobile/android-components/issues/11300 + // https://github.com/mozilla-mobile/android-components/issues/11653 + trimMemoryAutomatically = false, + ) + ).apply { + components.events.registerFlowEvents(this) + + icons.install(engine, this) + + WebNotificationFeature( + context, + engine, + icons, + R.drawable.ic_launcher_foreground, + geckoSitePermissionsStorage, + NotificationActivity::class.java, + notificationsDelegate = components.notificationsDelegate, + ) + + MediaSessionFeature(context, MediaSessionService::class.java, this).start() + } + } + + /** + * The [CustomTabsServiceStore] holds global custom tabs related data. + */ + val customTabsStore by lazy { CustomTabsServiceStore() } + + /** + * The storage component for persisting browser tab sessions. + */ + val sessionStorage: SessionStorage by lazy { + SessionStorage(context, engine) + } + + /** + * The storage component to persist browsing history (with the exception of + * private sessions). + */ + val lazyHistoryStorage = lazy { PlacesHistoryStorage(context) } + + /** + * A convenience accessor to the [PlacesHistoryStorage]. + */ + val historyStorage by lazy { lazyHistoryStorage.value } + + val permissionStorage by lazy { PermissionStorage( geckoSitePermissionsStorage ) } + + /** + * Constructs a [TrackingProtectionPolicy] based on current preferences. + * + * @param prefs the shared preferences to use when reading tracking + * protection settings. + * @param normalMode whether or not tracking protection should be enabled + * in normal browsing mode, defaults to the current preference value. + * @param privateMode whether or not tracking protection should be enabled + * in private browsing mode, default to the current preference value. + * @return the constructed tracking protection policy based on preferences. + */ + private fun createTrackingProtectionPolicy( + trackingPolicy: EngineSession. TrackingProtectionPolicyForSessionTypes, + normalMode: Boolean = true, + privateMode: Boolean = true, + ): TrackingProtectionPolicy { + return when { + normalMode && privateMode -> trackingPolicy + normalMode && !privateMode -> trackingPolicy.forRegularSessionsOnly() + !normalMode && privateMode -> trackingPolicy.forPrivateSessionsOnly() + else -> TrackingProtectionPolicy.none() + } + } + +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt new file mode 100644 index 00000000..d0f198c8 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt @@ -0,0 +1,180 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.components + +import eu.weblibre.flutter_mozilla_components.api.ReaderViewEventsImpl +import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes +import eu.weblibre.flutter_mozilla_components.pigeons.FindResultState +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.weblibre.flutter_mozilla_components.pigeons.HistoryItem +import eu.weblibre.flutter_mozilla_components.pigeons.HistoryState +import eu.weblibre.flutter_mozilla_components.pigeons.ReaderableState +import eu.weblibre.flutter_mozilla_components.pigeons.SecurityInfoState +import eu.weblibre.flutter_mozilla_components.pigeons.TabContentState +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import mozilla.components.browser.state.action.BrowserAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.feature.addons.logger +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.ktx.kotlinx.coroutines.flow.filterChanged +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged + +class Events( + private val flutterEvents: GeckoStateEvents, +) { + val readerViewEvents by lazy { ReaderViewEventsImpl() } + + @OptIn(FlowPreview::class) + fun registerFlowEvents(stateFlow: Store) { + stateFlow.flowScoped { flow -> + flow.map { state -> state.selectedTabId } + .distinctUntilChanged() + .collect { tabId -> + flutterEvents.onSelectedTabChange( + System.currentTimeMillis(), + tabId + ) { _ -> } + } + } + + stateFlow.flowScoped { flow -> + flow.mapNotNull { state -> state.tabs } + .filterChanged { + it.content + } + .ifAnyChanged { arrayOf (it.content.icon) } + .debounce { 50 } + .collect { tab -> + val iconBytes = tab.content.icon?.toWebPBytes() + flutterEvents.onIconChange( + System.currentTimeMillis(), + tab.id, + iconBytes + ) { _ -> } + } + } + + stateFlow.flowScoped { flow -> + flow.mapNotNull { state -> state.tabs } + .filterChanged { + it.content.securityInfo + } + .debounce { 50 } + .collect { tab -> + flutterEvents.onSecurityInfoStateChange( + System.currentTimeMillis(), + tab.id, + SecurityInfoState( + tab.content.securityInfo.secure, + tab.content.securityInfo.host, + tab.content.securityInfo.issuer, + ) + ) { _ -> } + } + } + + stateFlow.flowScoped { flow -> + flow.mapNotNull { state -> state.tabs } + .filterChanged { + it.readerState + } + .ifAnyChanged { arrayOf( + it.readerState.readerable, + it.readerState.active, + ) + } + .debounce { 50 } + .collect { tab -> + flutterEvents.onReaderableStateChange( + System.currentTimeMillis(), + tab.id, + ReaderableState( + tab.readerState.readerable, + tab.readerState.active, + ) + ) { _ -> } + } + } + + stateFlow.flowScoped { flow -> + flow.mapNotNull { state -> state.tabs } + .filterChanged { + it.content + } + .ifAnyChanged { arrayOf( + it.content.history, + it.content.canGoBack, + it.content.canGoForward, + ) + } + .debounce { 50 } + .collect { tab -> + flutterEvents.onHistoryStateChange( + System.currentTimeMillis(), + tab.id, + HistoryState( + items = tab.content.history.items.map { item -> HistoryItem( + url = item.uri, + title = item.title + ) }, + currentIndex = tab.content.history.currentIndex.toLong(), + canGoBack = tab.content.canGoBack, + canGoForward = tab.content.canGoForward, + ) + ) { _ -> } + } + } + + stateFlow.flowScoped { flow -> + flow.mapNotNull { state -> state.tabs.map {tab -> tab.id} } + .distinctUntilChanged() + .collect { tabs -> + flutterEvents.onTabListChange(System.currentTimeMillis(), tabs) { _ -> } + } + } + + stateFlow.flowScoped { flow -> + flow.mapNotNull { state -> state.tabs } + .filterChanged { + it.content + } + .ifAnyChanged { arrayOf( + it.content.url, + it.content.title, + it.content.private, + it.content.fullScreen, + it.content.progress, + it.content.loading) + } + .debounce { 50 } + .collect { tab -> + logger.info("title: ${tab.content.title} ${tab.content.url}") + flutterEvents.onTabContentStateChange( + System.currentTimeMillis(), + TabContentState( + id = tab.id, + parentId = tab.parentId, + contextId = tab.contextId, + url = tab.content.url, + title = tab.content.title, + progress = tab.content.progress.toLong(), + isPrivate = tab.content.private, + isFullScreen = tab.content.fullScreen, + isLoading = tab.content.loading + ) + ) { _ -> } + } + } + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Features.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Features.kt new file mode 100644 index 00000000..737966e9 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Features.kt @@ -0,0 +1,36 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.components + +import eu.weblibre.flutter_mozilla_components.feature.ReadabilityExtractFeature +import eu.weblibre.flutter_mozilla_components.feature.WebExtensionToolbarFeature +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.Engine + +class Features( + private val engine: Engine, + private val store: BrowserStore, + private val addonEvents: GeckoAddonEvents, + private var tabContentEvents: GeckoTabContentEvents +) { + val webExtensionToolbarFeature by lazy { + WebExtensionToolbarFeature( + store, + addonEvents + ) + } + + val readabilityExtractFeature by lazy { + ReadabilityExtractFeature( + engine, + store, + tabContentEvents + ) + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Search.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Search.kt new file mode 100644 index 00000000..8ce7611c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Search.kt @@ -0,0 +1,42 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.components + +import android.content.Context +import mozilla.components.feature.awesomebar.provider.ClipboardSuggestionProvider +import mozilla.components.feature.awesomebar.provider.HistoryStorageSuggestionProvider +import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider + +class Search( + private val context: Context, + private val core: Core, + private val useCases: UseCases, +) { + val sessionSuggestions by lazy { + SessionSuggestionProvider( + context.resources, + core.store, + useCases.tabsUseCases.selectTab, + ) + } + + val clipboardSuggestions by lazy { + ClipboardSuggestionProvider( + context, + useCases.sessionUseCases.loadUrl, + ) + } + + val historySuggestions by lazy { + HistoryStorageSuggestionProvider( + core.historyStorage, + useCases.sessionUseCases.loadUrl, + core.icons, + core.engine, + ) + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt new file mode 100644 index 00000000..91dd2b63 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.components + +import android.content.Context +import androidx.preference.PreferenceManager +import eu.weblibre.flutter_mozilla_components.R +import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey +import mozilla.components.feature.app.links.AppLinksInterceptor +import mozilla.components.feature.tabs.TabsUseCases + +/** + * Component group which encapsulates foreground-friendly services. + */ +class Services( + private val context: Context, + private val tabsUseCases: TabsUseCases, +) { + private val prefs = PreferenceManager.getDefaultSharedPreferences(context) + + val appLinksInterceptor by lazy { + AppLinksInterceptor( + context, + launchInApp = { + prefs.getBoolean(context.getPreferenceKey(R.string.pref_key_launch_external_app), false) + }, + ) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt new file mode 100644 index 00000000..528575fe --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.components + +import android.content.Context +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.Engine +import mozilla.components.feature.app.links.AppLinksUseCases +import mozilla.components.feature.downloads.DownloadsUseCases +import mozilla.components.feature.session.SessionUseCases +import mozilla.components.feature.session.SettingsUseCases +import mozilla.components.feature.session.TrackingProtectionUseCases +import mozilla.components.feature.tabs.CustomTabsUseCases +import mozilla.components.feature.tabs.TabsUseCases + +/** + * Component group for all use cases. Use cases are provided by feature + * modules and can be triggered by UI interactions. + */ +class UseCases( + private val context: Context, + private val engine: Engine, + private val store: BrowserStore, +) { + /** + * Use cases that provide engine interactions for a given browser session. + */ + val sessionUseCases by lazy { SessionUseCases(store) } + + /** + * Use cases that provide tab management. + */ + val tabsUseCases: TabsUseCases by lazy { TabsUseCases(store) } + + /** + * Use cases that provide settings management. + */ + val settingsUseCases by lazy { SettingsUseCases(engine, store) } + + /** + * Use cases related to the downloads feature. + */ + val downloadsUseCases: DownloadsUseCases by lazy { DownloadsUseCases(store) } + + /** + * Use cases related to Custom Tabs. + */ + val customTabsUseCases: CustomTabsUseCases by lazy { CustomTabsUseCases(store, sessionUseCases.loadUrl) } + + val appLinksUseCases by lazy { AppLinksUseCases(context) } + + //val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/Bitmap.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/Bitmap.kt new file mode 100644 index 00000000..45dd4a0c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/Bitmap.kt @@ -0,0 +1,40 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.ext + +import android.graphics.Bitmap +import android.os.Build +import java.io.ByteArrayOutputStream + +fun Bitmap.resize(maxWidth: Int, maxHeight: Int): Bitmap { + var width = this.width + var height = this.height + + val aspectRatio: Float = width.toFloat() / height.toFloat() + + if (width > height) { + width = maxWidth + height = (width / aspectRatio).toInt() + } else { + height = maxHeight + width = (height * aspectRatio).toInt() + } + + return Bitmap.createScaledBitmap(this, width, height, true) +} + +fun Bitmap.toWebPBytes(): ByteArray { + val stream = ByteArrayOutputStream() + val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSLESS + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + compress(compressFormat, 100, stream) + return stream.toByteArray() +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/Context.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/Context.kt new file mode 100644 index 00000000..382b88b3 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/Context.kt @@ -0,0 +1,13 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.ext + +import android.content.Context +import androidx.annotation.StringRes + +fun Context.getPreferenceKey(@StringRes resourceId: Int): String = + resources.getString(resourceId) \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/BrowserExtensionFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/BrowserExtensionFeature.kt new file mode 100644 index 00000000..09ae79f8 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/BrowserExtensionFeature.kt @@ -0,0 +1,133 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.feature + +import androidx.annotation.VisibleForTesting +import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import mozilla.components.concept.engine.webextension.MessageHandler +import mozilla.components.concept.engine.webextension.Port +import mozilla.components.concept.engine.webextension.WebExtensionRuntime +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.webextensions.BuiltInWebExtensionController +import org.json.JSONArray +import org.json.JSONObject +import org.mozilla.gecko.util.ThreadUtils.runOnUiThread + +object BrowserExtensionFeature { + private val logger = Logger("browser_extension") + + private const val PREF_MANAGER_REPORTER_EXTENSION_ID = "browser_extension@weblibre.eu" + private const val PREF_MANAGER_REPORTER_EXTENSION_URL = + "resource://android/assets/extensions/browser_extension/" + private const val PREF_MANAGER_REPORTER_MESSAGING_ID = "mozacBrowserExtension" + + private var nextRequestId: Int = 0 + private val requestHandlers = HashMap>() + private val mutex = Mutex() + + @VisibleForTesting + // This is an internal var to make it mutable for unit testing purposes only + internal var extensionController = BuiltInWebExtensionController( + PREF_MANAGER_REPORTER_EXTENSION_ID, + PREF_MANAGER_REPORTER_EXTENSION_URL, + PREF_MANAGER_REPORTER_MESSAGING_ID + ) + + fun scheduleRequest( + command: String, + args: Any, + callback: ResultConsumer + ) { + val message = JSONObject() + message.put("action", command); + message.put( + "args", when (args) { + is List<*> -> JSONArray(args) + else -> args + } + ) + + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + message.put("id", nextRequestId) + + requestHandlers[nextRequestId] = callback + + nextRequestId += 1 + + extensionController.sendBackgroundMessage(message) + } + } + } + } + + private class ExtensionBackgroundMessageHandler( + private val extensionEvents: BrowserExtensionEvents + ) : MessageHandler { + override fun onPortMessage(message: Any, port: Port) { + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + val messageJSON = message as JSONObject; + val type = messageJSON.getString("type") + + if (type == "feedRequest") { + val url = messageJSON.getString("url") + + runOnUiThread { + extensionEvents.onFeedRequested( + System.currentTimeMillis(), + url + ) { _ -> } + } + } else if (type == "turndown") { + val requestId = messageJSON.getInt("id") + val status = messageJSON.getString("status") + if (status == "success") { + requestHandlers[requestId]?.success(message) + } else { + requestHandlers[requestId]?.error( + "Pref Manager", + "Failed to perform operation", + message.getString("error") + ) + } + } + } + } + } + } + } + + /** + * Installs the web extension in the runtime through the WebExtensionRuntime install method + * + * @param runtime a WebExtensionRuntime. + * @param productName a custom product name used to automatically label reports. Defaults to + * "android-components". + */ + fun install(runtime: WebExtensionRuntime, extensionEvents: BrowserExtensionEvents) { + extensionController.registerBackgroundMessageHandler( + ExtensionBackgroundMessageHandler(extensionEvents) + ) + extensionController.install( + runtime, + onSuccess = { + logger.debug("Installed browser_extension webextension: ${it.id}") + }, + onError = { throwable -> + logger.error("Failed to install browser_extension webextension: ", throwable) + }, + ) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ContainerProxyFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ContainerProxyFeature.kt new file mode 100644 index 00000000..fdb4b850 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ContainerProxyFeature.kt @@ -0,0 +1,125 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.feature + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import mozilla.components.concept.engine.webextension.MessageHandler +import mozilla.components.concept.engine.webextension.Port +import mozilla.components.concept.engine.webextension.WebExtensionRuntime +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.webextensions.BuiltInWebExtensionController +import org.json.JSONObject + +object ContainerProxyFeature { + private val logger = Logger("container_proxy") + + private const val CONTAINER_PROXY_REPORTER_EXTENSION_ID = "container-proxy@weblibre.eu" + private const val CONTAINER_PROXY_REPORTER_EXTENSION_URL = "resource://android/assets/extensions/container_proxy/" + private const val CONTAINER_PROXY_REPORTER_MESSAGING_ID = "containerProxy" + + private var nextRequestId: Int = 0 + private val requestHandlers = HashMap>() + private val mutex = Mutex() + + @VisibleForTesting + // This is an internal var to make it mutable for unit testing purposes only + internal var extensionController = BuiltInWebExtensionController( + CONTAINER_PROXY_REPORTER_EXTENSION_ID, + CONTAINER_PROXY_REPORTER_EXTENSION_URL, + CONTAINER_PROXY_REPORTER_MESSAGING_ID, + ) + + fun scheduleRequest(command: String, args: Any) { + val message = JSONObject() + message.put("action", command); + message.put("args", args) + + runBlocking { + withContext(Dispatchers.Default) { + extensionController.sendBackgroundMessage(message) + } + } + } + + fun scheduleRequestWithResponse( + command: String, + args: Any, + callback: ResultConsumer + ) { + val message = JSONObject() + message.put("action", command); + message.put("args", args) + + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + message.put("id", nextRequestId) + + requestHandlers[nextRequestId] = callback + + nextRequestId += 1 + + extensionController.sendBackgroundMessage(message) + } + } + } + } + + private class ContainerProxyBackgroundMessageHandler() : MessageHandler { + override fun onPortMessage(message: Any, port: Port) { + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + val messageJSON = message as JSONObject; + val type = messageJSON.getString("type") + + if (type == "healthcheck") { + val requestId = messageJSON.getInt("id") + val status = messageJSON.getString("status") + if (status == "success") { + requestHandlers[requestId]?.success(message) + } else { + requestHandlers[requestId]?.error( + "Container Proxy", + "Failed to perform operation", + message.getString("error") + ) + } + } + } + } + } + } + } + + /** + * Installs the web extension in the runtime through the WebExtensionRuntime install method + * + * @param runtime a WebExtensionRuntime. + * @param productName a custom product name used to automatically label reports. Defaults to + * "android-components". + */ + fun install(runtime: WebExtensionRuntime) { + extensionController.registerBackgroundMessageHandler( + ContainerProxyBackgroundMessageHandler() + ) + extensionController.install( + runtime, + onSuccess = { + logger.debug("Installed ContainerProxy webextension: ${it.id}") + }, + onError = { throwable -> + logger.error("Failed to install ContainerProxy webextension: ", throwable) + }, + ) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/CookieManagerFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/CookieManagerFeature.kt new file mode 100644 index 00000000..edaeb573 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/CookieManagerFeature.kt @@ -0,0 +1,115 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.feature + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import mozilla.components.concept.engine.webextension.MessageHandler +import mozilla.components.concept.engine.webextension.Port +import mozilla.components.concept.engine.webextension.WebExtensionRuntime +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.webextensions.BuiltInWebExtensionController +import org.json.JSONObject + +interface ResultConsumer { + fun success(result: T) + fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) +} + +/** + * A feature that enables users to report site issues to Mozilla's Web Compatibility team for + * further diagnosis. + */ +object CookieManagerFeature { + private val logger = Logger("cookie-manager") + + private const val COOKIE_MANAGER_REPORTER_EXTENSION_ID = "cookie-manager@weblibre.eu" + private const val COOKIE_MANAGER_REPORTER_EXTENSION_URL = "resource://android/assets/extensions/cookie-manager/" + private const val COOKIE_MANAGER_REPORTER_MESSAGING_ID = "cookieManager" + + private var nextRequestId: Int = 0 + private val requestHandlers = HashMap>() + private val mutex = Mutex() + + @VisibleForTesting + // This is an internal var to make it mutable for unit testing purposes only + internal var extensionController = BuiltInWebExtensionController( + COOKIE_MANAGER_REPORTER_EXTENSION_ID, + COOKIE_MANAGER_REPORTER_EXTENSION_URL, + COOKIE_MANAGER_REPORTER_MESSAGING_ID, + ) + + fun scheduleRequest(command: String, args: JSONObject, callback: ResultConsumer) { + val message = JSONObject() + message.put("action", command); + message.put("args", args) + + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + message.put("id", nextRequestId) + + requestHandlers[nextRequestId] = callback + + nextRequestId += 1 + + extensionController.sendBackgroundMessage(message) + } + } + } + } + + private class CookieManagerReporterBackgroundMessageHandler() : MessageHandler { + override fun onPortMessage(message: Any, port: Port) { + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + val messageJSON = message as JSONObject; + + val requestId = messageJSON.getInt("id") + val status = messageJSON.getString("status") + if (status == "success") { + requestHandlers[requestId]?.success(message) + } else { + requestHandlers[requestId]?.error( + "Cookie Manager", + "Failed to perform operation", + message.getString("error") + ) + } + } + } + } + } + } + + /** + * Installs the web extension in the runtime through the WebExtensionRuntime install method + * + * @param runtime a WebExtensionRuntime. + * @param productName a custom product name used to automatically label reports. Defaults to + * "android-components". + */ + fun install(runtime: WebExtensionRuntime) { + extensionController.registerBackgroundMessageHandler( + CookieManagerReporterBackgroundMessageHandler(), + ) + extensionController.install( + runtime, + onSuccess = { + logger.debug("Installed CookieManager webextension: ${it.id}") + }, + onError = { throwable -> + logger.error("Failed to install CookieManager webextension: ", throwable) + }, + ) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/DefaultSelectionActionDelegate.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/DefaultSelectionActionDelegate.kt new file mode 100644 index 00000000..79701c46 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/DefaultSelectionActionDelegate.kt @@ -0,0 +1,55 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.feature + +import android.util.Patterns +import eu.weblibre.flutter_mozilla_components.pigeons.CustomSelectionAction +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.SelectionPattern +import mozilla.components.concept.engine.selection.SelectionActionDelegate + +class DefaultSelectionActionDelegate( + private val selectionActionEvents: GeckoSelectionActionEvents, + private val actionSorter: ((Array) -> Array)? = null, +) : SelectionActionDelegate { + var actions: Map = emptyMap(); + + override fun getActionTitle(id: String): CharSequence? { + return actions[id]?.title; + } + + override fun getAllActions(): Array { + return actions.values.map { x -> x.id }.toTypedArray(); + } + + override fun isActionAvailable(id: String, selectedText: String): Boolean { + val action = actions[id]; + if (action != null) { + return when(action.pattern) { + SelectionPattern.PHONE -> Patterns.PHONE.matcher(selectedText.trim()).matches() + SelectionPattern.EMAIL -> Patterns.EMAIL_ADDRESS.matcher(selectedText.trim()).matches() + null -> true + } + } + + return false + } + + override fun performAction(id: String, selectedText: String): Boolean { + val action = actions[id]; + if (action != null) { + selectionActionEvents.performSelectionAction(id, selectedText) { _ -> } + return true + } + + return false + } + + override fun sortedActions(actions: Array): Array { + return actionSorter?.invoke(actions) ?: actions + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/PrefManagerFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/PrefManagerFeature.kt new file mode 100644 index 00000000..6b5277b7 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/PrefManagerFeature.kt @@ -0,0 +1,106 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.feature + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import mozilla.components.concept.engine.webextension.MessageHandler +import mozilla.components.concept.engine.webextension.Port +import mozilla.components.concept.engine.webextension.WebExtensionRuntime +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.webextensions.BuiltInWebExtensionController +import org.json.JSONObject + +object PrefManagerFeature { + private val logger = Logger("pref-manager") + + private const val PREF_MANAGER_REPORTER_EXTENSION_ID = "pref-manager@weblibre.eu" + private const val PREF_MANAGER_REPORTER_EXTENSION_URL = "resource://android/assets/extensions/pref_manager/" + private const val PREF_MANAGER_REPORTER_MESSAGING_ID = "prefManager" + + private var nextRequestId: Int = 0 + private val requestHandlers = HashMap>() + private val mutex = Mutex() + + @VisibleForTesting + // This is an internal var to make it mutable for unit testing purposes only + internal var extensionController = BuiltInWebExtensionController( + PREF_MANAGER_REPORTER_EXTENSION_ID, + PREF_MANAGER_REPORTER_EXTENSION_URL, + PREF_MANAGER_REPORTER_MESSAGING_ID, + ) + + fun scheduleRequest(command: String, args: Any, callback: ResultConsumer) { + val message = JSONObject() + message.put("action", command); + message.put("args", args) + + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + message.put("id", nextRequestId) + + requestHandlers[nextRequestId] = callback + + nextRequestId += 1 + + extensionController.sendBackgroundMessage(message) + } + } + } + } + + private class PrefManagerReporterBackgroundMessageHandler() : MessageHandler { + override fun onPortMessage(message: Any, port: Port) { + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + val messageJSON = message as JSONObject; + + val requestId = messageJSON.getInt("id") + val status = messageJSON.getString("status") + if (status == "success") { + requestHandlers[requestId]?.success(message) + } else { + requestHandlers[requestId]?.error( + "Pref Manager", + "Failed to perform operation", + message.getString("error") + ) + } + } + } + } + } + } + + /** + * Installs the web extension in the runtime through the WebExtensionRuntime install method + * + * @param runtime a WebExtensionRuntime. + * @param productName a custom product name used to automatically label reports. Defaults to + * "android-components". + */ + fun install(runtime: WebExtensionRuntime) { + extensionController.registerBackgroundMessageHandler( + PrefManagerReporterBackgroundMessageHandler(), + ) + extensionController.install( + runtime, + onSuccess = { + logger.debug("Installed PrefManager webextension: ${it.id}") + }, + onError = { throwable -> + logger.error("Failed to install PrefManager webextension: ", throwable) + }, + ) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ReadabilityExtractFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ReadabilityExtractFeature.kt new file mode 100644 index 00000000..31ec0bd0 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ReadabilityExtractFeature.kt @@ -0,0 +1,131 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.feature + +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents +import eu.weblibre.flutter_mozilla_components.pigeons.TabContent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.map +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.webextension.MessageHandler +import mozilla.components.concept.engine.webextension.Port +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.android.org.json.tryGetInt +import mozilla.components.support.ktx.android.org.json.tryGetString +import mozilla.components.support.ktx.kotlinx.coroutines.flow.filterChanged +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged +import mozilla.components.support.webextensions.BuiltInWebExtensionController +import org.json.JSONObject + +class ReadabilityExtractFeature( + private var engine: Engine, + private var store: BrowserStore, + private var tabContentEvents: GeckoTabContentEvents +): LifecycleAwareFeature { + companion object { + private val logger = Logger("ReadabilityExtract") + + internal const val READER_EXTRACT_EXTENSION_ID = "readability-extract@weblibre.eu" + internal const val READER_EXTRACT_CONTENT_PORT = "mozacReaderExtract" + internal const val READER_EXTRACT_EXTENSION_URL = + "resource://android/assets/extensions/readability_extract/" + } + + private class ContentMessageHandler( + private var tabContentEvents: GeckoTabContentEvents, + private var tabId: String + ) : MessageHandler { + override fun onPortConnected(port: Port) { + logger.debug("Port connected: ${port.name()}") + } + + override fun onPortMessage(message: Any, port: Port) { + if (message is JSONObject) { + tabContentEvents.onContentUpdate( + System.currentTimeMillis(), + contentArg = TabContent( + tabId = tabId, + fullContentMarkdown = message.tryGetString("fullContentMarkdown"), + fullContentPlain = message.tryGetString("fullContentPlain"), + isProbablyReaderable = message.tryGetInt("isProbablyReaderable") == 1, + extractedContentMarkdown = message.tryGetString("extractedContentMarkdown"), + extractedContentPlain = message.tryGetString("extractedContentPlain") + ) + ) {} + } + } + } + + private var sessionScope: CoroutineScope? = null + private var historyScope: CoroutineScope? = null + + private var extensionController = BuiltInWebExtensionController( + READER_EXTRACT_EXTENSION_ID, + READER_EXTRACT_EXTENSION_URL, + READER_EXTRACT_CONTENT_PORT, + ) + + override fun start() { + ensureExtensionInstalled() + } + + override fun stop() { + sessionScope?.cancel() + historyScope?.cancel() + } + + private fun ensureExtensionInstalled() { + extensionController.install( + engine, + onSuccess = { extension -> + sessionScope = store.flowScoped { flow -> + flow.map { it.tabs } + .filterChanged { it.engineState.engineSession } + .collect { state -> + val engineSession = state.engineState.engineSession ?: return@collect + + if (extension.hasContentMessageHandler(engineSession, READER_EXTRACT_CONTENT_PORT)) { + return@collect + } + + val handler = ContentMessageHandler(tabContentEvents, state.id) + extension.registerContentMessageHandler(engineSession, READER_EXTRACT_CONTENT_PORT, handler) + } + } + + historyScope = store.flowScoped { flow -> + flow.map { it.tabs } + .filterChanged { it.content.history } + .ifAnyChanged { arrayOf(it.content.history.currentIndex) } + .collect { state -> + val engineSession = state.engineState.engineSession ?: return@collect + + if(state.content.loading) { + return@collect + } + + if (extension.hasContentMessageHandler(engineSession, READER_EXTRACT_CONTENT_PORT)) { + if(extensionController.portConnected(engineSession)) { + extensionController.sendContentMessage( + JSONObject().put("action", "parseContent"), + engineSession + ) + } + } + } + } + }, + onError = { throwable -> + Logger.error("Could not install $READER_EXTRACT_EXTENSION_ID extension", throwable) + }, + ) + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/WebExtensionToolbarFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/WebExtensionToolbarFeature.kt new file mode 100644 index 00000000..3ffe523c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/WebExtensionToolbarFeature.kt @@ -0,0 +1,215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.feature + +import android.os.Handler +import android.os.HandlerThread +import androidx.annotation.VisibleForTesting +import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents +import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType +import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionData +import kotlinx.coroutines.* +import kotlinx.coroutines.android.asCoroutineDispatcher +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.SessionState +import mozilla.components.browser.state.state.WebExtensionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.webextension.Action +import mozilla.components.concept.engine.webextension.WebExtensionBrowserAction +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.base.log.Log +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged +import org.mozilla.gecko.util.ThreadUtils.runOnUiThread + +/** + * Web extension toolbar implementation that updates the toolbar whenever the state of web + * extensions changes. + */ +class WebExtensionToolbarFeature( + private var store: BrowserStore, + private var addonEvents: GeckoAddonEvents, +) : LifecycleAwareFeature { + // This maps web extension ids to [WebExtensionToolbarAction]s for efficient + // updates of global and tab-specific browser/page actions within the same + // lifecycle. + @VisibleForTesting + internal val webExtensionBrowserActions = HashMap() + internal val webExtensionPageActions = HashMap() + + private var scope: CoroutineScope? = null + + internal val iconThread = HandlerThread("IconThread") + internal val iconHandler by lazy { + iconThread.start() + Handler(iconThread.looper) + } + + internal var iconJobDispatcher: CoroutineDispatcher = Dispatchers.Main + + init { + renderWebExtensionActions(store.state) + } + + /** + * Starts observing for the state of web extensions changes + */ + override fun start() { + // The feature could start with an existing view and toolbar so + // we have to check if any stale actions (from uninstalled or + // disabled extensions) are being displayed and remove them. + webExtensionBrowserActions + .filterKeys { !store.state.extensions.containsKey(it) || store.state.extensions[it]?.enabled == false } + .forEach { (extensionId, action) -> +// toolbar.removeBrowserAction(action) +// toolbar.invalidateActions() + webExtensionBrowserActions.remove(extensionId) + } + + webExtensionPageActions + .filterKeys { !store.state.extensions.containsKey(it) || store.state.extensions[it]?.enabled == false } + .forEach { (extensionId, action) -> +// toolbar.removePageAction(action) +// toolbar.invalidateActions() + webExtensionPageActions.remove(extensionId) + } + + iconJobDispatcher = iconHandler.asCoroutineDispatcher("WebExtensionIconDispatcher") + scope = store.flowScoped { flow -> + flow.ifAnyChanged { arrayOf(it.selectedTab, it.extensions) } + .collect { state -> + renderWebExtensionActions(state, state.selectedTab) + } + } + } + + override fun stop() { + iconJobDispatcher.cancel() + scope?.cancel() + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun renderWebExtensionActions(state: BrowserState, tab: SessionState? = null) { + val extensions = state.extensions.values.toList() + extensions.filter { it.enabled }.sortedBy { it.name }.forEach { extension -> + if (extensionNotAllowedInTab(extension, tab)) { + webExtensionPageActions[extension.id]?.let { + addonEvents.onRemoveWebExtensionAction( + timestampArg = System.currentTimeMillis(), + extensionIdArg = extension.id, + actionTypeArg = WebExtensionActionType.PAGE, + ) {} + webExtensionPageActions.remove(extension.id) + } + webExtensionBrowserActions[extension.id]?.let { + addonEvents.onRemoveWebExtensionAction( + timestampArg = System.currentTimeMillis(), + extensionIdArg = extension.id, + actionTypeArg = WebExtensionActionType.BROWSER, + ) {} + webExtensionBrowserActions.remove(extension.id) + } + return@forEach + } + + extension.browserAction?.let { browserAction -> + addOrUpdateAction( + extension = extension, + globalAction = browserAction, + tabAction = tab?.extensionState?.get(extension.id)?.browserAction, + ) + } + + extension.pageAction?.let { pageAction -> + val tabPageAction = tab?.extensionState?.get(extension.id)?.pageAction + + // Unlike browser actions, page actions are not displayed by default (only if enabled): + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/page_action + if (pageAction.copyWithOverride(tabPageAction).enabled == true) { + addOrUpdateAction( + extension = extension, + globalAction = pageAction, + tabAction = tabPageAction, + isPageAction = true, + ) + } + } + } + } + + private fun extensionNotAllowedInTab( + extension: WebExtensionState?, + tab: SessionState?, + ): Boolean = extension?.allowedInPrivateBrowsing == false && tab?.content?.private == true + + fun invokeAddonBrowserAction(extensionId: String) { + webExtensionBrowserActions[extensionId]?.onClick?.invoke() + } + + fun invokeAddonPageAction(extensionId: String) { + webExtensionPageActions[extensionId]?.onClick?.invoke() + } + + private fun addOrUpdateAction( + extension: WebExtensionState, + globalAction: Action, + tabAction: Action?, + isPageAction: Boolean = false, + ) { + val actionMap = if (isPageAction) webExtensionPageActions else webExtensionBrowserActions + // Add the global page/browser action if it doesn't exist + var toolbarAction = actionMap.getOrPut(extension.id) { + CoroutineScope(iconJobDispatcher).launch { + try { + //TODO: make variable size + val icon = globalAction.loadIcon?.invoke(128) + icon?.let { + val imageBytes = icon.toWebPBytes() + runOnUiThread { + addonEvents.onUpdateWebExtensionIcon( + timestampArg = System.currentTimeMillis(), + extensionIdArg = extension.id, + actionTypeArg = if (isPageAction) WebExtensionActionType.PAGE else WebExtensionActionType.BROWSER, + iconArg = imageBytes + ) { } + } + } + } catch (throwable: Throwable) { + Log.log( + Log.Priority.ERROR, + "mozac-webextensions", + throwable, + "Failed to load browser action icon, falling back to default.", + ) + } + } + + globalAction + } + + // Apply tab-specific override of browser/page action + tabAction?.let { + toolbarAction = toolbarAction.copyWithOverride(it) + } + + val data = WebExtensionData( + extensionId = extension.id, + title = toolbarAction.title, + enabled = toolbarAction.enabled, + badgeText = toolbarAction.badgeText, + badgeTextColor = toolbarAction.badgeTextColor?.toLong(), + badgeBackgroundColor = toolbarAction.badgeBackgroundColor?.toLong(), + ) + + addonEvents.onUpsertWebExtensionAction( + timestampArg = System.currentTimeMillis(), + extensionIdArg = extension.id, + actionTypeArg = if (isPageAction) WebExtensionActionType.PAGE else WebExtensionActionType.BROWSER, + extensionDataArg = data + ) { } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/integration/ReaderViewIntegration.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/integration/ReaderViewIntegration.kt new file mode 100644 index 00000000..374293d4 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/integration/ReaderViewIntegration.kt @@ -0,0 +1,78 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.integration + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.api.ReaderViewEventsImpl +import eu.weblibre.flutter_mozilla_components.api.ReaderViewControllerListener +import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.Engine +import mozilla.components.feature.readerview.ReaderViewFeature +import mozilla.components.feature.readerview.view.ReaderViewControlsView +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.base.feature.UserInteractionHandler + +@Suppress("UndocumentedPublicClass") +class ReaderViewIntegration( + context: Context, + engine: Engine, + store: BrowserStore, + view: ReaderViewControlsView, + readerViewEvents: ReaderViewEventsImpl, + readerViewController: ReaderViewController +) : LifecycleAwareFeature, UserInteractionHandler { + + private val controllerListener = object : ReaderViewControllerListener { + override fun onReaderViewToggled(enabled: Boolean) { + if (enabled) { + feature.showReaderView() + + readerViewController.appearanceButtonVisibility(System.currentTimeMillis(),true) { _ -> }; + } else { + feature.hideReaderView() + feature.hideControls() + + readerViewController.appearanceButtonVisibility(System.currentTimeMillis(),false) { _ -> }; + } + } + + override fun onAppearanceButtonTap() { + feature.showControls() + } + } + + init { + readerViewEvents.addListener(controllerListener) + } + + private val feature = ReaderViewFeature(context, engine, store, view) + // Will be event based in flutter +// { available, active -> +// readerViewButtonVisible = available +// readerViewButton.setSelected(active) +// +// if (active) readerViewAppearanceButton.show() else readerViewAppearanceButton.hide() +// toolbar.invalidateActions() +// } + + override fun start() { + feature.start() + } + + override fun stop() { + feature.stop() + } + + override fun onBackPressed(): Boolean { + return feature.onBackPressed() + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt new file mode 100644 index 00000000..332ab3ee --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt @@ -0,0 +1,71 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.interceptor + +import android.content.Context +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import mozilla.components.browser.errorpages.ErrorPages +import mozilla.components.browser.errorpages.ErrorType +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.request.RequestInterceptor + +class AppRequestInterceptor(private val context: Context) : RequestInterceptor { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + + return when (uri) { +// "about:privatebrowsing" -> { +// val page = PrivatePage.createPrivateBrowsingPage(context, uri) +// RequestInterceptor.InterceptionResponse.Content(page, encoding = "base64") +// } + +// "about:crashes" -> { +// val intent = Intent(context, CrashListActivity::class.java) +// intent.addFlags(FLAG_ACTIVITY_NEW_TASK) +// context.startActivity(intent) +// +// RequestInterceptor.InterceptionResponse.Url("about:blank") +// } + + else -> { + components.services.appLinksInterceptor.onLoadRequest( + engineSession, + uri, + lastUri, + hasUserGesture, + isSameDomain, + isRedirect, + isDirectNavigation, + isSubframeRequest, + ) + } + } + } + + override fun onErrorRequest( + session: EngineSession, + errorType: ErrorType, + uri: String?, + ): RequestInterceptor.ErrorResponse { + val errorPage = ErrorPages.createUrlEncodedErrorPage(context, errorType, uri) + return RequestInterceptor.ErrorResponse(errorPage) + } + + override fun interceptsAppInitiatedRequests() = true +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt new file mode 100644 index 00000000..e3396be8 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt @@ -0,0 +1,142 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package eu.weblibre.flutter_mozilla_components.middleware + +import android.graphics.Bitmap +import android.util.Log +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.ext.resize +import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes +import eu.weblibre.flutter_mozilla_components.pigeons.AudioHitResult +import eu.weblibre.flutter_mozilla_components.pigeons.EmailHitResult +import eu.weblibre.flutter_mozilla_components.pigeons.FindResultState +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeoHitResult +import eu.weblibre.flutter_mozilla_components.pigeons.ImageHitResult +import eu.weblibre.flutter_mozilla_components.pigeons.ImageSrcHitResult +import eu.weblibre.flutter_mozilla_components.pigeons.PhoneHitResult +import eu.weblibre.flutter_mozilla_components.pigeons.UnknownHitResult +import eu.weblibre.flutter_mozilla_components.pigeons.VideoHitResult +import mozilla.components.browser.state.action.BrowserAction +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.action.LastAccessAction +import mozilla.components.browser.state.action.ReaderAction +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.action.WebExtensionAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.concept.engine.HitResult +import mozilla.components.feature.addons.logger +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import org.mozilla.gecko.util.ThreadUtils.runOnUiThread +import java.io.ByteArrayOutputStream +import kotlin.reflect.typeOf + + +/** + * [Middleware] implementation for handling [ContentAction.UpdateThumbnailAction] and storing + * the thumbnail to the disk cache. + */ +class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Middleware { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + @Suppress("ComplexMethod") + override fun invoke( + context: MiddlewareContext, + next: (BrowserAction) -> Unit, + action: BrowserAction, + ) { + when (action) { + is ContentAction.UpdateThumbnailAction -> { + val resized = action.thumbnail.resize(maxWidth = 1280, maxHeight = 800); + val bytes = resized.toWebPBytes() + + runOnUiThread { + flutterEvents.onThumbnailChange(System.currentTimeMillis(), action.sessionId, bytes) { _ -> } + } + } + //UpdateReaderConnectRequiredAction seems to be the only event that is called predictable + //after a hot reload + is ReaderAction.UpdateReaderConnectRequiredAction -> { + if(!components.engineReportedInitialized) { + runOnUiThread { + flutterEvents.onEngineReadyStateChange( + System.currentTimeMillis(), + true + ) { _ -> } + } + + components.engineReportedInitialized = true + } + } + is TabListAction.AddTabAction -> { + runOnUiThread { + flutterEvents.onTabAdded( + System.currentTimeMillis(), + action.tab.id + ) { _ -> } + } + } + is ContentAction.UpdateIconAction -> { + val bytes = action.icon.toWebPBytes() + + runOnUiThread { + flutterEvents.onIconUpdate( + System.currentTimeMillis(), + action.pageUrl, + bytes + ) { _ -> } + } + } + is ContentAction.UpdateHitResultAction -> { + runOnUiThread { + flutterEvents.onLongPress( + System.currentTimeMillis(), + action.sessionId, + when(val result = action.hitResult) { + is HitResult.AUDIO -> AudioHitResult(result.src, result.title) + is HitResult.EMAIL -> EmailHitResult(result.src) + is HitResult.GEO -> GeoHitResult(result.src) + is HitResult.IMAGE -> ImageHitResult(result.src, result.title) + is HitResult.IMAGE_SRC -> ImageSrcHitResult(result.src, result.uri) + is HitResult.PHONE -> PhoneHitResult(result.src) + is HitResult.UNKNOWN -> UnknownHitResult(result.src) + is HitResult.VIDEO -> VideoHitResult(result.src, result.title) + } + ) { _ -> } + } + } + is ContentAction.AddFindResultAction -> { + runOnUiThread { + flutterEvents.onFindResults( + System.currentTimeMillis(), + action.sessionId, + listOf( + FindResultState( + activeMatchOrdinal = action.findResult.activeMatchOrdinal.toLong(), + numberOfMatches = action.findResult.numberOfMatches.toLong(), + isDoneCounting = action.findResult.isDoneCounting, + ) + ) + ) { _ -> } + } + } + is ContentAction.ClearFindResultsAction -> { + runOnUiThread { + flutterEvents.onFindResults( + System.currentTimeMillis(), + action.sessionId, + listOf() + ) { _ -> } + } + } + else -> { + //logger.debug("Event fired: " + action.javaClass.name) + } + } + next(action) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt new file mode 100644 index 00000000..56be813d --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -0,0 +1,4521 @@ +// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package eu.weblibre.flutter_mozilla_components.pigeons + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object GeckoPigeonUtils { + + fun createConnectionError(channelName: String): FlutterError { + return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") } + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +/** Indicates what location the tabs should be restored at */ +enum class RestoreLocation(val raw: Int) { + /** Restore tabs at the beginning of the tab list */ + BEGINNING(0), + /** Restore tabs at the end of the tab list */ + END(1), + /** Restore tabs at a specific index in the tab list */ + AT_INDEX(2); + + companion object { + fun ofRaw(raw: Int): RestoreLocation? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** An icon resource type. */ +enum class IconType(val raw: Int) { + FAVICON(0), + APPLE_TOUCH_ICON(1), + FLUID_ICON(2), + IMAGE_SRC(3), + OPEN_GRAPH(4), + TWITTER(5), + MICROSOFT_TILE(6), + TIPPY_TOP(7), + MANIFEST_ICON(8); + + companion object { + fun ofRaw(raw: Int): IconType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** + * Supported sizes. + * + * We are trying to limit the supported sizes in order to optimize our caching strategy. + */ +enum class IconSize(val raw: Int) { + DEFAULT_SIZE(0), + LAUNCHER(1), + LAUNCHER_ADAPTIVE(2); + + companion object { + fun ofRaw(raw: Int): IconSize? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** The source of an [Icon]. */ +enum class IconSource(val raw: Int) { + /** This icon was generated. */ + GENERATOR(0), + /** This icon was downloaded. */ + DOWNLOAD(1), + /** This icon was inlined in the document. */ + INLINE(2), + /** This icon was loaded from an in-memory cache. */ + MEMORY(3), + /** This icon was loaded from a disk cache. */ + DISK(4); + + companion object { + fun ofRaw(raw: Int): IconSource? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class CookieSameSiteStatus(val raw: Int) { + NO_RESTRICTION(0), + LAX(1), + STRICT(2), + UNSPECIFIED(3); + + companion object { + fun ofRaw(raw: Int): CookieSameSiteStatus? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class SelectionPattern(val raw: Int) { + PHONE(0), + EMAIL(1); + + companion object { + fun ofRaw(raw: Int): SelectionPattern? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class WebExtensionActionType(val raw: Int) { + BROWSER(0), + PAGE(1); + + companion object { + fun ofRaw(raw: Int): WebExtensionActionType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class GeckoSuggestionType(val raw: Int) { + SESSION(0), + CLIPBOARD(1), + HISTORY(2); + + companion object { + fun ofRaw(raw: Int): GeckoSuggestionType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class TrackingProtectionPolicy(val raw: Int) { + NONE(0), + RECOMMENDED(1), + STRICT(2), + CUSTOM(3); + + companion object { + fun ofRaw(raw: Int): TrackingProtectionPolicy? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class HttpsOnlyMode(val raw: Int) { + DISABLED(0), + PRIVATE_ONLY(1), + ENABLED(2); + + companion object { + fun ofRaw(raw: Int): HttpsOnlyMode? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class ColorScheme(val raw: Int) { + SYSTEM(0), + LIGHT(1), + DARK(2); + + companion object { + fun ofRaw(raw: Int): ColorScheme? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class CookieBannerHandlingMode(val raw: Int) { + DISABLED(0), + REJECT_ALL(1), + REJECT_OR_ACCEPT_ALL(2); + + companion object { + fun ofRaw(raw: Int): CookieBannerHandlingMode? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class WebContentIsolationStrategy(val raw: Int) { + ISOLATE_NOTHING(0), + ISOLATE_EVERYTHING(1), + ISOLATE_HIGH_VALUE(2); + + companion object { + fun ofRaw(raw: Int): WebContentIsolationStrategy? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** Status that represents every state that a download can be in. */ +enum class DownloadStatus(val raw: Int) { + /** Indicates that the download is in the first state after creation but not yet [DOWNLOADING]. */ + INITIATED(0), + /** Indicates that an [INITIATED] download is now actively being downloaded. */ + DOWNLOADING(1), + /** Indicates that the download that has been [DOWNLOADING] has been paused. */ + PAUSED(2), + /** Indicates that the download that has been [DOWNLOADING] has been cancelled. */ + CANCELLED(3), + /** + * Indicates that the download that has been [DOWNLOADING] has moved to failed because + * something unexpected has happened. + */ + FAILED(4), + /** Indicates that the [DOWNLOADING] download has been completed. */ + COMPLETED(5); + + companion object { + fun ofRaw(raw: Int): DownloadStatus? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** + * Translation options that map to the Gecko Translations Options. + * + * @property downloadModel If the necessary models should be downloaded on request. If false, then + * the translation will not complete and throw an exception if the models are not already available. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class TranslationOptions ( + val downloadModel: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): TranslationOptions { + val downloadModel = pigeonVar_list[0] as Boolean + return TranslationOptions(downloadModel) + } + } + fun toList(): List { + return listOf( + downloadModel, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is TranslationOptions) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Value type that represents the state of reader mode/view. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ReaderState ( + /** + * Whether or not the current page can be transformed to + * be displayed in a reader view. + */ + val readerable: Boolean, + /** Whether or not reader view is active. */ + val active: Boolean, + /** + * Whether or not a readerable check is required for the + * current page. + */ + val checkRequired: Boolean, + /** + * Whether or not a new connection to the reader view + * content script is required. + */ + val connectRequired: Boolean, + /** The base URL of the reader view extension page. */ + val baseUrl: String? = null, + /** The URL of the page currently displayed in reader view. */ + val activeUrl: String? = null, + /** + * The vertical scroll position of the page currently + * displayed in reader view. + */ + val scrollY: Long? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): ReaderState { + val readerable = pigeonVar_list[0] as Boolean + val active = pigeonVar_list[1] as Boolean + val checkRequired = pigeonVar_list[2] as Boolean + val connectRequired = pigeonVar_list[3] as Boolean + val baseUrl = pigeonVar_list[4] as String? + val activeUrl = pigeonVar_list[5] as String? + val scrollY = pigeonVar_list[6] as Long? + return ReaderState(readerable, active, checkRequired, connectRequired, baseUrl, activeUrl, scrollY) + } + } + fun toList(): List { + return listOf( + readerable, + active, + checkRequired, + connectRequired, + baseUrl, + activeUrl, + scrollY, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ReaderState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Details about the last playing media in this tab. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class LastMediaAccessState ( + /** + * [TabContentState.url] when media started playing. + * This is not the URL of the media but of the page when media started. + * Defaults to "" (an empty String) if media hasn't started playing. + * This value is only updated when media starts playing. + * Can be used as a backup to [mediaSessionActive] for knowing the user is still on the same website + * on which media was playing before media started playing in another tab. + */ + val lastMediaUrl: String, + /** + * The last time media started playing in the current web document. + * Defaults to [0] if media hasn't started playing. + * This value is only updated when media starts playing. + */ + val lastMediaAccess: Long, + /** + * Whether or not the last accessed media is still active. + * Can be used as a backup to [lastMediaUrl] on websites which allow media to continue playing + * even when the users accesses another page (with another URL) in that same HTML document. + */ + val mediaSessionActive: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): LastMediaAccessState { + val lastMediaUrl = pigeonVar_list[0] as String + val lastMediaAccess = pigeonVar_list[1] as Long + val mediaSessionActive = pigeonVar_list[2] as Boolean + return LastMediaAccessState(lastMediaUrl, lastMediaAccess, mediaSessionActive) + } + } + fun toList(): List { + return listOf( + lastMediaUrl, + lastMediaAccess, + mediaSessionActive, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is LastMediaAccessState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Represents a set of history metadata values that uniquely identify a record. Note that + * when recording observations, the same set of values may or may not cause a new record to be + * created, depending on the de-bouncing logic of the underlying storage i.e. recording history + * metadata observations with the exact same values may be combined into a single record. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class HistoryMetadataKey ( + /** A url of the page. */ + val url: String, + /** + * An optional search term if this record was + * created as part of a search by the user. + */ + val searchTerm: String? = null, + /** + * An optional url of the parent/referrer if + * this record was created in response to a user opening + * a page in a new tab. + */ + val referrerUrl: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): HistoryMetadataKey { + val url = pigeonVar_list[0] as String + val searchTerm = pigeonVar_list[1] as String? + val referrerUrl = pigeonVar_list[2] as String? + return HistoryMetadataKey(url, searchTerm, referrerUrl) + } + } + fun toList(): List { + return listOf( + url, + searchTerm, + referrerUrl, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is HistoryMetadataKey) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class PackageCategoryValue ( + val value: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): PackageCategoryValue { + val value = pigeonVar_list[0] as Long + return PackageCategoryValue(value) + } + } + fun toList(): List { + return listOf( + value, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is PackageCategoryValue) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Describes an external package. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ExternalPackage ( + /** An Android package id. */ + val packageId: String, + /** A [PackageCategory] as defined by the application. */ + val category: PackageCategoryValue +) + { + companion object { + fun fromList(pigeonVar_list: List): ExternalPackage { + val packageId = pigeonVar_list[0] as String + val category = pigeonVar_list[1] as PackageCategoryValue + return ExternalPackage(packageId, category) + } + } + fun toList(): List { + return listOf( + packageId, + category, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ExternalPackage) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class LoadUrlFlagsValue ( + val value: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): LoadUrlFlagsValue { + val value = pigeonVar_list[0] as Long + return LoadUrlFlagsValue(value) + } + } + fun toList(): List { + return listOf( + value, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is LoadUrlFlagsValue) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class SourceValue ( + val id: Long, + val caller: ExternalPackage? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): SourceValue { + val id = pigeonVar_list[0] as Long + val caller = pigeonVar_list[1] as ExternalPackage? + return SourceValue(id, caller) + } + } + fun toList(): List { + return listOf( + id, + caller, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is SourceValue) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * A tab that is no longer open and in the list of tabs, but that can be restored (recovered) at + * any time if it's combined with an [EngineSessionState] to form a [RecoverableTab]. + * + * The values of this data class are usually filled with the values of a [TabSessionState] when + * getting closed. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class TabState ( + /** Unique ID identifying this tab. */ + val id: String, + /** The last URL of this tab. */ + val url: String, + /** + * The unique ID of the parent tab if this tab was opened from another tab (e.g. via + * the context menu). + */ + val parentId: String? = null, + /** The last title of this tab (or an empty String). */ + val title: String, + /** + * The last used search terms, or an empty string if no + * search was executed for this session. + */ + val searchTerm: String, + /** The context ID ("container") this tab used (or null). */ + val contextId: String? = null, + /** The last [ReaderState] of the tab. */ + val readerState: ReaderState, + /** The last time this tab was selected. */ + val lastAccess: Long, + /** Timestamp of the tab's creation. */ + val createdAt: Long, + /** Details about the last time was playing in this tab. */ + val lastMediaAccessState: LastMediaAccessState, + /** If tab was private. */ + val private: Boolean, + /** The last [HistoryMetadataKey] of the tab. */ + val historyMetadata: HistoryMetadataKey? = null, + /** The last [IconSource] of the tab. */ + val source: SourceValue, + /** The index the tab should be restored at. */ + val index: Long, + /** Whether the tab has form data. */ + val hasFormData: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): TabState { + val id = pigeonVar_list[0] as String + val url = pigeonVar_list[1] as String + val parentId = pigeonVar_list[2] as String? + val title = pigeonVar_list[3] as String + val searchTerm = pigeonVar_list[4] as String + val contextId = pigeonVar_list[5] as String? + val readerState = pigeonVar_list[6] as ReaderState + val lastAccess = pigeonVar_list[7] as Long + val createdAt = pigeonVar_list[8] as Long + val lastMediaAccessState = pigeonVar_list[9] as LastMediaAccessState + val private = pigeonVar_list[10] as Boolean + val historyMetadata = pigeonVar_list[11] as HistoryMetadataKey? + val source = pigeonVar_list[12] as SourceValue + val index = pigeonVar_list[13] as Long + val hasFormData = pigeonVar_list[14] as Boolean + return TabState(id, url, parentId, title, searchTerm, contextId, readerState, lastAccess, createdAt, lastMediaAccessState, private, historyMetadata, source, index, hasFormData) + } + } + fun toList(): List { + return listOf( + id, + url, + parentId, + title, + searchTerm, + contextId, + readerState, + lastAccess, + createdAt, + lastMediaAccessState, + private, + historyMetadata, + source, + index, + hasFormData, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is TabState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * A recoverable version of [TabState]. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class RecoverableTab ( + /** The [EngineSessionState] needed for restoring the previous state of this tab. */ + val engineSessionStateJson: String? = null, + /** A [TabState] instance containing basic tab state. */ + val state: TabState +) + { + companion object { + fun fromList(pigeonVar_list: List): RecoverableTab { + val engineSessionStateJson = pigeonVar_list[0] as String? + val state = pigeonVar_list[1] as TabState + return RecoverableTab(engineSessionStateJson, state) + } + } + fun toList(): List { + return listOf( + engineSessionStateJson, + state, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is RecoverableTab) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * A restored browser state, read from disk. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class RecoverableBrowserState ( + /** The list of restored tabs. */ + val tabs: List, + /** The ID of the selected tab in [tabs]. Or `null` if no selection was restored. */ + val selectedTabId: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): RecoverableBrowserState { + val tabs = pigeonVar_list[0] as List + val selectedTabId = pigeonVar_list[1] as String? + return RecoverableBrowserState(tabs, selectedTabId) + } + } + fun toList(): List { + return listOf( + tabs, + selectedTabId, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is RecoverableBrowserState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * A request to load an [Icon]. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class IconRequest ( + val url: String, + val size: IconSize, + val resources: List, + val color: Long? = null, + val isPrivate: Boolean, + val waitOnNetworkLoad: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): IconRequest { + val url = pigeonVar_list[0] as String + val size = pigeonVar_list[1] as IconSize + val resources = pigeonVar_list[2] as List + val color = pigeonVar_list[3] as Long? + val isPrivate = pigeonVar_list[4] as Boolean + val waitOnNetworkLoad = pigeonVar_list[5] as Boolean + return IconRequest(url, size, resources, color, isPrivate, waitOnNetworkLoad) + } + } + fun toList(): List { + return listOf( + url, + size, + resources, + color, + isPrivate, + waitOnNetworkLoad, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is IconRequest) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class ResourceSize ( + val height: Long, + val width: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): ResourceSize { + val height = pigeonVar_list[0] as Long + val width = pigeonVar_list[1] as Long + return ResourceSize(height, width) + } + } + fun toList(): List { + return listOf( + height, + width, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ResourceSize) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * An icon resource that can be loaded. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class Resource ( + val url: String, + val type: IconType, + val sizes: List, + val mimeType: String? = null, + val maskable: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): Resource { + val url = pigeonVar_list[0] as String + val type = pigeonVar_list[1] as IconType + val sizes = pigeonVar_list[2] as List + val mimeType = pigeonVar_list[3] as String? + val maskable = pigeonVar_list[4] as Boolean + return Resource(url, type, sizes, mimeType, maskable) + } + } + fun toList(): List { + return listOf( + url, + type, + sizes, + mimeType, + maskable, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is Resource) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * An [Icon] returned by [BrowserIcons] after processing an [IconRequest] + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class IconResult ( + /** The loaded icon as an [Uint8List]. */ + val image: ByteArray, + /** The dominant color of the icon. Will be null if no color could be extracted. */ + val color: Long? = null, + /** The source of the icon. */ + val source: IconSource, + /** True if the icon represents as full-bleed icon that can be cropped to other shapes. */ + val maskable: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): IconResult { + val image = pigeonVar_list[0] as ByteArray + val color = pigeonVar_list[1] as Long? + val source = pigeonVar_list[2] as IconSource + val maskable = pigeonVar_list[3] as Boolean + return IconResult(image, color, source, maskable) + } + } + fun toList(): List { + return listOf( + image, + color, + source, + maskable, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is IconResult) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class CookiePartitionKey ( + val topLevelSite: String +) + { + companion object { + fun fromList(pigeonVar_list: List): CookiePartitionKey { + val topLevelSite = pigeonVar_list[0] as String + return CookiePartitionKey(topLevelSite) + } + } + fun toList(): List { + return listOf( + topLevelSite, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is CookiePartitionKey) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class Cookie ( + val domain: String, + val expirationDate: Long? = null, + val firstPartyDomain: String, + val hostOnly: Boolean, + val httpOnly: Boolean, + val name: String, + val partitionKey: CookiePartitionKey? = null, + val path: String, + val secure: Boolean, + val session: Boolean, + val sameSite: CookieSameSiteStatus, + val storeId: String, + val value: String +) + { + companion object { + fun fromList(pigeonVar_list: List): Cookie { + val domain = pigeonVar_list[0] as String + val expirationDate = pigeonVar_list[1] as Long? + val firstPartyDomain = pigeonVar_list[2] as String + val hostOnly = pigeonVar_list[3] as Boolean + val httpOnly = pigeonVar_list[4] as Boolean + val name = pigeonVar_list[5] as String + val partitionKey = pigeonVar_list[6] as CookiePartitionKey? + val path = pigeonVar_list[7] as String + val secure = pigeonVar_list[8] as Boolean + val session = pigeonVar_list[9] as Boolean + val sameSite = pigeonVar_list[10] as CookieSameSiteStatus + val storeId = pigeonVar_list[11] as String + val value = pigeonVar_list[12] as String + return Cookie(domain, expirationDate, firstPartyDomain, hostOnly, httpOnly, name, partitionKey, path, secure, session, sameSite, storeId, value) + } + } + fun toList(): List { + return listOf( + domain, + expirationDate, + firstPartyDomain, + hostOnly, + httpOnly, + name, + partitionKey, + path, + secure, + session, + sameSite, + storeId, + value, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is Cookie) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class HistoryItem ( + val url: String, + val title: String +) + { + companion object { + fun fromList(pigeonVar_list: List): HistoryItem { + val url = pigeonVar_list[0] as String + val title = pigeonVar_list[1] as String + return HistoryItem(url, title) + } + } + fun toList(): List { + return listOf( + url, + title, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is HistoryItem) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class HistoryState ( + val items: List, + val currentIndex: Long, + val canGoBack: Boolean, + val canGoForward: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): HistoryState { + val items = pigeonVar_list[0] as List + val currentIndex = pigeonVar_list[1] as Long + val canGoBack = pigeonVar_list[2] as Boolean + val canGoForward = pigeonVar_list[3] as Boolean + return HistoryState(items, currentIndex, canGoBack, canGoForward) + } + } + fun toList(): List { + return listOf( + items, + currentIndex, + canGoBack, + canGoForward, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is HistoryState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class ReaderableState ( + /** + * Whether or not the current page can be transformed to + * be displayed in a reader view. + */ + val readerable: Boolean, + /** Whether or not reader view is active. */ + val active: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): ReaderableState { + val readerable = pigeonVar_list[0] as Boolean + val active = pigeonVar_list[1] as Boolean + return ReaderableState(readerable, active) + } + } + fun toList(): List { + return listOf( + readerable, + active, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ReaderableState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class SecurityInfoState ( + val secure: Boolean, + val host: String, + val issuer: String +) + { + companion object { + fun fromList(pigeonVar_list: List): SecurityInfoState { + val secure = pigeonVar_list[0] as Boolean + val host = pigeonVar_list[1] as String + val issuer = pigeonVar_list[2] as String + return SecurityInfoState(secure, host, issuer) + } + } + fun toList(): List { + return listOf( + secure, + host, + issuer, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is SecurityInfoState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class TabContentState ( + val id: String, + val parentId: String? = null, + val contextId: String? = null, + val url: String, + val title: String, + val progress: Long, + val isPrivate: Boolean, + val isFullScreen: Boolean, + val isLoading: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): TabContentState { + val id = pigeonVar_list[0] as String + val parentId = pigeonVar_list[1] as String? + val contextId = pigeonVar_list[2] as String? + val url = pigeonVar_list[3] as String + val title = pigeonVar_list[4] as String + val progress = pigeonVar_list[5] as Long + val isPrivate = pigeonVar_list[6] as Boolean + val isFullScreen = pigeonVar_list[7] as Boolean + val isLoading = pigeonVar_list[8] as Boolean + return TabContentState(id, parentId, contextId, url, title, progress, isPrivate, isFullScreen, isLoading) + } + } + fun toList(): List { + return listOf( + id, + parentId, + contextId, + url, + title, + progress, + isPrivate, + isFullScreen, + isLoading, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is TabContentState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class FindResultState ( + val activeMatchOrdinal: Long, + val numberOfMatches: Long, + val isDoneCounting: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): FindResultState { + val activeMatchOrdinal = pigeonVar_list[0] as Long + val numberOfMatches = pigeonVar_list[1] as Long + val isDoneCounting = pigeonVar_list[2] as Boolean + return FindResultState(activeMatchOrdinal, numberOfMatches, isDoneCounting) + } + } + fun toList(): List { + return listOf( + activeMatchOrdinal, + numberOfMatches, + isDoneCounting, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is FindResultState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class CustomSelectionAction ( + val id: String, + val title: String, + val pattern: SelectionPattern? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): CustomSelectionAction { + val id = pigeonVar_list[0] as String + val title = pigeonVar_list[1] as String + val pattern = pigeonVar_list[2] as SelectionPattern? + return CustomSelectionAction(id, title, pattern) + } + } + fun toList(): List { + return listOf( + id, + title, + pattern, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is CustomSelectionAction) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class WebExtensionData ( + val extensionId: String, + val title: String? = null, + val enabled: Boolean? = null, + val badgeText: String? = null, + val badgeTextColor: Long? = null, + val badgeBackgroundColor: Long? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): WebExtensionData { + val extensionId = pigeonVar_list[0] as String + val title = pigeonVar_list[1] as String? + val enabled = pigeonVar_list[2] as Boolean? + val badgeText = pigeonVar_list[3] as String? + val badgeTextColor = pigeonVar_list[4] as Long? + val badgeBackgroundColor = pigeonVar_list[5] as Long? + return WebExtensionData(extensionId, title, enabled, badgeText, badgeTextColor, badgeBackgroundColor) + } + } + fun toList(): List { + return listOf( + extensionId, + title, + enabled, + badgeText, + badgeTextColor, + badgeBackgroundColor, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is WebExtensionData) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class GeckoSuggestion ( + val id: String, + val type: GeckoSuggestionType, + val score: Long, + val title: String? = null, + val description: String? = null, + val editSuggestion: String? = null, + val icon: ByteArray? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): GeckoSuggestion { + val id = pigeonVar_list[0] as String + val type = pigeonVar_list[1] as GeckoSuggestionType + val score = pigeonVar_list[2] as Long + val title = pigeonVar_list[3] as String? + val description = pigeonVar_list[4] as String? + val editSuggestion = pigeonVar_list[5] as String? + val icon = pigeonVar_list[6] as ByteArray? + return GeckoSuggestion(id, type, score, title, description, editSuggestion, icon) + } + } + fun toList(): List { + return listOf( + id, + type, + score, + title, + description, + editSuggestion, + icon, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is GeckoSuggestion) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class TabContent ( + val tabId: String, + val fullContentMarkdown: String? = null, + val fullContentPlain: String? = null, + val isProbablyReaderable: Boolean, + val extractedContentMarkdown: String? = null, + val extractedContentPlain: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): TabContent { + val tabId = pigeonVar_list[0] as String + val fullContentMarkdown = pigeonVar_list[1] as String? + val fullContentPlain = pigeonVar_list[2] as String? + val isProbablyReaderable = pigeonVar_list[3] as Boolean + val extractedContentMarkdown = pigeonVar_list[4] as String? + val extractedContentPlain = pigeonVar_list[5] as String? + return TabContent(tabId, fullContentMarkdown, fullContentPlain, isProbablyReaderable, extractedContentMarkdown, extractedContentPlain) + } + } + fun toList(): List { + return listOf( + tabId, + fullContentMarkdown, + fullContentPlain, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is TabContent) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class GeckoEngineSettings ( + val javascriptEnabled: Boolean? = null, + val trackingProtectionPolicy: TrackingProtectionPolicy? = null, + val httpsOnlyMode: HttpsOnlyMode? = null, + val globalPrivacyControlEnabled: Boolean? = null, + val preferredColorScheme: ColorScheme? = null, + val cookieBannerHandlingMode: CookieBannerHandlingMode? = null, + val cookieBannerHandlingModePrivateBrowsing: CookieBannerHandlingMode? = null, + val cookieBannerHandlingGlobalRules: Boolean? = null, + val cookieBannerHandlingGlobalRulesSubFrames: Boolean? = null, + val webContentIsolationStrategy: WebContentIsolationStrategy? = null, + val userAgent: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): GeckoEngineSettings { + val javascriptEnabled = pigeonVar_list[0] as Boolean? + val trackingProtectionPolicy = pigeonVar_list[1] as TrackingProtectionPolicy? + val httpsOnlyMode = pigeonVar_list[2] as HttpsOnlyMode? + val globalPrivacyControlEnabled = pigeonVar_list[3] as Boolean? + val preferredColorScheme = pigeonVar_list[4] as ColorScheme? + val cookieBannerHandlingMode = pigeonVar_list[5] as CookieBannerHandlingMode? + val cookieBannerHandlingModePrivateBrowsing = pigeonVar_list[6] as CookieBannerHandlingMode? + val cookieBannerHandlingGlobalRules = pigeonVar_list[7] as Boolean? + val cookieBannerHandlingGlobalRulesSubFrames = pigeonVar_list[8] as Boolean? + val webContentIsolationStrategy = pigeonVar_list[9] as WebContentIsolationStrategy? + val userAgent = pigeonVar_list[10] as String? + return GeckoEngineSettings(javascriptEnabled, trackingProtectionPolicy, httpsOnlyMode, globalPrivacyControlEnabled, preferredColorScheme, cookieBannerHandlingMode, cookieBannerHandlingModePrivateBrowsing, cookieBannerHandlingGlobalRules, cookieBannerHandlingGlobalRulesSubFrames, webContentIsolationStrategy, userAgent) + } + } + fun toList(): List { + return listOf( + javascriptEnabled, + trackingProtectionPolicy, + httpsOnlyMode, + globalPrivacyControlEnabled, + preferredColorScheme, + cookieBannerHandlingMode, + cookieBannerHandlingModePrivateBrowsing, + cookieBannerHandlingGlobalRules, + cookieBannerHandlingGlobalRulesSubFrames, + webContentIsolationStrategy, + userAgent, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is GeckoEngineSettings) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class AutocompleteResult ( + val input: String, + val text: String, + val url: String, + val source: String, + val totalItems: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): AutocompleteResult { + val input = pigeonVar_list[0] as String + val text = pigeonVar_list[1] as String + val url = pigeonVar_list[2] as String + val source = pigeonVar_list[3] as String + val totalItems = pigeonVar_list[4] as Long + return AutocompleteResult(input, text, url, source, totalItems) + } + } + fun toList(): List { + return listOf( + input, + text, + url, + source, + totalItems, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is AutocompleteResult) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Represents all the different supported types of data that can be found from long clicking + * an element. + * + * Generated class from Pigeon that represents data sent in messages. + * This class should not be extended by any user class outside of the generated file. + */ +sealed class HitResult +/** + * Default type if we're unable to match the type to anything. It may or may not have a src. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class UnknownHitResult ( + val src: String +) : HitResult() + { + companion object { + fun fromList(pigeonVar_list: List): UnknownHitResult { + val src = pigeonVar_list[0] as String + return UnknownHitResult(src) + } + } + fun toList(): List { + return listOf( + src, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is UnknownHitResult) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * If the HTML element was of type 'HTMLImageElement'. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ImageHitResult ( + val src: String, + val title: String? = null +) : HitResult() + { + companion object { + fun fromList(pigeonVar_list: List): ImageHitResult { + val src = pigeonVar_list[0] as String + val title = pigeonVar_list[1] as String? + return ImageHitResult(src, title) + } + } + fun toList(): List { + return listOf( + src, + title, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ImageHitResult) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * If the HTML element was of type 'HTMLVideoElement'. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class VideoHitResult ( + val src: String, + val title: String? = null +) : HitResult() + { + companion object { + fun fromList(pigeonVar_list: List): VideoHitResult { + val src = pigeonVar_list[0] as String + val title = pigeonVar_list[1] as String? + return VideoHitResult(src, title) + } + } + fun toList(): List { + return listOf( + src, + title, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is VideoHitResult) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * If the HTML element was of type 'HTMLAudioElement'. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AudioHitResult ( + val src: String, + val title: String? = null +) : HitResult() + { + companion object { + fun fromList(pigeonVar_list: List): AudioHitResult { + val src = pigeonVar_list[0] as String + val title = pigeonVar_list[1] as String? + return AudioHitResult(src, title) + } + } + fun toList(): List { + return listOf( + src, + title, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is AudioHitResult) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * If the HTML element was of type 'HTMLImageElement' and contained a URI. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ImageSrcHitResult ( + val src: String, + val uri: String +) : HitResult() + { + companion object { + fun fromList(pigeonVar_list: List): ImageSrcHitResult { + val src = pigeonVar_list[0] as String + val uri = pigeonVar_list[1] as String + return ImageSrcHitResult(src, uri) + } + } + fun toList(): List { + return listOf( + src, + uri, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ImageSrcHitResult) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * The type used if the URI is prepended with 'tel:'. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PhoneHitResult ( + val src: String +) : HitResult() + { + companion object { + fun fromList(pigeonVar_list: List): PhoneHitResult { + val src = pigeonVar_list[0] as String + return PhoneHitResult(src) + } + } + fun toList(): List { + return listOf( + src, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is PhoneHitResult) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * The type used if the URI is prepended with 'mailto:'. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class EmailHitResult ( + val src: String +) : HitResult() + { + companion object { + fun fromList(pigeonVar_list: List): EmailHitResult { + val src = pigeonVar_list[0] as String + return EmailHitResult(src) + } + } + fun toList(): List { + return listOf( + src, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is EmailHitResult) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * The type used if the URI is prepended with 'geo:'. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class GeoHitResult ( + val src: String +) : HitResult() + { + companion object { + fun fromList(pigeonVar_list: List): GeoHitResult { + val src = pigeonVar_list[0] as String + return GeoHitResult(src) + } + } + fun toList(): List { + return listOf( + src, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is GeoHitResult) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class DownloadState ( + val url: String, + val fileName: String? = null, + val contentType: String? = null, + val contentLength: Long? = null, + val currentBytesCopied: Long? = null, + val status: DownloadStatus? = null, + val userAgent: String? = null, + val destinationDirectory: String? = null, + val directoryPath: String? = null, + val referrerUrl: String? = null, + val skipConfirmation: Boolean? = null, + val openInApp: Boolean? = null, + val id: String? = null, + val sessionId: String? = null, + val private: Boolean? = null, + val createdTime: Long? = null, + val notificationId: Long? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): DownloadState { + val url = pigeonVar_list[0] as String + val fileName = pigeonVar_list[1] as String? + val contentType = pigeonVar_list[2] as String? + val contentLength = pigeonVar_list[3] as Long? + val currentBytesCopied = pigeonVar_list[4] as Long? + val status = pigeonVar_list[5] as DownloadStatus? + val userAgent = pigeonVar_list[6] as String? + val destinationDirectory = pigeonVar_list[7] as String? + val directoryPath = pigeonVar_list[8] as String? + val referrerUrl = pigeonVar_list[9] as String? + val skipConfirmation = pigeonVar_list[10] as Boolean? + val openInApp = pigeonVar_list[11] as Boolean? + val id = pigeonVar_list[12] as String? + val sessionId = pigeonVar_list[13] as String? + val private = pigeonVar_list[14] as Boolean? + val createdTime = pigeonVar_list[15] as Long? + val notificationId = pigeonVar_list[16] as Long? + return DownloadState(url, fileName, contentType, contentLength, currentBytesCopied, status, userAgent, destinationDirectory, directoryPath, referrerUrl, skipConfirmation, openInApp, id, sessionId, private, createdTime, notificationId) + } + } + fun toList(): List { + return listOf( + url, + fileName, + contentType, + contentLength, + currentBytesCopied, + status, + userAgent, + destinationDirectory, + directoryPath, + referrerUrl, + skipConfirmation, + openInApp, + id, + sessionId, + private, + createdTime, + notificationId, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is DownloadState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class ShareInternetResourceState ( + val url: String, + val contentType: String? = null, + val private: Boolean, + val referrerUrl: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): ShareInternetResourceState { + val url = pigeonVar_list[0] as String + val contentType = pigeonVar_list[1] as String? + val private = pigeonVar_list[2] as Boolean + val referrerUrl = pigeonVar_list[3] as String? + return ShareInternetResourceState(url, contentType, private, referrerUrl) + } + } + fun toList(): List { + return listOf( + url, + contentType, + private, + referrerUrl, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ShareInternetResourceState) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class GeckoPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + RestoreLocation.ofRaw(it.toInt()) + } + } + 130.toByte() -> { + return (readValue(buffer) as Long?)?.let { + IconType.ofRaw(it.toInt()) + } + } + 131.toByte() -> { + return (readValue(buffer) as Long?)?.let { + IconSize.ofRaw(it.toInt()) + } + } + 132.toByte() -> { + return (readValue(buffer) as Long?)?.let { + IconSource.ofRaw(it.toInt()) + } + } + 133.toByte() -> { + return (readValue(buffer) as Long?)?.let { + CookieSameSiteStatus.ofRaw(it.toInt()) + } + } + 134.toByte() -> { + return (readValue(buffer) as Long?)?.let { + SelectionPattern.ofRaw(it.toInt()) + } + } + 135.toByte() -> { + return (readValue(buffer) as Long?)?.let { + WebExtensionActionType.ofRaw(it.toInt()) + } + } + 136.toByte() -> { + return (readValue(buffer) as Long?)?.let { + GeckoSuggestionType.ofRaw(it.toInt()) + } + } + 137.toByte() -> { + return (readValue(buffer) as Long?)?.let { + TrackingProtectionPolicy.ofRaw(it.toInt()) + } + } + 138.toByte() -> { + return (readValue(buffer) as Long?)?.let { + HttpsOnlyMode.ofRaw(it.toInt()) + } + } + 139.toByte() -> { + return (readValue(buffer) as Long?)?.let { + ColorScheme.ofRaw(it.toInt()) + } + } + 140.toByte() -> { + return (readValue(buffer) as Long?)?.let { + CookieBannerHandlingMode.ofRaw(it.toInt()) + } + } + 141.toByte() -> { + return (readValue(buffer) as Long?)?.let { + WebContentIsolationStrategy.ofRaw(it.toInt()) + } + } + 142.toByte() -> { + return (readValue(buffer) as Long?)?.let { + DownloadStatus.ofRaw(it.toInt()) + } + } + 143.toByte() -> { + return (readValue(buffer) as? List)?.let { + TranslationOptions.fromList(it) + } + } + 144.toByte() -> { + return (readValue(buffer) as? List)?.let { + ReaderState.fromList(it) + } + } + 145.toByte() -> { + return (readValue(buffer) as? List)?.let { + LastMediaAccessState.fromList(it) + } + } + 146.toByte() -> { + return (readValue(buffer) as? List)?.let { + HistoryMetadataKey.fromList(it) + } + } + 147.toByte() -> { + return (readValue(buffer) as? List)?.let { + PackageCategoryValue.fromList(it) + } + } + 148.toByte() -> { + return (readValue(buffer) as? List)?.let { + ExternalPackage.fromList(it) + } + } + 149.toByte() -> { + return (readValue(buffer) as? List)?.let { + LoadUrlFlagsValue.fromList(it) + } + } + 150.toByte() -> { + return (readValue(buffer) as? List)?.let { + SourceValue.fromList(it) + } + } + 151.toByte() -> { + return (readValue(buffer) as? List)?.let { + TabState.fromList(it) + } + } + 152.toByte() -> { + return (readValue(buffer) as? List)?.let { + RecoverableTab.fromList(it) + } + } + 153.toByte() -> { + return (readValue(buffer) as? List)?.let { + RecoverableBrowserState.fromList(it) + } + } + 154.toByte() -> { + return (readValue(buffer) as? List)?.let { + IconRequest.fromList(it) + } + } + 155.toByte() -> { + return (readValue(buffer) as? List)?.let { + ResourceSize.fromList(it) + } + } + 156.toByte() -> { + return (readValue(buffer) as? List)?.let { + Resource.fromList(it) + } + } + 157.toByte() -> { + return (readValue(buffer) as? List)?.let { + IconResult.fromList(it) + } + } + 158.toByte() -> { + return (readValue(buffer) as? List)?.let { + CookiePartitionKey.fromList(it) + } + } + 159.toByte() -> { + return (readValue(buffer) as? List)?.let { + Cookie.fromList(it) + } + } + 160.toByte() -> { + return (readValue(buffer) as? List)?.let { + HistoryItem.fromList(it) + } + } + 161.toByte() -> { + return (readValue(buffer) as? List)?.let { + HistoryState.fromList(it) + } + } + 162.toByte() -> { + return (readValue(buffer) as? List)?.let { + ReaderableState.fromList(it) + } + } + 163.toByte() -> { + return (readValue(buffer) as? List)?.let { + SecurityInfoState.fromList(it) + } + } + 164.toByte() -> { + return (readValue(buffer) as? List)?.let { + TabContentState.fromList(it) + } + } + 165.toByte() -> { + return (readValue(buffer) as? List)?.let { + FindResultState.fromList(it) + } + } + 166.toByte() -> { + return (readValue(buffer) as? List)?.let { + CustomSelectionAction.fromList(it) + } + } + 167.toByte() -> { + return (readValue(buffer) as? List)?.let { + WebExtensionData.fromList(it) + } + } + 168.toByte() -> { + return (readValue(buffer) as? List)?.let { + GeckoSuggestion.fromList(it) + } + } + 169.toByte() -> { + return (readValue(buffer) as? List)?.let { + TabContent.fromList(it) + } + } + 170.toByte() -> { + return (readValue(buffer) as? List)?.let { + GeckoEngineSettings.fromList(it) + } + } + 171.toByte() -> { + return (readValue(buffer) as? List)?.let { + AutocompleteResult.fromList(it) + } + } + 172.toByte() -> { + return (readValue(buffer) as? List)?.let { + UnknownHitResult.fromList(it) + } + } + 173.toByte() -> { + return (readValue(buffer) as? List)?.let { + ImageHitResult.fromList(it) + } + } + 174.toByte() -> { + return (readValue(buffer) as? List)?.let { + VideoHitResult.fromList(it) + } + } + 175.toByte() -> { + return (readValue(buffer) as? List)?.let { + AudioHitResult.fromList(it) + } + } + 176.toByte() -> { + return (readValue(buffer) as? List)?.let { + ImageSrcHitResult.fromList(it) + } + } + 177.toByte() -> { + return (readValue(buffer) as? List)?.let { + PhoneHitResult.fromList(it) + } + } + 178.toByte() -> { + return (readValue(buffer) as? List)?.let { + EmailHitResult.fromList(it) + } + } + 179.toByte() -> { + return (readValue(buffer) as? List)?.let { + GeoHitResult.fromList(it) + } + } + 180.toByte() -> { + return (readValue(buffer) as? List)?.let { + DownloadState.fromList(it) + } + } + 181.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareInternetResourceState.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is RestoreLocation -> { + stream.write(129) + writeValue(stream, value.raw) + } + is IconType -> { + stream.write(130) + writeValue(stream, value.raw) + } + is IconSize -> { + stream.write(131) + writeValue(stream, value.raw) + } + is IconSource -> { + stream.write(132) + writeValue(stream, value.raw) + } + is CookieSameSiteStatus -> { + stream.write(133) + writeValue(stream, value.raw) + } + is SelectionPattern -> { + stream.write(134) + writeValue(stream, value.raw) + } + is WebExtensionActionType -> { + stream.write(135) + writeValue(stream, value.raw) + } + is GeckoSuggestionType -> { + stream.write(136) + writeValue(stream, value.raw) + } + is TrackingProtectionPolicy -> { + stream.write(137) + writeValue(stream, value.raw) + } + is HttpsOnlyMode -> { + stream.write(138) + writeValue(stream, value.raw) + } + is ColorScheme -> { + stream.write(139) + writeValue(stream, value.raw) + } + is CookieBannerHandlingMode -> { + stream.write(140) + writeValue(stream, value.raw) + } + is WebContentIsolationStrategy -> { + stream.write(141) + writeValue(stream, value.raw) + } + is DownloadStatus -> { + stream.write(142) + writeValue(stream, value.raw) + } + is TranslationOptions -> { + stream.write(143) + writeValue(stream, value.toList()) + } + is ReaderState -> { + stream.write(144) + writeValue(stream, value.toList()) + } + is LastMediaAccessState -> { + stream.write(145) + writeValue(stream, value.toList()) + } + is HistoryMetadataKey -> { + stream.write(146) + writeValue(stream, value.toList()) + } + is PackageCategoryValue -> { + stream.write(147) + writeValue(stream, value.toList()) + } + is ExternalPackage -> { + stream.write(148) + writeValue(stream, value.toList()) + } + is LoadUrlFlagsValue -> { + stream.write(149) + writeValue(stream, value.toList()) + } + is SourceValue -> { + stream.write(150) + writeValue(stream, value.toList()) + } + is TabState -> { + stream.write(151) + writeValue(stream, value.toList()) + } + is RecoverableTab -> { + stream.write(152) + writeValue(stream, value.toList()) + } + is RecoverableBrowserState -> { + stream.write(153) + writeValue(stream, value.toList()) + } + is IconRequest -> { + stream.write(154) + writeValue(stream, value.toList()) + } + is ResourceSize -> { + stream.write(155) + writeValue(stream, value.toList()) + } + is Resource -> { + stream.write(156) + writeValue(stream, value.toList()) + } + is IconResult -> { + stream.write(157) + writeValue(stream, value.toList()) + } + is CookiePartitionKey -> { + stream.write(158) + writeValue(stream, value.toList()) + } + is Cookie -> { + stream.write(159) + writeValue(stream, value.toList()) + } + is HistoryItem -> { + stream.write(160) + writeValue(stream, value.toList()) + } + is HistoryState -> { + stream.write(161) + writeValue(stream, value.toList()) + } + is ReaderableState -> { + stream.write(162) + writeValue(stream, value.toList()) + } + is SecurityInfoState -> { + stream.write(163) + writeValue(stream, value.toList()) + } + is TabContentState -> { + stream.write(164) + writeValue(stream, value.toList()) + } + is FindResultState -> { + stream.write(165) + writeValue(stream, value.toList()) + } + is CustomSelectionAction -> { + stream.write(166) + writeValue(stream, value.toList()) + } + is WebExtensionData -> { + stream.write(167) + writeValue(stream, value.toList()) + } + is GeckoSuggestion -> { + stream.write(168) + writeValue(stream, value.toList()) + } + is TabContent -> { + stream.write(169) + writeValue(stream, value.toList()) + } + is GeckoEngineSettings -> { + stream.write(170) + writeValue(stream, value.toList()) + } + is AutocompleteResult -> { + stream.write(171) + writeValue(stream, value.toList()) + } + is UnknownHitResult -> { + stream.write(172) + writeValue(stream, value.toList()) + } + is ImageHitResult -> { + stream.write(173) + writeValue(stream, value.toList()) + } + is VideoHitResult -> { + stream.write(174) + writeValue(stream, value.toList()) + } + is AudioHitResult -> { + stream.write(175) + writeValue(stream, value.toList()) + } + is ImageSrcHitResult -> { + stream.write(176) + writeValue(stream, value.toList()) + } + is PhoneHitResult -> { + stream.write(177) + writeValue(stream, value.toList()) + } + is EmailHitResult -> { + stream.write(178) + writeValue(stream, value.toList()) + } + is GeoHitResult -> { + stream.write(179) + writeValue(stream, value.toList()) + } + is DownloadState -> { + stream.write(180) + writeValue(stream, value.toList()) + } + is ShareInternetResourceState -> { + stream.write(181) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoBrowserApi { + fun initialize() + fun showNativeFragment(): Boolean + fun onTrimMemory(level: Long) + + companion object { + /** The codec used by GeckoBrowserApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoBrowserApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoBrowserApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.initialize$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.initialize() + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.showNativeFragment$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.showNativeFragment()) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.onTrimMemory$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val levelArg = args[0] as Long + val wrapped: List = try { + api.onTrimMemory(levelArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoEngineSettingsApi { + fun setDefaultSettings(settings: GeckoEngineSettings) + fun updateRuntimeSettings(settings: GeckoEngineSettings) + + companion object { + /** The codec used by GeckoEngineSettingsApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoEngineSettingsApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoEngineSettingsApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setDefaultSettings$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val settingsArg = args[0] as GeckoEngineSettings + val wrapped: List = try { + api.setDefaultSettings(settingsArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.updateRuntimeSettings$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val settingsArg = args[0] as GeckoEngineSettings + val wrapped: List = try { + api.updateRuntimeSettings(settingsArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoSessionApi { + fun loadUrl(tabId: String?, url: String, flags: LoadUrlFlagsValue, additionalHeaders: Map?) + fun loadData(tabId: String?, data: String, mimeType: String, encoding: String) + fun reload(tabId: String?, flags: LoadUrlFlagsValue) + fun stopLoading(tabId: String?) + fun goBack(tabId: String?, userInteraction: Boolean) + fun goForward(tabId: String?, userInteraction: Boolean) + fun goToHistoryIndex(index: Long, tabId: String?) + fun requestDesktopSite(tabId: String?, enable: Boolean) + fun exitFullscreen(tabId: String?) + fun saveToPdf(tabId: String?) + fun printContent(tabId: String?) + fun translate(tabId: String?, fromLanguage: String, toLanguage: String, options: TranslationOptions?) + fun translateRestore(tabId: String?) + fun crashRecovery(tabIds: List?) + fun purgeHistory() + fun updateLastAccess(tabId: String?, lastAccess: Long?) + fun requestScreenshot(sendBack: Boolean, callback: (Result) -> Unit) + + companion object { + /** The codec used by GeckoSessionApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoSessionApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoSessionApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.loadUrl$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val urlArg = args[1] as String + val flagsArg = args[2] as LoadUrlFlagsValue + val additionalHeadersArg = args[3] as Map? + val wrapped: List = try { + api.loadUrl(tabIdArg, urlArg, flagsArg, additionalHeadersArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.loadData$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val dataArg = args[1] as String + val mimeTypeArg = args[2] as String + val encodingArg = args[3] as String + val wrapped: List = try { + api.loadData(tabIdArg, dataArg, mimeTypeArg, encodingArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.reload$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val flagsArg = args[1] as LoadUrlFlagsValue + val wrapped: List = try { + api.reload(tabIdArg, flagsArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.stopLoading$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val wrapped: List = try { + api.stopLoading(tabIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.goBack$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val userInteractionArg = args[1] as Boolean + val wrapped: List = try { + api.goBack(tabIdArg, userInteractionArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.goForward$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val userInteractionArg = args[1] as Boolean + val wrapped: List = try { + api.goForward(tabIdArg, userInteractionArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.goToHistoryIndex$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val indexArg = args[0] as Long + val tabIdArg = args[1] as String? + val wrapped: List = try { + api.goToHistoryIndex(indexArg, tabIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.requestDesktopSite$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val enableArg = args[1] as Boolean + val wrapped: List = try { + api.requestDesktopSite(tabIdArg, enableArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.exitFullscreen$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val wrapped: List = try { + api.exitFullscreen(tabIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.saveToPdf$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val wrapped: List = try { + api.saveToPdf(tabIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.printContent$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val wrapped: List = try { + api.printContent(tabIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.translate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val fromLanguageArg = args[1] as String + val toLanguageArg = args[2] as String + val optionsArg = args[3] as TranslationOptions? + val wrapped: List = try { + api.translate(tabIdArg, fromLanguageArg, toLanguageArg, optionsArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.translateRestore$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val wrapped: List = try { + api.translateRestore(tabIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.crashRecovery$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdsArg = args[0] as List? + val wrapped: List = try { + api.crashRecovery(tabIdsArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.purgeHistory$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.purgeHistory() + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.updateLastAccess$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val lastAccessArg = args[1] as Long? + val wrapped: List = try { + api.updateLastAccess(tabIdArg, lastAccessArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSessionApi.requestScreenshot$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val sendBackArg = args[0] as Boolean + api.requestScreenshot(sendBackArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoTabsApi { + fun syncEvents(onSelectedTabChange: Boolean, onTabListChange: Boolean, onTabContentStateChange: Boolean, onIconChange: Boolean, onSecurityInfoStateChange: Boolean, onReaderableStateChange: Boolean, onHistoryStateChange: Boolean, onFindResults: Boolean, onThumbnailChange: Boolean) + fun selectTab(tabId: String) + fun removeTab(tabId: String) + fun addTab(url: String, selectTab: Boolean, startLoading: Boolean, parentId: String?, flags: LoadUrlFlagsValue, contextId: String?, source: SourceValue, private: Boolean, historyMetadata: HistoryMetadataKey?, additionalHeaders: Map?): String + fun removeAllTabs(recoverable: Boolean) + fun removeTabs(ids: List) + fun removeNormalTabs() + fun removePrivateTabs() + fun undo() + fun restoreTabsByList(tabs: List, selectTabId: String?, restoreLocation: RestoreLocation) + fun restoreTabsByBrowserState(state: RecoverableBrowserState, restoreLocation: RestoreLocation) + /** + * Selects an already existing tab with the matching [HistoryMetadataKey] or otherwise + * creates a new tab with the given [url]. + */ + fun selectOrAddTabByHistory(url: String, historyMetadata: HistoryMetadataKey): String + /** Selects an already existing tab displaying [url] or otherwise creates a new tab. */ + fun selectOrAddTabByUrl(url: String, private: Boolean, source: SourceValue, flags: LoadUrlFlagsValue, ignoreFragment: Boolean): String + fun duplicateTab(selectTabId: String?, selectNewTab: Boolean, newContextId: String?): String + fun moveTabs(tabIds: List, targetTabId: String, placeAfter: Boolean) + fun migratePrivateTabUseCase(tabId: String, alternativeUrl: String?): String + + companion object { + /** The codec used by GeckoTabsApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoTabsApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoTabsApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.syncEvents$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val onSelectedTabChangeArg = args[0] as Boolean + val onTabListChangeArg = args[1] as Boolean + val onTabContentStateChangeArg = args[2] as Boolean + val onIconChangeArg = args[3] as Boolean + val onSecurityInfoStateChangeArg = args[4] as Boolean + val onReaderableStateChangeArg = args[5] as Boolean + val onHistoryStateChangeArg = args[6] as Boolean + val onFindResultsArg = args[7] as Boolean + val onThumbnailChangeArg = args[8] as Boolean + val wrapped: List = try { + api.syncEvents(onSelectedTabChangeArg, onTabListChangeArg, onTabContentStateChangeArg, onIconChangeArg, onSecurityInfoStateChangeArg, onReaderableStateChangeArg, onHistoryStateChangeArg, onFindResultsArg, onThumbnailChangeArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.selectTab$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String + val wrapped: List = try { + api.selectTab(tabIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.removeTab$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String + val wrapped: List = try { + api.removeTab(tabIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.addTab$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + val selectTabArg = args[1] as Boolean + val startLoadingArg = args[2] as Boolean + val parentIdArg = args[3] as String? + val flagsArg = args[4] as LoadUrlFlagsValue + val contextIdArg = args[5] as String? + val sourceArg = args[6] as SourceValue + val privateArg = args[7] as Boolean + val historyMetadataArg = args[8] as HistoryMetadataKey? + val additionalHeadersArg = args[9] as Map? + val wrapped: List = try { + listOf(api.addTab(urlArg, selectTabArg, startLoadingArg, parentIdArg, flagsArg, contextIdArg, sourceArg, privateArg, historyMetadataArg, additionalHeadersArg)) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.removeAllTabs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val recoverableArg = args[0] as Boolean + val wrapped: List = try { + api.removeAllTabs(recoverableArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.removeTabs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val idsArg = args[0] as List + val wrapped: List = try { + api.removeTabs(idsArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.removeNormalTabs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.removeNormalTabs() + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.removePrivateTabs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.removePrivateTabs() + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.undo$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.undo() + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.restoreTabsByList$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabsArg = args[0] as List + val selectTabIdArg = args[1] as String? + val restoreLocationArg = args[2] as RestoreLocation + val wrapped: List = try { + api.restoreTabsByList(tabsArg, selectTabIdArg, restoreLocationArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.restoreTabsByBrowserState$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val stateArg = args[0] as RecoverableBrowserState + val restoreLocationArg = args[1] as RestoreLocation + val wrapped: List = try { + api.restoreTabsByBrowserState(stateArg, restoreLocationArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.selectOrAddTabByHistory$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + val historyMetadataArg = args[1] as HistoryMetadataKey + val wrapped: List = try { + listOf(api.selectOrAddTabByHistory(urlArg, historyMetadataArg)) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.selectOrAddTabByUrl$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + val privateArg = args[1] as Boolean + val sourceArg = args[2] as SourceValue + val flagsArg = args[3] as LoadUrlFlagsValue + val ignoreFragmentArg = args[4] as Boolean + val wrapped: List = try { + listOf(api.selectOrAddTabByUrl(urlArg, privateArg, sourceArg, flagsArg, ignoreFragmentArg)) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.duplicateTab$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val selectTabIdArg = args[0] as String? + val selectNewTabArg = args[1] as Boolean + val newContextIdArg = args[2] as String? + val wrapped: List = try { + listOf(api.duplicateTab(selectTabIdArg, selectNewTabArg, newContextIdArg)) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.moveTabs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdsArg = args[0] as List + val targetTabIdArg = args[1] as String + val placeAfterArg = args[2] as Boolean + val wrapped: List = try { + api.moveTabs(tabIdsArg, targetTabIdArg, placeAfterArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.migratePrivateTabUseCase$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String + val alternativeUrlArg = args[1] as String? + val wrapped: List = try { + listOf(api.migratePrivateTabUseCase(tabIdArg, alternativeUrlArg)) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoFindApi { + fun findAll(tabId: String?, text: String) + fun findNext(tabId: String?, forward: Boolean) + fun clearMatches(tabId: String?) + + companion object { + /** The codec used by GeckoFindApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoFindApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoFindApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoFindApi.findAll$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val textArg = args[1] as String + val wrapped: List = try { + api.findAll(tabIdArg, textArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoFindApi.findNext$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val forwardArg = args[1] as Boolean + val wrapped: List = try { + api.findNext(tabIdArg, forwardArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoFindApi.clearMatches$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val wrapped: List = try { + api.clearMatches(tabIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoIconsApi { + fun loadIcon(request: IconRequest, callback: (Result) -> Unit) + + companion object { + /** The codec used by GeckoIconsApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoIconsApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoIconsApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoIconsApi.loadIcon$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val requestArg = args[0] as IconRequest + api.loadIcon(requestArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoPrefApi { + fun getPrefs(preferenceFilter: List?, callback: (Result>) -> Unit) + fun applyPrefs(prefBuffer: String, callback: (Result>) -> Unit) + fun resetPrefs(preferenceNames: List?, callback: (Result) -> Unit) + + companion object { + /** The codec used by GeckoPrefApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoPrefApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoPrefApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPrefApi.getPrefs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val preferenceFilterArg = args[0] as List? + api.getPrefs(preferenceFilterArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPrefApi.applyPrefs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val prefBufferArg = args[0] as String + api.applyPrefs(prefBufferArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPrefApi.resetPrefs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val preferenceNamesArg = args[0] as List? + api.resetPrefs(preferenceNamesArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoBrowserExtensionApi { + fun getMarkdown(htmlList: List, callback: (Result>) -> Unit) + + companion object { + /** The codec used by GeckoBrowserExtensionApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoBrowserExtensionApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoBrowserExtensionApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserExtensionApi.getMarkdown$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val htmlListArg = args[0] as List + api.getMarkdown(htmlListArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoContainerProxyApi { + fun setProxyPort(port: Long) + fun addContainerProxy(contextId: String) + fun removeContainerProxy(contextId: String) + fun healthcheck(callback: (Result) -> Unit) + + companion object { + /** The codec used by GeckoContainerProxyApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoContainerProxyApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoContainerProxyApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setProxyPort$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val portArg = args[0] as Long + val wrapped: List = try { + api.setProxyPort(portArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.addContainerProxy$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val contextIdArg = args[0] as String + val wrapped: List = try { + api.addContainerProxy(contextIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.removeContainerProxy$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val contextIdArg = args[0] as String + val wrapped: List = try { + api.removeContainerProxy(contextIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.healthcheck$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.healthcheck{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoCookieApi { + fun getCookie(firstPartyDomain: String?, name: String, partitionKey: CookiePartitionKey?, storeId: String?, url: String, callback: (Result) -> Unit) + fun getAllCookies(domain: String?, firstPartyDomain: String?, name: String?, partitionKey: CookiePartitionKey?, storeId: String?, url: String, callback: (Result>) -> Unit) + fun setCookie(domain: String?, expirationDate: Long?, firstPartyDomain: String?, httpOnly: Boolean?, name: String?, partitionKey: CookiePartitionKey?, path: String?, sameSite: CookieSameSiteStatus?, secure: Boolean?, storeId: String?, url: String, value: String?, callback: (Result) -> Unit) + fun removeCookie(firstPartyDomain: String?, name: String, partitionKey: CookiePartitionKey?, storeId: String?, url: String, callback: (Result) -> Unit) + + companion object { + /** The codec used by GeckoCookieApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoCookieApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoCookieApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoCookieApi.getCookie$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val firstPartyDomainArg = args[0] as String? + val nameArg = args[1] as String + val partitionKeyArg = args[2] as CookiePartitionKey? + val storeIdArg = args[3] as String? + val urlArg = args[4] as String + api.getCookie(firstPartyDomainArg, nameArg, partitionKeyArg, storeIdArg, urlArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoCookieApi.getAllCookies$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val domainArg = args[0] as String? + val firstPartyDomainArg = args[1] as String? + val nameArg = args[2] as String? + val partitionKeyArg = args[3] as CookiePartitionKey? + val storeIdArg = args[4] as String? + val urlArg = args[5] as String + api.getAllCookies(domainArg, firstPartyDomainArg, nameArg, partitionKeyArg, storeIdArg, urlArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoCookieApi.setCookie$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val domainArg = args[0] as String? + val expirationDateArg = args[1] as Long? + val firstPartyDomainArg = args[2] as String? + val httpOnlyArg = args[3] as Boolean? + val nameArg = args[4] as String? + val partitionKeyArg = args[5] as CookiePartitionKey? + val pathArg = args[6] as String? + val sameSiteArg = args[7] as CookieSameSiteStatus? + val secureArg = args[8] as Boolean? + val storeIdArg = args[9] as String? + val urlArg = args[10] as String + val valueArg = args[11] as String? + api.setCookie(domainArg, expirationDateArg, firstPartyDomainArg, httpOnlyArg, nameArg, partitionKeyArg, pathArg, sameSiteArg, secureArg, storeIdArg, urlArg, valueArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoCookieApi.removeCookie$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val firstPartyDomainArg = args[0] as String? + val nameArg = args[1] as String + val partitionKeyArg = args[2] as CookiePartitionKey? + val storeIdArg = args[3] as String? + val urlArg = args[4] as String + api.removeCookie(firstPartyDomainArg, nameArg, partitionKeyArg, storeIdArg, urlArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by GeckoStateEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + fun onViewReadyStateChange(timestampArg: Long, stateArg: Boolean, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onViewReadyStateChange$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, stateArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onEngineReadyStateChange(timestampArg: Long, stateArg: Boolean, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onEngineReadyStateChange$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, stateArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onIconUpdate(timestampArg: Long, urlArg: String, bytesArg: ByteArray, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, urlArg, bytesArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onTabAdded(timestampArg: Long, tabIdArg: String, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabAdded$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, tabIdArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onTabListChange(timestampArg: Long, tabIdsArg: List, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabListChange$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, tabIdsArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onSelectedTabChange(timestampArg: Long, idArg: String?, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSelectedTabChange$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, idArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onTabContentStateChange(timestampArg: Long, stateArg: TabContentState, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabContentStateChange$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, stateArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onHistoryStateChange(timestampArg: Long, idArg: String, stateArg: HistoryState, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onHistoryStateChange$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, idArg, stateArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onReaderableStateChange(timestampArg: Long, idArg: String, stateArg: ReaderableState, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onReaderableStateChange$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, idArg, stateArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onSecurityInfoStateChange(timestampArg: Long, idArg: String, stateArg: SecurityInfoState, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSecurityInfoStateChange$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, idArg, stateArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onIconChange(timestampArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconChange$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, idArg, bytesArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onThumbnailChange(timestampArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onThumbnailChange$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, idArg, bytesArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onFindResults(timestampArg: Long, idArg: String, resultsArg: List, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onFindResults$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, idArg, resultsArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onLongPress(timestampArg: Long, idArg: String, hitResultArg: HitResult, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onLongPress$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, idArg, hitResultArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface ReaderViewEvents { + fun onToggleReaderView(enable: Boolean) + fun onAppearanceButtonTap() + + companion object { + /** The codec used by ReaderViewEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `ReaderViewEvents` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: ReaderViewEvents?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.ReaderViewEvents.onToggleReaderView$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val enableArg = args[0] as Boolean + val wrapped: List = try { + api.onToggleReaderView(enableArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.ReaderViewEvents.onAppearanceButtonTap$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.onAppearanceButtonTap() + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class ReaderViewController(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by ReaderViewController. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + fun appearanceButtonVisibility(timestampArg: Long, visibleArg: Boolean, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.ReaderViewController.appearanceButtonVisibility$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, visibleArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoSelectionActionController { + fun setActions(actions: List) + + companion object { + /** The codec used by GeckoSelectionActionController. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoSelectionActionController` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoSelectionActionController?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSelectionActionController.setActions$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val actionsArg = args[0] as List + val wrapped: List = try { + api.setActions(actionsArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class GeckoSelectionActionEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by GeckoSelectionActionEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + fun performSelectionAction(idArg: String, selectedTextArg: String, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSelectionActionEvents.performSelectionAction$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(idArg, selectedTextArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoAddonsApi { + fun startAddonManagerActivity() + fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) + fun installAddon(url: String, callback: (Result) -> Unit) + + companion object { + /** The codec used by GeckoAddonsApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoAddonsApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoAddonsApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.startAddonManagerActivity$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.startAddonManagerActivity() + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.invokeAddonAction$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val extensionIdArg = args[0] as String + val actionTypeArg = args[1] as WebExtensionActionType + val wrapped: List = try { + api.invokeAddonAction(extensionIdArg, actionTypeArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.installAddon$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + api.installAddon(urlArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by GeckoAddonEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + fun onUpsertWebExtensionAction(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, extensionDataArg: WebExtensionData, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpsertWebExtensionAction$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg, extensionDataArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onRemoveWebExtensionAction(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onRemoveWebExtensionAction$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onUpdateWebExtensionIcon(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, iconArg: ByteArray, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpdateWebExtensionIcon$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg, iconArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoSuggestionApi { + fun getAutocompleteSuggestion(query: String, callback: (Result) -> Unit) + fun querySuggestions(text: String, providers: List) + + companion object { + /** The codec used by GeckoSuggestionApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoSuggestionApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoSuggestionApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionApi.getAutocompleteSuggestion$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val queryArg = args[0] as String + api.getAutocompleteSuggestion(queryArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionApi.querySuggestions$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val textArg = args[0] as String + val providersArg = args[1] as List + val wrapped: List = try { + api.querySuggestions(textArg, providersArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class GeckoSuggestionEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by GeckoSuggestionEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + fun onSuggestionResult(timestampArg: Long, suggestionTypeArg: GeckoSuggestionType, suggestionsArg: List, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionEvents.onSuggestionResult$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, suggestionTypeArg, suggestionsArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class GeckoTabContentEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by GeckoTabContentEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + fun onContentUpdate(timestampArg: Long, contentArg: TabContent, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabContentEvents.onContentUpdate$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, contentArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoDeleteBrowsingDataController { + fun deleteTabs(callback: (Result) -> Unit) + fun deleteBrowsingHistory(callback: (Result) -> Unit) + fun deleteCookiesAndSiteData(callback: (Result) -> Unit) + fun deleteCachedFiles(callback: (Result) -> Unit) + fun deleteSitePermissions(callback: (Result) -> Unit) + fun deleteDownloads(callback: (Result) -> Unit) + + companion object { + /** The codec used by GeckoDeleteBrowsingDataController. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoDeleteBrowsingDataController` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoDeleteBrowsingDataController?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoDeleteBrowsingDataController.deleteTabs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.deleteTabs{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoDeleteBrowsingDataController.deleteBrowsingHistory$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.deleteBrowsingHistory{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoDeleteBrowsingDataController.deleteCookiesAndSiteData$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.deleteCookiesAndSiteData{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoDeleteBrowsingDataController.deleteCachedFiles$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.deleteCachedFiles{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoDeleteBrowsingDataController.deleteSitePermissions$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.deleteSitePermissions{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoDeleteBrowsingDataController.deleteDownloads$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.deleteDownloads{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoDownloadsApi { + fun requestDownload(tabId: String, state: DownloadState) + fun copyInternetResource(tabId: String, state: ShareInternetResourceState) + fun shareInternetResource(tabId: String, state: ShareInternetResourceState) + + companion object { + /** The codec used by GeckoDownloadsApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoDownloadsApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoDownloadsApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoDownloadsApi.requestDownload$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String + val stateArg = args[1] as DownloadState + val wrapped: List = try { + api.requestDownload(tabIdArg, stateArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoDownloadsApi.copyInternetResource$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String + val stateArg = args[1] as ShareInternetResourceState + val wrapped: List = try { + api.copyInternetResource(tabIdArg, stateArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoDownloadsApi.shareInternetResource$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String + val stateArg = args[1] as ShareInternetResourceState + val wrapped: List = try { + api.shareInternetResource(tabIdArg, stateArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class BrowserExtensionEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by BrowserExtensionEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + fun onFeedRequested(timestampArg: Long, urlArg: String, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(timestampArg, urlArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pip/PictureInPictureIntegration.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pip/PictureInPictureIntegration.kt new file mode 100644 index 00000000..254c1c0c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pip/PictureInPictureIntegration.kt @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.pip + +import android.app.Activity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.mapNotNull +import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.feature.session.PictureInPictureFeature +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature + +class PictureInPictureIntegration( + private val store: BrowserStore, + activity: Activity, + private val customTabId: String?, + private val whiteList: List = listOf("youtube.com/tv"), +) : LifecycleAwareFeature { + private var scope: CoroutineScope? = null + private val pictureFeature = PictureInPictureFeature(store, activity) + private var whiteListed = false + + override fun start() { + scope = store.flowScoped { flow -> + flow.mapNotNull { state -> state.findTabOrCustomTabOrSelectedTab(customTabId) } + .distinctUntilChangedBy { it.content.url } + .collect { whiteListed = isWhitelisted(it.content.url) } + } + } + + override fun stop() { + scope?.cancel() + } + + fun onHomePressed() = if (whiteListed) { + pictureFeature.enterPipModeCompat() + } else { + pictureFeature.onHomePressed() + } + + private fun isWhitelisted(url: String): Boolean { + val exists = whiteList.firstOrNull { url.contains(it) } + return exists != null + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/CustomTabsService.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/CustomTabsService.kt new file mode 100644 index 00000000..028cd47b --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/CustomTabsService.kt @@ -0,0 +1,21 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.services + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import mozilla.components.concept.engine.Engine +import mozilla.components.feature.customtabs.AbstractCustomTabsService +import mozilla.components.feature.customtabs.store.CustomTabsServiceStore + +class CustomTabsService : AbstractCustomTabsService() { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override val customTabsServiceStore: CustomTabsServiceStore by lazy { components.core.customTabsStore } + override val engine: Engine by lazy { components.core.engine } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/DownloadService.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/DownloadService.kt new file mode 100644 index 00000000..3d2217b7 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/DownloadService.kt @@ -0,0 +1,26 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.services + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.feature.downloads.AbstractFetchDownloadService +import mozilla.components.feature.downloads.DownloadEstimator +import mozilla.components.feature.downloads.FileSizeFormatter +import mozilla.components.support.base.android.NotificationsDelegate + +class DownloadService : AbstractFetchDownloadService() { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override val httpClient by lazy { components.core.client } + override val store: BrowserStore by lazy { components.core.store } + override val notificationsDelegate: NotificationsDelegate by lazy { components.notificationsDelegate } + override val fileSizeFormatter: FileSizeFormatter by lazy { components.fileSizeFormatter } + override val downloadEstimator: DownloadEstimator by lazy { components.downloadEstimator } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/MediaSessionService.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/MediaSessionService.kt new file mode 100644 index 00000000..027076e0 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/services/MediaSessionService.kt @@ -0,0 +1,26 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.services + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.feature.media.service.AbstractMediaSessionService +import mozilla.components.support.base.android.NotificationsDelegate + +/** + * See [AbstractMediaSessionService]. + */ +class MediaSessionService : AbstractMediaSessionService() { + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override val crashReporter: CrashReporting? by lazy { null } + override val store: BrowserStore by lazy { components.core.store } + override val notificationsDelegate: NotificationsDelegate by lazy { components.notificationsDelegate } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/res/drawable/addon_textview_selector.xml b/packages/flutter_mozilla_components/android/src/main/res/drawable/addon_textview_selector.xml new file mode 100644 index 00000000..8e89011a --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/drawable/addon_textview_selector.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/res/drawable/ic_launcher_foreground.xml b/packages/flutter_mozilla_components/android/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..da5281c8 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_extensions_black.xml b/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_extensions_black.xml new file mode 100644 index 00000000..673ffa98 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_extensions_black.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_permissions.xml b/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_permissions.xml new file mode 100644 index 00000000..cf50e31d --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_permissions.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml new file mode 100644 index 00000000..5f42e87b --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml new file mode 100644 index 00000000..02a20ae5 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml @@ -0,0 +1,11 @@ + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml new file mode 100644 index 00000000..1d509233 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml @@ -0,0 +1,33 @@ + + + + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_settings.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_settings.xml new file mode 100644 index 00000000..7fa6553f --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_settings.xml @@ -0,0 +1,12 @@ + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml new file mode 100644 index 00000000..7469a77e --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + +