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
| | | | |
|:----------------------------------------------------:|:----------------------------------------------------:|:----------------------------------------------------:|:----------------------------------------------------:|
|  |  |  |  |
|  |  |  |  |
## 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

================================================
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
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\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\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.