master 321a8526a1eb cached
265 files
674.2 KB
155.1k tokens
1 requests
Download .txt
Showing preview only (760K chars total). Download the full file or copy to clipboard to get everything.
Repository: DesarrolloAntonio/Shiori-Android-Client
Branch: master
Commit: 321a8526a1eb
Files: 265
Total size: 674.2 KB

Directory structure:
gitextract_o9exicg3/

├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   └── feature_request.md
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── LICENSE
├── README.md
├── build.gradle
├── common/
│   ├── .gitignore
│   ├── README.md
│   ├── build.gradle.kts
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── common/
│                           └── result/
│                               ├── ErrorHandler.kt
│                               ├── NetworkLogEntry.kt
│                               └── Result.kt
├── data/
│   ├── .gitignore
│   ├── build.gradle.kts
│   ├── consumer-rules.pro
│   ├── proguard-rules.pro
│   └── src/
│       ├── androidTest/
│       │   └── java/
│       │       └── com/
│       │           └── desarrollodroide/
│       │               └── data/
│       │                   └── local/
│       │                       └── room/
│       │                           ├── BookmarkHtmlDaoTest.kt
│       │                           ├── BookmarksDaoTest.kt
│       │                           └── TagsDaoTest.kt
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── java/
│       │   │   └── com/
│       │   │       └── desarrollodroide/
│       │   │           └── data/
│       │   │               ├── di/
│       │   │               │   ├── DataModule.kt
│       │   │               │   └── PersistenceModule.kt
│       │   │               ├── extensions/
│       │   │               │   ├── GSONS.kt
│       │   │               │   ├── IntExtensions.kt
│       │   │               │   ├── StringExtensions.kt
│       │   │               │   └── TagExtensions.kt
│       │   │               ├── helpers/
│       │   │               │   ├── Constants.kt
│       │   │               │   ├── CrashHandler.kt
│       │   │               │   ├── CrashHandlerImpl.kt
│       │   │               │   ├── GSON.kt
│       │   │               │   └── TagTypeAdapter.kt
│       │   │               ├── local/
│       │   │               │   ├── datastore/
│       │   │               │   │   ├── ChangeListVersions.kt
│       │   │               │   │   ├── HideTagSerializer.kt
│       │   │               │   │   ├── RememberUserPreferencesSerializer.kt
│       │   │               │   │   ├── SystemPreferencesSerializer.kt
│       │   │               │   │   └── UserPreferencesSerializer.kt
│       │   │               │   ├── preferences/
│       │   │               │   │   ├── SettingsPreferenceDataSource.kt
│       │   │               │   │   └── SettingsPreferencesDataSourceImpl.kt
│       │   │               │   └── room/
│       │   │               │       ├── converters/
│       │   │               │       │   └── TagsConverter.kt
│       │   │               │       ├── dao/
│       │   │               │       │   ├── BookmarkHtmlDao.kt
│       │   │               │       │   ├── BookmarksDao.kt
│       │   │               │       │   └── TagDao.kt
│       │   │               │       ├── database/
│       │   │               │       │   └── BookmarksDatabase.kt
│       │   │               │       └── entity/
│       │   │               │           ├── BookmarkEntity.kt
│       │   │               │           ├── BookmarkHtmlEntity.kt
│       │   │               │           ├── BookmarkTagCrossRef.kt
│       │   │               │           ├── BookmarkWithTags.kt
│       │   │               │           └── TagEntity.kt
│       │   │               ├── mapper/
│       │   │               │   └── Mapper.kt
│       │   │               ├── repository/
│       │   │               │   ├── AuthRepository.kt
│       │   │               │   ├── AuthRepositoryImpl.kt
│       │   │               │   ├── BookmarksRepository.kt
│       │   │               │   ├── BookmarksRepositoryImpl.kt
│       │   │               │   ├── ErrorHandlerImpl.kt
│       │   │               │   ├── FileRepository.kt
│       │   │               │   ├── FileRepositoryImpl.kt
│       │   │               │   ├── SettingsRepository.kt
│       │   │               │   ├── SettingsRepositoryImpl.kt
│       │   │               │   ├── SyncWorks.kt
│       │   │               │   ├── SyncWorksImpl.kt
│       │   │               │   ├── SystemRepository.kt
│       │   │               │   ├── SystemRepositoryImpl.kt
│       │   │               │   ├── TagsRepository.kt
│       │   │               │   ├── TagsRepositoryImpl.kt
│       │   │               │   ├── paging/
│       │   │               │   │   ├── BookmarkPagingSource.kt
│       │   │               │   │   ├── BookmarksRemoteMediator.kt
│       │   │               │   │   └── LocalBookmarkPagingSource.kt
│       │   │               │   └── workers/
│       │   │               │       └── SyncWorker.kt
│       │   │               └── util/
│       │   │                   └── SyncUtilities.kt
│       │   └── proto/
│       │       └── prefs.proto
│       └── test/
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── data/
│                           ├── extensions/
│                           │   ├── IntExtensionsTest.kt
│                           │   └── StringExtensionsKtTest.kt
│                           ├── helpers/
│                           │   └── TagTypeAdapterTest.kt
│                           ├── local/
│                           │   ├── datastore/
│                           │   │   ├── HideTagSerializerTest.kt
│                           │   │   ├── RememberUserPreferencesSerializerTest.kt
│                           │   │   └── UserPreferencesSerializerTest.kt
│                           │   ├── preferences/
│                           │   │   └── SettingsPreferencesDataSourceTest.kt
│                           │   └── room/
│                           │       └── converters/
│                           │           └── TagsConverterTest.kt
│                           ├── mapper/
│                           │   └── MapperTest.kt
│                           └── repository/
│                               ├── AuthRepositoryTest.kt
│                               └── BookmarksRepositoryTest.kt
├── domain/
│   ├── .gitignore
│   ├── build.gradle.kts
│   ├── consumer-rules.pro
│   ├── proguard-rules.pro
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── domain/
│                           └── usecase/
│                               ├── AddBookmarkUseCase.kt
│                               ├── DeleteBookmarkUseCase.kt
│                               ├── DeleteLocalBookmarkUseCase.kt
│                               ├── DownloadFileUseCase.kt
│                               ├── EditBookmarkUseCase.kt
│                               ├── GetAllRemoteBookmarksUseCase.kt
│                               ├── GetBookmarkReadableContentUseCase.kt
│                               ├── GetBookmarkUseCase.kt
│                               ├── GetBookmarksUseCase.kt
│                               ├── GetLocalPagingBookmarksUseCase.kt
│                               ├── GetTagsUseCase.kt
│                               ├── SendLoginUseCase.kt
│                               ├── SendLogoutUseCase.kt
│                               ├── SuspendUseCase.kt
│                               ├── SyncBookmarksUseCase.kt
│                               ├── SystemLivenessUseCase.kt
│                               └── UpdateBookmarkCacheUseCase.kt
├── fastlane/
│   └── metadata/
│       └── android/
│           ├── de/
│           │   ├── full_description.txt
│           │   └── short_description.txt
│           └── en-US/
│               ├── changelogs/
│               │   └── default.txt
│               ├── full_description.txt
│               ├── short_description.txt
│               └── title.txt
├── gradle/
│   ├── libs.versions.toml
│   └── wrapper/
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── model/
│   ├── .gitignore
│   ├── build.gradle.kts
│   ├── proguard-rules.pro
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── model/
│                           ├── Account.kt
│                           ├── Bookmark.kt
│                           ├── Bookmarks.kt
│                           ├── LivenessResponse.kt
│                           ├── LoginResponseMessage.kt
│                           ├── ModifiedBookmarks.kt
│                           ├── PendingJob.kt
│                           ├── ReadableContent.kt
│                           ├── ReadableMessage.kt
│                           ├── ReleaseInfo.kt
│                           ├── SyncBookmarksRequestPayload.kt
│                           ├── SyncBookmarksResponse.kt
│                           ├── Tag.kt
│                           ├── UpdateCachePayload.kt
│                           └── User.kt
├── network/
│   ├── .gitignore
│   ├── README.md
│   ├── build.gradle.kts
│   ├── lint.xml
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── network/
│                           ├── di/
│                           │   └── NetworkingModule.kt
│                           ├── model/
│                           │   ├── AccountDTO.kt
│                           │   ├── ApiResponse.kt
│                           │   ├── BookmarkDTO.kt
│                           │   ├── BookmarkResponseDTO.kt
│                           │   ├── BookmarksDTO.kt
│                           │   ├── LivenessResponseDTO.kt
│                           │   ├── LoginRequestPayload.kt
│                           │   ├── LoginResponseDTO.kt
│                           │   ├── LoginResponseMessageDTO.kt
│                           │   ├── ModifiedBookmarksDTO.kt
│                           │   ├── ReadableContentResponseDTO.kt
│                           │   ├── ReadableMessageDto.kt
│                           │   ├── ReleaseInfoDTO.kt
│                           │   ├── SessionDTO.kt
│                           │   ├── SyncBookmarksMessageDTO.kt
│                           │   ├── SyncBookmarksResponseDTO.kt
│                           │   ├── TagDTO.kt
│                           │   ├── TagsDTO.kt
│                           │   ├── UpdateCachePayloadDTO.kt
│                           │   ├── UpdateCachePayloadV1DTO.kt
│                           │   └── util/
│                           │       └── NetworkChangeList.kt
│                           └── retrofit/
│                               ├── FileRemoteDataSource.kt
│                               ├── NetworkBoundResource.kt
│                               ├── NetworkLoggerInterceptor.kt
│                               └── RetrofitNetwork.kt
├── presentation/
│   ├── .gitignore
│   ├── build.gradle.kts
│   ├── proguard-rules.pro
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── java/
│       │   │   └── com/
│       │   │       └── desarrollodroide/
│       │   │           └── pagekeeper/
│       │   │               ├── ComposeSetup.kt
│       │   │               ├── MainActivity.kt
│       │   │               ├── ShioriApp.kt
│       │   │               ├── di/
│       │   │               │   ├── AppModule.kt
│       │   │               │   └── PresenterModule.kt
│       │   │               ├── extensions/
│       │   │               │   ├── ContextExtensions.kt
│       │   │               │   ├── ImageLoaderExtensions.kt
│       │   │               │   ├── LongExtensions.kt
│       │   │               │   └── StringExtensions.kt
│       │   │               ├── helpers/
│       │   │               │   ├── ThemeManager.kt
│       │   │               │   └── ThemeManagerImpl.kt
│       │   │               ├── navigation/
│       │   │               │   ├── NavItem.kt
│       │   │               │   └── Navigation.kt
│       │   │               └── ui/
│       │   │                   ├── bookmarkeditor/
│       │   │                   │   ├── BookmarkEditorActivity.kt
│       │   │                   │   ├── BookmarkEditorScreen.kt
│       │   │                   │   ├── BookmarkEditorView.kt
│       │   │                   │   ├── BookmarkViewModel.kt
│       │   │                   │   ├── NotSessionScreen.kt
│       │   │                   │   └── ProgressButton.kt
│       │   │                   ├── components/
│       │   │                   │   ├── CategoriesView.kt
│       │   │                   │   ├── Dialogs.kt
│       │   │                   │   ├── LoadingButton.kt
│       │   │                   │   ├── UiState.kt
│       │   │                   │   └── pulltorefresh/
│       │   │                   │       ├── PullRefresh.kt
│       │   │                   │       ├── PullRefreshIndicator.kt
│       │   │                   │       ├── PullRefreshIndicatorTransform.kt
│       │   │                   │       └── PullRefreshState.kt
│       │   │                   ├── feed/
│       │   │                   │   ├── BookmarkViewer.kt
│       │   │                   │   ├── CategoriesView.kt
│       │   │                   │   ├── FeedContent.kt
│       │   │                   │   ├── FeedScreen.kt
│       │   │                   │   ├── FeedViewModel.kt
│       │   │                   │   ├── ItemLazyLoad.kt
│       │   │                   │   ├── NoContentView.kt
│       │   │                   │   ├── SearchBarView.kt
│       │   │                   │   ├── SearchViewModel.kt
│       │   │                   │   └── item/
│       │   │                   │       ├── BookmarkImageView.kt
│       │   │                   │       ├── BookmarkItem.kt
│       │   │                   │       ├── ButtonsView.kt
│       │   │                   │       ├── ClickableCategoriesView.kt
│       │   │                   │       ├── FullBookmarkView.kt
│       │   │                   │       ├── PendingSyncBanner.kt
│       │   │                   │       └── SmallBookmarkView.kt
│       │   │                   ├── home/
│       │   │                   │   ├── BottomNavItem.kt
│       │   │                   │   └── HomeScreen.kt
│       │   │                   ├── login/
│       │   │                   │   ├── LoginButton.kt
│       │   │                   │   ├── LoginScreen.kt
│       │   │                   │   ├── LoginViewModel.kt
│       │   │                   │   ├── PasswordTextField.kt
│       │   │                   │   ├── RememberSessionSection.kt
│       │   │                   │   ├── ServerUrlTextField.kt
│       │   │                   │   └── UserTextField.kt
│       │   │                   ├── readablecontent/
│       │   │                   │   ├── ErrorView.kt
│       │   │                   │   ├── ReadableContentScreen.kt
│       │   │                   │   ├── ReadableContentViewModel.kt
│       │   │                   │   └── TopSection.kt
│       │   │                   ├── settings/
│       │   │                   │   ├── AccountSection.kt
│       │   │                   │   ├── ClickableOption.kt
│       │   │                   │   ├── DataSection.kt
│       │   │                   │   ├── DebugSection.kt
│       │   │                   │   ├── DefaultsSection.kt
│       │   │                   │   ├── FeedSection.kt
│       │   │                   │   ├── HideCategoryOptionView.kt
│       │   │                   │   ├── LinkableText.kt
│       │   │                   │   ├── PrivacyPolicyScreen.kt
│       │   │                   │   ├── SettingsScreen.kt
│       │   │                   │   ├── SettingsSectionState.kt
│       │   │                   │   ├── SettingsViewModel.kt
│       │   │                   │   ├── SwitchOption.kt
│       │   │                   │   ├── TermsOfUseScreen.kt
│       │   │                   │   ├── VisualSection.kt
│       │   │                   │   ├── crash/
│       │   │                   │   │   ├── CrashLogScreen.kt
│       │   │                   │   │   └── CrashLogViewModel.kt
│       │   │                   │   └── logcat/
│       │   │                   │       ├── NetworkLogScreen.kt
│       │   │                   │       └── NetworkLogViewModel.kt
│       │   │                   └── theme/
│       │   │                       ├── Color.kt
│       │   │                       ├── Shape.kt
│       │   │                       ├── Theme.kt
│       │   │                       └── Type.kt
│       │   └── res/
│       │       ├── drawable/
│       │       │   ├── curved_wave_bottom.xml
│       │       │   ├── curved_wave_top.xml
│       │       │   ├── ic_book.xml
│       │       │   ├── ic_empty_list.xml
│       │       │   └── img_authentication_failed.xml
│       │       ├── mipmap-anydpi-v26/
│       │       │   ├── ic_launcher.xml
│       │       │   └── ic_launcher_round.xml
│       │       ├── values/
│       │       │   ├── dimens.xml
│       │       │   ├── strings.xml
│       │       │   └── themes.xml
│       │       ├── values-large/
│       │       │   └── dimens.xml
│       │       └── xml/
│       │           ├── data_extraction_rules.xml
│       │           └── file_paths.xml
│       └── test/
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── pagekeeper/
│                           └── extensions/
│                               └── StringExtensionsKtTest.kt
└── settings.gradle

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug Report
about: Create a report to help us improve
title: "[BUG] "
labels: bug
assignees: ''

---

**Bug Description**
A clear and concise description of what the bug is.

**Steps to Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected Behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Device Information**
- Brand and Model: [e.g., Samsung Galaxy S20]
- Android Version: [e.g., Android 11]
- App Version: [e.g., 1.2.0]

**Additional Context**
Add any other context about the problem here.


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature Request
about: Suggest an idea for this project
title: "[FEATURE] "
labels: enhancement
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.

**Potential UI/UX Design Sketches**
If you have any ideas or sketches of how the feature should look or function, please attach or describe them here.


================================================
FILE: .github/workflows/ci.yml
================================================
name: Android CI

on:
  push:
    branches:
      - master
      - develop
      - testing
  pull_request:
    branches:
      - master
      - develop

jobs:
  unit_tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: 21
      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4
      - name: Run Unit Tests
        run: ./gradlew testDebugUnitTest

  android_tests:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: 21

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666"' | sudo tee /etc/udev/rules.d/99-kvm.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Run Android Emulator
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 29
          target: default
          arch: x86_64
          profile: Nexus 6
          emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect
          script: ./gradlew connectedCheck

  build_and_release:
    if: github.ref == 'refs/heads/master'
    runs-on: ubuntu-latest
    needs: [unit_tests, android_tests]
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Decode keystore
        run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > ${{ github.workspace }}/key_store.jks

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Build app
        run: ./gradlew assembleProductionRelease
        env:
          KEYSTORE_PATH: ${{ github.workspace }}/key_store.jks
          RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
          RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
          RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}

      - name: Retrieve Version
        run: echo "APP_VERSION_NAME=$(grep '^versionName=' gradle.properties | cut -d'=' -f2)" >> $GITHUB_ENV

      - name: Create Release on GitHub
        uses: softprops/action-gh-release@v2
        env:
          GITHUB_TOKEN: ${{ secrets.SHIORI_TOKEN }}
        with:
          tag_name: v${{ env.APP_VERSION_NAME }}
          name: Release - v${{ env.APP_VERSION_NAME }}
          generate_release_notes: true
          prerelease: false
          files: presentation/build/outputs/apk/production/release/*.apk


================================================
FILE: .gitignore
================================================
*.iml
.gradle
/local.properties
/.idea/*
/.idea/codeStyles
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/appInsightsSettings.xml
/.idea/migrations.xml
/.kotlin
presentation/release
presentation/production
presentation/staging
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
keystore.properties
resources
cambios.patch
*.aab
*.apk
/docs


================================================
FILE: LICENSE
================================================
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.


================================================
FILE: README.md
================================================
<h1 align="center">
  <img src="images/page_keeper_logo.png" width="120" alt="EhViewer">
  <br>Shiori<br>
</h1>

<p align="center">
  <a href="https://github.com/DesarrolloAntonio/Shiori-Android-Client/actions">
    <img src="https://github.com/DesarrolloAntonio/Shiori-Android-Client/actions/workflows/ci.yml/badge.svg" alt="GitHub Actions">
  </a>
  <a href="https://github.com/DesarrolloAntonio/Shiori-Android-Client/blob/master/LICENSE">
    <img src="https://img.shields.io/github/license/DesarrolloAntonio/Shiori-Android-Client" alt="License">
  </a>
  <a href="https://github.com/DesarrolloAntonio/Shiori-Android-Client/releases">
    <img src="https://img.shields.io/github/v/release/DesarrolloAntonio/Shiori-Android-Client" alt="Release">
  </a>
  <a href="https://github.com/DesarrolloAntonio/Shiori-Android-Client/issues">
    <img src="https://img.shields.io/github/issues/DesarrolloAntonio/Shiori-Android-Client" alt="Issues">
  </a>
 <a href="https://github.com/DesarrolloAntonio/Shiori-Android-Client/commits">
    <img src="https://img.shields.io/github/commit-activity/m/DesarrolloAntonio/Shiori-Android-Client" alt="Commit Activity">
  </a>
</p>

<div align="center">
  <h3>
    <a href="#description">Description</a>
    <span> | </span>
    <a href="#screenshot">Screenshot</a>
    <span> | </span>
    <a href="#features">Features</a>
    <span> | </span>
    <a href="#technologies-used">Technologies Used</a>
    <span> | </span>
    <a href="#download">Download</a>
    <span> | </span>
    <a href="#license">License</a>
  </h3>
</div>

## Description
Shiori is an innovative bookmark management application that revolutionizes the way users save, organize, and access their favorite web pages. Built upon the robust [Shiori platform](https://github.com/go-shiori/shiori), Shiori offers a seamless experience across all devices.

## Screenshots
|                                                      |                                                      |                                                      |                                                      |
|:----------------------------------------------------:|:----------------------------------------------------:|:----------------------------------------------------:|:----------------------------------------------------:|
| ![Screenshot 1](images/screenshots/Screenshot_1.png) | ![Screenshot 2](images/screenshots/Screenshot_2.png) | ![Screenshot 3](images/screenshots/Screenshot_3.png) | ![Screenshot 4](images/screenshots/Screenshot_4.png) |
| ![Screenshot 5](images/screenshots/Screenshot_5.png) | ![Screenshot 6](images/screenshots/Screenshot_6.png) | ![Screenshot 7](images/screenshots/Screenshot_7.png) | ![Screenshot 8](images/screenshots/Screenshot_8.png) |


## Features
- **Save Pages Easily**: Instantly capture and access web pages at any time, even offline.
- **Superior Organization**: Custom labels, descriptions, and thumbnails for efficient bookmark sorting.
- **Cloud Synchronization**: Sync your bookmarks across all devices.
- **Intuitive Interface**: User-friendly navigation for a seamless experience.

## Technologies Used
Shiori is built using a variety of modern and robust technologies to ensure scalability, maintainability, and performance:
- **Clean Architecture**: Ensuring separation of concerns and modular design.
- **Dependency Injection (DI)**: For managing dependencies effectively.
- **Model-View-ViewModel (MVVM)**: For a responsive and powerful user interface.
- **Use Cases**: Defining clear business logic.
- **Repository Pattern**: For efficient data handling and abstraction.
- **Protobuf (Proto)**: For efficient data serialization.
- 
## Development Status ⚠️
Please note that Shiori is currently under development. While we strive to provide a stable experience, you may encounter bugs or incomplete features. We encourage users to:
- Report any issues you find on our [GitHub Issues page](https://github.com/DesarrolloAntonio/Shiori-Android-Client/issues)
- Be aware that some features might be unstable or work in progress
- Expect regular updates as we continue to improve the application

## Download

Shiori is available for download on various platforms:

<p>
  <a href="https://github.com/DesarrolloAntonio/Shiori-Android-Client/releases/latest">
    <img src="images/badge_github.png" alt="Get it on GitHub" height="80">
  </a>
  <a href="https://play.google.com/store/apps/details?id=com.desarrollodroide.pagekeeper">
    <img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" alt="Get Shiori on Google Play" height="80">
  </a>
  <a href="https://apt.izzysoft.de/fdroid/index/apk/com.desarrollodroide.pagekeeper">
    <img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get Shiori on IzzyOnDroid" height="80">
  </a>
  <a href="https://f-droid.org/en/packages/com.desarrollodroide.pagekeeper">
    <img src="images/badge_fdroid.png" alt="Get it on F-Droid" height="80">
  </a>
</p>

## License
This project is licensed under the Apache License - see the [LICENSE](LICENSE) file for details.



================================================
FILE: build.gradle
================================================
buildscript {
    ext {
        compose_ui_version = '1.1.1'
    }
    dependencies {
        classpath 'com.google.protobuf:protobuf-java:3.19.4'
        classpath "de.mannodermaus.gradle.plugins:android-junit5:1.10.2.0"
    }
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    id 'com.android.application' version '8.5.2' apply false
    id 'com.android.library' version '8.5.2' apply false
    id 'org.jetbrains.kotlin.android' version '2.0.0' apply false
    id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false
}

================================================
FILE: common/.gitignore
================================================
/build

================================================
FILE: common/README.md
================================================
# :core:common module

![Dependency graph](../../docs/images/graphs/dep_graph_core_common.png)


================================================
FILE: common/build.gradle.kts
================================================
plugins {
    id("com.android.library")
    id ("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.desarrollodroide.common"
    compileSdk = (findProperty("compileSdkVersion") as String).toInt()

    defaultConfig {
        minSdk = (findProperty("minSdkVersion") as String).toInt()
        targetSdk = (findProperty("targetSdkVersion") as String).toInt()
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_21
        targetCompatibility = JavaVersion.VERSION_21
    }
    kotlinOptions {
        jvmTarget = "21"
    }
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}


================================================
FILE: common/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

</manifest>

================================================
FILE: common/src/main/java/com/desarrollodroide/common/result/ErrorHandler.kt
================================================
package com.desarrollodroide.common.result

/**
 * Defines a contract for handling errors that may occur during the application's operations.
 * Allows obtaining a specific [Result.ErrorType] based on the error or API status code.
 */
interface ErrorHandler {
    /**
     * Returns an [Result.ErrorType] based on the given throwable.
     *
     * @param throwable The throwable that caused the error.
     * @return The specific [Result.ErrorType] that represents the error.
     */
    fun getError(throwable: Throwable): Result.ErrorType

    /**
     * Returns an [Result.ErrorType] for API errors based on the status code, optional throwable, and message.
     *
     * @param statusCode The HTTP status code of the API error.
     * @param throwable Optional throwable that may have caused the API error.
     * @param message Optional message describing the API error.
     * @return The specific [Result.ErrorType] that represents the API error.
     */
    fun getApiError(statusCode: Int, throwable: Throwable? = null, message: String? = null): Result.ErrorType
}


================================================
FILE: common/src/main/java/com/desarrollodroide/common/result/NetworkLogEntry.kt
================================================
package com.desarrollodroide.common.result

data class NetworkLogEntry(
    val timestamp: String,
    val priority: String, // "I" for Info (request), "S" for Success (response), "E" for Error
    val url: String,
    val message: String
)

================================================
FILE: common/src/main/java/com/desarrollodroide/common/result/Result.kt
================================================
package com.desarrollodroide.common.result
/**
 * Represents the outcome of an operation that can end in success, failure, or be in progress.
 * It is a sealed class that can take one of the following forms:
 * - Success: Indicates the operation was successful.
 * - Loading: Indicates the operation is in progress.
 * - Error: Indicates the operation failed.
 *
 * @param T The expected data type in case of success.
 * @param data The resulting data in case of success. Null if the operation was not successful.
 * @param error The error that occurred if the operation failed.
 */
sealed class Result<out T>(
    val data: T? = null,
    val error: ErrorType? = null
) {
    class Success<T>(data: T) : Result<T>(data)
    class Loading<T>(data: T? = null) : Result<T>(data)
    class Error<T>(error: ErrorType? = null, data: T? = null) : Result<T>(data, error)

    /**
     * Represents various error types that can occur.
     * Includes:
     * - DatabaseError: For errors related to database operations.
     * - IOError: For input/output operation failures.
     * - HttpError: For HTTP request failures, with status code and optional message.
     * - Unknown: For undetermined errors.
     * - SessionExpired: Specifically for session expiration errors.
     */
    sealed class ErrorType(
        val throwable: Throwable? = null,
        val statusCode: Int? = null,
        val message: String? = null
    ) {
        class DatabaseError(throwable: Throwable? = null) : ErrorType(throwable)
        class IOError(throwable: Throwable? = null) : ErrorType(throwable)
        class HttpError(throwable: Throwable? = null, statusCode: Int, message: String? = null) : ErrorType(throwable, statusCode, message)
        class Unknown(throwable: Throwable? = null) : ErrorType(throwable)
        class SessionExpired(throwable: Throwable? = null, message: String? = null) : ErrorType(throwable, message = message)
        class SyncErrorException(errorType: ErrorType) : Exception(errorType.toString())
    }
}


================================================
FILE: data/.gitignore
================================================
/build

================================================
FILE: data/build.gradle.kts
================================================
plugins {
    id ("com.android.library")
    id ("org.jetbrains.kotlin.android")
    id ("com.google.devtools.ksp") version "2.0.0-1.0.21"
    id ("com.google.protobuf") version "0.9.4"
    id ("de.mannodermaus.android-junit5")
}

android {
    namespace = "com.desarrollodroide.data"
    compileSdk = (findProperty("compileSdkVersion") as String).toInt()

    defaultConfig {
        testInstrumentationRunnerArguments += mapOf("runnerBuilder" to "de.mannodermaus.junit5.AndroidJUnit5Builder")
        minSdk = (findProperty("minSdkVersion") as String).toInt()
        targetSdk = (findProperty("targetSdkVersion") as String).toInt()

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

        vectorDrawables {
            useSupportLibrary = true
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_21
        targetCompatibility = JavaVersion.VERSION_21
    }
    kotlinOptions {
        jvmTarget = "21"
    }
    packagingOptions {
        jniLibs {
            excludes += setOf("META-INF/LICENSE*")
        }
        resources {
            excludes += setOf("META-INF/LICENSE*")
        }
    }
    // JUnit 5 will bundle in files with identical paths, exclude them
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    // Project module dependencies
    implementation(project(":network"))
    implementation(project(":model"))
    implementation(project(":common"))

    // Retrofit for HTTP requests and networking
    implementation (libs.bundles.retrofit) // Retrofit with logging, Gson, and scalar converters for REST API communication.

    // Koin for dependency injection, specifically tailored for use with Jetpack Compose
    implementation (libs.koin.androidx.compose) // Koin library for dependency injection within Android Compose applications.

    // AndroidX core libraries for fundamental functionality
    implementation (libs.androidx.core) // Core utility functions and backward-compatible versions of Android framework components.
    implementation (libs.androidx.datastore.preferences) // DataStore for storing key-value pairs asynchronously and transactionally.
    implementation (libs.androidx.datastore.core) // Core DataStore functionality.
    implementation (libs.androidx.paging.compose) // Paging library for Jetpack Compose.
    implementation (libs.androidx.lifecycle.runtime) // Lifecycle components for Jetpack Compose.

    // Protocol Buffers for efficient serialization of structured data
    implementation(libs.protobuf.kotlin.lite) // Protocol Buffers Lite for Kotlin, for efficient data serialization.

    // Room for abstracting SQLite database access and providing compile-time checks of SQL queries
    implementation(libs.androidx.room) // Room for database access, abstracting SQLite and providing LiveData support.
    ksp(libs.androidx.room.compiler) // Kotlin Symbol Processing (KSP) for Room to generate database access code at compile time.
    implementation(libs.androidx.room.paging) // Replace with the appropriate version if different.

    // WorkManager
    implementation(libs.androidx.work) // WorkManager for managing background tasks.

    // Testing libraries
    testImplementation(libs.junit.jupiter) // JUnit Jupiter for unit testing with JUnit 5.
    testRuntimeOnly(libs.junit.jupiter.engine) // JUnit Jupiter Engine for running JUnit 5 tests.
    testImplementation(libs.junit.jupiter.api) // JUnit Jupiter API for writing tests and extensions in JUnit 5.
    testImplementation(libs.mockito.core) // Mockito for mocking objects in tests.
    testImplementation(libs.mockito.kotlin) // Kotlin extension for Mockito to better support Kotlin features.
    testImplementation(libs.kotlin.coroutines.test) // Coroutines Test library for testing Kotlin coroutines.
    testImplementation(libs.kotlin.test.junit5) // Kotlin Test library for JUnit 5 support.
    testImplementation(libs.androidx.paging.common) // Common Paging library for testing.
    testImplementation("app.cash.turbine:turbine:1.1.0") // Turbine for testing flows.


    // Android Testing libraries
    androidTestImplementation ("androidx.test:core:1.5.0") // Core testing library for Android, providing API for test infrastructure.
    androidTestImplementation ("androidx.test:runner:1.5.0") // Android Test Runner for running instrumented tests.
    androidTestImplementation ("androidx.test:rules:1.5.0") // Android Test Rules for defining complex test cases.
    androidTestImplementation(libs.androidx.room.testing) // Room Testing support for testing Room databases.
    androidTestImplementation(libs.kotlin.coroutines.test) // Coroutines Test library for testing coroutines in Android tests.
    androidTestImplementation("de.mannodermaus.junit5:android-test-core:1.2.2") // Android support for JUnit 5 tests.
    androidTestRuntimeOnly("de.mannodermaus.junit5:android-test-runner:1.2.2") // JUnit 5 Runner for running Android tests with JUnit 5.
}


// Setup protobuf configuration, generating lite Java and Kotlin classes
protobuf {
    protoc {
        artifact = libs.protobuf.protoc.get().toString()
    }
    generateProtoTasks {
        all().forEach { task ->
            task.builtins {
                val java by registering {
                    option("lite")
                }
                val kotlin by registering {
                    option("lite")
                }
            }
        }
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
    testLogging {
        events("passed", "failed", "skipped")
        showStandardStreams = true
    }
}


================================================
FILE: data/consumer-rules.pro
================================================


================================================
FILE: data/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
#   http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
#   public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

================================================
FILE: data/src/androidTest/java/com/desarrollodroide/data/local/room/BookmarkHtmlDaoTest.kt
================================================
package com.desarrollodroide.data.local.room

import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.desarrollodroide.data.local.room.dao.BookmarkHtmlDao
import com.desarrollodroide.data.local.room.database.BookmarksDatabase
import com.desarrollodroide.data.local.room.entity.BookmarkHtmlEntity
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Test
class BookmarkHtmlDaoTest {

    private lateinit var database: BookmarksDatabase
    private lateinit var bookmarkHtmlDao: BookmarkHtmlDao

    private val bookmarkHtml = BookmarkHtmlEntity(
        id = 1,
        url = "http://example.com",
        readableContentHtml = "<html>Test Content</html>"
    )

    @Before
    fun setup() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            BookmarksDatabase::class.java
        )
            .allowMainThreadQueries()
            .build()

        bookmarkHtmlDao = database.bookmarkHtmlDao()
    }

    @After
    fun tearDown() {
        database.close()
    }

    @Test
    fun testInsertAndFetchBookmarkHtml(): Unit = runBlocking {
        bookmarkHtmlDao.insertOrUpdate(bookmarkHtml)
        val retrievedHtml = bookmarkHtmlDao.getHtmlContent(bookmarkHtml.id)
        Assert.assertEquals(bookmarkHtml.readableContentHtml, retrievedHtml)
        bookmarkHtmlDao.getBookmarkHtml(bookmarkHtml.id)?.let {
            Assert.assertEquals(bookmarkHtml, it)
        }
    }

    @Test
    fun testUpdateBookmarkHtml() = runBlocking {
        bookmarkHtmlDao.insertOrUpdate(bookmarkHtml)
        val updatedBookmarkHtml = bookmarkHtml.copy(readableContentHtml = "<html>Updated Content</html>")
        bookmarkHtmlDao.insertOrUpdate(updatedBookmarkHtml)
        val retrievedHtml = bookmarkHtmlDao.getHtmlContent(bookmarkHtml.id)
        Assert.assertEquals(updatedBookmarkHtml.readableContentHtml, retrievedHtml)
    }
}

================================================
FILE: data/src/androidTest/java/com/desarrollodroide/data/local/room/BookmarksDaoTest.kt
================================================
package com.desarrollodroide.data.local.room

import androidx.paging.PagingSource
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.local.room.database.BookmarksDatabase
import com.desarrollodroide.data.local.room.entity.BookmarkEntity
import com.desarrollodroide.data.local.room.entity.TagEntity
import com.desarrollodroide.model.Tag
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test

class BookmarksDaoTest {

    private lateinit var database: BookmarksDatabase
    private lateinit var bookmarksDao: BookmarksDao
    private val bookmark = BookmarkEntity(
        id = 1,
        url = "http://example.com",
        title = "Test Bookmark",
        excerpt = "This is a test bookmark",
        author = "Author Name",
        isPublic = 1,
        modified = "2020-01-01",
        createdAt = "2020-01-02",
        imageURL = "http://example.com/image.png",
        hasContent = true,
        hasArchive = true,
        hasEbook = true,
        tags = listOf(),
        createArchive = true,
        createEbook = true
    )

    private val tag = Tag(id = 1, name = "Test Tag")


    @Before
    fun setup() {
        database = Room.inMemoryDatabaseBuilder(
            InstrumentationRegistry.getInstrumentation().context,
            BookmarksDatabase::class.java
        )
            .allowMainThreadQueries()
            .build()

        bookmarksDao = database.bookmarksDao()
    }

    @After
    fun tearDown() {
        database.close()
    }

    @Test
    fun testInsertAndFetchBookmarks() = runBlocking {
        bookmarksDao.insertAll(listOf(bookmark))
        val retrievedBookmarks = bookmarksDao.getAll().first()
        assertTrue(retrievedBookmarks.contains(bookmark))
        bookmarksDao.deleteAll()
        assertTrue(bookmarksDao.getAll().first().isEmpty())
    }

    @Test
    fun testUpdateBookmark() = runBlocking {
        bookmarksDao.insertAll(listOf(bookmark))
        val updatedBookmark = bookmark.copy(title = "Updated Title", url = "http://updated.com", modified = "2020-01-03")
        bookmarksDao.insertAll(listOf(updatedBookmark))
        val retrievedBookmarks = bookmarksDao.getAll().first()
        assertTrue(retrievedBookmarks.any {
            it.id == bookmark.id && it.title == "Updated Title" && it.url == "http://updated.com" && it.modified == "2020-01-03"
        })
    }

    @Test
    fun testDeleteBookmarkById() = runBlocking {
        bookmarksDao.insertAll(listOf(bookmark))
        val deletedRows = bookmarksDao.deleteBookmarkById(1)
        assertEquals(1, deletedRows)
        assertTrue(bookmarksDao.getAll().first().isEmpty())
    }

    @Test
    fun testIsEmpty() = runBlocking {
        assertTrue(bookmarksDao.isEmpty())
        bookmarksDao.insertAll(listOf(bookmark))
        assertFalse(bookmarksDao.isEmpty())
    }

    @Test
    fun testGetPagingBookmarksWithoutTags() = runBlocking {
        bookmarksDao.insertAll(listOf(bookmark))
        val pagingSource = bookmarksDao.getPagingBookmarksWithoutTags("Test")
        val loadResult = pagingSource.load(
            PagingSource.LoadParams.Refresh(
                key = null,
                loadSize = 1,
                placeholdersEnabled = false
            )
        )
        assertTrue(loadResult is PagingSource.LoadResult.Page)
        assertEquals(1, (loadResult as PagingSource.LoadResult.Page).data.size)
    }

    @Test
    fun testInsertAllWithTags() = runBlocking {
        val bookmarkWithTag = bookmark.copy(tags = listOf(tag))
        bookmarksDao.insertAllWithTags(listOf(bookmarkWithTag))
        val retrievedBookmarks = bookmarksDao.getAll().first()
        assertEquals(1, retrievedBookmarks.size)
        assertEquals(1, retrievedBookmarks[0].tags.size)
        assertEquals("Test Tag", retrievedBookmarks[0].tags[0].name)
    }

    @Test
    fun testUpdateBookmarkWithTags(): Unit = runBlocking {
        // Insert the initial bookmark
        bookmarksDao.insertAllWithTags(listOf(bookmark))

        // Create an updated version of the bookmark with changed fields
        val updatedTag = Tag(id = 2, name = "Updated Tag")
        val updatedBookmark = bookmark.copy(
            title = "Updated Title",
            url = "http://updated-example.com",
            excerpt = "This is an updated test bookmark",
            author = "Updated Author Name",
            isPublic = 0,
            modified = "2023-01-01",
            createdAt = "2023-01-02",
            imageURL = "http://updated-example.com/image.png",
            hasContent = false,
            hasArchive = false,
            hasEbook = false,
            tags = listOf(updatedTag),
            createArchive = false,
            createEbook = false
        )

        // Update the bookmark
        bookmarksDao.updateBookmarkWithTags(updatedBookmark)

        // Retrieve the updated bookmark
        val retrievedBookmark = bookmarksDao.getBookmarkById(1)

        // Assert that the bookmark is not null
        assertNotNull(retrievedBookmark)

        // Check all fields of the updated bookmark
        retrievedBookmark?.let { bookmark ->
            assertEquals(1, bookmark.id)
            assertEquals("Updated Title", bookmark.title)
            assertEquals("http://updated-example.com", bookmark.url)
            assertEquals("This is an updated test bookmark", bookmark.excerpt)
            assertEquals("Updated Author Name", bookmark.author)
            assertEquals(0, bookmark.isPublic)
            assertEquals("2023-01-01", bookmark.modified)
            assertEquals("2023-01-02", bookmark.createdAt)
            assertEquals("http://updated-example.com/image.png", bookmark.imageURL)
            assertFalse(bookmark.hasContent)
            assertFalse(bookmark.hasArchive)
            assertFalse(bookmark.hasEbook)
            assertFalse(bookmark.createArchive)
            assertFalse(bookmark.createEbook)

            // Check the updated tag
            assertEquals(1, bookmark.tags.size)
            assertEquals(2, bookmark.tags[0].id)
        }
    }

    @Test
    fun testGetAllBookmarkIds() = runBlocking {
        bookmarksDao.insertAll(listOf(bookmark, bookmark.copy(id = 2)))
        val bookmarkIds = bookmarksDao.getAllBookmarkIds()
        assertEquals(listOf(1, 2), bookmarkIds)
    }

    @Test
    fun testGetBookmarkById() = runBlocking {
        bookmarksDao.insertAll(listOf(bookmark))
        val retrievedBookmark = bookmarksDao.getBookmarkById(1)
        assertNotNull(retrievedBookmark)
        assertEquals(bookmark, retrievedBookmark)
    }

}


================================================
FILE: data/src/androidTest/java/com/desarrollodroide/data/local/room/TagsDaoTest.kt
================================================
package com.desarrollodroide.data.local.room

import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.desarrollodroide.data.local.room.dao.TagDao
import com.desarrollodroide.data.local.room.database.BookmarksDatabase
import com.desarrollodroide.data.local.room.entity.TagEntity
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test

class TagDaoTest {

    private lateinit var database: BookmarksDatabase
    private lateinit var tagDao: TagDao

    private val tag = TagEntity(
        id = 1,
        name = "Test Tag",
        nBookmarks = 5
    )

    @Before
    fun setup() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            BookmarksDatabase::class.java
        )
            .allowMainThreadQueries()
            .build()

        tagDao = database.tagDao()
    }

    @After
    fun tearDown() {
        database.close()
    }

    @Test
    fun testInsertAndFetchTags() = runBlocking {
        tagDao.insertTag(tag)
        val retrievedTags = tagDao.getAllTags().first()
        assertTrue(retrievedTags.contains(tag))
        tagDao.deleteAllTags()
        assertTrue(tagDao.getAllTags().first().isEmpty())
    }

    @Test
    fun testDeleteTag() = runBlocking {
        tagDao.insertTag(tag)
        tagDao.deleteTag(tag)
        val retrievedTags = tagDao.getAllTags().first()
        assertFalse(retrievedTags.contains(tag))
    }

    @Test
    fun testInsertAndFetchMultipleTags() = runBlocking {
        val tags = listOf(
            TagEntity(1, "Tag1", 2),
            TagEntity(2, "Tag2", 3)
        )
        tagDao.insertAllTags(tags)
        val retrievedTags = tagDao.getAllTags().first()
        assertTrue(retrievedTags.containsAll(tags))
    }
}


================================================
FILE: data/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

</manifest>

================================================
FILE: data/src/main/java/com/desarrollodroide/data/di/DataModule.kt
================================================
package com.desarrollodroide.data.di

import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.work.WorkManager
import com.desarrollodroide.common.result.ErrorHandler
import com.desarrollodroide.data.helpers.CrashHandler
import com.desarrollodroide.data.helpers.CrashHandlerImpl
import com.desarrollodroide.data.local.datastore.HideTagSerializer
import com.desarrollodroide.data.local.datastore.RememberUserPreferencesSerializer
import com.desarrollodroide.data.local.datastore.SystemPreferencesSerializer
import com.desarrollodroide.data.local.datastore.UserPreferencesSerializer
import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource
import com.desarrollodroide.data.local.preferences.SettingsPreferencesDataSourceImpl
import com.desarrollodroide.data.repository.BookmarksRepository
import com.desarrollodroide.data.repository.BookmarksRepositoryImpl
import com.desarrollodroide.data.repository.AuthRepository
import com.desarrollodroide.data.repository.AuthRepositoryImpl
import com.desarrollodroide.data.repository.ErrorHandlerImpl
import com.desarrollodroide.data.repository.FileRepository
import com.desarrollodroide.data.repository.FileRepositoryImpl
import com.desarrollodroide.data.repository.SettingsRepository
import com.desarrollodroide.data.repository.SettingsRepositoryImpl
import com.desarrollodroide.data.repository.SyncWorks
import com.desarrollodroide.data.repository.SyncWorksImpl
import com.desarrollodroide.data.repository.SystemRepository
import com.desarrollodroide.data.repository.SystemRepositoryImpl
import com.desarrollodroide.data.repository.TagsRepository
import com.desarrollodroide.data.repository.TagsRepositoryImpl
import com.desarrollodroide.data.repository.workers.SyncWorker
import com.desarrollodroide.network.retrofit.FileRemoteDataSource
import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named
import org.koin.dsl.module

fun dataModule() = module {

    val preferencesDataStoreQualifier = named("preferencesDataStore")
    val protoDataStoreQualifier = named("protoDataStore")
    val protoRememberUserDataStoreQualifier = named("protoRememberUserDataStore")
    val protoHideTagDataStoreQualifier = named("protoHideTagDataStore")
    val protoSystemDataStoreQualifier = named("protoSystemDataStore")

    single(preferencesDataStoreQualifier) {
        PreferenceDataStoreFactory.create(
            corruptionHandler = ReplaceFileCorruptionHandler(
                produceNewData = { emptyPreferences() }
            ),
            produceFile = { androidContext().preferencesDataStoreFile("user_data") }
        )
    }

    single(protoDataStoreQualifier) {
        DataStoreFactory.create(
            serializer = UserPreferencesSerializer,
            produceFile = { androidContext().preferencesDataStoreFile("objects_data")},
            corruptionHandler = null,
        )
    }

    single(protoRememberUserDataStoreQualifier) {
        DataStoreFactory.create(
            serializer = RememberUserPreferencesSerializer,
            produceFile = { androidContext().preferencesDataStoreFile("remember_user_data")},
            corruptionHandler = null,
        )
    }

    single(protoHideTagDataStoreQualifier) {
        DataStoreFactory.create(
            serializer = HideTagSerializer,
            produceFile = { androidContext().preferencesDataStoreFile("hide_tag_data")},
            corruptionHandler = null,
        )
    }

    single(protoSystemDataStoreQualifier) {
        DataStoreFactory.create(
            serializer = SystemPreferencesSerializer,
            produceFile = { androidContext().preferencesDataStoreFile("system_data")},
            corruptionHandler = null,
        )
    }

    single { SettingsPreferencesDataSourceImpl(
        dataStore = get(preferencesDataStoreQualifier),
        protoDataStore = get(protoDataStoreQualifier),
        systemPreferences = get(protoSystemDataStoreQualifier),
        rememberUserProtoDataStore = get(protoRememberUserDataStoreQualifier),
        hideTagDataStore = get(protoHideTagDataStoreQualifier)
    ) as SettingsPreferenceDataSource }



    single { AuthRepositoryImpl(
        apiService = get(),
        settingsPreferenceDataSource = get(),
        errorHandler = get()
    ) as AuthRepository }

    single { SettingsRepositoryImpl(
        settingsPreferenceDataSource = get()
    ) as SettingsRepository }

    single { BookmarksRepositoryImpl(
        apiService = get(),
        bookmarksDao = get(),
        errorHandler = get()
    ) as BookmarksRepository }

    single { FileRepositoryImpl(
        context = androidContext(),
        remoteDataSource = get(),
    ) as FileRepository }

    single {
        SystemRepositoryImpl(
            apiService = get(),
            settingsPreferenceDataSource = get(),
            errorHandler = get()
        ) as SystemRepository
    }

    single {
        TagsRepositoryImpl(
            apiService = get(),
            tagsDao = get(),
            errorHandler = get()
        ) as TagsRepository
    }

    single { FileRemoteDataSource() }
    single { ErrorHandlerImpl() as ErrorHandler }

    single { WorkManager.getInstance(get<Context>()) }
    single { SyncWorker.Factory() }

    single { SyncWorksImpl(
        workManager = get(),
        bookmarksDao = get(),
        ) as SyncWorks
    }

    single {
        CrashHandlerImpl(
            settingsPreferenceDataSource = get()
        ) as CrashHandler
    }

}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/di/PersistenceModule.kt
================================================
package com.desarrollodroide.data.di

import com.desarrollodroide.data.local.room.database.BookmarksDatabase
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module

fun databaseModule() = module {

  single { BookmarksDatabase.create(androidContext()) }
  single { get<BookmarksDatabase>().bookmarksDao() }
  single { get<BookmarksDatabase>().tagDao() }
  single { get<BookmarksDatabase>().bookmarkHtmlDao() }

}



================================================
FILE: data/src/main/java/com/desarrollodroide/data/extensions/GSONS.kt
================================================
package com.desarrollodroide.data.extensions

import com.desarrollodroide.data.helpers.GSON
import com.google.gson.JsonElement

inline fun <reified T> String.toBean() = GSON.fromJson<T>(this)

inline fun <reified T> JsonElement.toBean() = GSON.fromJson<T>(this)

fun Any.toJson() = GSON.toJson(this)

fun JsonElement.toJson() = GSON.toJson(this)



================================================
FILE: data/src/main/java/com/desarrollodroide/data/extensions/IntExtensions.kt
================================================
package com.desarrollodroide.data.extensions

/**
 * Checks if an integer ID is a temporary timestamp-based ID rather than a real server ID.
 *
 * Temporary IDs are generated from System.currentTimeMillis() / 1000 (epoch seconds),
 * producing values like 1,700,000,000+. Real server IDs are sequential (1, 2, 3...),
 * so any ID over 1 million is clearly a temporary local ID.
 */
fun Int.isTimestampId(): Boolean = this > 1_000_000

================================================
FILE: data/src/main/java/com/desarrollodroide/data/extensions/StringExtensions.kt
================================================
package com.desarrollodroide.data.extensions

fun String.removeTrailingSlash(): String {
    return if (this.endsWith("/")) {
        this.dropLast(1)
    } else {
        this
    }
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/extensions/TagExtensions.kt
================================================
package com.desarrollodroide.data.extensions

import com.desarrollodroide.model.Tag

fun List<Tag>.toTagPattern(): String {
    if (isEmpty()) return ""

    val escapedNames = map { tag ->
        "\"name\":\"${tag.name.replace("\"", "\\\"").replace("'", "''")}\""
    }
    return "%${escapedNames.joinToString("%' OR tags LIKE '%")}%"
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/helpers/Constants.kt
================================================
package com.desarrollodroide.data.helpers

enum class ThemeMode {
    DARK, LIGHT, AUTO
}
enum class BookmarkViewType {
    FULL,
    SMALL
}

const val SHIORI_GITHUB_URL = "https://github.com/go-shiori/shiori"
const val SHIORI_ANDROID_CLIENT_GITHUB_URL = "https://github.com/DesarrolloAntonio/Shiori-Android-Client"
const val SESSION_HAS_BEEN_EXPIRED = "session has been expired"



================================================
FILE: data/src/main/java/com/desarrollodroide/data/helpers/CrashHandler.kt
================================================
package com.desarrollodroide.data.helpers

import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource

interface CrashHandler {
    fun initialize()

    companion object {
        fun create(settingsPreferenceDataSource: SettingsPreferenceDataSource): CrashHandler {
            return CrashHandlerImpl(settingsPreferenceDataSource).also { handler ->
                Thread.setDefaultUncaughtExceptionHandler(handler)
            }
        }
    }
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/helpers/CrashHandlerImpl.kt
================================================
package com.desarrollodroide.data.helpers

import android.util.Log
import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

class CrashHandlerImpl(
    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,
) : Thread.UncaughtExceptionHandler, CrashHandler {

    private val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
    private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    override fun initialize() {
        Thread.setDefaultUncaughtExceptionHandler(this)
        Log.d("CrashHandler", "Initialized")
    }

    override fun uncaughtException(thread: Thread, throwable: Throwable) {
        try {
            val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
            val stackTrace = throwable.stackTraceToString()

            val crashLog = buildString {
                appendLine("Timestamp: $timestamp")
                appendLine("Thread: ${thread.name}")
                appendLine("Exception: ${throwable.javaClass.name}")
                appendLine("Message: ${throwable.message}")
                appendLine("\nStack trace:")
                appendLine(stackTrace)
            }

            Log.d("CrashHandler", "Saving crash: $crashLog")

            coroutineScope.launch {
                try {
                    settingsPreferenceDataSource.setLastCrashLog(crashLog)
                    Log.d("CrashHandler", "Crash saved successfully")

                    // Verificar inmediatamente que se guardó
                    val saved = settingsPreferenceDataSource.getLastCrashLog()
                    Log.d("CrashHandler", "Verified saved crash: $saved")
                } catch (e: Exception) {
                    Log.e("CrashHandler", "Error saving crash", e)
                }
            }
        } catch (e: Exception) {
            Log.e("CrashHandler", "Error in uncaughtException", e)
        }

        previousHandler?.uncaughtException(thread, throwable)
    }
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/helpers/GSON.kt
================================================
package com.desarrollodroide.data.helpers

import com.google.gson.GsonBuilder
import com.google.gson.JsonElement
import com.google.gson.reflect.TypeToken

object GSON {

    var gson = GsonBuilder().setLenient().create()

    inline fun <reified T> fromJson(json: String): T {
        val type = object : TypeToken<T>() {}.type
        return gson.fromJson(json, type)
    }

    inline fun <reified T> fromJson(jsonElement: JsonElement): T {
        val type = object : TypeToken<T>() {}.type
        return gson.fromJson(jsonElement, type)
    }

    fun toJson(any: Any) = gson.toJson(any)

    fun toJson(jsonElement: JsonElement) = gson.toJson(jsonElement)

}


================================================
FILE: data/src/main/java/com/desarrollodroide/data/helpers/TagTypeAdapter.kt
================================================
package com.desarrollodroide.data.helpers

import com.google.gson.*
import com.desarrollodroide.model.Tag
import com.desarrollodroide.network.model.TagDTO
import java.lang.reflect.Type

class TagTypeAdapter : JsonSerializer<Tag> {
    override fun serialize(src: Tag?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
        val jsonObject = JsonObject()
        if (src != null) {
            jsonObject.addProperty("name", src.name)
        }
        return jsonObject
    }
}


class AddTagDTOAdapter : JsonSerializer<TagDTO> {
    override fun serialize(src: TagDTO?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
        val jsonObject = JsonObject()
        if (src?.name != null) {
            jsonObject.addProperty("name", src.name)
        }
        return jsonObject
    }
}


================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/datastore/ChangeListVersions.kt
================================================
package com.desarrollodroide.data.local.datastore

/**
 * Class summarizing the local version of each model for sync
 */
data class ChangeListVersions(
    val topicVersion: Int = -1,
    val newsResourceVersion: Int = -1,
)


================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/datastore/HideTagSerializer.kt
================================================
package com.desarrollodroide.data.local.datastore

import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.desarrollodroide.data.HideTag
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream

/**
 * Serializer for the [HideTag] object defined in your .proto file.
 */
object HideTagSerializer : Serializer<HideTag> {
    override val defaultValue: HideTag = HideTag.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): HideTag {
        try {
            return HideTag.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override suspend fun writeTo(t: HideTag, output: OutputStream) = t.writeTo(output)
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/datastore/RememberUserPreferencesSerializer.kt
================================================
package com.desarrollodroide.data.local.datastore

import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.desarrollodroide.data.RememberUserPreferences
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream

/**
 * Serializer for the [RememberUserPreferences] object defined in user_prefs.proto.
 */
object RememberUserPreferencesSerializer : Serializer<RememberUserPreferences> {
    override val defaultValue: RememberUserPreferences = RememberUserPreferences.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): RememberUserPreferences {
        try {
            return RememberUserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override suspend fun writeTo(t: RememberUserPreferences, output: OutputStream) = t.writeTo(output)
}



================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/datastore/SystemPreferencesSerializer.kt
================================================
package com.desarrollodroide.data.local.datastore

import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.desarrollodroide.data.SystemPreferences
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream

/**
 * Serializer for the [SystemPreferencesSerializer] object defined in user_prefs.proto.
 */
object SystemPreferencesSerializer : Serializer<SystemPreferences> {
    override val defaultValue: SystemPreferences = SystemPreferences.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): SystemPreferences {
        try {
            return SystemPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }
    override suspend fun writeTo(t: SystemPreferences, output: OutputStream) = t.writeTo(output)
}



================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/datastore/UserPreferencesSerializer.kt
================================================
package com.desarrollodroide.data.local.datastore

import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import com.desarrollodroide.data.UserPreferences
import java.io.InputStream
import java.io.OutputStream

/**
 * Serializer for the [UserPreferences] object defined in user_prefs.proto.
 */
object UserPreferencesSerializer : Serializer<UserPreferences> {
    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): UserPreferences {
        try {
            return UserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }
    override suspend fun writeTo(t: UserPreferences, output: OutputStream) = t.writeTo(output)
}



================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/preferences/SettingsPreferenceDataSource.kt
================================================
package com.desarrollodroide.data.local.preferences

import com.desarrollodroide.data.UserPreferences
import com.desarrollodroide.data.helpers.ThemeMode
import com.desarrollodroide.model.Account
import com.desarrollodroide.model.Tag
import com.desarrollodroide.model.User
import kotlinx.coroutines.flow.Flow

interface SettingsPreferenceDataSource {

    val userDataStream: Flow<User>
    val compactViewFlow: Flow<Boolean>
    val makeArchivePublicFlow: Flow<Boolean>
    val createEbookFlow: Flow<Boolean>
    val autoAddBookmarkFlow: Flow<Boolean>
    val createArchiveFlow: Flow<Boolean>
    val hideTagFlow: Flow<Tag?>
    val selectedCategoriesFlow: Flow<List<String>>

    fun getUser(): Flow<User>
    suspend fun saveUser(
        session: UserPreferences,
        serverUrl: String,
        password: String,
    )
    val rememberUserDataStream: Flow<Account>
    fun getRememberUser(): Flow<Account>
    suspend fun saveRememberUser(
        url: String,
        userName: String,
        password: String,
    )

    suspend fun getUrl(): String
    suspend fun getSession(): String
    suspend fun getToken(): String
    suspend fun resetData()
    suspend fun resetRememberUser()
    fun setTheme(mode: ThemeMode)
    fun getThemeMode(): ThemeMode
    suspend fun setMakeArchivePublic(newValue: Boolean)
    suspend fun setCreateEbook(newValue: Boolean)
    suspend fun setCreateArchive(newValue: Boolean)
    suspend fun setCompactView(isCompactView: Boolean)
    suspend fun setAutoAddBookmark(isAutoAddBookmark: Boolean)
    suspend fun getCategoriesVisible(): Boolean
    suspend fun setCategoriesVisible(isCategoriesVisible: Boolean)
    suspend fun setSelectedCategories(categories: List<String>)
    fun getUseDynamicColors(): Boolean
    fun setUseDynamicColors(newValue: Boolean)
    suspend fun setHideTag(tag: Tag?)
    suspend fun addSelectedCategory(tag: Tag)
    suspend fun removeSelectedCategory(tag: Tag)
    suspend fun getLastSyncTimestamp(): Long
    suspend fun setLastSyncTimestamp(timestamp: Long)
    suspend fun setCurrentTimeStamp()
    suspend fun getServerVersion(): String
    suspend fun setServerVersion(version: String)
    suspend fun getLastCrashLog(): String
    suspend fun setLastCrashLog(crash: String)
    suspend fun clearLastCrashLog()
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/preferences/SettingsPreferencesDataSourceImpl.kt
================================================
package com.desarrollodroide.data.local.preferences

import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import com.desarrollodroide.data.UserPreferences
import com.desarrollodroide.data.copy
import com.desarrollodroide.data.mapper.toProtoEntity
import com.desarrollodroide.model.Account
import com.desarrollodroide.model.User
import com.desarrollodroide.network.model.SessionDTO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import com.desarrollodroide.data.HideTag
import com.desarrollodroide.data.RememberUserPreferences
import com.desarrollodroide.data.SystemPreferences
import com.desarrollodroide.data.helpers.ThemeMode
import com.desarrollodroide.model.Tag
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import java.time.ZoneId
import java.time.ZonedDateTime

class SettingsPreferencesDataSourceImpl(
    private val dataStore: DataStore<Preferences>,
    private val protoDataStore: DataStore<UserPreferences>,
    private val rememberUserProtoDataStore: DataStore<RememberUserPreferences>,
    private val systemPreferences: DataStore<SystemPreferences>,
    private val hideTagDataStore: DataStore<HideTag>,

    ) : SettingsPreferenceDataSource {

    val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
    val CATEGORIES_VISIBLE = booleanPreferencesKey("categories_visible")
    val USE_DYNAMIC_COLORS = booleanPreferencesKey("use_dynamic_colors")

    // Use with stateIn
    override val userDataStream = protoDataStore.data
        .map {
            User(
                token = it.token,
                session = it.session,
                account = Account(
                    id = it.id,
                    userName = it.username,
                    owner = it.owner,
                    password = it.password,
                    serverUrl = it.url,
                )
            )
        }

    override fun getUser(): Flow<User> {
        return protoDataStore.data
            .catch {
                Log.v("Error!!!", it.message.toString())
            }
            .map { preference ->
                User(
                    token = preference.token,
                    session = preference.session,
                    account = Account(
                        id = preference.id,
                        userName = preference.username,
                        owner = preference.owner,
                        password = preference.password,
                        serverUrl = preference.url,
                    )
                )
            }
    }

    override suspend fun saveUser(
        session: UserPreferences,
        serverUrl: String,
        password: String,
    ) {
        protoDataStore.updateData { protoSession ->
            protoSession.copy {
                this.id = session.id
                this.username = session.username
                this.password = password
                this.session = session.session
                this.url = serverUrl
                this.token = session.token
            }
        }
    }

    override val rememberUserDataStream = rememberUserProtoDataStore.data
        .map {
            Account(
                id = it.id,
                userName = it.username,
                owner = false,
                password = it.password,
                serverUrl = it.url,
            )
        }

    override fun getRememberUser(): Flow<Account> {
        return rememberUserProtoDataStore.data
            .catch {
                Log.v("Error!!!", it.message.toString())
            }
            .map { preference ->
                Account(
                    id = preference.id,
                    userName = preference.username,
                    owner = false,
                    password = preference.password,
                    serverUrl = preference.url,
                )
            }
    }

    override suspend fun saveRememberUser(
        url: String,
        userName: String,
        password: String,
    ) {
        rememberUserProtoDataStore.updateData { protoSession ->
            protoSession.copy {
                this.id = 1
                this.username = userName
                this.password = password
                this.url = url
            }
        }
    }

    override suspend fun getUrl(): String = getUser().first().account.serverUrl

    override suspend fun getSession(): String = getUser().first().session

    override suspend fun getToken(): String = getUser().first().token

    override suspend fun resetData() {
        saveUser(
            password = "",
            session = SessionDTO(null, null, null).toProtoEntity(),
            serverUrl = "",
        )
        setHideTag(null)
        setSelectedCategories(emptyList())
        setLastSyncTimestamp(0)
        setServerVersion("")
    }

    override suspend fun resetRememberUser() {
        saveRememberUser(
            url = "",
            userName = "",
            password = ""
        )
    }

    override fun setTheme(mode: ThemeMode) {
        runBlocking {
            dataStore.edit { preferences ->
                preferences[THEME_MODE_KEY] = mode.name
            }
        }
    }

    override fun getThemeMode(): ThemeMode {
        return runBlocking {
            val preferences = dataStore.data.firstOrNull()
            val modeName = preferences?.get(THEME_MODE_KEY) ?: ThemeMode.AUTO.name
            ThemeMode.valueOf(modeName)
        }
    }

    override val compactViewFlow: Flow<Boolean> by lazy {
        systemPreferences.data
            .map { it.compactView }
    }

    override suspend fun setCompactView(isCompactView: Boolean) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder().setCompactView(isCompactView).build()
        }
    }

    override suspend fun setCategoriesVisible(isCategoriesVisible: Boolean) {
        runBlocking {
            dataStore.edit { preferences ->
                preferences[CATEGORIES_VISIBLE] = isCategoriesVisible
            }
        }
    }
    override suspend fun getCategoriesVisible(): Boolean = runBlocking {
        dataStore.data.firstOrNull()?.get(CATEGORIES_VISIBLE) ?: false
    }

    override val makeArchivePublicFlow: Flow<Boolean> by lazy {
        systemPreferences.data
            .map { it.makeArchivePublic }
    }

    override suspend fun setMakeArchivePublic(newValue: Boolean) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder().setMakeArchivePublic(newValue).build()
        }
    }

    override val createEbookFlow: Flow<Boolean> by lazy {
        systemPreferences.data
            .map { it.createEbook }
    }

    override suspend fun setCreateEbook(newValue: Boolean) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder().setCreateEbook(newValue).build()
        }
    }

    override fun getUseDynamicColors(): Boolean = runBlocking {
        dataStore.data.firstOrNull()?.get(USE_DYNAMIC_COLORS) ?: false
    }

    override fun setUseDynamicColors(newValue: Boolean) {
        runBlocking {
            dataStore.edit { preferences ->
                preferences[USE_DYNAMIC_COLORS] = newValue
            }
        }
    }

    override val autoAddBookmarkFlow: Flow<Boolean> by lazy {
        systemPreferences.data
            .map { it.autoAddBookmark }
    }

    override suspend fun setAutoAddBookmark(isAutoAddBookmark: Boolean) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder().setAutoAddBookmark(isAutoAddBookmark).build()
        }
    }

    override val createArchiveFlow: Flow<Boolean> by lazy {
        systemPreferences.data
            .map { it.createArchive }
    }

    override suspend fun setCreateArchive(newValue: Boolean) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder().setCreateArchive(newValue).build()
        }
    }

    override val hideTagFlow: Flow<Tag?> by lazy {
        hideTagDataStore.data
            .map { hideTag ->
                if (hideTag == HideTag.getDefaultInstance()) null
                else Tag(id = hideTag.id, name = hideTag.name, selected = false, nBookmarks = 0)
            }
    }

    override suspend fun setHideTag(tag: Tag?) {
        hideTagDataStore.updateData { currentHideTag ->
            when (tag) {
                null -> HideTag.getDefaultInstance()
                else -> currentHideTag.toBuilder()
                    .setId(tag.id)
                    .setName(tag.name)
                    .build()
            }
        }
    }

    override val selectedCategoriesFlow: Flow<List<String>> = systemPreferences.data
        .map { preferences ->
            preferences.selectedCategoriesList.distinct()
        }

    override suspend fun setSelectedCategories(categories: List<String>) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder()
                .clearSelectedCategories()
                .addAllSelectedCategories(categories.distinct())
                .build()
        }
    }

    override suspend fun addSelectedCategory(tag: Tag) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder()
                .addSelectedCategories(tag.id.toString())
                .build()
        }
    }

    override suspend fun removeSelectedCategory(tag: Tag) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder()
                .clearSelectedCategories()
                .addAllSelectedCategories(preferences.selectedCategoriesList.filter { it != tag.id.toString() })
                .build()
        }
    }

    override suspend fun getLastSyncTimestamp(): Long {
        return systemPreferences.data.map { preferences ->
            preferences.lastSyncTimestamp
        }.first()
    }

    override suspend fun setLastSyncTimestamp(timestamp: Long) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder()
                .setLastSyncTimestamp(timestamp)
                .build()
        }
    }

    override suspend fun setCurrentTimeStamp() {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder()
                .setLastSyncTimestamp(ZonedDateTime.now(ZoneId.systemDefault()).toEpochSecond())
                .build()
        }
    }

    override suspend fun getServerVersion(): String {
        return systemPreferences.data.map { preferences ->
            preferences.serverVersion
        }.first()
    }

    override suspend fun setServerVersion(version: String) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder()
                .setServerVersion(version)
                .build()
        }
    }

    override suspend fun getLastCrashLog(): String {
        return systemPreferences.data.map { it.lastCrashLog }.first()
    }

    override suspend fun setLastCrashLog(crash: String) {
        systemPreferences.updateData { preferences ->
            preferences.toBuilder()
                .setLastCrashLog(crash)
                .build()
        }
    }

    override suspend fun clearLastCrashLog() {
        setLastCrashLog("")
    }
}


================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/room/converters/TagsConverter.kt
================================================
package com.desarrollodroide.data.local.room.converters

import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.JsonParseException
import com.google.gson.reflect.TypeToken
import com.desarrollodroide.model.Tag

class TagsConverter {
    @TypeConverter
    fun fromTagsList(tags: List<Tag>): String {
        val gson = Gson()
        return gson.toJson(tags)
    }

    @TypeConverter
    fun toTagsList(tagsString: String): List<Tag> {
        return try {
            val type = object : TypeToken<List<Tag>>() {}.type
            Gson().fromJson(tagsString, type)
        } catch (e: JsonParseException) {
            emptyList()
        }
    }
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/room/dao/BookmarkHtmlDao.kt
================================================
package com.desarrollodroide.data.local.room.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.desarrollodroide.data.local.room.entity.BookmarkHtmlEntity

@Dao
interface BookmarkHtmlDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertOrUpdate(bookmarkHtml: BookmarkHtmlEntity)

    @Query("SELECT readableContentHtml FROM bookmark_html WHERE id = :bookmarkId")
    suspend fun getHtmlContent(bookmarkId: Int): String?

    @Query("SELECT * FROM bookmark_html WHERE id = :bookmarkId")
    suspend fun getBookmarkHtml(bookmarkId: Int): BookmarkHtmlEntity?
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/room/dao/BookmarksDao.kt
================================================
package com.desarrollodroide.data.local.room.dao

import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.desarrollodroide.data.local.room.entity.BookmarkEntity
import com.desarrollodroide.data.local.room.entity.BookmarkTagCrossRef
import kotlinx.coroutines.flow.Flow

@Dao
interface BookmarksDao {

  // Basic CRUD operations

  /**
   * Retrieves all bookmarks from the database.
   * @return A Flow of List<BookmarkEntity> representing all bookmarks.
   */
  @Query("SELECT * FROM bookmarks")
  fun getAll(): Flow<List<BookmarkEntity>>

  /**
   * Inserts a single bookmark into the database and returns the new rowId.
   * @param bookmark The BookmarkEntity to insert.
   * @return The new rowId for the inserted item.
   */
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertBookmark(bookmark: BookmarkEntity): Long

  /**
   * Inserts a list of bookmarks into the database, replacing any existing entries with the same IDs.
   * @param bookmarks The list of BookmarkEntity objects to insert.
   */
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertAll(bookmarks: List<BookmarkEntity>)

  /**
   * Deletes all bookmarks from the database.
   */
  @Query("DELETE FROM bookmarks")
  suspend fun deleteAll()

  /**
   * Deletes a specific bookmark by its ID.
   * @param bookmarkId The ID of the bookmark to delete.
   * @return The number of rows affected (should be 1 if successful, 0 if the bookmark was not found).
   */
  @Query("DELETE FROM bookmarks WHERE id = :bookmarkId")
  suspend fun deleteBookmarkById(bookmarkId: Int): Int

  /**
   * Checks if the bookmarks table is empty.
   * @return true if the table is empty, false otherwise.
   */
  @Query("SELECT (SELECT COUNT(*) FROM bookmarks) == 0")
  suspend fun isEmpty(): Boolean

  // Paging operations

  /**
   * Retrieves bookmarks for paging, filtered by search text and tags.
   * @param searchText The text to search for in bookmark titles.
   * @param tagIds The list of tag IDs to filter by.
   * @return A PagingSource of BookmarkEntity objects.
   */
  @Query("""
        SELECT * FROM bookmarks
        WHERE (:searchText = '' OR title LIKE '%' || :searchText || '%')
        AND EXISTS (
            SELECT 1 FROM bookmark_tag_cross_ref 
            WHERE bookmark_tag_cross_ref.bookmarkId = bookmarks.id
            AND bookmark_tag_cross_ref.tagId IN (:tagIds)
        )
        ORDER BY id DESC
    """)
  fun getPagingBookmarks(
    searchText: String,
    tagIds: List<Int>
  ): PagingSource<Int, BookmarkEntity>

  /**
   * Retrieves bookmarks for paging, filtered by search text without considering tags.
   * @param searchText The text to search for in bookmark titles.
   * @return A PagingSource of BookmarkEntity objects.
   */
  @Query("""
        SELECT * FROM bookmarks
        WHERE title LIKE '%' || :searchText || '%'
        ORDER BY id DESC
    """)
  fun getPagingBookmarksWithoutTags(searchText: String): PagingSource<Int, BookmarkEntity>

  /**
   * Retrieves bookmarks for paging, filtered by tags.
   * @param tagIds The list of tag IDs to filter by.
   * @return A PagingSource of BookmarkEntity objects.
   */
  @Query("""
        SELECT * FROM bookmarks
        WHERE EXISTS (
            SELECT 1 FROM bookmark_tag_cross_ref 
            WHERE bookmark_tag_cross_ref.bookmarkId = bookmarks.id
            AND bookmark_tag_cross_ref.tagId IN (:tagIds)
        )
        ORDER BY id DESC
    """)
  fun getPagingBookmarksByTags(tagIds: List<Int>): PagingSource<Int, BookmarkEntity>

  /**
   * Retrieves all bookmarks for paging without any filters.
   * @return A PagingSource of BookmarkEntity objects.
   */
  @Query("""
        SELECT * FROM bookmarks
        ORDER BY id DESC
    """)
  fun getAllPagingBookmarks(): PagingSource<Int, BookmarkEntity>

  // Tag-related operations

  /**
   * Inserts bookmark-tag cross references into the database.
   * @param crossRefs The list of BookmarkTagCrossRef objects to insert.
   */
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertBookmarkTagCrossRefs(crossRefs: List<BookmarkTagCrossRef>)

  /**
   * Clears all bookmark-tag cross references from the database.
   */
  @Query("DELETE FROM bookmark_tag_cross_ref")
  suspend fun clearBookmarkTagCrossRefs()

  /**
   * Inserts a list of bookmarks along with their associated tags.
   * This method performs the following steps in a single transaction:
   * 1. Clears existing bookmark-tag cross references
   * 2. Deletes all existing bookmarks
   * 3. Inserts the new bookmarks
   * 4. Creates new bookmark-tag cross references for bookmarks with tags
   *
   * @param bookmarks The list of BookmarkEntity objects to insert, including their tags.
   */
  @Transaction
  suspend fun insertAllWithTags(bookmarks: List<BookmarkEntity>) {
    clearBookmarkTagCrossRefs()
    deleteAll()
    insertAll(bookmarks)
    val bookmarksWithTags = bookmarks.filter { it.tags.isNotEmpty() }
    bookmarksWithTags.forEach { bookmark ->
      val crossRefs = bookmark.tags.map { tag ->
        BookmarkTagCrossRef(bookmarkId = bookmark.id, tagId = tag.id)
      }
      insertBookmarkTagCrossRefs(crossRefs)
    }
  }

  /**
   * Updates an existing bookmark in the local database.
   *
   * This method uses Room's @Update annotation, which generates the necessary SQL
   * to update the bookmark based on its primary key. If the bookmark doesn't exist
   * in the database, no action will be taken.
   *
   * @param bookmark The BookmarkEntity to be updated in the database.
   *                 It must have a valid ID that matches an existing entry.
   */
  @Update
  suspend fun updateBookmark(bookmark: BookmarkEntity)

  /**
   * Retrieves a list of all bookmark IDs from the local database.
   * This can be useful for performing operations on all bookmarks, such as
   * deleting or updating them.
   *
   * @return A list of all bookmark IDs in the database.
   */
  @Query("SELECT id FROM bookmarks")
  suspend fun getAllBookmarkIds(): List<Int>

  /**
   * Retrieves a bookmark by its ID.
   * This can be useful to determine if a bookmark already exists in the database
   * and if its version is outdated.
   *
   * @param bookmarkId The ID of the bookmark to retrieve.
   * @return The BookmarkEntity if found, or null otherwise.
   */
  @Query("SELECT * FROM bookmarks WHERE id = :bookmarkId")
  suspend fun getBookmarkById(bookmarkId: Int): BookmarkEntity?

  /**
   * Updates an existing bookmark in the local database, including its associated tags.
   *
   * This method performs the following steps in a single transaction:
   * 1. Updates the bookmark entity
   * 2. Deletes all existing tag associations for the bookmark
   * 3. Inserts new tag associations for the bookmark
   *
   * @param bookmark The BookmarkEntity to be updated in the database.
   *                 It must have a valid ID that matches an existing entry.
   */
  @Transaction
  suspend fun updateBookmarkWithTags(bookmark: BookmarkEntity) {
    updateBookmark(bookmark)
    deleteBookmarkTagCrossRefs(bookmark.id)
    val newCrossRefs = bookmark.tags.map { tag ->
      BookmarkTagCrossRef(bookmarkId = bookmark.id, tagId = tag.id)
    }
    insertBookmarkTagCrossRefs(newCrossRefs)
  }

  /**
   * Deletes all bookmark-tag cross references associated with a bookmark.
   *
   * @param bookmarkId The ID of the bookmark to delete associated tags for.
   */
  @Query("DELETE FROM bookmark_tag_cross_ref WHERE bookmarkId = :bookmarkId")
  suspend fun deleteBookmarkTagCrossRefs(bookmarkId: Int)

}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/room/dao/TagDao.kt
================================================
package com.desarrollodroide.data.local.room.dao

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.desarrollodroide.data.local.room.entity.TagEntity
import kotlinx.coroutines.flow.Flow

@Dao
interface TagDao {
    @Query("SELECT * FROM tags")
    fun getAllTags(): Flow<List<TagEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTag(tag: TagEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAllTags(tags: List<TagEntity>)

    @Delete
    suspend fun deleteTag(tag: TagEntity)

    @Query("DELETE FROM tags")
    suspend fun deleteAllTags()

    @Transaction
    @Query("""
        SELECT DISTINCT t.* 
        FROM tags t
        LEFT JOIN bookmark_tag_cross_ref bt ON t.id = bt.tagId 
        ORDER BY t.name
    """)
    fun observeAllTags(): Flow<List<TagEntity>>
}


================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/room/database/BookmarksDatabase.kt
================================================
package com.desarrollodroide.data.local.room.database

import android.content.Context
import android.util.Log
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.local.room.entity.BookmarkEntity
import com.desarrollodroide.data.local.room.converters.TagsConverter
import com.desarrollodroide.data.local.room.dao.BookmarkHtmlDao
import com.desarrollodroide.data.local.room.dao.TagDao
import com.desarrollodroide.data.local.room.entity.BookmarkHtmlEntity
import com.desarrollodroide.data.local.room.entity.BookmarkTagCrossRef
import com.desarrollodroide.data.local.room.entity.TagEntity
import java.util.concurrent.Executors

@Database(
    entities = [BookmarkEntity::class, TagEntity::class, BookmarkHtmlEntity::class, BookmarkTagCrossRef::class],
    version = 7
)
@TypeConverters(TagsConverter::class)
abstract class BookmarksDatabase : RoomDatabase() {

    abstract fun bookmarksDao(): BookmarksDao
    abstract fun tagDao(): TagDao
    abstract fun bookmarkHtmlDao(): BookmarkHtmlDao

    companion object {
        // Migraciones anteriores
        val MIGRATION_1_2: Migration = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("ALTER TABLE bookmarks ADD COLUMN has_ebook INTEGER NOT NULL DEFAULT 0")
            }
        }

        val MIGRATION_2_3: Migration = object : Migration(2, 3) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("ALTER TABLE bookmarks ADD COLUMN create_ebook INTEGER NOT NULL DEFAULT 0")
            }
        }

        val MIGRATION_3_4: Migration = object : Migration(3, 4) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL(
                    """
                    CREATE TABLE IF NOT EXISTS `tags` (
                        `id` INTEGER PRIMARY KEY NOT NULL,
                        `name` TEXT NOT NULL,
                        `n_bookmarks` INTEGER NOT NULL
                    )
                    """
                )
            }
        }

        val MIGRATION_4_5: Migration = object : Migration(4, 5) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL(
                    """
                    CREATE TABLE IF NOT EXISTS `bookmark_html` (
                        `id` INTEGER PRIMARY KEY NOT NULL,
                        `url` TEXT NOT NULL,
                        `readableContentHtml` TEXT NOT NULL
                    )
                    """
                )
            }
        }

        val MIGRATION_5_6: Migration = object : Migration(5, 6) {
            override fun migrate(db: SupportSQLiteDatabase) {
                db.execSQL("""
                    CREATE TABLE IF NOT EXISTS `bookmark_tag_cross_ref` (
                        `bookmarkId` INTEGER NOT NULL,
                        `tagId` INTEGER NOT NULL,
                        PRIMARY KEY(`bookmarkId`, `tagId`)
                    )
                """)
            }
        }

        val MIGRATION_6_7: Migration = object : Migration(6, 7) {
            override fun migrate(db: SupportSQLiteDatabase) {
                db.execSQL("ALTER TABLE bookmarks ADD COLUMN created_at TEXT NOT NULL DEFAULT ''")
            }
        }

        fun create(context: Context): BookmarksDatabase {
            return Room.databaseBuilder(
                context,
                BookmarksDatabase::class.java, "bookmarks_database"
            )
                .allowMainThreadQueries()
                .addMigrations(
                    MIGRATION_1_2,
                    MIGRATION_2_3,
                    MIGRATION_3_4,
                    MIGRATION_4_5,
                    MIGRATION_5_6,
                    MIGRATION_6_7
                )
                .setQueryCallback({ sqlQuery, bindArgs ->
                    Log.d("SQL Query", "SQL Query: $sqlQuery SQL Args: $bindArgs")
                }, Executors.newSingleThreadExecutor())
                .build()
        }
    }
}


================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkEntity.kt
================================================
package com.desarrollodroide.data.local.room.entity

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.desarrollodroide.model.Tag

@Entity(tableName = "bookmarks")
data class BookmarkEntity(
    @PrimaryKey
    val id: Int,
    val url: String,
    val title: String,
    val excerpt: String,
    val author: String,
    @ColumnInfo(name = "is_public")
    val isPublic: Int,
    @ColumnInfo(name = "created_at")
    val createdAt: String,
    @ColumnInfo(name = "modified_date")
    val modified: String,
    @ColumnInfo(name = "image_url")
    val imageURL: String,
    @ColumnInfo(name = "has_content")
    val hasContent: Boolean,
    @ColumnInfo(name = "has_archive")
    val hasArchive: Boolean,
    @ColumnInfo(name = "has_ebook")
    val hasEbook: Boolean,
    val tags: List<Tag>,
    @ColumnInfo(name = "create_archive")
    val createArchive: Boolean,
    @ColumnInfo(name = "create_ebook")
    val createEbook: Boolean,
)

================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkHtmlEntity.kt
================================================
package com.desarrollodroide.data.local.room.entity

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "bookmark_html")
data class BookmarkHtmlEntity(
    @PrimaryKey
    val id: Int,
    val url: String,
    val readableContentHtml: String
)


================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkTagCrossRef.kt
================================================
package com.desarrollodroide.data.local.room.entity

import androidx.room.ColumnInfo
import androidx.room.Entity

@Entity(tableName = "bookmark_tag_cross_ref", primaryKeys = ["bookmarkId", "tagId"])
data class BookmarkTagCrossRef(
    @ColumnInfo(name = "bookmarkId") val bookmarkId: Int,
    @ColumnInfo(name = "tagId") val tagId: Int
)

================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkWithTags.kt
================================================
package com.desarrollodroide.data.local.room.entity

import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation

data class BookmarkWithTags(
    @Embedded val bookmark: BookmarkEntity,
    @Relation(
        parentColumn = "id",
        entityColumn = "id",
        associateBy = Junction(BookmarkTagCrossRef::class)
    )
    val tags: List<TagEntity>
)


================================================
FILE: data/src/main/java/com/desarrollodroide/data/local/room/entity/TagEntity.kt
================================================
package com.desarrollodroide.data.local.room.entity

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "tags")
data class TagEntity(
    @PrimaryKey(autoGenerate = true) val id: Int,
    val name: String,
    @ColumnInfo(name = "n_bookmarks") val nBookmarks: Int,
)

================================================
FILE: data/src/main/java/com/desarrollodroide/data/mapper/Mapper.kt
================================================
package com.desarrollodroide.data.mapper

import com.desarrollodroide.data.UserPreferences
import com.desarrollodroide.data.helpers.AddTagDTOAdapter
import com.desarrollodroide.data.helpers.TagTypeAdapter
import com.desarrollodroide.data.local.room.entity.BookmarkEntity
import com.desarrollodroide.data.local.room.entity.TagEntity
import com.desarrollodroide.model.*
import com.desarrollodroide.network.model.*
import com.google.gson.ExclusionStrategy
import com.google.gson.FieldAttributes
import com.google.gson.GsonBuilder

fun SessionDTO.toDomainModel() = User(
    token = token?:"",
    session = session?:"",
    account = account?.toDomainModel()?:Account()
)

fun AccountDTO.toDomainModel() = Account(
    id = -1,
    userName = userName?:"",
    password = password?:"",
    owner = isOwner?:false,
    serverUrl = "",
)

fun SessionDTO.toProtoEntity(): UserPreferences = UserPreferences.newBuilder()
    .setSession(session?:"")
    .setUsername(account?.userName?:"")
    .setId(account?.id?:-1)
    .setOwner(account?.isOwner?:false)
    .build()

fun BookmarkDTO.toDomainModel(serverUrl: String = "") = Bookmark(
    id = id?:0,
    url = url?:"",
    title = title?:"",
    excerpt = excerpt?:"",
    author = author?:"",
    public = public?:0,
    createAt = createdAt?:"",
    modified = modified?:"",
    imageURL = "$serverUrl$imageURL",
    hasContent = hasContent?:false,
    hasArchive = hasArchive?:false,
    hasEbook = hasEbook?:false,
    tags = tags?.map { it.toDomainModel() }?: emptyList(),
    createArchive = createArchive?:false,
    createEbook = createEbook?:false,
)

fun BookmarksDTO.toDomainModel(serverUrl: String) = Bookmarks(
    error = "",
    page = resolvedPage()?:0,
    maxPage = resolvedMaxPage()?:0,
    bookmarks = resolvedBookmarks()?.map { it.toDomainModel(serverUrl) }?: emptyList()
)

fun TagDTO.toDomainModel() = Tag(
    id = id?:0,
    name = name?:"",
    selected = false,
    nBookmarks = nBookmarks?:0
)

fun TagDTO.toEntityModel() = TagEntity(
    id = id?:0,
    name = name?:"",
    nBookmarks = nBookmarks?:0
)

fun TagEntity.toDomainModel() = Tag(
    id = id,
    name = name,
    selected = false,
    nBookmarks = nBookmarks
)

fun Account.toRequestBody() =
    LoginRequestPayload(
        username = userName,
        password = password
    )

fun Tag.toEntityModel() = TagEntity(
    id = id,
    name = name,
    nBookmarks = nBookmarks
)

fun BookmarkDTO.toEntityModel() = BookmarkEntity(
    id = id?:0,
    url = url?:"",
    title = title?:"",
    excerpt = excerpt?:"",
    author = author?:"",
    isPublic = public?:0,
    createdAt = createdAt?:"",
    modified = modified?:"",
    imageURL = imageURL?:"",
    hasContent = hasContent?:false,
    hasArchive = hasArchive?:false,
    hasEbook = hasEbook?:false,
    tags = tags?.map { it.toDomainModel() } ?: emptyList(),
    createArchive = createArchive?:false,
    createEbook = createEbook?:false,
)

fun BookmarkEntity.toDomainModel() = Bookmark(
    id = id,
    url = url,
    title = title,
    excerpt = excerpt,
    author = author,
    public = isPublic,
    createAt = createdAt,
    modified = modified,
    imageURL = imageURL,
    hasContent = hasContent,
    hasArchive = hasArchive,
    hasEbook = hasEbook,
    tags = tags,
    createArchive = createArchive,
    createEbook = createEbook,
)

fun Bookmark.toEntityModel(modified: String? = null) = BookmarkEntity(
    id = id,
    url = url,
    title = title,
    excerpt = excerpt,
    author = author,
    isPublic = public,
    createdAt = createAt,
    modified = modified ?: this.modified,
    imageURL = imageURL,
    hasContent = hasContent,
    hasArchive = hasArchive,
    hasEbook = hasEbook,
    tags = tags,
    createArchive = createArchive,
    createEbook = createEbook,
)

fun UpdateCachePayload.toDTO() = UpdateCachePayloadDTO(
    createArchive = createArchive,
    createEbook = createEbook,
    ids = ids,
    keepMetadata = keepMetadata,
)

fun UpdateCachePayload.toV1DTO() = UpdateCachePayloadV1DTO(
    createArchive = createArchive,
    createEbook = createEbook,
    ids = ids,
    keepMetadata = keepMetadata,
    skipExist = skipExist
)

fun LivenessResponseDTO.toDomainModel() = LivenessResponse(
    ok = ok?:false,
    message = message?.toDomainModel()
)

fun ReleaseInfoDTO.toDomainModel() = ReleaseInfo(
    version = version?:"",
    date = date?:"",
    commit = commit?:""
)

fun LoginResponseDTO.toProtoEntity(
    userName: String,
): UserPreferences = UserPreferences.newBuilder()
    .setSession(message?.session ?: message?.token ?: "")
    .setUsername(userName)
    .setToken(message?.token?:"")
    .build()

fun LoginResponseMessageDTO.toDomainModel() = LoginResponseMessage(
    expires = expires?:0,
    session = session?:"",
    token = token?:""
)

fun ReadableContentResponseDTO.toDomainModel() = ReadableContent(
    ok = ok?:false,
    message = resolvedMessage()?.toDomainModel() ?: ReadableMessage("", "")
)

fun ReadableMessageDto.toDomainModel() = ReadableMessage(
    content = content?:"",
    html = html?:""
)


fun SyncBookmarksResponseDTO.toDomainModel(): SyncBookmarksResponse {
    return SyncBookmarksResponse(
        deleted = message.deleted ?: emptyList(),
        modified = message.modified?.toDomainModel() ?: ModifiedBookmarks(emptyList(), 0, 0)
    )
}

fun ModifiedBookmarksDTO.toDomainModel(): ModifiedBookmarks {
    return ModifiedBookmarks(
        bookmarks = bookmarks?.map { it.toDomainModel() } ?: emptyList(),
        maxPage = maxPage ?: 0,
        page = page ?: 0
    )
}

fun Bookmark.toAddBookmarkDTO() = BookmarkDTO(
    id = null,
    url = url,
    title = title,
    excerpt = excerpt,
    author = null,
    public = public,
    createdAt = null,
    modified = null,
    imageURL = null,
    hasContent = null,
    hasArchive = null,
    hasEbook = null,
    tags = tags.map { TagDTO(id = null, name = it.name.lowercase().trim(), nBookmarks = null) },
    createArchive = createArchive,
    createEbook = createEbook
)

fun Bookmark.toEditBookmarkDTO() = BookmarkDTO(
    id = id,
    url = url,
    title = title,
    excerpt = excerpt,
    author = author,
    public = public,
    createdAt = createAt,
    modified = modified,
    imageURL = imageURL,
    hasContent = hasContent,
    hasArchive = hasArchive,
    hasEbook = hasEbook,
    tags = tags.map { TagDTO(id = it.id, name = it.name.lowercase().trim(), nBookmarks = null) },
    createArchive = createArchive,
    createEbook = createEbook
)

/**
 * Converts a Bookmark to JSON format for updating existing bookmarks.
 * Includes all fields of the bookmark in the JSON output.
 */
fun BookmarkDTO.toEditBookmarkJson() = GsonBuilder()
    .registerTypeAdapter(TagDTO::class.java, AddTagDTOAdapter())
    .setExclusionStrategies(object : ExclusionStrategy {
        override fun shouldSkipField(f: FieldAttributes): Boolean {
            return f.name == "hasEbook" || f.name == "createEbook"
        }
        override fun shouldSkipClass(clazz: Class<*>): Boolean = false
    })
    .create()
    .toJson(this)





================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/AuthRepository.kt
================================================
package com.desarrollodroide.data.repository

import com.desarrollodroide.common.result.Result
import com.desarrollodroide.model.User
import kotlinx.coroutines.flow.Flow

interface AuthRepository {

  fun sendLogin(
    username: String,
    password: String,
    serverUrl: String
  ): Flow<Result<User?>>

  fun sendLogout(
    serverUrl: String,
    xSession: String
  ): Flow<Result<String?>>

  fun sendLoginV1(
    username: String,
    password: String,
    serverUrl: String
  ): Flow<Result<User?>>
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/AuthRepositoryImpl.kt
================================================
package com.desarrollodroide.data.repository

import com.desarrollodroide.common.result.ErrorHandler
import com.desarrollodroide.data.extensions.removeTrailingSlash
import com.desarrollodroide.data.extensions.toJson
import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource
import com.desarrollodroide.data.mapper.*
import com.desarrollodroide.model.User
import com.desarrollodroide.network.model.LoginRequestPayload
import com.desarrollodroide.network.model.LoginResponseDTO
import com.desarrollodroide.network.model.SessionDTO
import com.desarrollodroide.network.retrofit.NetworkBoundResource
import com.desarrollodroide.network.retrofit.RetrofitNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn

class AuthRepositoryImpl(
    private val apiService: RetrofitNetwork,
    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,
    private val errorHandler: ErrorHandler
) : AuthRepository {

    override fun sendLogin(
        username: String,
        password: String,
        serverUrl: String
    ) = object :
        NetworkBoundResource<SessionDTO, User>(errorHandler = errorHandler) {

        override suspend fun saveRemoteData(response: SessionDTO) {
            settingsPreferenceDataSource.saveUser(
                password = password,
                session = response.toProtoEntity(),
                serverUrl = serverUrl,
            )
        }
        override fun fetchFromLocal() = settingsPreferenceDataSource.getUser()

        override suspend fun fetchFromRemote() = apiService.sendLogin(
            "${serverUrl.removeTrailingSlash()}/api/login",
            LoginRequestPayload(
                username = username,
                password = password
            ).toJson()
        )

        override fun shouldFetch(data: User?) = true

    }.asFlow().flowOn(Dispatchers.IO)


    override fun sendLogout(
        serverUrl: String,
        xSession: String
    ) = object :
        NetworkBoundResource<String, String>(errorHandler = errorHandler) {

        override suspend fun saveRemoteData(response: String) {
            settingsPreferenceDataSource.resetData()
        }

        override fun fetchFromLocal() = flowOf("")

        override suspend fun fetchFromRemote() = apiService.sendLogout(
            xSessionId = xSession,
            url = "${serverUrl.removeTrailingSlash()}/api/logout")

        override fun shouldFetch(data: String?) = true

    }.asFlow().flowOn(Dispatchers.IO)

    override fun sendLoginV1(
        username: String,
        password: String,
        serverUrl: String
    ) = object :
        NetworkBoundResource<LoginResponseDTO, User>(errorHandler = errorHandler) {

        override suspend fun saveRemoteData(response: LoginResponseDTO) {
            settingsPreferenceDataSource.saveUser(
                password = password,
                session = response.toProtoEntity(username),
                serverUrl = serverUrl,
            )
        }
        override fun fetchFromLocal() = settingsPreferenceDataSource.getUser()

        override suspend fun fetchFromRemote() = apiService.sendLoginV1(
            "${serverUrl.removeTrailingSlash()}/api/v1/auth/login",
            LoginRequestPayload(
                username = username,
                password = password
            ).toJson()
        )

        override fun shouldFetch(data: User?) = true

    }.asFlow().flowOn(Dispatchers.IO)

}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/BookmarksRepository.kt
================================================
package com.desarrollodroide.data.repository

import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.common.result.Result
import com.desarrollodroide.model.ReadableContent
import com.desarrollodroide.model.SyncBookmarksRequestPayload
import com.desarrollodroide.model.SyncBookmarksResponse
import com.desarrollodroide.model.Tag
import com.desarrollodroide.model.UpdateCachePayload

interface BookmarksRepository {

  fun getBookmarks(
    xSession: String,
    serverUrl: String
  ): Flow<Result<List<Bookmark>?>>

  fun getPagingBookmarks(
      xSession: String,
      serverUrl: String,
      searchText: String,
      tags: List<Tag>,
      saveToLocal: Boolean
  ): Flow<PagingData<Bookmark>>

  suspend fun addBookmark(
    xSession: String,
    serverUrl: String,
    bookmark: Bookmark
  ): Bookmark

  suspend fun deleteBookmark(
    xSession: String,
    serverUrl: String,
    bookmarkId: Int
  )

  suspend fun editBookmark(
    xSession: String,
    serverUrl: String,
    bookmark: Bookmark
  ): Bookmark

  suspend fun deleteAllLocalBookmarks()

  suspend fun updateBookmarkCacheV1(
    token: String,
    serverUrl: String,
    updateCachePayload: UpdateCachePayload,
    bookmark: Bookmark?,
  ): List<Bookmark>

  fun getBookmarkReadableContent(
    token: String,
    serverUrl: String,
    bookmarkId: Int
  ): Flow<Result<ReadableContent>>

  suspend fun syncAllBookmarks(
    xSession: String,
    serverUrl: String
  ): Flow<SyncStatus>

  fun getLocalPagingBookmarks(
    tags: List<Tag>,
    searchText: String
  ): Flow<PagingData<Bookmark>>

  fun syncBookmarks(
    token: String,
    serverUrl: String,
    syncBookmarksRequestPayload: SyncBookmarksRequestPayload
  ): Flow<Result<SyncBookmarksResponse>>

  fun getBookmarkById(
      token: String,
      serverUrl: String,
      bookmarkId: Int
    ): Flow<Result<Bookmark?>>
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/BookmarksRepositoryImpl.kt
================================================
package com.desarrollodroide.data.repository

import android.util.Log
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import com.desarrollodroide.common.result.ErrorHandler
import com.desarrollodroide.common.result.Result
import com.desarrollodroide.data.extensions.removeTrailingSlash
import com.desarrollodroide.data.extensions.toJson
import com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.local.room.entity.BookmarkEntity
import com.desarrollodroide.data.mapper.*
import com.desarrollodroide.data.repository.paging.BookmarkPagingSource
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.ReadableContent
import com.desarrollodroide.model.SyncBookmarksRequestPayload
import com.desarrollodroide.model.SyncBookmarksResponse
import com.desarrollodroide.model.Tag
import com.desarrollodroide.model.UpdateCachePayload
import com.desarrollodroide.network.model.BookmarkDTO
import com.desarrollodroide.network.model.BookmarksDTO
import com.desarrollodroide.network.model.SingleBookmarkResponseDTO
import com.desarrollodroide.network.model.ReadableContentResponseDTO
import com.desarrollodroide.network.model.SyncBookmarksResponseDTO
import com.desarrollodroide.network.retrofit.NetworkBoundResource
import com.desarrollodroide.network.retrofit.NetworkNoCacheResource
import com.desarrollodroide.network.retrofit.RetrofitNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import retrofit2.Response
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

class BookmarksRepositoryImpl(
    private val apiService: RetrofitNetwork,
    private val bookmarksDao: BookmarksDao,
    private val errorHandler: ErrorHandler
) : BookmarksRepository {

    private val TAG = "BookmarksRepository"

    override fun getBookmarks(
        xSession: String,
        serverUrl: String
    ) = object :
        NetworkBoundResource<BookmarksDTO, List<Bookmark>>(errorHandler = errorHandler) {

        override suspend fun saveRemoteData(response: BookmarksDTO) {
            response.resolvedBookmarks()?.map { it.toEntityModel() }?.let { bookmarksList ->
                bookmarksDao.deleteAll()
                bookmarksDao.insertAll(bookmarksList)
            }
        }

        override fun fetchFromLocal() = bookmarksDao.getAll().map { bookmarks ->
            bookmarks.map { it.toDomainModel() }
        }

        override suspend fun fetchFromRemote() = apiService.getBookmarks(
            xSessionId = xSession,
            url = "${serverUrl.removeTrailingSlash()}/api/bookmarks"
        )

        override fun shouldFetch(data: List<Bookmark>?) = true

    }.asFlow().flowOn(Dispatchers.IO)

    override fun getPagingBookmarks(
        xSession: String,
        serverUrl: String,
        searchText: String,
        tags: List<Tag>,
        saveToLocal: Boolean
    ): Flow<PagingData<Bookmark>> {
        return Pager(
            config = PagingConfig(pageSize = 20, prefetchDistance = 2),
            pagingSourceFactory = {
                BookmarkPagingSource(
                    remoteDataSource = apiService,
                    bookmarksDao = bookmarksDao,
                    serverUrl = serverUrl,
                    xSessionId = xSession,
                    searchText = searchText,
                    tags = tags,
                    saveToLocal = saveToLocal
                )
            }
        ).flow
    }

    /**
     * Retrieves a paginated list of bookmarks from the local database using Room and Paging.
     *
     * Configurations:
     * - `pageSize = 30`: Suggests loading 30 items per page.
     * - `prefetchDistance = 2`: Prefetches 2 pages ahead of the currently loaded page.
     * - `enablePlaceholders = false`: Disables placeholders for unloaded items.
     *
     * Behavior:
     * - Although `pageSize` is set to 30, Room may initially load more items (90 in this case) as an optimization
     *   to reduce database queries and improve user experience during initial loads.
     * - Subsequent loads will fetch additional items in increments of 30 as the user scrolls.
     *
     * @param tags List of tags to filter bookmarks.
     * @param searchText Text to search bookmarks by title.
     * @return A Flow of paginated data to observe and update the UI as more data is loaded.
     */

    override fun getLocalPagingBookmarks(
        tags: List<Tag>,
        searchText: String
    ): Flow<PagingData<Bookmark>> {
        val processedSearchText = searchText.trim()
        val tagIds = tags.map { it.id }
        return Pager(
            config = PagingConfig(
                pageSize = 30,
                prefetchDistance = 2,
                enablePlaceholders = false
            ),
            pagingSourceFactory = {
                when {
                    processedSearchText.isNotEmpty() && tagIds.isNotEmpty() -> {
                        bookmarksDao.getPagingBookmarks(searchText = processedSearchText, tagIds = tagIds)
                    }
                    processedSearchText.isNotEmpty() && tagIds.isEmpty() -> {
                        bookmarksDao.getPagingBookmarksWithoutTags(searchText = processedSearchText)
                    }
                    processedSearchText.isEmpty() && tagIds.isNotEmpty() -> {
                        bookmarksDao.getPagingBookmarksByTags(tagIds = tagIds)
                    }
                    else -> {
                        bookmarksDao.getAllPagingBookmarks()
                    }
                }
            }
        ).flow.map { pagingData ->
            pagingData.map {
                it.toDomainModel()
            }
        }
    }

    /**
     * Synchronizes all bookmarks from the remote server to the local database.
     *
     * This method performs a full synchronization of all bookmarks, regardless of the current
     * pagination state or user scroll position. It fetches all pages of bookmarks from the server
     * and updates the local database accordingly.
     *
     * @param xSession The session token for authentication with the remote API.
     * @param serverUrl The base URL of the server API.
     * @return Flow<SyncStatus> A flow emitting the current status of the synchronization process.
     *
     * The flow emits the following states:
     * - SyncStatus.Started: When the sync process begins.
     * - SyncStatus.InProgress(currentPage: Int): As each page is being fetched and processed.
     * - SyncStatus.Completed(totalBookmarks: Int): When all bookmarks have been successfully synced.
     * - SyncStatus.Error(error: Result.ErrorType): If an error occurs during the sync process.
     *
     * Note: This method performs a complete sync independently of RemoteMediator.
     * Use it for full synchronization when RemoteMediator's on-demand loading is insufficient.
     */
    override suspend fun syncAllBookmarks(
        xSession: String,
        serverUrl: String,
    ): Flow<SyncStatus> = flow {
        var currentPage = 1
        var hasNextPage = true
        val allBookmarks = mutableListOf<BookmarkEntity>()
        try {
            Log.d(TAG, "Sync started")
            emit(SyncStatus.Started)

            while (hasNextPage) {
                Log.d(TAG, "Fetching bookmarks for page $currentPage")
                emit(SyncStatus.InProgress(currentPage))
                val bookmarksDto = apiService.getPagingBookmarks(
                    xSessionId = xSession,
                    url = "${serverUrl.removeTrailingSlash()}/api/bookmarks?page=$currentPage"
                )
                Log.d(TAG, "Received response for page $currentPage with status: ${bookmarksDto.code()}")
                if (bookmarksDto.errorBody()?.string() == SESSION_HAS_BEEN_EXPIRED) {
                    Log.e(TAG, "Session has expired")
                    emit(SyncStatus.Error(Result.ErrorType.SessionExpired(message = SESSION_HAS_BEEN_EXPIRED)))
                    return@flow
                }
                val bookmarks = bookmarksDto.body()?.resolvedBookmarks()?.map { it.toEntityModel() } ?: emptyList()
                Log.d(TAG, "Fetched ${bookmarks.size} bookmarks for page $currentPage")
                allBookmarks.addAll(bookmarks)
                hasNextPage = hasNextPage(bookmarksDto)
                Log.d(TAG, "Has next page: $hasNextPage")
                if (hasNextPage) {
                    currentPage++
                }
            }
            Log.d(TAG, "Inserting ${allBookmarks.size} bookmarks into database")
            bookmarksDao.insertAllWithTags(allBookmarks)
            Log.d(TAG, "Sync completed with ${allBookmarks.size} bookmarks")
            emit(SyncStatus.Completed(allBookmarks.size))
        } catch (e: Exception) {
            Log.e(TAG, "Error during sync: ${e.message}")
            emit(SyncStatus.Error(Result.ErrorType.Unknown(throwable = e)))
        }
    }

    private fun hasNextPage(bookmarksDto: Response<BookmarksDTO>): Boolean {
        val body = bookmarksDto.body() ?: return false
        val currentPage = body.resolvedPage() ?: return false
        val maxPage = body.resolvedMaxPage() ?: return false
        val bookmarks = body.resolvedBookmarks()

        return currentPage < maxPage && bookmarks?.isNotEmpty() == true
    }

    override suspend fun addBookmark(
        xSession: String,
        serverUrl: String,
        bookmark: Bookmark
    ): Bookmark {
        val response = apiService.addBookmark(
            url = "${serverUrl.removeTrailingSlash()}/api/bookmarks",
            xSessionId = xSession,
            body = bookmark.toAddBookmarkDTO().toJson()
        )
        if (response.isSuccessful) {
            response.body()?.resolvedBookmark()?.let {
                return it.toDomainModel()
            }
            throw IllegalStateException("Response body is null")
        } else {
            throw IllegalStateException("Error adding bookmark: ${response.errorBody()?.string()}")
        }
    }

    /**
     * Deletes a bookmark from the remote server.
     * The method uses a NetworkNoCacheResource to handle the network operation and error handling.
     *
     * @param xSession The session token for authentication with the remote API.
     * @param serverUrl The base URL of the server API.
     * @param bookmarkId The ID of the bookmark to be added.
     * @return A Flow emitting a Result<Bookmark> representing the outcome of the add operation.
     *         It can emit Loading, Success with the added bookmark, or Error states.
     */
    override suspend fun deleteBookmark(
        xSession: String,
        serverUrl: String,
        bookmarkId: Int
    ) {
        val response = apiService.deleteBookmarks(
            url = "${serverUrl.removeTrailingSlash()}/api/bookmarks",
            xSessionId = xSession,
            bookmarkIds = listOf(bookmarkId)
        )
        if (!response.isSuccessful) {
            throw IllegalStateException("Error deleting bookmark: ${response.errorBody()?.string()}")
        }
    }

    /**
     * Edits an existing bookmark both on the remote server and in the local database.
     *
     * This method performs the following steps:
     * 1. Sends an edit request to the remote server.
     * 2. If the server update is successful, updates the local database.
     * 3. Emits the updated bookmark if both operations are successful.
     *
     * The method uses a NetworkNoCacheResource to handle the network operation and error handling.
     *
     * @param xSession The session token for authentication with the remote API.
     * @param serverUrl The base URL of the server API.
     * @param bookmark The Bookmark object containing the updated information.
     * @return A Flow emitting a Result<Bookmark> representing the outcome of the edit operation.
     *         It can emit Loading, Success with the updated bookmark, or Error states.
     */
    override suspend fun editBookmark(
        xSession: String,
        serverUrl: String,
        bookmark: Bookmark
    ): Bookmark {
        val response = apiService.editBookmark(
            url = "${serverUrl.removeTrailingSlash()}/api/bookmarks",
            xSessionId = xSession,
            body = bookmark.toEditBookmarkDTO().toEditBookmarkJson()
        )
        if (response.isSuccessful) {
            response.body()?.resolvedBookmark()?.let { bookmarkDTO ->
                // TODO force fields to avoid invalid backend response
                val updatedEntity = bookmarkDTO.toEntityModel().copy(
                    hasEbook = bookmark.hasEbook,
                    createEbook = bookmark.createEbook
                )
                bookmarksDao.updateBookmark(updatedEntity)
                return updatedEntity.toDomainModel()
            }
            throw IllegalStateException("Response body is null")
        } else {
            throw IllegalStateException("${response.errorBody()?.string()}")
        }
    }


    override suspend fun updateBookmarkCacheV1(
        token: String,
        serverUrl: String,
        updateCachePayload: UpdateCachePayload,
        bookmark: Bookmark?,
    ): List<Bookmark>  {
        val response = apiService.updateBookmarksCacheV1(
            url = "${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/cache",
            authorization = "Bearer $token",
            body = updateCachePayload.toDTO().toJson()
        )
        if (response.isSuccessful) {
            response.body()?.let {
                it.message?.forEach { dto->
                    // TODO change to toEntityModel when backend is fixed
                    val updatedEntity = dto.toEntityModel().copy(
                        createEbook = if (updateCachePayload.createEbook) true else bookmark?.createEbook?: false,
                        createArchive = if (updateCachePayload.createArchive) true else bookmark?.createArchive?: false,
                        hasEbook = if (updateCachePayload.createEbook) true else bookmark?.hasEbook?: false,
                        hasArchive = if (updateCachePayload.createArchive) true else bookmark?.hasArchive?: false,
                        modified = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
                    )
                    bookmarksDao.updateBookmark(updatedEntity)
                }
               return  it.message?.map { it.toDomainModel() }?: emptyList()
            }
            throw IllegalStateException("Response body is null")
        } else {
            throw IllegalStateException("${response.errorBody()?.string()}")
        }
    }

    override suspend fun deleteAllLocalBookmarks()  { bookmarksDao.deleteAll() }

    override fun getBookmarkReadableContent(
        token: String,
        serverUrl: String,
        bookmarkId: Int
    ) = object :
        NetworkNoCacheResource<ReadableContentResponseDTO, ReadableContent>(errorHandler = errorHandler) {
        override suspend fun fetchFromRemote(): Response<ReadableContentResponseDTO> = apiService.getBookmarkReadableContent(
            url = "${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/${bookmarkId}/readable",
            authorization = "Bearer $token",
        )

        override fun fetchResult(data: ReadableContentResponseDTO): Flow<ReadableContent> {
            return flow {
                    emit(data.toDomainModel())
            }
        }
    }.asFlow().flowOn(Dispatchers.IO)

    /**
     * Syncs the bookmarks between the remote server and the local database.
     *
     * This method performs the following steps:
     * 1. Sends a sync request to the remote server.
     * 2. If the server update is successful, updates the local database.
     * 3. Emits the sync status if both operations are successful.
     *
     * The method uses a NetworkNoCacheResource to handle the network operation and error handling.
     *
     * @param token The session token for authentication with the remote API.
     * @param serverUrl The base URL of the server API.
     * @param syncBookmarksRequestPayload The payload containing the bookmarks to be synced.
     * @return A Flow emitting a Result<SyncBookmarksResponse> representing the outcome of the sync operation.
     *         It can emit Loading, Success with the sync result, or Error states.
     */
    override fun syncBookmarks(
        token: String,
        serverUrl: String,
        syncBookmarksRequestPayload: SyncBookmarksRequestPayload
    ): Flow<Result<SyncBookmarksResponse>> {
        return object : NetworkNoCacheResource<SyncBookmarksResponseDTO, SyncBookmarksResponse>(errorHandler = errorHandler) {
            override suspend fun fetchFromRemote(): Response<SyncBookmarksResponseDTO> {
                return apiService.syncBookmarks(
                    url = "${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/sync",
                    authorization = "Bearer $token",
                    body = syncBookmarksRequestPayload.toJson()
                )
            }

            override fun fetchResult(data: SyncBookmarksResponseDTO): Flow<SyncBookmarksResponse> {
                return flow {
                    emit(data.toDomainModel())
                }
            }
        }.asFlow().flowOn(Dispatchers.IO)
    }

    override fun getBookmarkById(
        token: String,
        serverUrl: String,
        bookmarkId: Int
    ) = object :
        NetworkNoCacheResource<SingleBookmarkResponseDTO, Bookmark>(errorHandler = errorHandler) {

        override suspend fun fetchFromRemote(): Response<SingleBookmarkResponseDTO> = apiService.getBookmark(
            url = "${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/$bookmarkId",
            authorization = "Bearer $token",
        )

        override fun fetchResult(data: SingleBookmarkResponseDTO): Flow<Bookmark> {
            return flow {
                val bookmark = data.resolvedBookmark()
                    ?: throw IllegalStateException("Could not resolve bookmark from response")
                emit(bookmark.toDomainModel())
            }
        }
    }.asFlow().flowOn(Dispatchers.IO)

}

sealed class SyncStatus {
    data object Started : SyncStatus()
    data class InProgress(val currentPage: Int) : SyncStatus()
    data class Completed(val totalSynced: Int) : SyncStatus()
    data class Error(val error: Result.ErrorType) : SyncStatus()
}



================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/ErrorHandlerImpl.kt
================================================
package com.desarrollodroide.data.repository

import com.desarrollodroide.common.result.ErrorHandler
import com.desarrollodroide.common.result.Result
import com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED
import java.io.IOException
import java.sql.SQLException

class ErrorHandlerImpl : ErrorHandler {
    override fun getError(throwable: Throwable): Result.ErrorType {
        return when (throwable) {
            is IOException -> Result.ErrorType.IOError(throwable)
            is SQLException -> Result.ErrorType.DatabaseError(throwable)
            else -> Result.ErrorType.Unknown(throwable)
        }
    }

    override fun getApiError(
        statusCode: Int,
        throwable: Throwable?,
        message: String?
    ): Result.ErrorType {
        return if (message?.contains(SESSION_HAS_BEEN_EXPIRED) == true)
            Result.ErrorType.SessionExpired(throwable, message) else
            Result.ErrorType.HttpError(throwable, statusCode, message)
    }
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/FileRepository.kt
================================================
package com.desarrollodroide.data.repository

import java.io.File

interface FileRepository {
    suspend fun downloadFile(
        url: String,
        fileName: String,
        sessionId: String,
    ): File
}


================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/FileRepositoryImpl.kt
================================================
package com.desarrollodroide.data.repository

import android.content.Context
import com.desarrollodroide.network.retrofit.FileRemoteDataSource
import java.io.File

class FileRepositoryImpl(
    private val context: Context,
    private val remoteDataSource: FileRemoteDataSource
) : FileRepository {
    override suspend fun downloadFile(
        url: String,
        fileName: String,
        sessionId: String,
    ): File {
        return remoteDataSource.downloadFile(
            context = context,
            url = url,
            fileName = fileName,
            sessionId = sessionId
        )
    }
}




================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/SettingsRepository.kt
================================================
package com.desarrollodroide.data.repository

import com.desarrollodroide.data.helpers.ThemeMode
import com.desarrollodroide.model.User
import kotlinx.coroutines.flow.Flow

interface SettingsRepository {
    suspend fun getUser(): User
    suspend fun getUserName(): Flow<String>

    val userDataStream: Flow<User>
    fun getThemeMode(): ThemeMode
    suspend fun setThemeMode(themeMode: ThemeMode)

    fun getUseDynamicColors(): Boolean
    suspend fun setUseDynamicColors(useDynamicColors: Boolean)
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/SettingsRepositoryImpl.kt
================================================
package com.desarrollodroide.data.repository

import com.desarrollodroide.data.helpers.ThemeMode
import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource
import com.desarrollodroide.model.Account
import com.desarrollodroide.model.User
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map

class SettingsRepositoryImpl(
    private val settingsPreferenceDataSource: SettingsPreferenceDataSource
): SettingsRepository {
    override suspend fun getUser() = settingsPreferenceDataSource.userDataStream.map {
        User(
            token = it.token,
            session = it.session,
            account = Account(
                id = it.account.id,
                userName = it.account.userName,
                password = it.account.password,
                owner = it.account.owner,
                serverUrl = it.account.serverUrl,
            )
        )
    }.first()

    override suspend fun getUserName() = settingsPreferenceDataSource.userDataStream.map { it.account.userName }

    override val userDataStream: Flow<User> =
        settingsPreferenceDataSource.userDataStream

    override suspend fun setThemeMode(themeMode: ThemeMode) {
       settingsPreferenceDataSource.setTheme(themeMode)
    }
    override fun getThemeMode() = settingsPreferenceDataSource.getThemeMode()

    override fun getUseDynamicColors() = settingsPreferenceDataSource.getUseDynamicColors()
    override suspend fun setUseDynamicColors(useDynamicColors: Boolean) {
        settingsPreferenceDataSource.setUseDynamicColors(useDynamicColors)
    }

}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/SyncWorks.kt
================================================
package com.desarrollodroide.data.repository

import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.PendingJob
import com.desarrollodroide.model.SyncOperationType
import com.desarrollodroide.model.UpdateCachePayload
import kotlinx.coroutines.flow.Flow

interface SyncWorks {

    fun scheduleSyncWork(
        operationType: SyncOperationType,
        bookmark: Bookmark,
        updateCachePayload: UpdateCachePayload? = null
    )
    fun getPendingJobs(): Flow<List<PendingJob>>
    fun cancelAllSyncWorkers()
    suspend fun retryAllPendingJobs()

}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/SyncWorksImpl.kt
================================================
package com.desarrollodroide.data.repository

import android.util.Log
import androidx.lifecycle.asFlow
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkRequest
import androidx.work.workDataOf
import com.desarrollodroide.data.extensions.toJson
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.mapper.toDomainModel
import com.desarrollodroide.data.repository.workers.SyncWorker
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.PendingJob
import com.desarrollodroide.model.SyncOperationType
import com.desarrollodroide.model.UpdateCachePayload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.concurrent.TimeUnit

class SyncWorksImpl(
    private val workManager: WorkManager,
    private val bookmarksDao: BookmarksDao,
) : SyncWorks {
    override fun scheduleSyncWork(
        operationType: SyncOperationType,
        bookmark: Bookmark,
        updateCachePayload: UpdateCachePayload?
    ) {
        val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
        val encodedTitle = URLEncoder.encode(bookmark.title, "UTF-8")
        val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
            .setInputData(workDataOf(
                "operationType" to operationType.name,
                "bookmarkId" to bookmark.id,
                "updateCachePayload" to updateCachePayload?.toJson()
            ))
            .addTag("worker_${SyncWorker::class.java.name}")
            .addTag("operationType_${operationType.name}")
            .addTag("bookmarkId_${bookmark.id}")
            .addTag("bookmarkTitle_$encodedTitle")
            .setBackoffCriteria(
                BackoffPolicy.LINEAR,
                WorkRequest.MIN_BACKOFF_MILLIS,
                TimeUnit.MILLISECONDS
            )
            .setConstraints(constraints)
            .build()

        workManager.beginUniqueWork(
            "sync_bookmark_${operationType.name}_${bookmark.id}",
            ExistingWorkPolicy.REPLACE,
            listOf(syncWorkRequest)
        ).enqueue()
    }

    override fun getPendingJobs(): Flow<List<PendingJob>> =
        workManager.getWorkInfosByTagLiveData("worker_${SyncWorker::class.java.name}")
            .asFlow()
            .map { workInfos ->
                workInfos
                    .filter { !it.state.isFinished }
                    .mapNotNull { workInfo ->
                        Log.d("SyncManager", "WorkInfo: id=${workInfo.id}, state=${workInfo.state}, tags=${workInfo.tags}")

                        val operationType = workInfo.getSyncOperationType()
                        Log.d("SyncManager", "OperationType: $operationType")

                        operationType?.let {
                            PendingJob(
                                operationType = it,
                                state = workInfo.state.name,
                                bookmarkId = workInfo.getBookmarkId() ?: -1,
                                bookmarkTitle = workInfo.getBookmarkTitle() ?: "Unknown",
                            )
                        }
                    }
                    .also { jobs ->
                        Log.d("SyncManager", "Pending Jobs: ${jobs.size}")
                    }
            }
            .flowOn(Dispatchers.IO)


    override fun cancelAllSyncWorkers() {
        workManager.cancelAllWorkByTag(SyncWorker::class.java.name)
    }

    override suspend fun retryAllPendingJobs() {
        val allWorkInfos = withContext(Dispatchers.IO) {
            workManager.getWorkInfosByTag("worker_${SyncWorker::class.java.name}").get()
        }.filter { !it.state.isFinished }

        allWorkInfos.forEach { workInfo ->
            val operationType = workInfo.getSyncOperationType()
            val bookmarkId = workInfo.getBookmarkId()

            if (operationType != null && bookmarkId != null) {
                val bookmark = bookmarksDao.getBookmarkById(bookmarkId)?.toDomainModel()

                if (bookmark != null) {
                    scheduleSyncWork(operationType, bookmark)
                }
            }
        }
    }

    fun WorkInfo.getSyncOperationType(): SyncOperationType? {
        return tags
            .firstOrNull { it.startsWith("operationType_") }
            ?.substringAfter("operationType_")
            ?.let { SyncOperationType.fromString(it) }
            .also { Log.d("SyncManager", "Parsed SyncOperationType: $it") }
    }

    fun WorkInfo.getBookmarkId(): Int? {
        return tags
            .firstOrNull { it.startsWith("bookmarkId_") }
            ?.substringAfter("bookmarkId_")
            ?.toIntOrNull()
            .also { Log.d("SyncManager", "BookmarkId: $it") }
    }

    fun WorkInfo.getBookmarkTitle(): String? {
        return tags
            .firstOrNull { it.startsWith("bookmarkTitle_") }
            ?.substringAfter("bookmarkTitle_")
            ?.let { URLDecoder.decode(it, "UTF-8") }
            .also { Log.d("SyncManager", "BookmarkTitle: $it") }
    }
}


================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/SystemRepository.kt
================================================
package com.desarrollodroide.data.repository

import com.desarrollodroide.common.result.Result
import com.desarrollodroide.model.LivenessResponse
import kotlinx.coroutines.flow.Flow

interface SystemRepository {

    fun liveness(
      serverUrl: String
    ): Flow<Result<LivenessResponse?>>
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/SystemRepositoryImpl.kt
================================================
package com.desarrollodroide.data.repository

import com.desarrollodroide.common.result.ErrorHandler
import com.desarrollodroide.data.extensions.removeTrailingSlash
import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource
import com.desarrollodroide.data.mapper.toDomainModel
import com.desarrollodroide.model.LivenessResponse
import com.desarrollodroide.network.model.LivenessResponseDTO
import com.desarrollodroide.network.retrofit.NetworkNoCacheResource
import com.desarrollodroide.network.retrofit.RetrofitNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn

class SystemRepositoryImpl(
    private val apiService: RetrofitNetwork,
    private val settingsPreferenceDataSource: SettingsPreferenceDataSource,
    private val errorHandler: ErrorHandler
) : SystemRepository {
    override fun liveness(
        serverUrl: String,
    ) = object :
        NetworkNoCacheResource<LivenessResponseDTO, LivenessResponse?>(errorHandler = errorHandler) {

        override suspend fun fetchFromRemote() = apiService.systemLiveness(
            url = "${serverUrl.removeTrailingSlash()}/system/liveness"
        )

        override fun fetchResult(data: LivenessResponseDTO): Flow<LivenessResponse?> {
            return flow {
                data?.let {
                    emit(it.toDomainModel())
                }
            }
        }
    }.asFlow().flowOn(Dispatchers.IO)

}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/TagsRepository.kt
================================================
package com.desarrollodroide.data.repository

import kotlinx.coroutines.flow.Flow
import com.desarrollodroide.common.result.Result
import com.desarrollodroide.model.Tag

interface TagsRepository {

  fun getTags(
    token: String,
    serverUrl: String
  ): Flow<Result<List<Tag>?>>

  fun getLocalTags(): Flow<List<Tag>>
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/TagsRepositoryImpl.kt
================================================
package com.desarrollodroide.data.repository

import android.util.Log
import com.desarrollodroide.common.result.ErrorHandler
import com.desarrollodroide.data.extensions.removeTrailingSlash
import com.desarrollodroide.data.local.room.dao.TagDao
import com.desarrollodroide.data.mapper.*
import com.desarrollodroide.model.Tag
import com.desarrollodroide.network.model.TagsDTO
import com.desarrollodroide.network.retrofit.NetworkBoundResource
import com.desarrollodroide.network.retrofit.RetrofitNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach

class TagsRepositoryImpl(
    private val apiService: RetrofitNetwork,
    private val tagsDao: TagDao,
    private val errorHandler: ErrorHandler
) : TagsRepository {

    override fun getTags(
        token: String,
        serverUrl: String
    ) = object :
        NetworkBoundResource<TagsDTO, List<Tag>>(errorHandler = errorHandler) {

        override suspend fun saveRemoteData(response: TagsDTO) {
            response.message?.map { it.toEntityModel() }?.let { tagsList ->
                tagsDao.deleteAllTags()
                tagsDao.insertAllTags(tagsList)
            }
        }

        override fun fetchFromLocal(): Flow<List<Tag>> = tagsDao.getAllTags().map {
            it.map { it.toDomainModel() }
        }

        override suspend fun fetchFromRemote() = apiService.getTags(
            authorization = "Bearer $token",
            url = "${serverUrl.removeTrailingSlash()}/api/v1/tags"
        )

        override fun shouldFetch(data: List<Tag>?) = true

    }.asFlow().flowOn(Dispatchers.IO)

    override fun getLocalTags(): Flow<List<Tag>> {
        return tagsDao.observeAllTags()
            .onEach { entities ->
                Log.d("TagsRepository", "Tags updated in repository: ${entities.size}")
            }
            .map { entities ->
                entities.map { it.toDomainModel() }
            }
    }

}



================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/paging/BookmarkPagingSource.kt
================================================
package com.desarrollodroide.data.repository.paging

import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.desarrollodroide.data.extensions.removeTrailingSlash
import com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.mapper.toDomainModel
import com.desarrollodroide.data.mapper.toEntityModel
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.Tag
import com.desarrollodroide.network.retrofit.RetrofitNetwork
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import retrofit2.HttpException
import java.io.IOException

class BookmarkPagingSource(
    private val remoteDataSource: RetrofitNetwork,
    private val bookmarksDao: BookmarksDao,
    private val serverUrl: String,
    private val xSessionId: String,
    private val searchText: String,
    private val tags: List<Tag>,
    private val saveToLocal: Boolean,
) : PagingSource<Int, Bookmark>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Bookmark> {
        return try {
            val page = params.key ?: 1
            val pageSize = params.loadSize // Not needed
            val searchKeywordsParams = if (searchText.isNotEmpty())"&keyword=$searchText" else ""
            val searchTagsParams = if (tags.isNotEmpty())"&tags=${tags.joinToString(",") { it.name }}" else ""
            val bookmarksDto = remoteDataSource.getPagingBookmarks(
                xSessionId = xSessionId,
                url = "${serverUrl.removeTrailingSlash()}/api/bookmarks?page=$page$searchKeywordsParams$searchTagsParams",
            )
            if (bookmarksDto.errorBody()?.string() == SESSION_HAS_BEEN_EXPIRED) {
                return LoadResult.Error(Exception(SESSION_HAS_BEEN_EXPIRED))
            }
            if (saveToLocal){
                bookmarksDto.body()?.resolvedBookmarks()?.map { it.toEntityModel() }?.let { bookmarksList ->
                    if (page == 1) {
                        bookmarksDao.deleteAll()
                    }
                    bookmarksDao.insertAll(bookmarksList)
                }
            }
            val bookmarks = bookmarksDto.body()?.resolvedBookmarks()?.map { it.toDomainModel() }?: emptyList()
            LoadResult.Page(
                data = bookmarks,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if ((bookmarksDto.body()?.resolvedPage() ?: 0) >= (bookmarksDto.body()?.resolvedMaxPage() ?: 0)) null else page + 1
            )
        } catch (exception: IOException) {
            Log.e("BookmarkPagingSource", "IOException", exception)
            return loadFromLocalWhenError()
        } catch (exception: HttpException) {
            Log.e("BookmarkPagingSource", "HttpException", exception)
            return loadFromLocalWhenError()
        }
    }

    private suspend fun loadFromLocalWhenError(): LoadResult.Page<Int, Bookmark> {
        val bookmarks = bookmarksDao.getAll().map { bookmarks ->
            bookmarks.map { it.toDomainModel() }
        }.first().reversed()
        return LoadResult.Page(
            data = bookmarks,
            prevKey = null,
            nextKey = null
        )
    }

    override fun getRefreshKey(state: PagingState<Int, Bookmark>): Int? {
        return state.anchorPosition
    }

}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/paging/BookmarksRemoteMediator.kt
================================================
package com.desarrollodroide.data.repository.paging

import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.desarrollodroide.data.extensions.removeTrailingSlash
import com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.mapper.toDomainModel
import com.desarrollodroide.data.mapper.toEntityModel
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.Tag
import com.desarrollodroide.network.retrofit.RetrofitNetwork
import kotlinx.coroutines.flow.first

@OptIn(ExperimentalPagingApi::class)
class BookmarksRemoteMediator(
    private val apiService: RetrofitNetwork,
    private val bookmarksDao: BookmarksDao,
    private val serverUrl: String,
    private val xSessionId: String,
    private val searchText: String,
    private val tags: List<Tag>
) : RemoteMediator<Int, Bookmark>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Bookmark>
    ): MediatorResult {
        return try {
            val page = when (loadType) {
                LoadType.REFRESH -> 1
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull()
                    if (lastItem == null) {
                        1
                    } else {
                        (lastItem.id / state.config.pageSize) + 1
                    }
                }
            }

            val response = apiService.getPagingBookmarks(
                xSessionId = xSessionId,
                url = "${serverUrl.removeTrailingSlash()}/api/bookmarks?page=$page&keyword=$searchText&tags=${tags.joinToString(",") { it.name }}",
            )

            if (response.isSuccessful) {
                val bookmarksDto = response.body()
                val bookmarks = bookmarksDto?.bookmarks?.map { it.toEntityModel() } ?: emptyList()

                if (loadType == LoadType.REFRESH) {
                    bookmarksDao.deleteAll()
                }
                bookmarksDao.insertAll(bookmarks)

                val endOfPaginationReached = (bookmarksDto?.page ?: 0) >= (bookmarksDto?.maxPage ?: 0)

                MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
            } else {
                if (response.errorBody()?.string() == SESSION_HAS_BEEN_EXPIRED) {
                    MediatorResult.Error(Exception(SESSION_HAS_BEEN_EXPIRED))
                } else {
                    MediatorResult.Error(Exception("Error loading data"))
                }
            }
        } catch (e: Exception) {
            // If there's a network error, we load data from local database
            val localBookmarks = loadFromLocalWhenError()
            MediatorResult.Success(endOfPaginationReached = true)
        }
    }

    private suspend fun loadFromLocalWhenError(): List<Bookmark> {
        return bookmarksDao.getAll()
            .first()
            .map { it.toDomainModel() }
            .reversed()
    }
}

================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/paging/LocalBookmarkPagingSource.kt
================================================
package com.desarrollodroide.data.repository.paging

import android.annotation.SuppressLint
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.mapper.toDomainModel
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.Tag

//@SuppressLint("LongLogTag")
//class LocalBookmarkPagingSource(
//    private val bookmarksDao: BookmarksDao,
//    private val searchText: String,
//    private val tags: List<Tag>
//) : PagingSource<Int, Bookmark>() {
//
//    companion object {
//        private const val TAG = "LocalBookmarkPagingSource"
//        private const val STARTING_PAGE_INDEX = 0
//        private const val PAGE_SIZE = 30
//    }
//
//    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Bookmark> {
//        return try {
//            val page = params.key ?: STARTING_PAGE_INDEX
//            val offset = page * PAGE_SIZE
//
//            Log.d(TAG, "Loading page: $page, pageSize: $PAGE_SIZE, offset: $offset")
//            Log.d(TAG, "Search text: '$searchText', Tags: ${tags.map { it.name }.joinToString()}")
//
//            val bookmarks = bookmarksDao.getPagingBookmarks(
//                searchText = searchText,
//                tags = tags.map { it.name },
//                tagsListSize = tags.size,
//                limit = PAGE_SIZE,
//                offset = offset
//            )
//
//            Log.d(TAG, "Loaded ${bookmarks.size} bookmarks")
//
//            val totalCount = bookmarksDao.getPagingBookmarksCount(
//                searchText = searchText,
//                tags = tags.map { it.name },
//                tagsListSize = tags.size
//            )
//
//            Log.d(TAG, "Total count of bookmarks matching criteria: $totalCount")
//
//            val nextKey = if (offset + bookmarks.size < totalCount) page + 1 else null
//            val prevKey = if (page > 0) page - 1 else null
//
//            Log.d(TAG, "Next key: $nextKey, Previous key: $prevKey")
//
//            LoadResult.Page(
//                data = bookmarks.map { it.toDomainModel() }.also {
//                    Log.d(TAG, "Mapped ${it.size} bookmarks to domain model")
//                },
//                prevKey = prevKey,
//                nextKey = nextKey
//            )
//        } catch (e: Exception) {
//            Log.e(TAG, "Error loading bookmarks", e)
//            LoadResult.Error(e)
//        }
//    }
//
//    override fun getRefreshKey(state: PagingState<Int, Bookmark>): Int? {
//        return state.anchorPosition?.let { anchorPosition ->
//            val anchorPage = state.closestPageToPosition(anchorPosition)
//            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
//        }.also { Log.d(TAG, "Refresh key: $it") }
//    }
//}


================================================
FILE: data/src/main/java/com/desarrollodroide/data/repository/workers/SyncWorker.kt
================================================
package com.desarrollodroide.data.repository.workers

import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.desarrollodroide.data.extensions.isTimestampId
import com.desarrollodroide.data.extensions.toBean
import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.mapper.toDomainModel
import com.desarrollodroide.data.mapper.toEntityModel
import com.desarrollodroide.data.repository.AuthRepository
import com.desarrollodroide.data.repository.BookmarksRepository
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.SyncOperationType
import kotlinx.coroutines.flow.first
import org.koin.core.component.inject
import org.koin.core.component.KoinComponent
import com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED
import com.desarrollodroide.model.UpdateCachePayload
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

class BookmarkNotFoundException(bookmarkId: Int) :
    Exception("Bookmark not found for ID: $bookmarkId")

class SyncWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params), KoinComponent {

    private val bookmarksRepository: BookmarksRepository by inject()
    private val bookmarksDao: BookmarksDao by inject()
    private val settingsPreferenceDataSource: SettingsPreferenceDataSource by inject()
    private val authRepository: AuthRepository by inject()

    override suspend fun doWork(): Result {
        val operationType = inputData.getString("operationType")?.let { SyncOperationType.valueOf(it) }
        val bookmarkId = inputData.getInt("bookmarkId", -1)
        val updateCachePayload = inputData.getString("updateCachePayload")?.toBean<UpdateCachePayload>()

        Log.v("SyncWorker", "Performing sync operation: $operationType")
        Log.v("SyncWorker", "BookmarkId: $bookmarkId")
        Log.v("SyncWorker", "UpdateCachePayload: $updateCachePayload")

        if (operationType == null || bookmarkId == -1) {
            return Result.failure()
        }

        return try {
            val xSession = settingsPreferenceDataSource.getSession()
            val serverUrl = settingsPreferenceDataSource.getUrl()
            val token = settingsPreferenceDataSource.getToken()

            try {
                performSyncOperation(
                    xSession = xSession,
                    serverUrl = serverUrl,
                    operationType = operationType,
                    bookmarkId = bookmarkId,
                    updateCachePayload = updateCachePayload,
                    token = token
                )
                Log.v("SyncWorker", "Sync completed successfully")
                Result.success()
            } catch (e: Exception) {
                if (isSessionExpiredException(e)) {
                    val sessionRefreshed = refreshSession()
                    if (sessionRefreshed) {
                        try {
                            val newSession = settingsPreferenceDataSource.getSession()
                            performSyncOperation(
                                xSession = newSession,
                                serverUrl = serverUrl,
                                operationType = operationType,
                                bookmarkId = bookmarkId,
                                updateCachePayload = updateCachePayload,
                                token = token
                            )
                            Log.v("SyncWorker", "Sync completed successfully after session refresh")
                            Result.success()
                        } catch (retryException: Exception) {
                            Log.e("SyncWorker", "Error after session refresh: ${retryException.message}")
                            Result.retry()
                        }
                    } else {
                        Log.e("SyncWorker", "Failed to refresh session")
                        Result.retry()
                    }
                } else if (e is BookmarkNotFoundException) {
                    Log.w("SyncWorker", "Bookmark not found, marking as success to avoid retry loop: ${e.message}")
                    Result.success()
                } else {
                    Log.e("SyncWorker", "Error during sync: ${e.message}", e)
                    Result.retry()
                }
            }
        } catch (e: Exception) {
            Log.e("SyncWorker", "Unexpected error: ${e.message}")
            Result.retry()
        }
    }


    private suspend fun refreshSession(): Boolean {
        val serverUrl = settingsPreferenceDataSource.getUrl()
        val rememberedUser = settingsPreferenceDataSource.getUser().first()

        if (rememberedUser.account.userName.isEmpty() || rememberedUser.account.password.isEmpty()) {
            return false
        }

        return authRepository.sendLoginV1(
            username = rememberedUser.account.userName,
            password = rememberedUser.account.password,
            serverUrl = serverUrl
        )
            .filterNot { it is com.desarrollodroide.common.result.Result.Loading }
            .firstOrNull()?.let { result ->
                when (result) {
                    is com.desarrollodroide.common.result.Result.Success -> true
                    else -> false
                }
            } ?: false
    }

    private suspend fun performSyncOperation(
        xSession: String,
        serverUrl: String,
        operationType: SyncOperationType,
        bookmarkId: Int,
        updateCachePayload: UpdateCachePayload?,
        token: String
    ) {
        when (operationType) {
            SyncOperationType.CREATE -> {
                val updatedBookmark = syncCreateBookmark(xSession, serverUrl, bookmarkId)
                bookmarksDao.deleteBookmarkById(bookmarkId)
                bookmarksDao.insertBookmark(updatedBookmark.toEntityModel(
                    modified = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
                ))
                val outputData = workDataOf(
                    "syncResult" to "SUCCESS",
                    "originalBookmarkId" to bookmarkId,
                    "newBookmarkId" to updatedBookmark.id
                )
                Result.success(outputData)
            }
            SyncOperationType.UPDATE -> {
                if (bookmarkId.isTimestampId()) {
                    Result.success()
                } else {
                    syncUpdateBookmark(xSession, serverUrl, bookmarkId)
                }
            }
            SyncOperationType.DELETE -> syncDeleteBookmark(xSession, serverUrl, bookmarkId)
            SyncOperationType.CACHE -> syncCacheBookmark(token, serverUrl, bookmarkId, updateCachePayload)

        }
    }

    private fun isSessionExpiredException(e: Exception): Boolean {
        return e.message?.contains(SESSION_HAS_BEEN_EXPIRED) == true
    }

    private suspend fun syncCreateBookmark(xSession: String, serverUrl: String, bookmarkId: Int): Bookmark {
        val bookmark = bookmarksDao.getBookmarkById(bookmarkId)?.toDomainModel()
            ?: throw BookmarkNotFoundException(bookmarkId)
        return bookmarksRepository.addBookmark(xSession, serverUrl, bookmark)
    }

    private suspend fun syncUpdateBookmark(xSession: String, serverUrl: String, bookmarkId: Int) {
        val bookmark = bookmarksDao.getBookmarkById(bookmarkId)?.toDomainModel()
            ?: throw BookmarkNotFoundException(bookmarkId)
        bookmarksRepository.editBookmark(xSession, serverUrl, bookmark)
    }

    private suspend fun syncDeleteBookmark(xSession: String, serverUrl: String, bookmarkId: Int) {
        bookmarksRepository.deleteBookmark(xSession, serverUrl, bookmarkId)
    }
    private suspend fun syncCacheBookmark(token: String, serverUrl: String, bookmarkId: Int, updateCachePayload: UpdateCachePayload?) {
        if (updateCachePayload == null) {
            Log.e("SyncWorker", "UpdateCachePayload is null for CACHE operation")
            throw IllegalStateException("UpdateCachePayload is required for CACHE operation")
        }
        val bookmark = bookmarksDao.getBookmarkById(bookmarkId)?.toDomainModel()
        bookmarksRepository.updateBookmarkCacheV1(token, serverUrl, updateCachePayload, bookmark)
    }

    class Factory : WorkerFactory(), KoinComponent {
        override fun createWorker(
            appContext: Context,
            workerClassName: String,
            workerParameters: WorkerParameters
        ): ListenableWorker? {
            return when (workerClassName) {
                SyncWorker::class.java.name -> SyncWorker(appContext, workerParameters)
                else -> null
            }
        }
    }
}


================================================
FILE: data/src/main/java/com/desarrollodroide/data/util/SyncUtilities.kt
================================================
package com.desarrollodroide.data.util

import android.util.Log
import com.desarrollodroide.data.local.datastore.ChangeListVersions
import com.desarrollodroide.network.model.util.NetworkChangeList
import kotlin.coroutines.cancellation.CancellationException

/**
 * Interface marker for a class that manages synchronization between local data and a remote
 * source for a [Syncable].
 */
interface Synchronizer {
    suspend fun getChangeListVersions(): ChangeListVersions

    suspend fun updateChangeListVersions(update: ChangeListVersions.() -> ChangeListVersions)

    /**
     * Syntactic sugar to call [Syncable.syncWith] while omitting the synchronizer argument
     */
    suspend fun Syncable.sync() = this@sync.syncWith(this@Synchronizer)
}

/**
 * Interface marker for a class that is synchronized with a remote source. Syncing must not be
 * performed concurrently and it is the [Synchronizer]'s responsibility to ensure this.
 */
interface Syncable {
    /**
     * Synchronizes the local database backing the repository with the network.
     * Returns if the sync was successful or not.
     */
    suspend fun syncWith(synchronizer: Synchronizer): Boolean
}

/**
 * Attempts [block], returning a successful [Result] if it succeeds, otherwise a [Result.Failure]
 * taking care not to break structured concurrency
 */
private suspend fun <T> suspendRunCatching(block: suspend () -> T): Result<T> = try {
    Result.success(block())
} catch (cancellationException: CancellationException) {
    throw cancellationException
} catch (exception: Exception) {
    Log.i(
        "suspendRunCatching",
        "Failed to evaluate a suspendRunCatchingBlock. Returning failure Result",
        exception,
    )
    Result.failure(exception)
}

/**
 * Utility function for syncing a repository with the network.
 * [versionReader] Reads the current version of the model that needs to be synced
 * [changeListFetcher] Fetches the change list for the model
 * [versionUpdater] Updates the [ChangeListVersions] after a successful sync
 * [modelDeleter] Deletes models by consuming the ids of the models that have been deleted.
 * [modelUpdater] Updates models by consuming the ids of the models that have changed.
 *
 * Note that the blocks defined above are never run concurrently, and the [Synchronizer]
 * implementation must guarantee this.
 */
suspend fun Synchronizer.changeListSync(
    versionReader: (ChangeListVersions) -> Int,
    changeListFetcher: suspend (Int) -> List<NetworkChangeList>,
    versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions,
    modelDeleter: suspend (List<String>) -> Unit,
    modelUpdater: suspend (List<String>) -> Unit,
) = suspendRunCatching {
    // Fetch the change list since last sync (akin to a git fetch)
    val currentVersion = versionReader(getChangeListVersions())
    val changeList = changeListFetcher(currentVersion)
    if (changeList.isEmpty()) return@suspendRunCatching true

    val (deleted, updated) = changeList.partition(NetworkChangeList::isDelete)

    // Delete models that have been deleted server-side
    modelDeleter(deleted.map(NetworkChangeList::id))

    // Using the change list, pull down and save the changes (akin to a git pull)
    modelUpdater(updated.map(NetworkChangeList::id))

    // Update the last synced version (akin to updating local git HEAD)
    val latestVersion = changeList.last().changeListVersion
    updateChangeListVersions {
        versionUpdater(latestVersion)
    }
}.isSuccess


================================================
FILE: data/src/main/proto/prefs.proto
================================================
syntax = "proto3";

option java_package = "com.desarrollodroide.data";
option java_multiple_files = true;

message UserPreferences {
  reserved 8; // number previously used for isLegacyApi

  uint32 id = 1;
  string username = 2;
  bool owner = 3;
  string password = 4;
  string session = 5;
  string url = 6;
  bool rememberPassword = 7;
  string token = 9;

}

message RememberUserPreferences {

  uint32 id = 1;
  string username = 2;
  string password = 3;
  string url = 4;

}

message SystemPreferences {

  bool makeArchivePublic = 1;
  bool createEbook = 2;
  bool createArchive = 3;
  bool autoAddBookmark = 4;
  bool compactView = 5;
  repeated string selectedCategories = 6;
  int64 lastSyncTimestamp = 7;
  string serverVersion = 8;
  string lastCrashLog = 9;

}


message HideTag {

  int32 id = 1;
  string name = 2;

}

================================================
FILE: data/src/test/java/com/desarrollodroide/data/extensions/IntExtensionsTest.kt
================================================
package com.desarrollodroide.data.extensions

import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

class IntExtensionsTest {

    @Test
    fun `isTimestampId returns true for timestamp-based ids`() {
        // Given - a value generated from System.currentTimeMillis() / 1000
        val timestampId = 1739000000

        // When
        val result = timestampId.isTimestampId()

        // Then
        assertTrue(result)
    }

    @Test
    fun `isTimestampId returns true for values just above threshold`() {
        // Given
        val id = 1_000_001

        // When
        val result = id.isTimestampId()

        // Then
        assertTrue(result)
    }

    @Test
    fun `isTimestampId returns false for regular server ids`() {
        // Given
        val regularId = 1

        // When
        val result = regularId.isTimestampId()

        // Then
        assertFalse(result)
    }

    @Test
    fun `isTimestampId returns false for threshold value`() {
        // Given
        val thresholdId = 1_000_000

        // When
        val result = thresholdId.isTimestampId()

        // Then
        assertFalse(result)
    }

    @Test
    fun `isTimestampId returns false for large server ids`() {
        // Given - even a server with many bookmarks
        val largeServerId = 999_999

        // When
        val result = largeServerId.isTimestampId()

        // Then
        assertFalse(result)
    }
}

================================================
FILE: data/src/test/java/com/desarrollodroide/data/extensions/StringExtensionsKtTest.kt
================================================
package com.desarrollodroide.data.extensions

import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
class StringExtensionsTest {

    @Test
    fun `removeTrailingSlash removes trailing slash if present`() {
        // Given a string with a trailing slash
        val stringWithSlash = "https://www.example.com/"

        // When removeTrailingSlash is called
        val result = stringWithSlash.removeTrailingSlash()

        // Then the result should not have a trailing slash
        assertEquals("https://www.example.com", result)
    }

    @Test
    fun `removeTrailingSlash does nothing if no trailing slash present`() {
        // Given a string without a trailing slash
        val stringWithoutSlash = "https://www.example.com"

        // When removeTrailingSlash is called
        val result = stringWithoutSlash.removeTrailingSlash()

        // Then the result should be the same as the input
        assertEquals(stringWithoutSlash, result)
    }

    @Test
    fun `removeTrailingSlash works with empty string`() {
        // Given an empty string
        val emptyString = ""

        // When removeTrailingSlash is called
        val result = emptyString.removeTrailingSlash()

        // Then the result should still be an empty string
        assertEquals("", result)
    }

    @Test
    fun `removeTrailingSlash does nothing to string without any slash`() {
        // Given a string without any slashes
        val stringWithoutAnySlash = "www.example.com"

        // When removeTrailingSlash is called
        val result = stringWithoutAnySlash.removeTrailingSlash()

        // Then the result should be the same as the input
        assertEquals(stringWithoutAnySlash, result)
    }

}


================================================
FILE: data/src/test/java/com/desarrollodroide/data/helpers/TagTypeAdapterTest.kt
================================================
package com.desarrollodroide.data.helpers

import com.desarrollodroide.model.Tag
import com.google.gson.GsonBuilder
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class TagTypeAdapterTest {

    @Test
    fun `secondary constructor initializes properties correctly`() {
        // Given a tag name
        val tagName = "exampleTag"

        // When creating a Tag using the secondary constructor
        val tag = Tag(id = 1, tagName)

        // Then the properties should be set to default values except for the name
        assertEquals(1, tag.id)
        assertEquals(tagName, tag.name)
        assertEquals(false, tag.selected)
        assertEquals(0, tag.nBookmarks)
    }

    @Test
    fun `TagTypeAdapter serializes Tag correctly with all fields`() {
        // Given a Tag object with all fields initialized
        val tag = Tag(1, "exampleTag", true, 5)

        // And a Gson instance with TagTypeAdapter registered
        val gson = GsonBuilder()
            .registerTypeAdapter(Tag::class.java, TagTypeAdapter())
            .create()

        // When serializing the Tag object
        val json = gson.toJson(tag, Tag::class.java)

        // Then the resulting JSON should contain all the necessary properties
        val expectedJson = "{\"name\":\"exampleTag\"}" // Note: Only 'name' is expected as per TagTypeAdapter
        assertEquals(expectedJson, json)
    }

}

================================================
FILE: data/src/test/java/com/desarrollodroide/data/local/datastore/HideTagSerializerTest.kt
================================================
package com.desarrollodroide.data.local.datastore

import androidx.datastore.core.CorruptionException
import com.desarrollodroide.data.HideTag
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class HideTagSerializerTest {

    private val testHideTag = HideTag.newBuilder()
        .setId(1)
        .setName("TestTag")
        .build()

    @Test
    fun `test writeTo serializes object correctly`() = runBlocking {
        val testOutputStream = ByteArrayOutputStream()
        HideTagSerializer.writeTo(testHideTag, testOutputStream)
        val serializedData = testOutputStream.toByteArray()
        assertTrue(serializedData.isNotEmpty())
    }

    @Test
    fun `test readFrom deserializes object correctly`() = runBlocking {
        val testOutputStream = ByteArrayOutputStream()
        HideTagSerializer.writeTo(testHideTag, testOutputStream)
        val serializedData = testOutputStream.toByteArray()
        val testInputStream = ByteArrayInputStream(serializedData)
        val deserializedObject = HideTagSerializer.readFrom(testInputStream)
        assertEquals(testHideTag.id, deserializedObject.id)
        assertEquals(testHideTag.name, deserializedObject.name)
    }

    @Test
    fun `test readFrom throws CorruptionException on corrupted data`(): Unit = runBlocking {
        val corruptedData = "corruptedData".toByteArray()
        val testInputStream = ByteArrayInputStream(corruptedData)
        assertThrows<CorruptionException> {
            HideTagSerializer.readFrom(testInputStream)
        }
    }
}

================================================
FILE: data/src/test/java/com/desarrollodroide/data/local/datastore/RememberUserPreferencesSerializerTest.kt
================================================
package com.desarrollodroide.data.local.datastore

import androidx.datastore.core.CorruptionException
import com.desarrollodroide.data.RememberUserPreferences
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream

class RememberUserPreferencesSerializerTest {

    private val testRememberUserPreferences = RememberUserPreferences.newBuilder()
        .setId(1)
        .setUsername("userTest")
        .setPassword("passTest")
        .setUrl("https://example.com")
        .build()

    @Test
    fun `test writeTo serializes object correctly`() = runBlocking {
        val testOutputStream = ByteArrayOutputStream()
        RememberUserPreferencesSerializer.writeTo(testRememberUserPreferences, testOutputStream)
        val serializedData = testOutputStream.toByteArray()
        assertTrue(serializedData.isNotEmpty())
    }

    @Test
    fun `test readFrom deserializes object correctly`() = runBlocking {
        val testOutputStream = ByteArrayOutputStream()
        RememberUserPreferencesSerializer.writeTo(testRememberUserPreferences, testOutputStream)
        val serializedData = testOutputStream.toByteArray()
        val testInputStream = ByteArrayInputStream(serializedData)
        val deserializedObject = RememberUserPreferencesSerializer.readFrom(testInputStream)
        assertEquals(testRememberUserPreferences.id, deserializedObject.id)
        assertEquals(testRememberUserPreferences.username, deserializedObject.username)
        assertEquals(testRememberUserPreferences.password, deserializedObject.password)
        assertEquals(testRememberUserPreferences.url, deserializedObject.url)
    }


    @Test
    fun `test readFrom throws CorruptionException on corrupted data`(): Unit = runBlocking {
        val corruptedData = "corruptedData".toByteArray()
        val testInputStream = ByteArrayInputStream(corruptedData)
        assertThrows<CorruptionException> {
            RememberUserPreferencesSerializer.readFrom(testInputStream)
        }
    }
}


================================================
FILE: data/src/test/java/com/desarrollodroide/data/local/datastore/UserPreferencesSerializerTest.kt
================================================
package com.desarrollodroide.data.local.datastore

import androidx.datastore.core.CorruptionException
import com.desarrollodroide.data.UserPreferences
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream

class UserPreferencesSerializerTest {

    private val testUserPreferences = UserPreferences.newBuilder()
        .setId(1)
        .setUsername("testUser")
        .setPassword("testPass")
        .setOwner(true)
        .setSession("testSession")
        .setUrl("https://test.url")
        .setRememberPassword(true)
        .setToken("testToken")
        .build()

    @Test
    fun `writeTo serializes UserPreferences correctly`() = runBlocking {
        val outputStream = ByteArrayOutputStream()
        UserPreferencesSerializer.writeTo(testUserPreferences, outputStream)
        val serializedData = outputStream.toByteArray()
        assertTrue(serializedData.isNotEmpty(), "Serialized data should not be empty")
    }

    @Test
    fun `readFrom deserializes UserPreferences correctly`() = runBlocking {
        val outputStream = ByteArrayOutputStream()
        UserPreferencesSerializer.writeTo(testUserPreferences, outputStream)
        val serializedData = outputStream.toByteArray()
        val inputStream = ByteArrayInputStream(serializedData)
        val deserializedPreferences = UserPreferencesSerializer.readFrom(inputStream)
        assertEquals(testUserPreferences, deserializedPreferences, "Deserialized object should match the original")
    }

    @Test
    fun `readFrom throws CorruptionException on corrupted data`(): Unit = runBlocking {
        val corruptedData = "corruptedData".toByteArray()
        val inputStream = ByteArrayInputStream(corruptedData)
        assertThrows<CorruptionException> {
            UserPreferencesSerializer.readFrom(inputStream)
        }
    }
}


================================================
FILE: data/src/test/java/com/desarrollodroide/data/local/preferences/SettingsPreferencesDataSourceTest.kt
================================================
package com.desarrollodroide.data.local.preferences

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.mutablePreferencesOf
import androidx.datastore.preferences.core.preferencesOf
import com.desarrollodroide.data.UserPreferences
import com.desarrollodroide.data.helpers.ThemeMode
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.mockito.kotlin.*
import androidx.datastore.preferences.core.stringPreferencesKey
import com.desarrollodroide.data.HideTag
import com.desarrollodroide.data.RememberUserPreferences
import com.desarrollodroide.data.SystemPreferences
import kotlinx.coroutines.flow.first
import app.cash.turbine.test
import com.desarrollodroide.model.Tag
import kotlinx.coroutines.flow.Flow
import org.mockito.Mockito.`when`
import java.time.ZoneId
import java.time.ZonedDateTime

@ExperimentalCoroutinesApi
class SettingsPreferencesDataSourceImplTest {

    private lateinit var settingsPreferencesDataSourceImpl: SettingsPreferencesDataSourceImpl
    private var preferencesDataStore: DataStore<Preferences> = mock()
    private val protoDataStoreMock: DataStore<UserPreferences> = mock()
    private val systemPreferencesDataStoreMock: DataStore<SystemPreferences> = mock()
    private val hideTagDataStoreMock: DataStore<HideTag> = mock()
    private val rememberUserProtoDataStoreMock: DataStore<RememberUserPreferences> = mock()

    private val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
    private val CATEGORIES_VISIBLE_KEY = booleanPreferencesKey("categories_visible")
    private val USE_DYNAMIC_COLORS = booleanPreferencesKey("use_dynamic_colors")

    @BeforeEach
    fun setUp() {
        settingsPreferencesDataSourceImpl = SettingsPreferencesDataSourceImpl(
            dataStore = preferencesDataStore,
            protoDataStore = protoDataStoreMock,
            systemPreferences = systemPreferencesDataStoreMock,
            rememberUserProtoDataStore = rememberUserProtoDataStoreMock,
            hideTagDataStore = hideTagDataStoreMock
        )
    }

    // --- Dynamic Colors Tests ---

    @Test
    fun `getUseDynamicColors returns expected value when set`() = runTest {
        val expectedValue = true
        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf(USE_DYNAMIC_COLORS to expectedValue)))

        val actualValue = settingsPreferencesDataSourceImpl.getUseDynamicColors()

        assertEquals(expectedValue, actualValue)
    }

    @Test
    fun `getUseDynamicColors returns false by default when not set`() = runTest {
        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf()))

        val actualValue = settingsPreferencesDataSourceImpl.getUseDynamicColors()

        assertFalse(actualValue)
    }

    @Test
    fun `setUseDynamicColors updates preference correctly`() = runTest {
        val newValue = true

        settingsPreferencesDataSourceImpl.setUseDynamicColors(newValue)

        verifyPreferenceEdit(preferencesDataStore, USE_DYNAMIC_COLORS, newValue)
    }

    @Test
    fun `setUseDynamicColors can disable dynamic colors`() = runTest {
        val newValue = false

        settingsPreferencesDataSourceImpl.setUseDynamicColors(newValue)

        verifyPreferenceEdit(preferencesDataStore, USE_DYNAMIC_COLORS, newValue)
    }

    // --- Theme Mode Tests ---

    @Test
    fun `getThemeMode returns expected value`() = runTest {
        val expectedThemeMode = ThemeMode.LIGHT
        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf(THEME_MODE_KEY to expectedThemeMode.name)))
        val actualThemeMode = settingsPreferencesDataSourceImpl.getThemeMode()
        assertEquals(expectedThemeMode, actualThemeMode)
    }

    @Test
    fun `setThemeMode updates theme mode to DARK`() = runTest {
        val themeMode = ThemeMode.DARK
        settingsPreferencesDataSourceImpl.setTheme(themeMode)
        verifyPreferenceEdit(preferencesDataStore, THEME_MODE_KEY, themeMode.name)
    }

    @Test
    fun `getThemeMode retrieves persisted theme mode correctly after app restart`() = runTest {
        val expectedThemeMode = ThemeMode.DARK
        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf(THEME_MODE_KEY to expectedThemeMode.name)))
        val actualThemeMode = settingsPreferencesDataSourceImpl.getThemeMode()
        assertEquals(expectedThemeMode, actualThemeMode)
    }

    @Test
    fun `getThemeMode returns default theme mode when none is set`() = runTest {
        val defaultThemeMode = ThemeMode.AUTO
        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf()))
        val actualThemeMode = settingsPreferencesDataSourceImpl.getThemeMode()
        assertEquals(defaultThemeMode, actualThemeMode)
    }

    // --- Categories Visibility Tests ---

    @Test
    fun `getCategoriesVisible returns expected value`() = runTest {
        val expectedValue = true
        whenever(preferencesDataStore.data).thenReturn(flowOf(preferencesOf(CATEGORIES_VISIBLE_KEY to expectedValue)))
        val actualValue = settingsPreferencesDataSourceImpl.getCategoriesVisible()
        assertEquals(expectedValue, actualValue)
    }

    @Test
    fun `setCategoriesVisible updates categories visible to false`() = runTest {
        val categoriesVisible = false
        settingsPreferencesDataSourceImpl.setCategoriesVisible(categoriesVisible)
        verifyPreferenceEdit(preferencesDataStore, CATEGORIES_VISIBLE_KEY, categoriesVisible)
    }

    // --- Selected Categories Tests ---

    @Test
    fun `setSelectedCategories updates selected categories correctly`() = runTest {
        val selectedCategories = listOf("1", "2", "3")
        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()

        settingsPreferencesDataSourceImpl.setSelectedCategories(selectedCategories)

        verify(systemPreferencesDataStoreMock).updateData(captor.capture())
        val testPreferences = SystemPreferences.getDefaultInstance()
        val updatedPreferences = captor.firstValue.invoke(testPreferences)
        assertEquals(selectedCategories, updatedPreferences.selectedCategoriesList)
    }

    @Test
    fun `addSelectedCategory adds category correctly`() = runTest {
        val newTag = Tag(id = 4, name = "New Category", selected = false, nBookmarks = 0)
        val existingCategories = listOf("1", "2", "3")
        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()

        val initialPreferences = SystemPreferences.newBuilder()
            .addAllSelectedCategories(existingCategories)
            .build()

        settingsPreferencesDataSourceImpl.addSelectedCategory(newTag)

        verify(systemPreferencesDataStoreMock).updateData(captor.capture())
        val updatedPreferences = captor.firstValue.invoke(initialPreferences)
        assertEquals(existingCategories + "4", updatedPreferences.selectedCategoriesList)
    }

    @Test
    fun `removeSelectedCategory removes category correctly`() = runTest {
        val tagToRemove = Tag(id = 2, name = "Category to Remove", selected = false, nBookmarks = 0)
        val existingCategories = listOf("1", "2", "3")
        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()

        val initialPreferences = SystemPreferences.newBuilder()
            .addAllSelectedCategories(existingCategories)
            .build()

        settingsPreferencesDataSourceImpl.removeSelectedCategory(tagToRemove)

        verify(systemPreferencesDataStoreMock).updateData(captor.capture())
        val updatedPreferences = captor.firstValue.invoke(initialPreferences)
        assertEquals(listOf("1", "3"), updatedPreferences.selectedCategoriesList)
    }

    // --- UserPreferences Tests ---

    @Test
    fun `getUser returns User with correct data`() = runTest {
        val expectedUser = UserPreferences.newBuilder()
            .setId(1)
            .setUsername("testUser")
            .setSession("session123")
            .setToken("tokenABC")
            .build()
        whenever(protoDataStoreMock.data).thenReturn(flowOf(expectedUser))
        val actualUser = settingsPreferencesDataSourceImpl.getUser().first()
        assertEquals(expectedUser.username, actualUser.account.userName)
        assertEquals(expectedUser.session, actualUser.session)
        assertEquals(expectedUser.token, actualUser.token)
    }

    @Test
    fun `saveUser updates UserPreferences correctly`() = runTest {
        val userPreferences = UserPreferences.newBuilder().setId(1).build()
        val serverUrl = "https://example.com"
        val password = "password123"
        settingsPreferencesDataSourceImpl.saveUser(userPreferences, serverUrl, password)
        verify(protoDataStoreMock).updateData(any())
    }

    @Test
    fun `resetUser resets user data correctly`() = runTest {
        settingsPreferencesDataSourceImpl.resetData()
        verify(protoDataStoreMock).updateData(any())
    }

    // --- RememberUserPreferences Tests ---

    @Test
    fun `resetRememberUser resets remembered user data correctly`() = runTest {
        settingsPreferencesDataSourceImpl.resetRememberUser()
        verify(rememberUserProtoDataStoreMock).updateData(any())
    }

    @Test
    fun `getRememberUser returns Account with correct data`() = runTest {
        val expectedAccount = RememberUserPreferences.newBuilder()
            .setId(1)
            .setUsername("rememberUser")
            .setPassword("password123")
            .setUrl("https://example-remember.com")
            .build()
        whenever(rememberUserProtoDataStoreMock.data).thenReturn(flowOf(expectedAccount))
        val actualAccount = settingsPreferencesDataSourceImpl.getRememberUser().first()
        assertEquals(expectedAccount.username, actualAccount.userName)
        assertEquals(expectedAccount.url, actualAccount.serverUrl)
        assertEquals(expectedAccount.password, actualAccount.password)
    }

    @Test
    fun `saveRememberUser updates RememberUserPreferences correctly`() = runTest {
        val url = "https://example-save.com"
        val userName = "saveUser"
        val password = "savePass123"
        settingsPreferencesDataSourceImpl.saveRememberUser(url, userName, password)
        verify(rememberUserProtoDataStoreMock).updateData(any())
    }

    // --- System Preferences Tests ---

    @Test
    fun `setMakeArchivePublic updates preference correctly`() = runTest {
        val newValue = true
        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()
        settingsPreferencesDataSourceImpl.setMakeArchivePublic(newValue)
        verify(systemPreferencesDataStoreMock).updateData(captor.capture())
        val testPreferences = SystemPreferences.getDefaultInstance()
        val updatedPreferences = captor.firstValue.invoke(testPreferences)
        assertEquals(newValue, updatedPreferences.makeArchivePublic)
    }

    @Test
    fun `setCreateEbook updates preference correctly`() = runTest {
        val newValue = true
        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()
        settingsPreferencesDataSourceImpl.setCreateEbook(newValue)
        verify(systemPreferencesDataStoreMock).updateData(captor.capture())
        val testPreferences = SystemPreferences.getDefaultInstance()
        val updatedPreferences = captor.firstValue.invoke(testPreferences)
        assertEquals(newValue, updatedPreferences.createEbook)
    }

    // --- Flow Tests ---

    // --- CompactView Tests ---

    @Test
    fun `compactViewFlow emits correct value`() = runTest {
        val mockSystemPreferences = SystemPreferences.newBuilder()
            .setCompactView(true)
            .build()
        val mockSystemPreferencesFlow: Flow<SystemPreferences> = flowOf(mockSystemPreferences)
        `when`(systemPreferencesDataStoreMock.data).thenReturn(mockSystemPreferencesFlow)

        settingsPreferencesDataSourceImpl.compactViewFlow.test {
            val emittedItem = awaitItem()
            assertEquals(true, emittedItem)
            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `setCompactView updates compact view preference correctly`() = runTest {
        // Given
        val newCompactViewValue = true
        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()

        // When
        settingsPreferencesDataSourceImpl.setCompactView(newCompactViewValue)

        // Then
        verify(systemPreferencesDataStoreMock).updateData(captor.capture())
        val testPreferences = SystemPreferences.getDefaultInstance()
        val updatedPreferences = captor.firstValue.invoke(testPreferences)
        assertEquals(newCompactViewValue, updatedPreferences.compactView)
    }

    @Test
    fun `setCompactView toggles from true to false correctly`() = runTest {
        // Given
        val initialPreferences = SystemPreferences.newBuilder()
            .setCompactView(true)
            .build()
        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()

        // When
        settingsPreferencesDataSourceImpl.setCompactView(false)

        // Then
        verify(systemPreferencesDataStoreMock).updateData(captor.capture())
        val updatedPreferences = captor.firstValue.invoke(initialPreferences)
        assertFalse(updatedPreferences.compactView)
    }

    @Test
    fun `compact view state is correctly propagated through flow`() = runTest {
        // Given
        val initialPreferences = SystemPreferences.newBuilder()
            .setCompactView(true)
            .build()
        val updatedPreferences = SystemPreferences.newBuilder()
            .setCompactView(false)
            .build()

        // Create a flow that will emit both values
        val preferencesFlow = flowOf(initialPreferences, updatedPreferences)
        whenever(systemPreferencesDataStoreMock.data).thenReturn(preferencesFlow)

        // Then
        settingsPreferencesDataSourceImpl.compactViewFlow.test {
            assertEquals(true, awaitItem()) // First emission
            assertEquals(false, awaitItem()) // Second emission
            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `makeArchivePublicFlow emits correct value`() = runTest {
        val mockSystemPreferences = SystemPreferences.newBuilder()
            .setMakeArchivePublic(true)
            .build()
        val mockSystemPreferencesFlow: Flow<SystemPreferences> = flowOf(mockSystemPreferences)
        `when`(systemPreferencesDataStoreMock.data).thenReturn(mockSystemPreferencesFlow)

        settingsPreferencesDataSourceImpl.makeArchivePublicFlow.test {
            val emittedItem = awaitItem()
            assertEquals(true, emittedItem)
            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `createEbookFlow emits correct value`() = runTest {
        val mockSystemPreferences = SystemPreferences.newBuilder()
            .setCreateEbook(true)
            .build()
        val mockSystemPreferencesFlow: Flow<SystemPreferences> = flowOf(mockSystemPreferences)
        `when`(systemPreferencesDataStoreMock.data).thenReturn(mockSystemPreferencesFlow)

        settingsPreferencesDataSourceImpl.createEbookFlow.test {
            val emittedItem = awaitItem()
            assertEquals(true, emittedItem)
            cancelAndIgnoreRemainingEvents()
        }
    }

    // --- AutoAddBookmark Tests ---

    @Test
    fun `setAutoAddBookmark updates preference correctly`() = runTest {
        // Given
        val newValue = true
        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()

        // When
        settingsPreferencesDataSourceImpl.setAutoAddBookmark(newValue)

        // Then
        verify(systemPreferencesDataStoreMock).updateData(captor.capture())
        val testPreferences = SystemPreferences.getDefaultInstance()
        val updatedPreferences = captor.firstValue.invoke(testPreferences)
        assertEquals(newValue, updatedPreferences.autoAddBookmark)
    }

    @Test
    fun `setAutoAddBookmark can disable auto-add bookmark`() = runTest {
        // Given
        val initialPreferences = SystemPreferences.newBuilder()
            .setAutoAddBookmark(true)
            .build()
        val captor = argumentCaptor<suspend (SystemPreferences) -> SystemPreferences>()

        // When
        settingsPreferencesDataSourceImpl.setAutoAddBookmark(false)

        // Then
        verify(systemPreferencesDataStoreMo
Download .txt
gitextract_o9exicg3/

├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   └── feature_request.md
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── LICENSE
├── README.md
├── build.gradle
├── common/
│   ├── .gitignore
│   ├── README.md
│   ├── build.gradle.kts
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── common/
│                           └── result/
│                               ├── ErrorHandler.kt
│                               ├── NetworkLogEntry.kt
│                               └── Result.kt
├── data/
│   ├── .gitignore
│   ├── build.gradle.kts
│   ├── consumer-rules.pro
│   ├── proguard-rules.pro
│   └── src/
│       ├── androidTest/
│       │   └── java/
│       │       └── com/
│       │           └── desarrollodroide/
│       │               └── data/
│       │                   └── local/
│       │                       └── room/
│       │                           ├── BookmarkHtmlDaoTest.kt
│       │                           ├── BookmarksDaoTest.kt
│       │                           └── TagsDaoTest.kt
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── java/
│       │   │   └── com/
│       │   │       └── desarrollodroide/
│       │   │           └── data/
│       │   │               ├── di/
│       │   │               │   ├── DataModule.kt
│       │   │               │   └── PersistenceModule.kt
│       │   │               ├── extensions/
│       │   │               │   ├── GSONS.kt
│       │   │               │   ├── IntExtensions.kt
│       │   │               │   ├── StringExtensions.kt
│       │   │               │   └── TagExtensions.kt
│       │   │               ├── helpers/
│       │   │               │   ├── Constants.kt
│       │   │               │   ├── CrashHandler.kt
│       │   │               │   ├── CrashHandlerImpl.kt
│       │   │               │   ├── GSON.kt
│       │   │               │   └── TagTypeAdapter.kt
│       │   │               ├── local/
│       │   │               │   ├── datastore/
│       │   │               │   │   ├── ChangeListVersions.kt
│       │   │               │   │   ├── HideTagSerializer.kt
│       │   │               │   │   ├── RememberUserPreferencesSerializer.kt
│       │   │               │   │   ├── SystemPreferencesSerializer.kt
│       │   │               │   │   └── UserPreferencesSerializer.kt
│       │   │               │   ├── preferences/
│       │   │               │   │   ├── SettingsPreferenceDataSource.kt
│       │   │               │   │   └── SettingsPreferencesDataSourceImpl.kt
│       │   │               │   └── room/
│       │   │               │       ├── converters/
│       │   │               │       │   └── TagsConverter.kt
│       │   │               │       ├── dao/
│       │   │               │       │   ├── BookmarkHtmlDao.kt
│       │   │               │       │   ├── BookmarksDao.kt
│       │   │               │       │   └── TagDao.kt
│       │   │               │       ├── database/
│       │   │               │       │   └── BookmarksDatabase.kt
│       │   │               │       └── entity/
│       │   │               │           ├── BookmarkEntity.kt
│       │   │               │           ├── BookmarkHtmlEntity.kt
│       │   │               │           ├── BookmarkTagCrossRef.kt
│       │   │               │           ├── BookmarkWithTags.kt
│       │   │               │           └── TagEntity.kt
│       │   │               ├── mapper/
│       │   │               │   └── Mapper.kt
│       │   │               ├── repository/
│       │   │               │   ├── AuthRepository.kt
│       │   │               │   ├── AuthRepositoryImpl.kt
│       │   │               │   ├── BookmarksRepository.kt
│       │   │               │   ├── BookmarksRepositoryImpl.kt
│       │   │               │   ├── ErrorHandlerImpl.kt
│       │   │               │   ├── FileRepository.kt
│       │   │               │   ├── FileRepositoryImpl.kt
│       │   │               │   ├── SettingsRepository.kt
│       │   │               │   ├── SettingsRepositoryImpl.kt
│       │   │               │   ├── SyncWorks.kt
│       │   │               │   ├── SyncWorksImpl.kt
│       │   │               │   ├── SystemRepository.kt
│       │   │               │   ├── SystemRepositoryImpl.kt
│       │   │               │   ├── TagsRepository.kt
│       │   │               │   ├── TagsRepositoryImpl.kt
│       │   │               │   ├── paging/
│       │   │               │   │   ├── BookmarkPagingSource.kt
│       │   │               │   │   ├── BookmarksRemoteMediator.kt
│       │   │               │   │   └── LocalBookmarkPagingSource.kt
│       │   │               │   └── workers/
│       │   │               │       └── SyncWorker.kt
│       │   │               └── util/
│       │   │                   └── SyncUtilities.kt
│       │   └── proto/
│       │       └── prefs.proto
│       └── test/
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── data/
│                           ├── extensions/
│                           │   ├── IntExtensionsTest.kt
│                           │   └── StringExtensionsKtTest.kt
│                           ├── helpers/
│                           │   └── TagTypeAdapterTest.kt
│                           ├── local/
│                           │   ├── datastore/
│                           │   │   ├── HideTagSerializerTest.kt
│                           │   │   ├── RememberUserPreferencesSerializerTest.kt
│                           │   │   └── UserPreferencesSerializerTest.kt
│                           │   ├── preferences/
│                           │   │   └── SettingsPreferencesDataSourceTest.kt
│                           │   └── room/
│                           │       └── converters/
│                           │           └── TagsConverterTest.kt
│                           ├── mapper/
│                           │   └── MapperTest.kt
│                           └── repository/
│                               ├── AuthRepositoryTest.kt
│                               └── BookmarksRepositoryTest.kt
├── domain/
│   ├── .gitignore
│   ├── build.gradle.kts
│   ├── consumer-rules.pro
│   ├── proguard-rules.pro
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── domain/
│                           └── usecase/
│                               ├── AddBookmarkUseCase.kt
│                               ├── DeleteBookmarkUseCase.kt
│                               ├── DeleteLocalBookmarkUseCase.kt
│                               ├── DownloadFileUseCase.kt
│                               ├── EditBookmarkUseCase.kt
│                               ├── GetAllRemoteBookmarksUseCase.kt
│                               ├── GetBookmarkReadableContentUseCase.kt
│                               ├── GetBookmarkUseCase.kt
│                               ├── GetBookmarksUseCase.kt
│                               ├── GetLocalPagingBookmarksUseCase.kt
│                               ├── GetTagsUseCase.kt
│                               ├── SendLoginUseCase.kt
│                               ├── SendLogoutUseCase.kt
│                               ├── SuspendUseCase.kt
│                               ├── SyncBookmarksUseCase.kt
│                               ├── SystemLivenessUseCase.kt
│                               └── UpdateBookmarkCacheUseCase.kt
├── fastlane/
│   └── metadata/
│       └── android/
│           ├── de/
│           │   ├── full_description.txt
│           │   └── short_description.txt
│           └── en-US/
│               ├── changelogs/
│               │   └── default.txt
│               ├── full_description.txt
│               ├── short_description.txt
│               └── title.txt
├── gradle/
│   ├── libs.versions.toml
│   └── wrapper/
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── model/
│   ├── .gitignore
│   ├── build.gradle.kts
│   ├── proguard-rules.pro
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── model/
│                           ├── Account.kt
│                           ├── Bookmark.kt
│                           ├── Bookmarks.kt
│                           ├── LivenessResponse.kt
│                           ├── LoginResponseMessage.kt
│                           ├── ModifiedBookmarks.kt
│                           ├── PendingJob.kt
│                           ├── ReadableContent.kt
│                           ├── ReadableMessage.kt
│                           ├── ReleaseInfo.kt
│                           ├── SyncBookmarksRequestPayload.kt
│                           ├── SyncBookmarksResponse.kt
│                           ├── Tag.kt
│                           ├── UpdateCachePayload.kt
│                           └── User.kt
├── network/
│   ├── .gitignore
│   ├── README.md
│   ├── build.gradle.kts
│   ├── lint.xml
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── network/
│                           ├── di/
│                           │   └── NetworkingModule.kt
│                           ├── model/
│                           │   ├── AccountDTO.kt
│                           │   ├── ApiResponse.kt
│                           │   ├── BookmarkDTO.kt
│                           │   ├── BookmarkResponseDTO.kt
│                           │   ├── BookmarksDTO.kt
│                           │   ├── LivenessResponseDTO.kt
│                           │   ├── LoginRequestPayload.kt
│                           │   ├── LoginResponseDTO.kt
│                           │   ├── LoginResponseMessageDTO.kt
│                           │   ├── ModifiedBookmarksDTO.kt
│                           │   ├── ReadableContentResponseDTO.kt
│                           │   ├── ReadableMessageDto.kt
│                           │   ├── ReleaseInfoDTO.kt
│                           │   ├── SessionDTO.kt
│                           │   ├── SyncBookmarksMessageDTO.kt
│                           │   ├── SyncBookmarksResponseDTO.kt
│                           │   ├── TagDTO.kt
│                           │   ├── TagsDTO.kt
│                           │   ├── UpdateCachePayloadDTO.kt
│                           │   ├── UpdateCachePayloadV1DTO.kt
│                           │   └── util/
│                           │       └── NetworkChangeList.kt
│                           └── retrofit/
│                               ├── FileRemoteDataSource.kt
│                               ├── NetworkBoundResource.kt
│                               ├── NetworkLoggerInterceptor.kt
│                               └── RetrofitNetwork.kt
├── presentation/
│   ├── .gitignore
│   ├── build.gradle.kts
│   ├── proguard-rules.pro
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── java/
│       │   │   └── com/
│       │   │       └── desarrollodroide/
│       │   │           └── pagekeeper/
│       │   │               ├── ComposeSetup.kt
│       │   │               ├── MainActivity.kt
│       │   │               ├── ShioriApp.kt
│       │   │               ├── di/
│       │   │               │   ├── AppModule.kt
│       │   │               │   └── PresenterModule.kt
│       │   │               ├── extensions/
│       │   │               │   ├── ContextExtensions.kt
│       │   │               │   ├── ImageLoaderExtensions.kt
│       │   │               │   ├── LongExtensions.kt
│       │   │               │   └── StringExtensions.kt
│       │   │               ├── helpers/
│       │   │               │   ├── ThemeManager.kt
│       │   │               │   └── ThemeManagerImpl.kt
│       │   │               ├── navigation/
│       │   │               │   ├── NavItem.kt
│       │   │               │   └── Navigation.kt
│       │   │               └── ui/
│       │   │                   ├── bookmarkeditor/
│       │   │                   │   ├── BookmarkEditorActivity.kt
│       │   │                   │   ├── BookmarkEditorScreen.kt
│       │   │                   │   ├── BookmarkEditorView.kt
│       │   │                   │   ├── BookmarkViewModel.kt
│       │   │                   │   ├── NotSessionScreen.kt
│       │   │                   │   └── ProgressButton.kt
│       │   │                   ├── components/
│       │   │                   │   ├── CategoriesView.kt
│       │   │                   │   ├── Dialogs.kt
│       │   │                   │   ├── LoadingButton.kt
│       │   │                   │   ├── UiState.kt
│       │   │                   │   └── pulltorefresh/
│       │   │                   │       ├── PullRefresh.kt
│       │   │                   │       ├── PullRefreshIndicator.kt
│       │   │                   │       ├── PullRefreshIndicatorTransform.kt
│       │   │                   │       └── PullRefreshState.kt
│       │   │                   ├── feed/
│       │   │                   │   ├── BookmarkViewer.kt
│       │   │                   │   ├── CategoriesView.kt
│       │   │                   │   ├── FeedContent.kt
│       │   │                   │   ├── FeedScreen.kt
│       │   │                   │   ├── FeedViewModel.kt
│       │   │                   │   ├── ItemLazyLoad.kt
│       │   │                   │   ├── NoContentView.kt
│       │   │                   │   ├── SearchBarView.kt
│       │   │                   │   ├── SearchViewModel.kt
│       │   │                   │   └── item/
│       │   │                   │       ├── BookmarkImageView.kt
│       │   │                   │       ├── BookmarkItem.kt
│       │   │                   │       ├── ButtonsView.kt
│       │   │                   │       ├── ClickableCategoriesView.kt
│       │   │                   │       ├── FullBookmarkView.kt
│       │   │                   │       ├── PendingSyncBanner.kt
│       │   │                   │       └── SmallBookmarkView.kt
│       │   │                   ├── home/
│       │   │                   │   ├── BottomNavItem.kt
│       │   │                   │   └── HomeScreen.kt
│       │   │                   ├── login/
│       │   │                   │   ├── LoginButton.kt
│       │   │                   │   ├── LoginScreen.kt
│       │   │                   │   ├── LoginViewModel.kt
│       │   │                   │   ├── PasswordTextField.kt
│       │   │                   │   ├── RememberSessionSection.kt
│       │   │                   │   ├── ServerUrlTextField.kt
│       │   │                   │   └── UserTextField.kt
│       │   │                   ├── readablecontent/
│       │   │                   │   ├── ErrorView.kt
│       │   │                   │   ├── ReadableContentScreen.kt
│       │   │                   │   ├── ReadableContentViewModel.kt
│       │   │                   │   └── TopSection.kt
│       │   │                   ├── settings/
│       │   │                   │   ├── AccountSection.kt
│       │   │                   │   ├── ClickableOption.kt
│       │   │                   │   ├── DataSection.kt
│       │   │                   │   ├── DebugSection.kt
│       │   │                   │   ├── DefaultsSection.kt
│       │   │                   │   ├── FeedSection.kt
│       │   │                   │   ├── HideCategoryOptionView.kt
│       │   │                   │   ├── LinkableText.kt
│       │   │                   │   ├── PrivacyPolicyScreen.kt
│       │   │                   │   ├── SettingsScreen.kt
│       │   │                   │   ├── SettingsSectionState.kt
│       │   │                   │   ├── SettingsViewModel.kt
│       │   │                   │   ├── SwitchOption.kt
│       │   │                   │   ├── TermsOfUseScreen.kt
│       │   │                   │   ├── VisualSection.kt
│       │   │                   │   ├── crash/
│       │   │                   │   │   ├── CrashLogScreen.kt
│       │   │                   │   │   └── CrashLogViewModel.kt
│       │   │                   │   └── logcat/
│       │   │                   │       ├── NetworkLogScreen.kt
│       │   │                   │       └── NetworkLogViewModel.kt
│       │   │                   └── theme/
│       │   │                       ├── Color.kt
│       │   │                       ├── Shape.kt
│       │   │                       ├── Theme.kt
│       │   │                       └── Type.kt
│       │   └── res/
│       │       ├── drawable/
│       │       │   ├── curved_wave_bottom.xml
│       │       │   ├── curved_wave_top.xml
│       │       │   ├── ic_book.xml
│       │       │   ├── ic_empty_list.xml
│       │       │   └── img_authentication_failed.xml
│       │       ├── mipmap-anydpi-v26/
│       │       │   ├── ic_launcher.xml
│       │       │   └── ic_launcher_round.xml
│       │       ├── values/
│       │       │   ├── dimens.xml
│       │       │   ├── strings.xml
│       │       │   └── themes.xml
│       │       ├── values-large/
│       │       │   └── dimens.xml
│       │       └── xml/
│       │           ├── data_extraction_rules.xml
│       │           └── file_paths.xml
│       └── test/
│           └── java/
│               └── com/
│                   └── desarrollodroide/
│                       └── pagekeeper/
│                           └── extensions/
│                               └── StringExtensionsKtTest.kt
└── settings.gradle
Condensed preview — 265 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (740K chars).
[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 684,
    "preview": "---\nname: Bug Report\nabout: Create a report to help us improve\ntitle: \"[BUG] \"\nlabels: bug\nassignees: ''\n\n---\n\n**Bug Des"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 766,
    "preview": "---\nname: Feature Request\nabout: Suggest an idea for this project\ntitle: \"[FEATURE] \"\nlabels: enhancement\nassignees: ''\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 2843,
    "preview": "name: Android CI\n\non:\n  push:\n    branches:\n      - master\n      - develop\n      - testing\n  pull_request:\n    branches:"
  },
  {
    "path": ".gitignore",
    "chars": 442,
    "preview": "*.iml\n.gradle\n/local.properties\n/.idea/*\n/.idea/codeStyles\n/.idea/caches\n/.idea/libraries\n/.idea/modules.xml\n/.idea/work"
  },
  {
    "path": "LICENSE",
    "chars": 11357,
    "preview": "                                 Apache License\n                           Version 2.0, January 2004\n                   "
  },
  {
    "path": "README.md",
    "chars": 5107,
    "preview": "<h1 align=\"center\">\n  <img src=\"images/page_keeper_logo.png\" width=\"120\" alt=\"EhViewer\">\n  <br>Shiori<br>\n</h1>\n\n<p alig"
  },
  {
    "path": "build.gradle",
    "chars": 597,
    "preview": "buildscript {\n    ext {\n        compose_ui_version = '1.1.1'\n    }\n    dependencies {\n        classpath 'com.google.prot"
  },
  {
    "path": "common/.gitignore",
    "chars": 6,
    "preview": "/build"
  },
  {
    "path": "common/README.md",
    "chars": 95,
    "preview": "# :core:common module\n\n![Dependency graph](../../docs/images/graphs/dep_graph_core_common.png)\n"
  },
  {
    "path": "common/build.gradle.kts",
    "chars": 649,
    "preview": "plugins {\n    id(\"com.android.library\")\n    id (\"org.jetbrains.kotlin.android\")\n}\n\nandroid {\n    namespace = \"com.desarr"
  },
  {
    "path": "common/src/main/AndroidManifest.xml",
    "chars": 121,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n</manifest"
  },
  {
    "path": "common/src/main/java/com/desarrollodroide/common/result/ErrorHandler.kt",
    "chars": 1075,
    "preview": "package com.desarrollodroide.common.result\n\n/**\n * Defines a contract for handling errors that may occur during the appl"
  },
  {
    "path": "common/src/main/java/com/desarrollodroide/common/result/NetworkLogEntry.kt",
    "chars": 240,
    "preview": "package com.desarrollodroide.common.result\n\ndata class NetworkLogEntry(\n    val timestamp: String,\n    val priority: Str"
  },
  {
    "path": "common/src/main/java/com/desarrollodroide/common/result/Result.kt",
    "chars": 2017,
    "preview": "package com.desarrollodroide.common.result\n/**\n * Represents the outcome of an operation that can end in success, failur"
  },
  {
    "path": "data/.gitignore",
    "chars": 6,
    "preview": "/build"
  },
  {
    "path": "data/build.gradle.kts",
    "chars": 5735,
    "preview": "plugins {\n    id (\"com.android.library\")\n    id (\"org.jetbrains.kotlin.android\")\n    id (\"com.google.devtools.ksp\") vers"
  },
  {
    "path": "data/consumer-rules.pro",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "data/proguard-rules.pro",
    "chars": 754,
    "preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
  },
  {
    "path": "data/src/androidTest/java/com/desarrollodroide/data/local/room/BookmarkHtmlDaoTest.kt",
    "chars": 1994,
    "preview": "package com.desarrollodroide.data.local.room\n\nimport androidx.room.Room\nimport androidx.test.core.app.ApplicationProvide"
  },
  {
    "path": "data/src/androidTest/java/com/desarrollodroide/data/local/room/BookmarksDaoTest.kt",
    "chars": 6930,
    "preview": "package com.desarrollodroide.data.local.room\n\nimport androidx.paging.PagingSource\nimport androidx.room.Room\nimport andro"
  },
  {
    "path": "data/src/androidTest/java/com/desarrollodroide/data/local/room/TagsDaoTest.kt",
    "chars": 1954,
    "preview": "package com.desarrollodroide.data.local.room\n\nimport androidx.room.Room\nimport androidx.test.core.app.ApplicationProvide"
  },
  {
    "path": "data/src/main/AndroidManifest.xml",
    "chars": 121,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n</manifest"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/di/DataModule.kt",
    "chars": 5772,
    "preview": "package com.desarrollodroide.data.di\n\nimport android.content.Context\nimport androidx.datastore.core.DataStoreFactory\nimp"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/di/PersistenceModule.kt",
    "chars": 434,
    "preview": "package com.desarrollodroide.data.di\n\nimport com.desarrollodroide.data.local.room.database.BookmarksDatabase\nimport org."
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/extensions/GSONS.kt",
    "chars": 347,
    "preview": "package com.desarrollodroide.data.extensions\n\nimport com.desarrollodroide.data.helpers.GSON\nimport com.google.gson.JsonE"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/extensions/IntExtensions.kt",
    "chars": 433,
    "preview": "package com.desarrollodroide.data.extensions\n\n/**\n * Checks if an integer ID is a temporary timestamp-based ID rather th"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/extensions/StringExtensions.kt",
    "chars": 184,
    "preview": "package com.desarrollodroide.data.extensions\n\nfun String.removeTrailingSlash(): String {\n    return if (this.endsWith(\"/"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/extensions/TagExtensions.kt",
    "chars": 339,
    "preview": "package com.desarrollodroide.data.extensions\n\nimport com.desarrollodroide.model.Tag\n\nfun List<Tag>.toTagPattern(): Strin"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/helpers/Constants.kt",
    "chars": 382,
    "preview": "package com.desarrollodroide.data.helpers\n\nenum class ThemeMode {\n    DARK, LIGHT, AUTO\n}\nenum class BookmarkViewType {\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/helpers/CrashHandler.kt",
    "chars": 471,
    "preview": "package com.desarrollodroide.data.helpers\n\nimport com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSour"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/helpers/CrashHandlerImpl.kt",
    "chars": 2263,
    "preview": "package com.desarrollodroide.data.helpers\n\nimport android.util.Log\nimport com.desarrollodroide.data.local.preferences.Se"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/helpers/GSON.kt",
    "chars": 665,
    "preview": "package com.desarrollodroide.data.helpers\n\nimport com.google.gson.GsonBuilder\nimport com.google.gson.JsonElement\nimport "
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/helpers/TagTypeAdapter.kt",
    "chars": 830,
    "preview": "package com.desarrollodroide.data.helpers\n\nimport com.google.gson.*\nimport com.desarrollodroide.model.Tag\nimport com.des"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/datastore/ChangeListVersions.kt",
    "chars": 225,
    "preview": "package com.desarrollodroide.data.local.datastore\n\n/**\n * Class summarizing the local version of each model for sync\n */"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/datastore/HideTagSerializer.kt",
    "chars": 856,
    "preview": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.da"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/datastore/RememberUserPreferencesSerializer.kt",
    "chars": 1002,
    "preview": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.da"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/datastore/SystemPreferencesSerializer.kt",
    "chars": 957,
    "preview": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.da"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/datastore/UserPreferencesSerializer.kt",
    "chars": 929,
    "preview": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.da"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/preferences/SettingsPreferenceDataSource.kt",
    "chars": 2294,
    "preview": "package com.desarrollodroide.data.local.preferences\n\nimport com.desarrollodroide.data.UserPreferences\nimport com.desarro"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/preferences/SettingsPreferencesDataSourceImpl.kt",
    "chars": 11511,
    "preview": "package com.desarrollodroide.data.local.preferences\n\nimport android.util.Log\nimport androidx.datastore.core.DataStore\nim"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/converters/TagsConverter.kt",
    "chars": 681,
    "preview": "package com.desarrollodroide.data.local.room.converters\n\nimport androidx.room.TypeConverter\nimport com.google.gson.Gson\n"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/dao/BookmarkHtmlDao.kt",
    "chars": 671,
    "preview": "package com.desarrollodroide.data.local.room.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.r"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/dao/BookmarksDao.kt",
    "chars": 7716,
    "preview": "package com.desarrollodroide.data.local.room.dao\n\nimport androidx.paging.PagingSource\nimport androidx.room.Dao\nimport an"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/dao/TagDao.kt",
    "chars": 981,
    "preview": "package com.desarrollodroide.data.local.room.dao\n\nimport androidx.room.Dao\nimport androidx.room.Delete\nimport androidx.r"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/database/BookmarksDatabase.kt",
    "chars": 4308,
    "preview": "package com.desarrollodroide.data.local.room.database\n\nimport android.content.Context\nimport android.util.Log\nimport and"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkEntity.kt",
    "chars": 986,
    "preview": "package com.desarrollodroide.data.local.room.entity\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport "
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkHtmlEntity.kt",
    "chars": 274,
    "preview": "package com.desarrollodroide.data.local.room.entity\n\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\n\n@Entit"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkTagCrossRef.kt",
    "chars": 337,
    "preview": "package com.desarrollodroide.data.local.room.entity\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\n\n@Entit"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/entity/BookmarkWithTags.kt",
    "chars": 387,
    "preview": "package com.desarrollodroide.data.local.room.entity\n\nimport androidx.room.Embedded\nimport androidx.room.Junction\nimport "
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/local/room/entity/TagEntity.kt",
    "chars": 328,
    "preview": "package com.desarrollodroide.data.local.room.entity\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport "
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/mapper/Mapper.kt",
    "chars": 7074,
    "preview": "package com.desarrollodroide.data.mapper\n\nimport com.desarrollodroide.data.UserPreferences\nimport com.desarrollodroide.d"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/AuthRepository.kt",
    "chars": 509,
    "preview": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroi"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/AuthRepositoryImpl.kt",
    "chars": 3498,
    "preview": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrol"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/BookmarksRepository.kt",
    "chars": 1944,
    "preview": "package com.desarrollodroide.data.repository\n\nimport androidx.paging.PagingData\nimport kotlinx.coroutines.flow.Flow\nimpo"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/BookmarksRepositoryImpl.kt",
    "chars": 18629,
    "preview": "package com.desarrollodroide.data.repository\n\nimport android.util.Log\nimport androidx.paging.Pager\nimport androidx.pagin"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/ErrorHandlerImpl.kt",
    "chars": 983,
    "preview": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrol"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/FileRepository.kt",
    "chars": 212,
    "preview": "package com.desarrollodroide.data.repository\n\nimport java.io.File\n\ninterface FileRepository {\n    suspend fun downloadFi"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/FileRepositoryImpl.kt",
    "chars": 614,
    "preview": "package com.desarrollodroide.data.repository\n\nimport android.content.Context\nimport com.desarrollodroide.network.retrofi"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SettingsRepository.kt",
    "chars": 505,
    "preview": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodr"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SettingsRepositoryImpl.kt",
    "chars": 1620,
    "preview": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.data.helpers.ThemeMode\nimport com.desarrollodr"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SyncWorks.kt",
    "chars": 576,
    "preview": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.mod"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SyncWorksImpl.kt",
    "chars": 5506,
    "preview": "package com.desarrollodroide.data.repository\n\nimport android.util.Log\nimport androidx.lifecycle.asFlow\nimport androidx.w"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SystemRepository.kt",
    "chars": 295,
    "preview": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.Result\nimport com.desarrollodroi"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/SystemRepositoryImpl.kt",
    "chars": 1505,
    "preview": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrol"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/TagsRepository.kt",
    "chars": 324,
    "preview": "package com.desarrollodroide.data.repository\n\nimport kotlinx.coroutines.flow.Flow\nimport com.desarrollodroide.common.res"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/TagsRepositoryImpl.kt",
    "chars": 2044,
    "preview": "package com.desarrollodroide.data.repository\n\nimport android.util.Log\nimport com.desarrollodroide.common.result.ErrorHan"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/paging/BookmarkPagingSource.kt",
    "chars": 3424,
    "preview": "package com.desarrollodroide.data.repository.paging\n\nimport android.util.Log\nimport androidx.paging.PagingSource\nimport "
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/paging/BookmarksRemoteMediator.kt",
    "chars": 3224,
    "preview": "package com.desarrollodroide.data.repository.paging\n\nimport androidx.paging.ExperimentalPagingApi\nimport androidx.paging"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/paging/LocalBookmarkPagingSource.kt",
    "chars": 2886,
    "preview": "package com.desarrollodroide.data.repository.paging\n\nimport android.annotation.SuppressLint\nimport android.util.Log\nimpo"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/repository/workers/SyncWorker.kt",
    "chars": 9099,
    "preview": "package com.desarrollodroide.data.repository.workers\n\nimport android.content.Context\nimport android.util.Log\nimport andr"
  },
  {
    "path": "data/src/main/java/com/desarrollodroide/data/util/SyncUtilities.kt",
    "chars": 3489,
    "preview": "package com.desarrollodroide.data.util\n\nimport android.util.Log\nimport com.desarrollodroide.data.local.datastore.ChangeL"
  },
  {
    "path": "data/src/main/proto/prefs.proto",
    "chars": 834,
    "preview": "syntax = \"proto3\";\n\noption java_package = \"com.desarrollodroide.data\";\noption java_multiple_files = true;\n\nmessage UserP"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/extensions/IntExtensionsTest.kt",
    "chars": 1449,
    "preview": "package com.desarrollodroide.data.extensions\n\nimport org.junit.jupiter.api.Assertions.*\nimport org.junit.jupiter.api.Tes"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/extensions/StringExtensionsKtTest.kt",
    "chars": 1736,
    "preview": "package com.desarrollodroide.data.extensions\n\nimport org.junit.jupiter.api.Assertions.*\nimport org.junit.jupiter.api.Tes"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/helpers/TagTypeAdapterTest.kt",
    "chars": 1430,
    "preview": "package com.desarrollodroide.data.helpers\n\nimport com.desarrollodroide.model.Tag\nimport com.google.gson.GsonBuilder\nimpo"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/local/datastore/HideTagSerializerTest.kt",
    "chars": 1734,
    "preview": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport com.desarro"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/local/datastore/RememberUserPreferencesSerializerTest.kt",
    "chars": 2221,
    "preview": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport com.desarro"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/local/datastore/UserPreferencesSerializerTest.kt",
    "chars": 2055,
    "preview": "package com.desarrollodroide.data.local.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport com.desarro"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/local/preferences/SettingsPreferencesDataSourceTest.kt",
    "chars": 29844,
    "preview": "package com.desarrollodroide.data.local.preferences\n\nimport kotlinx.coroutines.ExperimentalCoroutinesApi\nimport kotlinx."
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/local/room/converters/TagsConverterTest.kt",
    "chars": 1382,
    "preview": "package com.desarrollodroide.data.local.room.converters\n\nimport com.desarrollodroide.model.Tag\nimport org.junit.jupiter."
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/mapper/MapperTest.kt",
    "chars": 28019,
    "preview": "package com.desarrollodroide.data.mapper\n\nimport com.desarrollodroide.data.local.room.entity.BookmarkEntity\nimport com.d"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/repository/AuthRepositoryTest.kt",
    "chars": 13179,
    "preview": "package com.desarrollodroide.data.repository\n\nimport com.desarrollodroide.common.result.ErrorHandler\nimport com.desarrol"
  },
  {
    "path": "data/src/test/java/com/desarrollodroide/data/repository/BookmarksRepositoryTest.kt",
    "chars": 10090,
    "preview": "package com.desarrollodroide.data.repository\n\nimport androidx.paging.Pager\nimport androidx.paging.PagingConfig\nimport an"
  },
  {
    "path": "domain/.gitignore",
    "chars": 6,
    "preview": "/build"
  },
  {
    "path": "domain/build.gradle.kts",
    "chars": 1405,
    "preview": "plugins {\n    id(\"com.android.library\")\n    id (\"org.jetbrains.kotlin.android\")\n}\n\nandroid {\n    namespace = \"com.desarr"
  },
  {
    "path": "domain/consumer-rules.pro",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "domain/proguard-rules.pro",
    "chars": 754,
    "preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
  },
  {
    "path": "domain/src/main/AndroidManifest.xml",
    "chars": 121,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n</manifest"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/AddBookmarkUseCase.kt",
    "chars": 915,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.model.Bookmark\nimport com.desarrollodroide.data"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/DeleteBookmarkUseCase.kt",
    "chars": 559,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.des"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/DeleteLocalBookmarkUseCase.kt",
    "chars": 1045,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.local.room.dao.BookmarksDao\nimport com.des"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/DownloadFileUseCase.kt",
    "chars": 468,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.repository.FileRepository\nimport java.io.F"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/EditBookmarkUseCase.kt",
    "chars": 1184,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport android.os.Build\nimport androidx.annotation.RequiresApi\nimport com.d"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetAllRemoteBookmarksUseCase.kt",
    "chars": 1215,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport android.annotation.SuppressLint\nimport android.util.Log\nimport com.d"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetBookmarkReadableContentUseCase.kt",
    "chars": 762,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\ni"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetBookmarkUseCase.kt",
    "chars": 728,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com."
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetBookmarksUseCase.kt",
    "chars": 676,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.repository.BookmarksRepository\nimport com."
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetLocalPagingBookmarksUseCase.kt",
    "chars": 1380,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport androidx.paging.PagingData\nimport androidx.paging.filter\nimport com."
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/GetTagsUseCase.kt",
    "chars": 756,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.Flow\ni"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/SendLoginUseCase.kt",
    "chars": 617,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.repository.AuthRepository\nimport com.desar"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/SendLogoutUseCase.kt",
    "chars": 1673,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport com.desarrollodroide.data.repository.AuthRepository\nimport kotlinx.c"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/SuspendUseCase.kt",
    "chars": 127,
    "preview": "package com.desarrollodroide.domain.usecase\n\ninterface SuspendUseCase<in Params, out T> {\n    fun execute(params: Params"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/SyncBookmarksUseCase.kt",
    "chars": 4579,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport android.os.Build\nimport android.util.Log\nimport androidx.annotation."
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/SystemLivenessUseCase.kt",
    "chars": 578,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport kotlinx.coroutines.flow.Flow\nimport com.desarrollodroide.common.resu"
  },
  {
    "path": "domain/src/main/java/com/desarrollodroide/domain/usecase/UpdateBookmarkCacheUseCase.kt",
    "chars": 1202,
    "preview": "package com.desarrollodroide.domain.usecase\n\nimport android.os.Build\nimport androidx.annotation.RequiresApi\nimport com.d"
  },
  {
    "path": "fastlane/metadata/android/de/full_description.txt",
    "chars": 1370,
    "preview": "Entdecken Sie mit <b>Pagekeeper</b> eine neue Möglichkeit, Ihre Lieblingswebseiten zu speichern, zu organisieren und dar"
  },
  {
    "path": "fastlane/metadata/android/de/short_description.txt",
    "chars": 19,
    "preview": "Lesezeichen-Manager"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/default.txt",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "fastlane/metadata/android/en-US/full_description.txt",
    "chars": 1093,
    "preview": "Discover a new way to save, organize, and access your favorite web pages with <b>Pagekeeper</b>. Built on the renowned <"
  },
  {
    "path": "fastlane/metadata/android/en-US/short_description.txt",
    "chars": 46,
    "preview": "Android client for the Shiori Bookmark Manager"
  },
  {
    "path": "fastlane/metadata/android/en-US/title.txt",
    "chars": 6,
    "preview": "Shiori"
  },
  {
    "path": "gradle/libs.versions.toml",
    "chars": 4855,
    "preview": "[versions]\n\ndatastorePreferences = \"1.0.0\"\njunitJupiter = \"5.8.1\"\njunitPlatformSuiteApi = \"1.8.1\"\nkoinAndroidxCompose = "
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "chars": 230,
    "preview": "#Mon Mar 25 12:51:42 CET 2024\ndistributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://"
  },
  {
    "path": "gradle.properties",
    "chars": 1531,
    "preview": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will ov"
  },
  {
    "path": "gradlew",
    "chars": 5766,
    "preview": "#!/usr/bin/env sh\n\n#\n# Copyright 2015 the original author or authors.\n#\n# Licensed under the Apache License, Version 2.0"
  },
  {
    "path": "gradlew.bat",
    "chars": 2674,
    "preview": "@rem\n@rem Copyright 2015 the original author or authors.\n@rem\n@rem Licensed under the Apache License, Version 2.0 (the \""
  },
  {
    "path": "model/.gitignore",
    "chars": 6,
    "preview": "/build"
  },
  {
    "path": "model/build.gradle.kts",
    "chars": 648,
    "preview": "plugins {\n    id(\"com.android.library\")\n    id (\"org.jetbrains.kotlin.android\")\n}\n\nandroid {\n    namespace = \"com.desarr"
  },
  {
    "path": "model/proguard-rules.pro",
    "chars": 750,
    "preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
  },
  {
    "path": "model/src/main/AndroidManifest.xml",
    "chars": 121,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n</manifest"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/Account.kt",
    "chars": 576,
    "preview": "package com.desarrollodroide.model\n\nclass Account(\n    val id: Int = -1,\n    val userName: String,\n    val password: Str"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/Bookmark.kt",
    "chars": 2978,
    "preview": "package com.desarrollodroide.model\n\nimport android.webkit.URLUtil\nimport java.time.LocalDateTime\nimport java.time.format"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/Bookmarks.kt",
    "chars": 302,
    "preview": "package com.desarrollodroide.model\n\ndata class Bookmarks (\n    val error: String,\n    var maxPage: Int,\n    var page: In"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/LivenessResponse.kt",
    "chars": 113,
    "preview": "package com.desarrollodroide.model\n\nclass LivenessResponse (\n    val ok: Boolean,\n    val message: ReleaseInfo?\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/LoginResponseMessage.kt",
    "chars": 139,
    "preview": "package com.desarrollodroide.model\n\ndata class LoginResponseMessage(\n    val expires: Int,\n    val session: String,\n    "
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/ModifiedBookmarks.kt",
    "chars": 142,
    "preview": "package com.desarrollodroide.model\n\ndata class ModifiedBookmarks(\n    val bookmarks: List<Bookmark>,\n    val maxPage: In"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/PendingJob.kt",
    "chars": 398,
    "preview": "package com.desarrollodroide.model\n\ndata class PendingJob(\n    val operationType: SyncOperationType,\n    val state: Stri"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/ReadableContent.kt",
    "chars": 120,
    "preview": "package com.desarrollodroide.model\n\ndata class ReadableContent(\n    val ok: Boolean,\n    val message: ReadableMessage,\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/ReadableMessage.kt",
    "chars": 111,
    "preview": "package com.desarrollodroide.model\n\ndata class ReadableMessage(\n    val content: String,\n    val html: String\n)"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/ReleaseInfo.kt",
    "chars": 131,
    "preview": "package com.desarrollodroide.model\n\ndata class ReleaseInfo(\n    val version: String,\n    val commit: String,\n    val dat"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/SyncBookmarksRequestPayload.kt",
    "chars": 144,
    "preview": "package com.desarrollodroide.model\n\ndata class SyncBookmarksRequestPayload(\n    val ids: List<Int>,\n    val last_sync: L"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/SyncBookmarksResponse.kt",
    "chars": 135,
    "preview": "package com.desarrollodroide.model\n\ndata class SyncBookmarksResponse(\n    val deleted: List<Int>,\n    val modified: Modi"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/Tag.kt",
    "chars": 238,
    "preview": "package com.desarrollodroide.model\n\n\ndata class Tag (\n    val id: Int,\n    val name: String,\n    var selected: Boolean,\n"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/UpdateCachePayload.kt",
    "chars": 216,
    "preview": "package com.desarrollodroide.model\n\n\ndata class UpdateCachePayload(\n    val createArchive : Boolean,\n    val createEbook"
  },
  {
    "path": "model/src/main/java/com/desarrollodroide/model/User.kt",
    "chars": 612,
    "preview": "package com.desarrollodroide.model\n\ndata class User(\n    val session: String,\n    val token: String,\n    val account: Ac"
  },
  {
    "path": "network/.gitignore",
    "chars": 6,
    "preview": "/build"
  },
  {
    "path": "network/README.md",
    "chars": 97,
    "preview": "# :core:network module\n\n![Dependency graph](../../docs/images/graphs/dep_graph_core_network.png)\n"
  },
  {
    "path": "network/build.gradle.kts",
    "chars": 1192,
    "preview": "plugins {\n    id (\"com.android.library\")\n    id (\"org.jetbrains.kotlin.android\")\n}\n\n\nandroid {\n    namespace = \"com.desa"
  },
  {
    "path": "network/lint.xml",
    "chars": 1031,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the"
  },
  {
    "path": "network/src/main/AndroidManifest.xml",
    "chars": 188,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <uses-p"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/di/NetworkingModule.kt",
    "chars": 1875,
    "preview": "package com.desarrollodroide.network.di\n\nimport com.desarrollodroide.network.retrofit.NetworkLoggerInterceptor\nimport co"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/AccountDTO.kt",
    "chars": 514,
    "preview": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class AccountDTO(\n\n "
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/ApiResponse.kt",
    "chars": 157,
    "preview": "package com.desarrollodroide.network.model\n\n\ndata class ApiResponse<T>(\n    val success: Boolean,\n    val data: T? = nul"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/BookmarkDTO.kt",
    "chars": 664,
    "preview": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class BookmarkDTO (\n"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/BookmarkResponseDTO.kt",
    "chars": 1583,
    "preview": "package com.desarrollodroide.network.model\n\ndata class BookmarkResponseDTO (\n    val ok: Boolean?,\n    val message: List"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/BookmarksDTO.kt",
    "chars": 654,
    "preview": "package com.desarrollodroide.network.model\n\ndata class BookmarksDTO (\n    val ok: Boolean? = null,\n    val message: Book"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/LivenessResponseDTO.kt",
    "chars": 133,
    "preview": "package com.desarrollodroide.network.model\n\ndata class LivenessResponseDTO (\n    val ok: Boolean?,\n    val message: Rele"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/LoginRequestPayload.kt",
    "chars": 250,
    "preview": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class LoginRequestPa"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/LoginResponseDTO.kt",
    "chars": 163,
    "preview": "package com.desarrollodroide.network.model\n\ndata class LoginResponseDTO (\n    val ok: Boolean?,\n    val message: LoginRe"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/LoginResponseMessageDTO.kt",
    "chars": 240,
    "preview": "package com.desarrollodroide.network.model\n\ndata class LoginResponseMessageDTO (\n    val expires: Int?,    // Deprecated"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/ModifiedBookmarksDTO.kt",
    "chars": 159,
    "preview": "package com.desarrollodroide.network.model\n\ndata class ModifiedBookmarksDTO(\n    val bookmarks: List<BookmarkDTO>?,\n    "
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/ReadableContentResponseDTO.kt",
    "chars": 429,
    "preview": "package com.desarrollodroide.network.model\n\ndata class ReadableContentResponseDTO (\n    val ok: Boolean?,\n    val messag"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/ReadableMessageDto.kt",
    "chars": 124,
    "preview": "package com.desarrollodroide.network.model\n\ndata class ReadableMessageDto(\n    val content: String?,\n    val html: Strin"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/ReleaseInfoDTO.kt",
    "chars": 146,
    "preview": "package com.desarrollodroide.network.model\n\ndata class ReleaseInfoDTO (\n    val version: String?,\n    val commit: String"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/SessionDTO.kt",
    "chars": 333,
    "preview": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n//import com.shiori.domain"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/SyncBookmarksMessageDTO.kt",
    "chars": 150,
    "preview": "package com.desarrollodroide.network.model\n\ndata class SyncBookmarksMessageDTO(\n    val deleted: List<Int>?,\n    val mod"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/SyncBookmarksResponseDTO.kt",
    "chars": 152,
    "preview": "package com.desarrollodroide.network.model\n\ndata class SyncBookmarksResponseDTO(\n    val deleted: List<Int>?,\n    val me"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/TagDTO.kt",
    "chars": 317,
    "preview": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class TagDTO (\n    @"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/TagsDTO.kt",
    "chars": 114,
    "preview": "package com.desarrollodroide.network.model\n\nclass TagsDTO (\n    val ok: Boolean?,\n    val message: List<TagDTO>?\n)"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/UpdateCachePayloadDTO.kt",
    "chars": 359,
    "preview": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class UpdateCachePay"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/UpdateCachePayloadV1DTO.kt",
    "chars": 426,
    "preview": "package com.desarrollodroide.network.model\n\nimport com.google.gson.annotations.SerializedName\n\n\ndata class UpdateCachePa"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/model/util/NetworkChangeList.kt",
    "chars": 785,
    "preview": "package com.desarrollodroide.network.model.util\n\n/**\n * Network representation of a change list for a model.\n *\n * Chang"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/retrofit/FileRemoteDataSource.kt",
    "chars": 1053,
    "preview": "package com.desarrollodroide.network.retrofit\n\nimport android.content.Context\nimport okhttp3.OkHttpClient\nimport okhttp3"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/retrofit/NetworkBoundResource.kt",
    "chars": 3584,
    "preview": "package com.desarrollodroide.network.retrofit\n\nimport android.util.Log\nimport androidx.annotation.MainThread\nimport andr"
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/retrofit/NetworkLoggerInterceptor.kt",
    "chars": 2510,
    "preview": "package com.desarrollodroide.network.retrofit\n\nimport com.desarrollodroide.common.result.NetworkLogEntry\nimport kotlinx."
  },
  {
    "path": "network/src/main/java/com/desarrollodroide/network/retrofit/RetrofitNetwork.kt",
    "chars": 4659,
    "preview": "package com.desarrollodroide.network.retrofit\n\nimport com.desarrollodroide.network.model.AccountDTO\nimport com.desarroll"
  },
  {
    "path": "presentation/.gitignore",
    "chars": 6,
    "preview": "/build"
  },
  {
    "path": "presentation/build.gradle.kts",
    "chars": 4980,
    "preview": "plugins {\n    id(\"com.android.application\")\n    id(\"org.jetbrains.kotlin.android\")\n    id(\"de.mannodermaus.android-junit"
  },
  {
    "path": "presentation/proguard-rules.pro",
    "chars": 754,
    "preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
  },
  {
    "path": "presentation/src/main/AndroidManifest.xml",
    "chars": 2473,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:to"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ComposeSetup.kt",
    "chars": 1052,
    "preview": "package com.desarrollodroide.pagekeeper\n\nimport androidx.compose.foundation.isSystemInDarkTheme\nimport androidx.compose."
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/MainActivity.kt",
    "chars": 2664,
    "preview": "package com.desarrollodroide.pagekeeper\n\nimport android.content.Context\nimport android.os.Build\nimport android.os.Bundle"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ShioriApp.kt",
    "chars": 1288,
    "preview": "package com.desarrollodroide.pagekeeper\n\nimport android.app.Application\nimport coil.ImageLoader\nimport com.desarrollodro"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/di/AppModule.kt",
    "chars": 4801,
    "preview": "package com.desarrollodroide.pagekeeper.di\n\nimport android.content.Context\nimport android.util.Log\nimport coil.ImageLoad"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/di/PresenterModule.kt",
    "chars": 2642,
    "preview": "package com.desarrollodroide.pagekeeper.di\n\nimport com.desarrollodroide.pagekeeper.ui.feed.FeedViewModel\nimport com.desa"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/ContextExtensions.kt",
    "chars": 1393,
    "preview": "package com.desarrollodroide.pagekeeper.extensions\n\nimport android.content.Context\nimport android.content.Intent\nimport "
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/ImageLoaderExtensions.kt",
    "chars": 684,
    "preview": "package com.desarrollodroide.pagekeeper.extensions\n\nimport android.util.Log\nimport coil.ImageLoader\nimport coil.annotati"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/LongExtensions.kt",
    "chars": 374,
    "preview": "package com.desarrollodroide.pagekeeper.extensions\n\nfun Long.bytesToDisplaySize(): String {\n    val kb = this / 1024.0\n "
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/StringExtensions.kt",
    "chars": 891,
    "preview": "package com.desarrollodroide.pagekeeper.extensions\n\n/**\n * Determines if a string contains more than half Arabic charact"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/helpers/ThemeManager.kt",
    "chars": 263,
    "preview": "package com.desarrollodroide.pagekeeper.helpers\n\nimport androidx.compose.runtime.MutableState\nimport com.desarrollodroid"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/helpers/ThemeManagerImpl.kt",
    "chars": 424,
    "preview": "package com.desarrollodroide.pagekeeper.helpers\n\nimport androidx.compose.runtime.mutableStateOf\nimport com.desarrollodro"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/navigation/NavItem.kt",
    "chars": 1173,
    "preview": "package com.desarrollodroide.pagekeeper.navigation\n\nimport android.net.Uri\nimport androidx.navigation.NavType\nimport and"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/navigation/Navigation.kt",
    "chars": 2662,
    "preview": "package com.desarrollodroide.pagekeeper.navigation\n\nimport android.os.Build\nimport androidx.annotation.RequiresApi\nimpor"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkEditorActivity.kt",
    "chars": 5582,
    "preview": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport android.content.Context\nimport android.content.Intent\n"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkEditorScreen.kt",
    "chars": 4537,
    "preview": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport android.util.Log\nimport androidx.activity.compose.Back"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkEditorView.kt",
    "chars": 10334,
    "preview": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport androidx.compose.foundation.BorderStroke\nimport androi"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkViewModel.kt",
    "chars": 4132,
    "preview": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/NotSessionScreen.kt",
    "chars": 2285,
    "preview": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport androidx.compose.foundation.Image\nimport androidx.comp"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/ProgressButton.kt",
    "chars": 1719,
    "preview": "package com.desarrollodroide.pagekeeper.ui.bookmarkeditor\n\nimport androidx.compose.foundation.background\nimport androidx"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/CategoriesView.kt",
    "chars": 5260,
    "preview": "package com.desarrollodroide.pagekeeper.ui.components\n\nimport android.util.Log\nimport androidx.compose.animation.Animate"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/Dialogs.kt",
    "chars": 11977,
    "preview": "package com.desarrollodroide.pagekeeper.ui.components\n\nimport androidx.compose.animation.AnimatedVisibility\nimport andro"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/LoadingButton.kt",
    "chars": 4944,
    "preview": "package com.desarrollodroide.pagekeeper.ui.components\n\nimport androidx.compose.animation.AnimatedVisibility\nimport andro"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/UiState.kt",
    "chars": 1419,
    "preview": "package com.desarrollodroide.pagekeeper.ui.components\n\nimport kotlinx.coroutines.flow.MutableStateFlow\n\ndata class UiSta"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefresh.kt",
    "chars": 4315,
    "preview": "package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh\n\nimport androidx.compose.ui.Modifier\nimport androidx"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefreshIndicator.kt",
    "chars": 7900,
    "preview": "package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh\n\nimport androidx.compose.animation.Crossfade\nimport "
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefreshIndicatorTransform.kt",
    "chars": 2436,
    "preview": "package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh\n\nimport androidx.compose.animation.core.LinearOutSlo"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefreshState.kt",
    "chars": 8341,
    "preview": "package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh\n\nimport androidx.compose.animation.core.animate\nimpo"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/BookmarkViewer.kt",
    "chars": 18733,
    "preview": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport android.webkit.WebView\nimport android.webkit.WebViewClient\nimpor"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/CategoriesView.kt",
    "chars": 10663,
    "preview": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.co"
  },
  {
    "path": "presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/FeedContent.kt",
    "chars": 10566,
    "preview": "package com.desarrollodroide.pagekeeper.ui.feed\n\nimport android.util.Log\nimport androidx.compose.animation.AnimatedVisib"
  }
]

// ... and 65 more files (download for full content)

About this extraction

This page contains the full source code of the DesarrolloAntonio/Shiori-Android-Client GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 265 files (674.2 KB), approximately 155.1k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!