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
================================================
Shiori
## 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:
## 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
================================================
================================================
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(
val data: T? = null,
val error: ErrorType? = null
) {
class Success(data: T) : Result(data)
class Loading(data: T? = null) : Result(data)
class Error(error: ErrorType? = null, data: T? = null) : Result(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 {
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 = "Test Content"
)
@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 = "Updated Content")
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
================================================
================================================
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()) }
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().bookmarksDao() }
single { get().tagDao() }
single { get().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 String.toBean() = GSON.fromJson(this)
inline fun JsonElement.toBean() = GSON.fromJson(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.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 fromJson(json: String): T {
val type = object : TypeToken() {}.type
return gson.fromJson(json, type)
}
inline fun fromJson(jsonElement: JsonElement): T {
val type = object : TypeToken() {}.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 {
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 {
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 {
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 {
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 {
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 {
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
val compactViewFlow: Flow
val makeArchivePublicFlow: Flow
val createEbookFlow: Flow
val autoAddBookmarkFlow: Flow
val createArchiveFlow: Flow
val hideTagFlow: Flow
val selectedCategoriesFlow: Flow>
fun getUser(): Flow
suspend fun saveUser(
session: UserPreferences,
serverUrl: String,
password: String,
)
val rememberUserDataStream: Flow
fun getRememberUser(): Flow
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)
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,
private val protoDataStore: DataStore,
private val rememberUserProtoDataStore: DataStore,
private val systemPreferences: DataStore,
private val hideTagDataStore: DataStore,
) : 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 {
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 {
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 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 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 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 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 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 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> = systemPreferences.data
.map { preferences ->
preferences.selectedCategoriesList.distinct()
}
override suspend fun setSelectedCategories(categories: List) {
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): String {
val gson = Gson()
return gson.toJson(tags)
}
@TypeConverter
fun toTagsList(tagsString: String): List {
return try {
val type = object : TypeToken>() {}.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 representing all bookmarks.
*/
@Query("SELECT * FROM bookmarks")
fun getAll(): Flow>
/**
* 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)
/**
* 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
): PagingSource
/**
* 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
/**
* 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): PagingSource
/**
* 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
// 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)
/**
* 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) {
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
/**
* 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>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTag(tag: TagEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAllTags(tags: List)
@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>
}
================================================
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,
@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
)
================================================
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>
fun sendLogout(
serverUrl: String,
xSession: String
): Flow>
fun sendLoginV1(
username: String,
password: String,
serverUrl: String
): Flow>
}
================================================
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(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(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(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?>>
fun getPagingBookmarks(
xSession: String,
serverUrl: String,
searchText: String,
tags: List,
saveToLocal: Boolean
): Flow>
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
fun getBookmarkReadableContent(
token: String,
serverUrl: String,
bookmarkId: Int
): Flow>
suspend fun syncAllBookmarks(
xSession: String,
serverUrl: String
): Flow
fun getLocalPagingBookmarks(
tags: List,
searchText: String
): Flow>
fun syncBookmarks(
token: String,
serverUrl: String,
syncBookmarksRequestPayload: SyncBookmarksRequestPayload
): Flow>
fun getBookmarkById(
token: String,
serverUrl: String,
bookmarkId: Int
): Flow>
}
================================================
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>(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?) = true
}.asFlow().flowOn(Dispatchers.IO)
override fun getPagingBookmarks(
xSession: String,
serverUrl: String,
searchText: String,
tags: List,
saveToLocal: Boolean
): Flow> {
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,
searchText: String
): Flow> {
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 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 = flow {
var currentPage = 1
var hasNextPage = true
val allBookmarks = mutableListOf()
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): 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 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 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 {
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(errorHandler = errorHandler) {
override suspend fun fetchFromRemote(): Response = apiService.getBookmarkReadableContent(
url = "${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/${bookmarkId}/readable",
authorization = "Bearer $token",
)
override fun fetchResult(data: ReadableContentResponseDTO): Flow {
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 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> {
return object : NetworkNoCacheResource(errorHandler = errorHandler) {
override suspend fun fetchFromRemote(): Response {
return apiService.syncBookmarks(
url = "${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/sync",
authorization = "Bearer $token",
body = syncBookmarksRequestPayload.toJson()
)
}
override fun fetchResult(data: SyncBookmarksResponseDTO): Flow {
return flow {
emit(data.toDomainModel())
}
}
}.asFlow().flowOn(Dispatchers.IO)
}
override fun getBookmarkById(
token: String,
serverUrl: String,
bookmarkId: Int
) = object :
NetworkNoCacheResource(errorHandler = errorHandler) {
override suspend fun fetchFromRemote(): Response = apiService.getBookmark(
url = "${serverUrl.removeTrailingSlash()}/api/v1/bookmarks/$bookmarkId",
authorization = "Bearer $token",
)
override fun fetchResult(data: SingleBookmarkResponseDTO): Flow {
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
val userDataStream: Flow
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 =
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>
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()
.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> =
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>
}
================================================
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(errorHandler = errorHandler) {
override suspend fun fetchFromRemote() = apiService.systemLiveness(
url = "${serverUrl.removeTrailingSlash()}/system/liveness"
)
override fun fetchResult(data: LivenessResponseDTO): Flow {
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?>>
fun getLocalTags(): Flow>
}
================================================
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>(errorHandler = errorHandler) {
override suspend fun saveRemoteData(response: TagsDTO) {
response.message?.map { it.toEntityModel() }?.let { tagsList ->
tagsDao.deleteAllTags()
tagsDao.insertAllTags(tagsList)
}
}
override fun fetchFromLocal(): Flow> = 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?) = true
}.asFlow().flowOn(Dispatchers.IO)
override fun getLocalTags(): Flow> {
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,
private val saveToLocal: Boolean,
) : PagingSource() {
override suspend fun load(params: LoadParams): LoadResult {
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 {
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? {
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
) : RemoteMediator() {
override suspend fun load(
loadType: LoadType,
state: PagingState
): 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 {
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
//) : PagingSource() {
//
// 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): LoadResult {
// 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? {
// 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()
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 suspendRunCatching(block: suspend () -> T): Result = 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,
versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions,
modelDeleter: suspend (List) -> Unit,
modelUpdater: suspend (List) -> 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 {
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 {
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 {
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 = mock()
private val protoDataStoreMock: DataStore = mock()
private val systemPreferencesDataStoreMock: DataStore = mock()
private val hideTagDataStoreMock: DataStore = mock()
private val rememberUserProtoDataStoreMock: DataStore = 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 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 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 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 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 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 = 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 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 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 = 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 = 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 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 SystemPreferences>()
// When
settingsPreferencesDataSourceImpl.setAutoAddBookmark(false)
// Then
verify(systemPreferencesDataStoreMock).updateData(captor.capture())
val updatedPreferences = captor.firstValue.invoke(initialPreferences)
assertFalse(updatedPreferences.autoAddBookmark)
}
@Test
fun `autoAddBookmarkFlow emits correct values`() = runTest {
// Given
val mockSystemPreferences = SystemPreferences.newBuilder()
.setAutoAddBookmark(true)
.build()
val mockSystemPreferencesFlow: Flow = flowOf(mockSystemPreferences)
whenever(systemPreferencesDataStoreMock.data).thenReturn(mockSystemPreferencesFlow)
// Then
settingsPreferencesDataSourceImpl.autoAddBookmarkFlow.test {
val emittedItem = awaitItem()
assertTrue(emittedItem)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `autoAddBookmarkFlow emits updates when preference changes`() = runTest {
// Given
val initialPreferences = SystemPreferences.newBuilder()
.setAutoAddBookmark(false)
.build()
val updatedPreferences = SystemPreferences.newBuilder()
.setAutoAddBookmark(true)
.build()
val preferencesFlow = flowOf(initialPreferences, updatedPreferences)
whenever(systemPreferencesDataStoreMock.data).thenReturn(preferencesFlow)
// Then
settingsPreferencesDataSourceImpl.autoAddBookmarkFlow.test {
assertFalse(awaitItem()) // Initial value
assertTrue(awaitItem()) // Updated value
cancelAndIgnoreRemainingEvents()
}
}
// --- CreateArchive Tests ---
@Test
fun `setCreateArchive updates preference correctly`() = runTest {
// Given
val newValue = true
val captor = argumentCaptor SystemPreferences>()
// When
settingsPreferencesDataSourceImpl.setCreateArchive(newValue)
// Then
verify(systemPreferencesDataStoreMock).updateData(captor.capture())
val testPreferences = SystemPreferences.getDefaultInstance()
val updatedPreferences = captor.firstValue.invoke(testPreferences)
assertEquals(newValue, updatedPreferences.createArchive)
}
@Test
fun `setCreateArchive can disable archive creation`() = runTest {
// Given
val initialPreferences = SystemPreferences.newBuilder()
.setCreateArchive(true)
.build()
val captor = argumentCaptor SystemPreferences>()
// When
settingsPreferencesDataSourceImpl.setCreateArchive(false)
// Then
verify(systemPreferencesDataStoreMock).updateData(captor.capture())
val updatedPreferences = captor.firstValue.invoke(initialPreferences)
assertFalse(updatedPreferences.createArchive)
}
@Test
fun `createArchiveFlow emits initial value correctly`() = runTest {
// Given
val mockSystemPreferences = SystemPreferences.newBuilder()
.setCreateArchive(true)
.build()
val mockSystemPreferencesFlow: Flow = flowOf(mockSystemPreferences)
whenever(systemPreferencesDataStoreMock.data).thenReturn(mockSystemPreferencesFlow)
// Then
settingsPreferencesDataSourceImpl.createArchiveFlow.test {
val emittedItem = awaitItem()
assertTrue(emittedItem)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `createArchiveFlow reflects preference changes`() = runTest {
// Given
val initialPreferences = SystemPreferences.newBuilder()
.setCreateArchive(false)
.build()
val updatedPreferences = SystemPreferences.newBuilder()
.setCreateArchive(true)
.build()
val preferencesFlow = flowOf(initialPreferences, updatedPreferences)
whenever(systemPreferencesDataStoreMock.data).thenReturn(preferencesFlow)
// Then
settingsPreferencesDataSourceImpl.createArchiveFlow.test {
assertFalse(awaitItem()) // Initial value
assertTrue(awaitItem()) // Updated value
cancelAndIgnoreRemainingEvents()
}
}
// --- User Preferences Getters Tests ---
// Tests for getUrl()
@Test
fun `getUrl returns correct server url from user preferences`() = runTest {
// Given
val expectedUrl = "https://example.com"
val userPreferences = UserPreferences.newBuilder()
.setUrl(expectedUrl)
.build()
whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))
// When
val actualUrl = settingsPreferencesDataSourceImpl.getUrl()
// Then
assertEquals(expectedUrl, actualUrl)
}
@Test
fun `getUrl returns empty string when no url is set`() = runTest {
// Given
val userPreferences = UserPreferences.getDefaultInstance()
whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))
// When
val actualUrl = settingsPreferencesDataSourceImpl.getUrl()
// Then
assertEquals("", actualUrl)
}
// Tests for getSession()
@Test
fun `getSession returns correct session from user preferences`() = runTest {
// Given
val expectedSession = "session123"
val userPreferences = UserPreferences.newBuilder()
.setSession(expectedSession)
.build()
whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))
// When
val actualSession = settingsPreferencesDataSourceImpl.getSession()
// Then
assertEquals(expectedSession, actualSession)
}
@Test
fun `getSession returns empty string when no session is set`() = runTest {
// Given
val userPreferences = UserPreferences.getDefaultInstance()
whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))
// When
val actualSession = settingsPreferencesDataSourceImpl.getSession()
// Then
assertEquals("", actualSession)
}
// Tests for getToken()
@Test
fun `getToken returns correct token from user preferences`() = runTest {
// Given
val expectedToken = "token123"
val userPreferences = UserPreferences.newBuilder()
.setToken(expectedToken)
.build()
whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))
// When
val actualToken = settingsPreferencesDataSourceImpl.getToken()
// Then
assertEquals(expectedToken, actualToken)
}
@Test
fun `getToken returns empty string when no token is set`() = runTest {
// Given
val userPreferences = UserPreferences.getDefaultInstance()
whenever(protoDataStoreMock.data).thenReturn(flowOf(userPreferences))
// When
val actualToken = settingsPreferencesDataSourceImpl.getToken()
// Then
assertEquals("", actualToken)
}
// --- Hide Tag Tests ---
// Tests for setHideTag()
@Test
fun `setHideTag updates tag correctly`() = runTest {
// Given
val tag = Tag(id = 1, name = "TestTag", selected = false, nBookmarks = 0)
val captor = argumentCaptor HideTag>()
// When
settingsPreferencesDataSourceImpl.setHideTag(tag)
// Then
verify(hideTagDataStoreMock).updateData(captor.capture())
val testHideTag = HideTag.getDefaultInstance()
val updatedHideTag = captor.firstValue.invoke(testHideTag)
assertEquals(tag.id, updatedHideTag.id)
assertEquals(tag.name, updatedHideTag.name)
}
@Test
fun `setHideTag handles null tag by returning default instance`() = runTest {
// Given
val captor = argumentCaptor HideTag>()
// When
settingsPreferencesDataSourceImpl.setHideTag(null)
// Then
verify(hideTagDataStoreMock).updateData(captor.capture())
val testHideTag = HideTag.getDefaultInstance()
val updatedHideTag = captor.firstValue.invoke(testHideTag)
assertEquals(HideTag.getDefaultInstance(), updatedHideTag)
}
// Tests for hideTagFlow
@Test
fun `hideTagFlow emits null when no tag is set`() = runTest {
// Given
val defaultHideTag = HideTag.getDefaultInstance()
whenever(hideTagDataStoreMock.data).thenReturn(flowOf(defaultHideTag))
// Then
settingsPreferencesDataSourceImpl.hideTagFlow.test {
val emittedItem = awaitItem()
assertNull(emittedItem)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `hideTagFlow emits correct tag when set`() = runTest {
// Given
val expectedTag = HideTag.newBuilder()
.setId(1)
.setName("TestTag")
.build()
whenever(hideTagDataStoreMock.data).thenReturn(flowOf(expectedTag))
// Then
settingsPreferencesDataSourceImpl.hideTagFlow.test {
val emittedItem = awaitItem()
assertNotNull(emittedItem)
assertEquals(expectedTag.id, emittedItem?.id)
assertEquals(expectedTag.name, emittedItem?.name)
assertEquals(false, emittedItem?.selected)
assertEquals(0, emittedItem?.nBookmarks)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `hideTagFlow reflects changes in hide tag`() = runTest {
// Given
val initialTag = HideTag.getDefaultInstance()
val updatedTag = HideTag.newBuilder()
.setId(1)
.setName("UpdatedTag")
.build()
val tagsFlow = flowOf(initialTag, updatedTag)
whenever(hideTagDataStoreMock.data).thenReturn(tagsFlow)
// Then
settingsPreferencesDataSourceImpl.hideTagFlow.test {
assertNull(awaitItem()) // Initial null value
val updatedItem = awaitItem()
assertNotNull(updatedItem)
assertEquals(updatedTag.id, updatedItem?.id)
assertEquals(updatedTag.name, updatedItem?.name)
cancelAndIgnoreRemainingEvents()
}
}
// --- Sync Timestamp Tests ---
// Tests for getLastSyncTimestamp()
@Test
fun `getLastSyncTimestamp returns correct timestamp`() = runTest {
// Given
val expectedTimestamp = 1234567890L
val systemPreferences = SystemPreferences.newBuilder()
.setLastSyncTimestamp(expectedTimestamp)
.build()
whenever(systemPreferencesDataStoreMock.data).thenReturn(flowOf(systemPreferences))
// When
val actualTimestamp = settingsPreferencesDataSourceImpl.getLastSyncTimestamp()
// Then
assertEquals(expectedTimestamp, actualTimestamp)
}
// Tests for setLastSyncTimestamp()
@Test
fun `setLastSyncTimestamp updates timestamp correctly`() = runTest {
// Given
val newTimestamp = 1234567890L
val captor = argumentCaptor SystemPreferences>()
// When
settingsPreferencesDataSourceImpl.setLastSyncTimestamp(newTimestamp)
// Then
verify(systemPreferencesDataStoreMock).updateData(captor.capture())
val testPreferences = SystemPreferences.getDefaultInstance()
val updatedPreferences = captor.firstValue.invoke(testPreferences)
assertEquals(newTimestamp, updatedPreferences.lastSyncTimestamp)
}
// Tests for setCurrentTimeStamp()
@Test
fun `setCurrentTimeStamp updates timestamp with current time`() = runTest {
// Given
val captor = argumentCaptor SystemPreferences>()
// When
settingsPreferencesDataSourceImpl.setCurrentTimeStamp()
// Then
verify(systemPreferencesDataStoreMock).updateData(captor.capture())
val testPreferences = SystemPreferences.getDefaultInstance()
val updatedPreferences = captor.firstValue.invoke(testPreferences)
// Verify timestamp is recent (within last minute)
val currentTime = ZonedDateTime.now(ZoneId.systemDefault()).toEpochSecond()
val timestampDiff = currentTime - updatedPreferences.lastSyncTimestamp
assertTrue(timestampDiff < 60) // Difference should be less than 60 seconds
}
// --- Selected Categories Flow Tests ---
// TODO
}
private suspend fun verifyPreferenceEdit(
preferencesDataStore: DataStore,
key: Preferences.Key,
expectedValue: T
) {
val argumentCaptor = argumentCaptor Preferences>()
verify(preferencesDataStore).updateData(argumentCaptor.capture())
val preferences = mutablePreferencesOf()
val updatedPreferences = argumentCaptor.firstValue(preferences)
assertEquals(expectedValue, updatedPreferences[key])
}
================================================
FILE: data/src/test/java/com/desarrollodroide/data/local/room/converters/TagsConverterTest.kt
================================================
package com.desarrollodroide.data.local.room.converters
import com.desarrollodroide.model.Tag
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TagsConverterTest {
private val converter = TagsConverter()
@Test
fun `fromTagsList converts list of tags to JSON string correctly`() {
val tags = listOf(
Tag(id = 1, name = "Tech", selected = true, nBookmarks = 10),
Tag(id = 2, name = "Science", selected = false, nBookmarks = 5)
)
val jsonResult = converter.fromTagsList(tags)
assertTrue(jsonResult.contains("Tech") && jsonResult.contains("Science"))
}
@Test
fun `toTagsList converts JSON string to list of tags correctly`() {
val json = """
[
{"id":1,"name":"Tech"},
{"id":2,"name":"Science"}
]
""".trimIndent()
val tagsList = converter.toTagsList(json)
assertEquals(2, tagsList.size)
assertEquals("Tech", tagsList[0].name)
assertEquals("Science", tagsList[1].name)
}
@Test
fun `toTagsList returns empty list on malformed JSON`() {
val malformedJson = "this is not a valid json"
val result = converter.toTagsList(malformedJson)
assertTrue(result.isEmpty())
}
}
================================================
FILE: data/src/test/java/com/desarrollodroide/data/mapper/MapperTest.kt
================================================
package com.desarrollodroide.data.mapper
import com.desarrollodroide.data.local.room.entity.BookmarkEntity
import com.desarrollodroide.data.local.room.entity.TagEntity
import com.desarrollodroide.model.Account
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.Tag
import com.desarrollodroide.model.UpdateCachePayload
import org.junit.jupiter.api.Assertions.*
import com.desarrollodroide.network.model.AccountDTO
import com.desarrollodroide.network.model.BookmarkDTO
import com.desarrollodroide.network.model.BookmarksDTO
import com.desarrollodroide.network.model.LivenessResponseDTO
import com.desarrollodroide.network.model.LoginResponseDTO
import com.desarrollodroide.network.model.LoginResponseMessageDTO
import com.desarrollodroide.network.model.ReadableContentResponseDTO
import com.desarrollodroide.network.model.ReadableMessageDto
import com.desarrollodroide.network.model.ReleaseInfoDTO
import com.desarrollodroide.network.model.SessionDTO
import com.desarrollodroide.network.model.TagDTO
import org.junit.jupiter.api.Test
class MapperTest {
@Test
fun `SessionDTO toDomainModel maps correctly`() {
val accountDTO = AccountDTO(
id = 1,
userName = "testUser",
password = "password",
isOwner = true,
oldPassword = "oldPass",
newPassword = "newPass",
)
val sessionDTO = SessionDTO(
token = "token123",
session = "session123",
account = accountDTO
)
val user = sessionDTO.toDomainModel()
assertEquals("token123", user.token)
assertEquals("session123", user.session)
assertEquals("testUser", user.account.userName)
assertEquals("password", user.account.password)
assertEquals(true, user.account.owner)
}
@Test
fun `SessionDTO toProtoEntity maps correctly`() {
val accountDTO = AccountDTO(
id = 1,
userName = "testUser",
password = "password",
isOwner = true,
oldPassword = "oldPass",
newPassword = "newPass",
)
val sessionDTO = SessionDTO(
token = "token123",
session = "session123",
account = accountDTO
)
val userPreferences = sessionDTO.toProtoEntity()
assertEquals(1, userPreferences.id)
assertEquals("testUser", userPreferences.username)
assertEquals(true, userPreferences.owner)
assertEquals("", userPreferences.password)
assertEquals("session123", userPreferences.session)
assertEquals("", userPreferences.url) // Assuming this is not set from DTO
assertEquals(false, userPreferences.rememberPassword) // Assuming default value
assertEquals("", userPreferences.token)
}
@Test
fun `AccountDTO toDomainModel maps correctly`() {
val accountDTO = AccountDTO(
id = 1,
userName = "testUser",
password = "password",
isOwner = true,
oldPassword = "oldPass",
newPassword = "newPass",
)
val account = accountDTO.toDomainModel()
assertEquals("testUser", account.userName)
assertEquals("password", account.password)
assertEquals(true, account.owner)
}
@Test
fun `BookmarkDTO toDomainModel maps correctly`() {
val tagDTO = TagDTO(
id = 1,
name = "tag1",
nBookmarks = 5
)
val bookmarkDTO = BookmarkDTO(
id = 1,
url = "http://example.com",
title = "Example Title",
excerpt = "Example Excerpt",
author = "Author Name",
public = 1,
modified = "2023-06-18",
createdAt = "2023-06-19",
imageURL = "/image.jpg",
hasContent = true,
hasArchive = true,
hasEbook = true,
tags = listOf(tagDTO),
createArchive = true,
createEbook = true
)
val serverUrl = "http://example.com"
val bookmark = bookmarkDTO.toDomainModel(serverUrl)
assertEquals(1, bookmark.id)
assertEquals("http://example.com", bookmark.url)
assertEquals("Example Title", bookmark.title)
assertEquals("Example Excerpt", bookmark.excerpt)
assertEquals("Author Name", bookmark.author)
assertEquals(1, bookmark.public)
assertEquals("2023-06-18", bookmark.modified)
assertEquals("2023-06-19", bookmark.createAt)
assertEquals("http://example.com/image.jpg", bookmark.imageURL)
assertEquals(true, bookmark.hasContent)
assertEquals(true, bookmark.hasArchive)
assertEquals(true, bookmark.hasEbook)
assertEquals(1, bookmark.tags.size)
assertEquals(1, bookmark.tags[0].id)
assertEquals("tag1", bookmark.tags[0].name)
assertEquals(5, bookmark.tags[0].nBookmarks)
assertEquals(true, bookmark.createArchive)
assertEquals(true, bookmark.createEbook)
}
@Test
fun `BookmarksDTO toDomainModel maps correctly`() {
val tagDTO = TagDTO(
id = 1,
name = "tag1",
nBookmarks = 5
)
val bookmarkDTO = BookmarkDTO(
id = 1,
url = "http://example.com",
title = "Example Title",
excerpt = "Example Excerpt",
author = "Author Name",
public = 1,
modified = "2023-06-18",
createdAt = "2023-06-19",
imageURL = "/image.jpg",
hasContent = true,
hasArchive = true,
hasEbook = true,
tags = listOf(tagDTO),
createArchive = true,
createEbook = true
)
val bookmarksDTO = BookmarksDTO(
page = 1,
maxPage = 10,
bookmarks = listOf(bookmarkDTO)
)
val serverUrl = "http://example.com"
val bookmarks = bookmarksDTO.toDomainModel(serverUrl)
assertEquals(1, bookmarks.page)
assertEquals(10, bookmarks.maxPage)
assertEquals(1, bookmarks.bookmarks.size)
val bookmark = bookmarks.bookmarks[0]
assertEquals(1, bookmark.id)
assertEquals("http://example.com", bookmark.url)
assertEquals("Example Title", bookmark.title)
assertEquals("Example Excerpt", bookmark.excerpt)
assertEquals("Author Name", bookmark.author)
assertEquals(1, bookmark.public)
assertEquals("2023-06-18", bookmark.modified)
assertEquals("2023-06-19", bookmark.createAt)
assertEquals("http://example.com/image.jpg", bookmark.imageURL)
assertEquals(true, bookmark.hasContent)
assertEquals(true, bookmark.hasArchive)
assertEquals(true, bookmark.hasEbook)
assertEquals(1, bookmark.tags.size)
assertEquals(1, bookmark.tags[0].id)
assertEquals("tag1", bookmark.tags[0].name)
assertEquals(5, bookmark.tags[0].nBookmarks)
assertEquals(true, bookmark.createArchive)
assertEquals(true, bookmark.createEbook)
}
@Test
fun `TagDTO toDomainModel maps correctly`() {
val tagDTO = TagDTO(
id = 1,
name = "tag1",
nBookmarks = 5
)
val tag = tagDTO.toDomainModel()
assertEquals(1, tag.id)
assertEquals("tag1", tag.name)
assertEquals(false, tag.selected) // Assuming selected is always false in the domain model
assertEquals(5, tag.nBookmarks)
}
@Test
fun `TagDTO toDomainModel with null fields maps correctly`() {
val tagDTO = TagDTO(
id = null,
name = null,
nBookmarks = null
)
val tag = tagDTO.toDomainModel()
assertEquals(0, tag.id) // Default value for id
assertEquals("", tag.name) // Default value for name
assertEquals(false, tag.selected) // Assuming selected is always false in the domain model
assertEquals(0, tag.nBookmarks) // Default value for nBookmarks
}
@Test
fun `TagDTO toEntityModel maps correctly`() {
val tagDTO = TagDTO(
id = 1,
name = "tag1",
nBookmarks = 5
)
val tagEntity = tagDTO.toEntityModel()
assertEquals(1, tagEntity.id)
assertEquals("tag1", tagEntity.name)
assertEquals(5, tagEntity.nBookmarks)
}
@Test
fun `TagDTO toEntityModel with null fields maps correctly`() {
val tagDTO = TagDTO(
id = null,
name = null,
nBookmarks = null
)
val tagEntity = tagDTO.toEntityModel()
assertEquals(0, tagEntity.id) // Default value for id
assertEquals("", tagEntity.name) // Default value for name
assertEquals(0, tagEntity.nBookmarks) // Default value for nBookmarks
}
@Test
fun `TagEntity toDomainModel maps correctly`() {
val tagEntity = TagEntity(
id = 1,
name = "tag1",
nBookmarks = 5
)
val tag = tagEntity.toDomainModel()
assertEquals(1, tag.id)
assertEquals("tag1", tag.name)
assertEquals(false, tag.selected) // Assuming selected is always false in the domain model
assertEquals(5, tag.nBookmarks)
}
@Test
fun `Account toRequestBody maps correctly`() {
val account = Account(
id = 1,
userName = "testUser",
password = "password",
owner = true,
serverUrl = "https://example.com",
)
val loginRequestPayload = account.toRequestBody()
assertEquals("testUser", loginRequestPayload.username)
assertEquals("password", loginRequestPayload.password)
}
@Test
fun `BookmarkDTO toEntityModel maps correctly`() {
val tagDTO = TagDTO(
id = 1,
name = "tag1",
nBookmarks = 5
)
val bookmarkDTO = BookmarkDTO(
id = 1,
url = "http://example.com",
title = "Example Title",
excerpt = "Example Excerpt",
author = "Author Name",
public = 1,
modified = "2023-06-18",
createdAt = "2023-06-19",
imageURL = "/image.jpg",
hasContent = true,
hasArchive = true,
hasEbook = true,
tags = listOf(tagDTO),
createArchive = true,
createEbook = true
)
val bookmarkEntity = bookmarkDTO.toEntityModel()
assertEquals(1, bookmarkEntity.id)
assertEquals("http://example.com", bookmarkEntity.url)
assertEquals("Example Title", bookmarkEntity.title)
assertEquals("Example Excerpt", bookmarkEntity.excerpt)
assertEquals("Author Name", bookmarkEntity.author)
assertEquals(1, bookmarkEntity.isPublic)
assertEquals("2023-06-18", bookmarkEntity.modified)
assertEquals("2023-06-19", bookmarkEntity.createdAt)
assertEquals("/image.jpg", bookmarkEntity.imageURL)
assertEquals(true, bookmarkEntity.hasContent)
assertEquals(true, bookmarkEntity.hasArchive)
assertEquals(true, bookmarkEntity.hasEbook)
assertEquals(1, bookmarkEntity.tags.size)
assertEquals(1, bookmarkEntity.tags[0].id)
assertEquals("tag1", bookmarkEntity.tags[0].name)
assertEquals(5, bookmarkEntity.tags[0].nBookmarks)
assertEquals(true, bookmarkEntity.createArchive)
assertEquals(true, bookmarkEntity.createEbook)
}
@Test
fun `BookmarkDTO toEntityModel with null fields maps correctly`() {
val bookmarkDTO = BookmarkDTO(
id = null,
url = null,
title = null,
excerpt = null,
author = null,
public = null,
modified = null,
createdAt = null,
imageURL = null,
hasContent = null,
hasArchive = null,
hasEbook = null,
tags = null,
createArchive = null,
createEbook = null
)
val bookmarkEntity = bookmarkDTO.toEntityModel()
assertEquals(0, bookmarkEntity.id) // Default value for id
assertEquals("", bookmarkEntity.url) // Default value for url
assertEquals("", bookmarkEntity.title) // Default value for title
assertEquals("", bookmarkEntity.excerpt) // Default value for excerpt
assertEquals("", bookmarkEntity.author) // Default value for author
assertEquals(0, bookmarkEntity.isPublic) // Default value for isPublic
assertEquals("", bookmarkEntity.modified) // Default value for modified
assertEquals("", bookmarkEntity.createdAt) // Default value for createdAt
assertEquals("", bookmarkEntity.imageURL) // Default value for imageURL
assertEquals(false, bookmarkEntity.hasContent) // Default value for hasContent
assertEquals(false, bookmarkEntity.hasArchive) // Default value for hasArchive
assertEquals(false, bookmarkEntity.hasEbook) // Default value for hasEbook
assertEquals(0, bookmarkEntity.tags.size) // Default empty list for tags
assertEquals(false, bookmarkEntity.createArchive) // Default value for createArchive
assertEquals(false, bookmarkEntity.createEbook) // Default value for createEbook
}
@Test
fun `BookmarkEntity toDomainModel maps correctly`() {
val tag = Tag(
id = 1,
name = "tag1",
selected = false,
nBookmarks = 5
)
val bookmarkEntity = BookmarkEntity(
id = 1,
url = "http://example.com",
title = "Example Title",
excerpt = "Example Excerpt",
author = "Author Name",
isPublic = 1,
modified = "2023-06-18",
createdAt = "2023-06-19",
imageURL = "/image.jpg",
hasContent = true,
hasArchive = true,
hasEbook = true,
tags = listOf(tag),
createArchive = true,
createEbook = true
)
val bookmark = bookmarkEntity.toDomainModel()
assertEquals(1, bookmark.id)
assertEquals("http://example.com", bookmark.url)
assertEquals("Example Title", bookmark.title)
assertEquals("Example Excerpt", bookmark.excerpt)
assertEquals("Author Name", bookmark.author)
assertEquals(1, bookmark.public)
assertEquals("2023-06-18", bookmark.modified)
assertEquals("2023-06-19", bookmark.createAt)
assertEquals("/image.jpg", bookmark.imageURL)
assertEquals(true, bookmark.hasContent)
assertEquals(true, bookmark.hasArchive)
assertEquals(true, bookmark.hasEbook)
assertEquals(1, bookmark.tags.size)
assertEquals(1, bookmark.tags[0].id)
assertEquals("tag1", bookmark.tags[0].name)
assertEquals(5, bookmark.tags[0].nBookmarks)
assertEquals(true, bookmark.createArchive)
assertEquals(true, bookmark.createEbook)
}
@Test
fun `BookmarkEntity toDomainModel with empty tags maps correctly`() {
val bookmarkEntity = BookmarkEntity(
id = 1,
url = "http://example.com",
title = "Example Title",
excerpt = "Example Excerpt",
author = "Author Name",
isPublic = 1,
modified = "2023-06-18",
createdAt = "2023-06-19",
imageURL = "/image.jpg",
hasContent = true,
hasArchive = true,
hasEbook = true,
tags = emptyList(),
createArchive = true,
createEbook = true
)
val bookmark = bookmarkEntity.toDomainModel()
assertEquals(1, bookmark.id)
assertEquals("http://example.com", bookmark.url)
assertEquals("Example Title", bookmark.title)
assertEquals("Example Excerpt", bookmark.excerpt)
assertEquals("Author Name", bookmark.author)
assertEquals(1, bookmark.public)
assertEquals("2023-06-18", bookmark.modified)
assertEquals("2023-06-19", bookmark.createAt)
assertEquals("/image.jpg", bookmark.imageURL)
assertEquals(true, bookmark.hasContent)
assertEquals(true, bookmark.hasArchive)
assertEquals(true, bookmark.hasEbook)
assertEquals(0, bookmark.tags.size) // Ensure tags are empty
assertEquals(true, bookmark.createArchive)
assertEquals(true, bookmark.createEbook)
}
@Test
fun `UpdateCachePayload toDTO maps correctly`() {
val updateCachePayload = UpdateCachePayload(
createArchive = true,
createEbook = false,
ids = listOf(1, 2, 3),
keepMetadata = true,
skipExist = false
)
val updateCachePayloadDTO = updateCachePayload.toDTO()
assertEquals(true, updateCachePayloadDTO.createArchive)
assertEquals(false, updateCachePayloadDTO.createEbook)
assertEquals(listOf(1, 2, 3), updateCachePayloadDTO.ids)
assertEquals(true, updateCachePayloadDTO.keepMetadata)
}
@Test
fun `LivenessResponseDTO toDomainModel maps correctly`() {
val releaseInfoDTO = ReleaseInfoDTO(
version = "1.0.0",
date = "2023-06-18",
commit = "abc123"
)
val livenessResponseDTO = LivenessResponseDTO(
ok = true,
message = releaseInfoDTO
)
val livenessResponse = livenessResponseDTO.toDomainModel()
assertEquals(true, livenessResponse.ok)
assertEquals("1.0.0", livenessResponse.message?.version)
assertEquals("2023-06-18", livenessResponse.message?.date)
assertEquals("abc123", livenessResponse.message?.commit)
}
@Test
fun `LivenessResponseDTO toDomainModel with null message maps correctly`() {
val livenessResponseDTO = LivenessResponseDTO(
ok = true,
message = null
)
val livenessResponse = livenessResponseDTO.toDomainModel()
assertEquals(true, livenessResponse.ok)
assertEquals(null, livenessResponse.message)
}
@Test
fun `LivenessResponseDTO toDomainModel with null ok maps correctly`() {
val releaseInfoDTO = ReleaseInfoDTO(
version = "1.0.0",
date = "2023-06-18",
commit = "abc123"
)
val livenessResponseDTO = LivenessResponseDTO(
ok = null,
message = releaseInfoDTO
)
val livenessResponse = livenessResponseDTO.toDomainModel()
assertEquals(false, livenessResponse.ok)
assertEquals("1.0.0", livenessResponse.message?.version)
assertEquals("2023-06-18", livenessResponse.message?.date)
assertEquals("abc123", livenessResponse.message?.commit)
}
@Test
fun `ReleaseInfoDTO toDomainModel maps correctly`() {
val releaseInfoDTO = ReleaseInfoDTO(
version = "1.0.0",
date = "2023-06-18",
commit = "abc123"
)
val releaseInfo = releaseInfoDTO.toDomainModel()
assertEquals("1.0.0", releaseInfo.version)
assertEquals("2023-06-18", releaseInfo.date)
assertEquals("abc123", releaseInfo.commit)
}
@Test
fun `ReleaseInfoDTO toDomainModel with null fields maps correctly`() {
val releaseInfoDTO = ReleaseInfoDTO(
version = null,
date = null,
commit = null
)
val releaseInfo = releaseInfoDTO.toDomainModel()
assertEquals("", releaseInfo.version) // Default value for version
assertEquals("", releaseInfo.date) // Default value for date
assertEquals("", releaseInfo.commit) // Default value for commit
}
@Test
fun `LoginResponseDTO toProtoEntity maps correctly`() {
val loginResponseMessageDTO = LoginResponseMessageDTO(
expires = 3600,
session = "session123",
token = "token123"
)
val loginResponseDTO = LoginResponseDTO(
ok = true,
message = loginResponseMessageDTO,
error = null
)
val userPreferences = loginResponseDTO.toProtoEntity(userName = "testUser")
assertEquals("session123", userPreferences.session)
assertEquals("testUser", userPreferences.username)
assertEquals("token123", userPreferences.token)
}
@Test
fun `LoginResponseDTO toProtoEntity with null message maps correctly`() {
val loginResponseDTO = LoginResponseDTO(
ok = true,
message = null,
error = null
)
val userPreferences = loginResponseDTO.toProtoEntity(userName = "testUser")
assertEquals("", userPreferences.session) // Default value for session
assertEquals("testUser", userPreferences.username)
assertEquals("", userPreferences.token) // Default value for token
}
@Test
fun `ReadableContentResponseDTO toDomainModel maps correctly`() {
val readableMessageDto = ReadableMessageDto(
content = "Sample Content",
html = "Sample HTML
"
)
val readableContentResponseDTO = ReadableContentResponseDTO(
ok = true,
message = readableMessageDto
)
val readableContent = readableContentResponseDTO.toDomainModel()
assertEquals(true, readableContent.ok)
assertEquals("Sample Content", readableContent.message.content)
assertEquals("Sample HTML
", readableContent.message.html)
}
@Test
fun `ReadableContentResponseDTO toDomainModel with null fields maps correctly`() {
val readableContentResponseDTO = ReadableContentResponseDTO(
ok = null,
message = null
)
val readableContent = readableContentResponseDTO.toDomainModel()
assertEquals(false, readableContent.ok) // Default value for ok
assertEquals("", readableContent.message.content) // Default value for content
assertEquals("", readableContent.message.html) // Default value for html
}
@Test
fun `ReadableMessageDto toDomainModel maps correctly`() {
val readableMessageDto = ReadableMessageDto(
content = "Sample Content",
html = "Sample HTML
"
)
val readableMessage = readableMessageDto.toDomainModel()
assertEquals("Sample Content", readableMessage.content)
assertEquals("Sample HTML
", readableMessage.html)
}
@Test
fun `ReadableMessageDto toDomainModel with null fields maps correctly`() {
val readableMessageDto = ReadableMessageDto(
content = null,
html = null
)
val readableMessage = readableMessageDto.toDomainModel()
assertEquals("", readableMessage.content) // Default value for content
assertEquals("", readableMessage.html) // Default value for html
}
@Test
fun `toAddBookmarkDTO should map fields correctly`() {
// Given
val tags = listOf(Tag(id = 1, name = "education"), Tag(id = 2, name = "reading"))
val bookmark = Bookmark(
url = "https://example.com",
tags = tags,
public = 1,
createArchive = true,
createEbook = true,
title = "Example Title"
)
// When
val dto = bookmark.toAddBookmarkDTO()
// Then
assertNull(dto.id)
assertEquals("https://example.com", dto.url)
assertEquals("Example Title", dto.title)
assertEquals("", dto.excerpt)
assertNull(dto.author)
assertEquals(1, dto.public)
assertNull(dto.createdAt)
assertNull(dto.modified)
assertNull(dto.imageURL)
assertNull(dto.hasContent)
assertNull(dto.hasArchive)
assertNull(dto.hasEbook)
assertEquals(2, dto.tags?.size)
assertEquals("education", dto.tags?.get(0)?.name)
assertEquals("reading", dto.tags?.get(1)?.name)
assertTrue(dto.createArchive == true)
assertTrue(dto.createEbook == true)
}
@Test
fun `toEditBookmarkDTO should map all fields correctly`() {
// Given
val tags = listOf(Tag(id = 1, name = "education"), Tag(id = 2, name = "reading"))
val bookmark = Bookmark(
id = 1,
url = "https://example.com",
title = "Example Title",
excerpt = "An example excerpt",
author = "Author Name",
public = 1,
createAt = "2023-01-01T12:00:00",
modified = "2023-01-01T12:00:00",
imageURL = "https://example.com/image.jpg",
hasContent = true,
hasArchive = false,
hasEbook = false,
tags = tags,
createArchive = true,
createEbook = false
)
// When
val dto = bookmark.toEditBookmarkDTO()
// Then
assertEquals(1, dto.id)
assertEquals("https://example.com", dto.url)
assertEquals("Example Title", dto.title)
assertEquals("An example excerpt", dto.excerpt)
assertEquals("Author Name", dto.author)
assertEquals(1, dto.public)
assertEquals("2023-01-01T12:00:00", dto.createdAt)
assertEquals("2023-01-01T12:00:00", dto.modified)
assertEquals("https://example.com/image.jpg", dto.imageURL)
assertTrue(dto.hasContent == true)
assertFalse(dto.hasArchive == true)
assertFalse(dto.hasEbook == true)
assertEquals(2, dto.tags?.size)
assertEquals("education", dto.tags?.get(0)?.name)
assertEquals("reading", dto.tags?.get(1)?.name)
assertTrue(dto.createArchive == true)
assertFalse(dto.createEbook == true)
}
@Test
fun `toEditBookmarkJson should include all fields except hasEbook and createEbook`() {
// Given
val tags = listOf(TagDTO(id = 1, name = "education", nBookmarks = null), TagDTO(id = 2, name = "reading", nBookmarks = null))
val bookmarkDTO = BookmarkDTO(
id = 1,
url = "https://example.com",
title = "Example Title",
excerpt = "An example excerpt",
author = "Author Name",
public = 1,
createdAt = "2023-01-01T12:00:00",
modified = "2023-01-01T12:00:00",
imageURL = "https://example.com/image.jpg",
hasContent = true,
hasArchive = false,
hasEbook = true,
tags = tags,
createArchive = true,
createEbook = true
)
// When
val json = bookmarkDTO.toEditBookmarkJson()
// Then
assertTrue(json.contains("\"id\":1"))
assertTrue(json.contains("\"url\":\"https://example.com\""))
assertTrue(json.contains("\"title\":\"Example Title\""))
assertTrue(json.contains("\"excerpt\":\"An example excerpt\""))
assertTrue(json.contains("\"author\":\"Author Name\""))
assertTrue(json.contains("\"public\":1"))
assertTrue(json.contains("\"createdAt\":\"2023-01-01T12:00:00\""))
assertTrue(json.contains("\"modified\":\"2023-01-01T12:00:00\""))
assertTrue(json.contains("\"imageURL\":\"https://example.com/image.jpg\""))
assertTrue(json.contains("\"hasContent\":true"))
assertTrue(json.contains("\"hasArchive\":false"))
assertTrue(json.contains("\"tags\":[{\"name\":\"education\"},{\"name\":\"reading\"}]"))
assertTrue(json.contains("\"create_archive\":true"))
assertFalse(json.contains("\"hasEbook\":true")) // Excluded in JSON
assertTrue(json.contains("\"create_archive\":true"))
}
}
================================================
FILE: data/src/test/java/com/desarrollodroide/data/repository/AuthRepositoryTest.kt
================================================
package com.desarrollodroide.data.repository
import com.desarrollodroide.common.result.ErrorHandler
import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource
import com.desarrollodroide.model.Account
import com.desarrollodroide.model.User
import com.desarrollodroide.network.model.AccountDTO
import com.desarrollodroide.network.model.SessionDTO
import com.desarrollodroide.network.retrofit.RetrofitNetwork
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.mockito.Mockito.*
import retrofit2.Response
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.check
import com.desarrollodroide.common.result.Result
import com.desarrollodroide.network.model.LoginResponseDTO
import com.desarrollodroide.network.model.LoginResponseMessageDTO
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.mockito.kotlin.anyOrNull
import java.io.IOException
@ExperimentalCoroutinesApi
class AuthRepositoryImplTest {
@Mock
private lateinit var apiService: RetrofitNetwork
@Mock
private lateinit var settingsPreferenceDataSource: SettingsPreferenceDataSource
@Mock
private lateinit var errorHandler: ErrorHandler
private lateinit var authRepository: AuthRepositoryImpl
@BeforeEach
fun setup() {
MockitoAnnotations.openMocks(this)
authRepository = AuthRepositoryImpl(apiService, settingsPreferenceDataSource, errorHandler)
}
@Test
fun `sendLogin should emit Loading and Success states when API call is successful`() = runTest {
// Arrange
val username = "testUser"
val password = "testPassword"
val serverUrl = "http://test.com"
val sessionDTO = SessionDTO(
"testSession",
"testToken",
AccountDTO(1, username, isOwner = false)
)
val expectedUser =
User("testToken", "testSession", Account(1, username, password, false, serverUrl))
`when`(apiService.sendLogin(anyString(), any())).thenReturn(Response.success(sessionDTO))
`when`(settingsPreferenceDataSource.getUser()).thenReturn(flowOf(expectedUser))
// Act
val results = authRepository.sendLogin(username, password, serverUrl).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null)
assertTrue(results[1] is Result.Loading && results[1].data != null)
assertTrue(results[2] is Result.Success && results[2].data == expectedUser)
verify(settingsPreferenceDataSource).saveUser(any(), eq(serverUrl), eq(password))
verify(apiService).sendLogin(check { it.endsWith("/api/login") }, any())
}
@Test
fun `sendLogin should emit Loading and Error states when API call fails`() = runTest {
// Arrange
val username = "testUser"
val password = "testPassword"
val serverUrl = "http://test.com"
val errorMessage = "Invalid credentials"
val errorResponseBody = errorMessage.toResponseBody("text/plain".toMediaTypeOrNull())
`when`(apiService.sendLogin(anyString(), any())).thenReturn(Response.error(400, errorResponseBody))
`when`(errorHandler.getApiError(eq(400), anyOrNull(), eq(errorMessage))).thenReturn(Result.ErrorType.HttpError(statusCode = 400, message = errorMessage))
`when`(settingsPreferenceDataSource.getUser()).thenReturn(flowOf()) // Ensure a valid empty flow is returned
// Act
val results = authRepository.sendLogin(username, password, serverUrl).toList()
// Debugging: Print results
results.forEachIndexed { index, result ->
println("Result $index: $result")
if (result is Result.Error) {
println("Result $index error: '${result.error?.message}'")
} else if (result is Result.Loading) {
println("Result $index loading data: '${result.data}'")
}
}
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null)
assertTrue(results[1] is Result.Loading && results[1].data == null)
assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.HttpError)
assertEquals((results[2] as Result.Error).error?.message, errorMessage)
verify(apiService).sendLogin(check { it.endsWith("/api/login") }, any())
}
@Test
fun `sendLogin should emit Loading and Error states when network error occurs`() = runTest {
// Arrange
val username = "testUser"
val password = "testPassword"
val serverUrl = "http://test.com"
val networkErrorMessage = "Network error"
val ioException = IOException(networkErrorMessage)
`when`(apiService.sendLogin(anyString(), any())).thenAnswer { invocation ->
throw ioException
}
`when`(errorHandler.getError(ioException)).thenReturn(Result.ErrorType.IOError(ioException))
`when`(settingsPreferenceDataSource.getUser()).thenReturn(flowOf()) // Ensure a valid empty flow is returned
// Act
val results = authRepository.sendLogin(username, password, serverUrl).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null)
assertTrue(results[1] is Result.Loading && results[1].data == null)
assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.IOError)
assertEquals(networkErrorMessage, (results[2] as Result.Error).error?.throwable?.message)
verify(apiService).sendLogin(check { it.endsWith("/api/login") }, any())
}
@Test
fun `sendLogin should not call saveUser when API call fails`() = runTest {
// Arrange
val username = "testUser"
val password = "testPassword"
val serverUrl = "http://test.com"
val errorMessage = "Invalid credentials"
val errorResponseBody = errorMessage.toResponseBody("text/plain".toMediaTypeOrNull())
`when`(apiService.sendLogin(anyString(), any())).thenReturn(Response.error(400, errorResponseBody))
`when`(errorHandler.getApiError(eq(400), anyOrNull(), eq(errorMessage))).thenReturn(Result.ErrorType.HttpError(statusCode = 400, message = errorMessage))
`when`(settingsPreferenceDataSource.getUser()).thenReturn(flowOf()) // Ensure a valid empty flow is returned
// Act
val results = authRepository.sendLogin(username, password, serverUrl).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null)
assertTrue(results[1] is Result.Loading && results[1].data == null)
assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.HttpError)
assertEquals((results[2] as Result.Error).error?.message, errorMessage)
verify(apiService).sendLogin(check { it.endsWith("/api/login") }, any())
verify(settingsPreferenceDataSource, never()).saveUser(any(), anyString(), anyString())
}
@Test
fun `sendLogout should emit Loading, Loading with data, and Success states when API call is successful`() = runTest {
// Arrange
val serverUrl = "http://test.com"
val xSession = "testSession"
val logoutResponse = "Logout successful" // La respuesta esperada del servidor
`when`(apiService.sendLogout(anyString(), anyString())).thenReturn(Response.success(logoutResponse))
// Act
val results = authRepository.sendLogout(serverUrl, xSession).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null, "First result should be Loading with null data")
assertTrue(results[1] is Result.Loading && results[1].data == "", "Second result should be Loading with empty data")
assertTrue(results[2] is Result.Success && (results[2] as Result.Success).data == "") {
"Expected third result to be Success with empty data after resetUser, but was '${(results[2] as Result.Success).data}'"
}
verify(settingsPreferenceDataSource).resetData()
verify(apiService).sendLogout(check { it.endsWith("/api/logout") }, eq(xSession))
}
@Test
fun `sendLogout should emit Loading and Error states when API call fails`() = runTest {
// Arrange
val serverUrl = "http://test.com"
val xSession = "testSession"
val errorMessage = "Logout failed"
val errorResponseBody = errorMessage.toResponseBody("text/plain".toMediaTypeOrNull())
`when`(apiService.sendLogout(anyString(), anyString())).thenReturn(Response.error(400, errorResponseBody))
`when`(errorHandler.getApiError(eq(400), anyOrNull(), eq(errorMessage))).thenReturn(Result.ErrorType.HttpError(statusCode = 400, message = errorMessage))
// Act
val results = authRepository.sendLogout(serverUrl, xSession).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null, "First result should be Loading with null data")
assertTrue(results[1] is Result.Loading && results[1].data == "", "Second result should be Loading with empty string data")
assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.HttpError, "Third result should be Error with HttpError type")
assertEquals((results[2] as Result.Error).error?.message, errorMessage, "Error message should match expected message")
verify(apiService).sendLogout(check { it.endsWith("/api/logout") }, eq(xSession))
}
@Test
fun `sendLogout should emit Loading and Error states when network error occurs`() = runTest {
// Arrange
val serverUrl = "http://test.com"
val xSession = "testSession"
val networkErrorMessage = "Network error"
val ioException = IOException(networkErrorMessage)
`when`(apiService.sendLogout(anyString(), anyString())).thenAnswer { invocation ->
throw ioException
}
`when`(errorHandler.getError(ioException)).thenReturn(Result.ErrorType.IOError(ioException))
// Act
val results = authRepository.sendLogout(serverUrl, xSession).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null)
assertTrue(results[1] is Result.Loading && results[1].data == "")
assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.IOError)
assertEquals(networkErrorMessage, (results[2] as Result.Error).error?.throwable?.message)
verify(apiService).sendLogout(check { it.endsWith("/api/logout") }, eq(xSession))
}
@Test
fun `sendLoginV1 should emit Loading and Success states when API call is successful`() = runTest {
// Arrange
val username = "testUser"
val password = "testPassword"
val serverUrl = "http://test.com"
val loginResponseMessageDTO = LoginResponseMessageDTO(
expires = null,
session = null,
token = "testToken"
)
val loginResponseDTO = LoginResponseDTO(
ok = true,
message = loginResponseMessageDTO,
error = null
)
val expectedUser =
User("testToken", "testSession", Account(1, username, password, false, serverUrl))
`when`(apiService.sendLoginV1(anyString(), any())).thenReturn(Response.success(loginResponseDTO))
`when`(settingsPreferenceDataSource.getUser()).thenReturn(flowOf(expectedUser))
// Act
val results = authRepository.sendLoginV1(username, password, serverUrl).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null)
assertTrue(results[1] is Result.Loading && results[1].data != null)
assertTrue(results[2] is Result.Success && results[2].data == expectedUser)
verify(settingsPreferenceDataSource).saveUser(any(), eq(serverUrl), eq(password))
verify(apiService).sendLoginV1(check { it.endsWith("/api/v1/auth/login") }, any())
}
}
================================================
FILE: data/src/test/java/com/desarrollodroide/data/repository/BookmarksRepositoryTest.kt
================================================
package com.desarrollodroide.data.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.map
import com.desarrollodroide.common.result.ErrorHandler
import com.desarrollodroide.network.retrofit.RetrofitNetwork
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.mockito.Mockito.*
import retrofit2.Response
import org.mockito.kotlin.eq
import org.mockito.kotlin.check
import com.desarrollodroide.common.result.Result
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.local.room.entity.BookmarkEntity
import com.desarrollodroide.data.mapper.toDomainModel
import com.desarrollodroide.data.repository.paging.BookmarkPagingSource
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.Tag
import com.desarrollodroide.network.model.BookmarkDTO
import com.desarrollodroide.network.model.BookmarksDTO
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.mockito.kotlin.anyOrNull
import java.io.IOException
@ExperimentalCoroutinesApi
class BookmarksRepositoryTest {
@Mock
private lateinit var apiService: RetrofitNetwork
@Mock
private lateinit var bookmarksDao: BookmarksDao
@Mock
private lateinit var errorHandler: ErrorHandler
private lateinit var bookmarksRepository: BookmarksRepositoryImpl
@BeforeEach
fun setup() {
MockitoAnnotations.openMocks(this)
bookmarksRepository = BookmarksRepositoryImpl(apiService, bookmarksDao, errorHandler)
}
@Test
fun `getBookmarks should emit Loading and Success states when API call is successful`() = runTest {
// Arrange
val xSessionId = "testSessionId"
val serverUrl = "http://test.com"
val bookmarksDTO = BookmarksDTO(
maxPage = 1,
page = 1,
bookmarks = listOf(
BookmarkDTO(1, "http://bookmark1.com", "Bookmark 1", "Excerpt 1", "Author 1", 1, "2023-01-01","2023-01-02", "http://image1.com", true, true, true, listOf(), true, true),
BookmarkDTO(2, "http://bookmark2.com", "Bookmark 2", "Excerpt 2", "Author 2", 1, "2023-01-02", "2023-01-02","http://image2.com", true, true, true, listOf(), true, true)
)
)
val bookmarkEntities = listOf(
BookmarkEntity(1, "http://bookmark1.com", "Bookmark 1", "Excerpt 1", "Author 1", 1, "2023-01-01", "2023-01-02","http://image1.com", true, true, true, listOf(), true, true),
BookmarkEntity(2, "http://bookmark2.com", "Bookmark 2", "Excerpt 2", "Author 2", 1, "2023-01-02", "2023-01-02","http://image2.com", true, true, true, listOf(), true, true)
)
val expectedBookmarks = bookmarkEntities.map { it.toDomainModel() }
`when`(apiService.getBookmarks(eq(xSessionId), anyString())).thenReturn(Response.success(bookmarksDTO))
`when`(bookmarksDao.getAll()).thenReturn(flowOf(bookmarkEntities))
// Act
val results = bookmarksRepository.getBookmarks(xSessionId, serverUrl).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null)
assertTrue(results[1] is Result.Loading && results[1].data != null)
assertTrue(results[2] is Result.Success && results[2].data == expectedBookmarks)
verify(bookmarksDao).deleteAll()
verify(bookmarksDao).insertAll(bookmarkEntities)
verify(apiService).getBookmarks(eq(xSessionId), check { it.endsWith("/api/bookmarks") })
}
@Test
fun `getBookmarks should emit Loading and Error states when API call fails`() = runTest {
// Arrange
val xSessionId = "testSessionId"
val serverUrl = "http://test.com"
val errorMessage = "Error fetching bookmarks"
val errorResponseBody = errorMessage.toResponseBody("text/plain".toMediaTypeOrNull())
`when`(apiService.getBookmarks(eq(xSessionId), anyString())).thenReturn(Response.error(400, errorResponseBody))
`when`(errorHandler.getApiError(eq(400), anyOrNull(), eq(errorMessage))).thenReturn(Result.ErrorType.HttpError(statusCode = 400, message = errorMessage))
`when`(bookmarksDao.getAll()).thenReturn(flowOf(emptyList())) // Ensure a valid empty flow is returned
// Act
val results = bookmarksRepository.getBookmarks(xSessionId, serverUrl).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null)
assertTrue(results[1] is Result.Loading && results[1].data == emptyList())
assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.HttpError)
assertEquals((results[2] as Result.Error).error?.message, errorMessage)
verify(apiService).getBookmarks(eq(xSessionId), check { it.endsWith("/api/bookmarks") })
}
@Test
fun `getBookmarks should emit Loading and Error states when network error occurs`() = runTest {
// Arrange
val xSessionId = "testSessionId"
val serverUrl = "http://test.com"
val networkErrorMessage = "Network error"
val ioException = IOException(networkErrorMessage)
`when`(apiService.getBookmarks(eq(xSessionId), anyString())).thenAnswer { throw ioException }
`when`(errorHandler.getError(ioException)).thenReturn(Result.ErrorType.IOError(ioException))
`when`(bookmarksDao.getAll()).thenReturn(flowOf(emptyList())) // Ensure a valid empty flow is returned
// Act
val results = bookmarksRepository.getBookmarks(xSessionId, serverUrl).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null)
assertTrue(results[1] is Result.Loading && results[1].data == emptyList())
assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.IOError)
assertEquals(networkErrorMessage, (results[2] as Result.Error).error?.throwable?.message)
verify(apiService).getBookmarks(eq(xSessionId), check { it.endsWith("/api/bookmarks") })
}
@Test
fun `getBookmarks should emit Loading and Error states when API call fails with HTTP error`() = runTest {
// Arrange
val xSessionId = "testSessionId"
val serverUrl = "http://test.com"
val errorMessage = "HTTP error"
val errorResponseBody = errorMessage.toResponseBody("text/plain".toMediaTypeOrNull())
`when`(apiService.getBookmarks(eq(xSessionId), anyString())).thenReturn(Response.error(400, errorResponseBody))
`when`(errorHandler.getApiError(eq(400), anyOrNull(), eq(errorMessage))).thenReturn(Result.ErrorType.HttpError(statusCode = 400, message = errorMessage))
`when`(bookmarksDao.getAll()).thenReturn(flowOf(emptyList())) // Ensure a valid empty flow is returned
// Act
val results = bookmarksRepository.getBookmarks(xSessionId, serverUrl).toList()
// Assert
assertEquals(3, results.size, "Expected 3 emitted results")
assertTrue(results[0] is Result.Loading && results[0].data == null)
assertTrue(results[1] is Result.Loading && results[1].data == emptyList())
assertTrue(results[2] is Result.Error && (results[2] as Result.Error).error is Result.ErrorType.HttpError)
assertEquals((results[2] as Result.Error).error?.message, errorMessage)
verify(apiService).getBookmarks(eq(xSessionId), check { it.endsWith("/api/bookmarks") })
}
@Test
fun `getPagingBookmarks should return paginated data when API call is successful`() = runTest {
// Arrange
val xSessionId = "testSessionId"
val serverUrl = "http://test.com"
val searchText = "test"
val tags = listOf()
val saveToLocal = true
val bookmarksDTO = BookmarksDTO(
maxPage = 1,
page = 1,
bookmarks = listOf(
BookmarkDTO(1, "http://bookmark1.com", "Bookmark 1", "Excerpt 1", "Author 1", 1, "2023-01-01", "", "http://image1.com", true, true, true, listOf(), true, true),
BookmarkDTO(2, "http://bookmark2.com", "Bookmark 2", "Excerpt 2", "Author 2", 1, "2023-01-02", "", "http://image2.com", true, true, true, listOf(), true, true)
)
)
val expectedBookmarks = bookmarksDTO.bookmarks?.map { it.toDomainModel() }
`when`(apiService.getPagingBookmarks(eq(xSessionId), anyString())).thenReturn(Response.success(bookmarksDTO))
`when`(bookmarksDao.getAll()).thenReturn(flowOf(emptyList()))
// Act
val pagingSource = BookmarkPagingSource(
remoteDataSource = apiService,
bookmarksDao = bookmarksDao,
serverUrl = serverUrl,
xSessionId = xSessionId,
searchText = searchText,
tags = tags,
saveToLocal = saveToLocal
)
val loadResult = pagingSource.load(
PagingSource.LoadParams.Refresh(
key = null,
loadSize = 20,
placeholdersEnabled = false
)
)
// Assert
assertTrue(loadResult is PagingSource.LoadResult.Page)
loadResult as PagingSource.LoadResult.Page
assertEquals(expectedBookmarks, loadResult.data)
}
}
================================================
FILE: domain/.gitignore
================================================
/build
================================================
FILE: domain/build.gradle.kts
================================================
plugins {
id("com.android.library")
id ("org.jetbrains.kotlin.android")
}
android {
namespace = "com.desarrollodroide.domain"
compileSdk = (findProperty("compileSdkVersion") as String).toInt()
defaultConfig {
minSdk = (findProperty("minSdkVersion") as String).toInt()
targetSdk = (findProperty("targetSdkVersion") as String).toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "21"
}
}
dependencies {
implementation(project(":data"))
implementation(project(":model"))
implementation(project(":common"))
// coroutines
implementation (libs.kotlinx.coroutines.android)
implementation (libs.androidx.paging.compose)
testImplementation (libs.kotlinx.coroutines.android)
testImplementation (libs.kotlin.coroutines.test)
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
================================================
FILE: domain/consumer-rules.pro
================================================
================================================
FILE: domain/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: domain/src/main/AndroidManifest.xml
================================================
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/AddBookmarkUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.mapper.toEntityModel
import com.desarrollodroide.data.repository.SyncWorks
import com.desarrollodroide.model.SyncOperationType
class AddBookmarkUseCase(
private val bookmarksDao: BookmarksDao,
private val syncManager: SyncWorks,
) {
suspend operator fun invoke(
bookmark: Bookmark
) {
// Insert the bookmark locally with a timestamp as a temporary ID
val timestampId = (System.currentTimeMillis() / 1000).toInt()
val bookmarkWithTempId = bookmark.copy(id = timestampId)
bookmarksDao.insertBookmark(bookmarkWithTempId.toEntityModel())
// Schedule the sync work and wait for it to complete
syncManager.scheduleSyncWork(SyncOperationType.CREATE, bookmark)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/DeleteBookmarkUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.repository.SyncWorks
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.SyncOperationType
class DeleteBookmarkUseCase(
private val bookmarksDao: BookmarksDao,
private val syncManager: SyncWorks
) {
suspend operator fun invoke(bookmark: Bookmark) {
bookmarksDao.deleteBookmarkById(bookmark.id)
syncManager.scheduleSyncWork(SyncOperationType.DELETE, bookmark)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/DeleteLocalBookmarkUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.model.Bookmark
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import com.desarrollodroide.common.result.Result
class DeleteLocalBookmarkUseCase(
private val bookmarksDao: BookmarksDao
) {
operator fun invoke(bookmark: Bookmark): Flow> = flow {
emit(Result.Loading())
try {
val result = bookmarksDao.deleteBookmarkById(bookmark.id)
if (result > 0) {
emit(Result.Success(result))
} else {
emit(Result.Error(Result.ErrorType.DatabaseError(BookmarkNotFoundException())))
}
} catch (e: Exception) {
emit(Result.Error(Result.ErrorType.DatabaseError(e)))
}
}.flowOn(Dispatchers.IO)
}
class BookmarkNotFoundException : Exception("Bookmark not found in local database")
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/DownloadFileUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import com.desarrollodroide.data.repository.FileRepository
import java.io.File
class DownloadFileUseCase(
private val fileRepository: FileRepository
) {
suspend fun execute(
url: String,
fileName: String,
sessionId: String,
): File {
return fileRepository.downloadFile(
url = url,
fileName = fileName,
sessionId = sessionId,
)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/EditBookmarkUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import android.os.Build
import androidx.annotation.RequiresApi
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.local.room.dao.TagDao
import com.desarrollodroide.data.mapper.toEntityModel
import com.desarrollodroide.data.repository.SyncWorks
import com.desarrollodroide.model.SyncOperationType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
class EditBookmarkUseCase(
private val bookmarksDao: BookmarksDao,
private val tagsDao: TagDao,
private val syncManager: SyncWorks
) {
@RequiresApi(Build.VERSION_CODES.O)
suspend operator fun invoke(
bookmark: Bookmark
) {
val updatedBookmark = bookmark.copy(
modified = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
)
updatedBookmark.tags.forEach { tag ->
tagsDao.insertTag(tag.toEntityModel())
}
bookmarksDao.updateBookmarkWithTags(updatedBookmark.toEntityModel())
syncManager.scheduleSyncWork(SyncOperationType.UPDATE, updatedBookmark)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/GetAllRemoteBookmarksUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import android.annotation.SuppressLint
import android.util.Log
import com.desarrollodroide.data.repository.BookmarksRepository
import com.desarrollodroide.data.repository.SyncStatus
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
class GetAllRemoteBookmarksUseCase(
private val bookmarksRepository: BookmarksRepository,
) {
private val TAG = "SyncInitialBookmarksUseCase"
@SuppressLint("LongLogTag")
suspend operator fun invoke(
serverUrl: String,
xSession: String,
): Flow> {
Log.d(TAG, "Invoking sync use case")
return bookmarksRepository.syncAllBookmarks(
xSession = xSession,
serverUrl = serverUrl
)
.map { status ->
Log.d(TAG, "Mapping sync status: $status")
Result.success(status)
}
.catch { e ->
Log.e(TAG, "Error caught in use case", e)
emit(Result.failure(e))
}
.flowOn(Dispatchers.IO)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/GetBookmarkReadableContentUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import com.desarrollodroide.common.result.Result
import com.desarrollodroide.data.repository.BookmarksRepository
import com.desarrollodroide.model.ReadableContent
class GetBookmarkReadableContentUseCase(
private val bookmarksRepository: BookmarksRepository
) {
operator fun invoke(
serverUrl: String,
token: String,
bookmarkId: Int
): Flow> {
return bookmarksRepository.getBookmarkReadableContent(
token = token,
serverUrl = serverUrl,
bookmarkId = bookmarkId
).flowOn(Dispatchers.IO)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/GetBookmarkUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import com.desarrollodroide.data.repository.BookmarksRepository
import com.desarrollodroide.model.Bookmark
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import com.desarrollodroide.common.result.Result
class GetBookmarkByIdUseCase(
private val bookmarksRepository: BookmarksRepository,
) {
operator fun invoke(
serverUrl: String,
token: String,
bookmarkId: Int
): Flow> {
return bookmarksRepository.getBookmarkById(
token = token,
serverUrl = serverUrl,
bookmarkId = bookmarkId
).flowOn(Dispatchers.IO)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/GetBookmarksUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import com.desarrollodroide.data.repository.BookmarksRepository
import com.desarrollodroide.model.Bookmark
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import com.desarrollodroide.common.result.Result
class GetBookmarksUseCase(
private val bookmarksRepository: BookmarksRepository,
) {
operator fun invoke(
serverUrl: String,
xSession: String,
): Flow?>> {
return bookmarksRepository.getBookmarks(
xSession = xSession,
serverUrl = serverUrl
).flowOn(Dispatchers.IO)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/GetLocalPagingBookmarksUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import androidx.paging.PagingData
import androidx.paging.filter
import com.desarrollodroide.data.repository.BookmarksRepository
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.model.Tag
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetLocalPagingBookmarksUseCase(
private val bookmarksRepository: BookmarksRepository,
) {
operator fun invoke(
serverUrl: String,
xSession: String,
searchText: String = "",
tags: List,
showOnlyHiddenTag: Boolean = false,
tagToHide: Tag? = null
): Flow> {
return bookmarksRepository.getLocalPagingBookmarks(tags, searchText)
.map { pagingData ->
pagingData.filter { bookmark ->
when {
showOnlyHiddenTag -> tagToHide?.let { bookmark.tags.any { tag -> tag.id == it.id } } ?: false
else -> {
if (tags.isEmpty()) {
!bookmark.tags.any { it.id == tagToHide?.id }
} else {
bookmark.tags.any { tags.any { t -> t.id == it.id } }
}
}
}
}
}
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/GetTagsUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import com.desarrollodroide.common.result.Result
import com.desarrollodroide.data.repository.TagsRepository
import com.desarrollodroide.model.Tag
class GetTagsUseCase(
private val tagsRepository: TagsRepository
) {
operator fun invoke(
serverUrl: String,
token: String,
): Flow?>> {
return tagsRepository.getTags(
token = token,
serverUrl = serverUrl
).flowOn(Dispatchers.IO)
}
fun getLocalTags(): Flow> {
return tagsRepository.getLocalTags()
.flowOn(Dispatchers.IO)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/SendLoginUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import com.desarrollodroide.data.repository.AuthRepository
import com.desarrollodroide.model.User
import kotlinx.coroutines.flow.Flow
import com.desarrollodroide.common.result.Result
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
class SendLoginUseCase(
private val authRepository: AuthRepository,
) {
operator fun invoke(
username: String,
password: String,
serverUrl: String,
): Flow> {
return authRepository.sendLoginV1(username, password, serverUrl).flowOn(Dispatchers.IO)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/SendLogoutUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import com.desarrollodroide.data.repository.AuthRepository
import kotlinx.coroutines.flow.Flow
import com.desarrollodroide.common.result.Result
import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource
import com.desarrollodroide.data.repository.BookmarksRepository
import com.desarrollodroide.data.repository.SyncWorks
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
class SendLogoutUseCase(
private val authRepository: AuthRepository,
private val syncManager: SyncWorks,
private val settingsPreferenceDataSource: SettingsPreferenceDataSource,
private val bookmarksRepository: BookmarksRepository
) {
operator fun invoke(
serverUrl: String,
xSession: String,
): Flow> = flow {
authRepository.sendLogout(
serverUrl = serverUrl,
xSession = xSession
).collect { result ->
when (result) {
is Result.Success -> {
performCleanup()
emit(Result.Success(result.data))
}
is Result.Error -> {
performCleanup()
emit(Result.Error(result.error, result.data))
}
is Result.Loading -> {
emit(result)
}
}
}
}.flowOn(Dispatchers.IO)
private suspend fun performCleanup() {
syncManager.cancelAllSyncWorkers()
settingsPreferenceDataSource.resetData()
bookmarksRepository.deleteAllLocalBookmarks()
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/SuspendUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
interface SuspendUseCase {
fun execute(params: Params) : T
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/SyncBookmarksUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import com.desarrollodroide.data.repository.BookmarksRepository
import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource
import com.desarrollodroide.model.SyncBookmarksRequestPayload
import com.desarrollodroide.model.SyncBookmarksResponse
import kotlinx.coroutines.flow.Flow
import com.desarrollodroide.common.result.Result
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.CoroutineScope
import com.desarrollodroide.data.mapper.toEntityModel
import java.time.ZoneId
import java.time.ZonedDateTime
class SyncBookmarksUseCase(
private val bookmarksRepository: BookmarksRepository,
private val bookmarkDatabase: BookmarksDao,
private val settingsPreferenceDataSource: SettingsPreferenceDataSource,
) {
operator fun invoke(
token: String,
serverUrl: String,
syncBookmarksRequestPayload: SyncBookmarksRequestPayload
): Flow> {
return bookmarksRepository.syncBookmarks(
token = token,
serverUrl = serverUrl,
syncBookmarksRequestPayload = syncBookmarksRequestPayload
).flowOn(Dispatchers.IO)
}
@RequiresApi(Build.VERSION_CODES.O)
fun handleSuccessfulSync(
syncResponse: SyncBookmarksResponse,
currentLastSync: Long
) {
CoroutineScope(Dispatchers.IO).launch {
try {
// Handle deleted bookmarks
syncResponse.deleted.forEach { id ->
bookmarkDatabase.deleteBookmarkById(id)
}
// Handle new and modified bookmarks
val bookmarkEntities = syncResponse.modified.bookmarks.map { remoteBookmark ->
val localBookmark = bookmarkDatabase.getBookmarkById(remoteBookmark.id)
Log.d("TAG", "Processing bookmark ID: ${remoteBookmark.id}, Local Modified: ${localBookmark?.modified}, Remote Modified: ${remoteBookmark.modified}")
remoteBookmark.toEntityModel()
}
if (bookmarkEntities.isNotEmpty()) {
bookmarkEntities.forEach { bookmark ->
val existingBookmark = bookmarkDatabase.getBookmarkById(bookmark.id)
if (existingBookmark == null) {
// New bookmark, insert it
bookmarkDatabase.insertBookmark(bookmark)
} else {
// Existing bookmark, update it
bookmarkDatabase.updateBookmarkWithTags(bookmark)
}
}
}
// Check if there are more pages to sync
val currentPage = syncResponse.modified.page
val maxPage = syncResponse.modified.maxPage
if (currentPage < maxPage) {
// Get updated list of all local bookmark IDs for the next page
val updatedLocalBookmarkIds = bookmarkDatabase.getAllBookmarkIds()
invoke(
token = settingsPreferenceDataSource.getToken(),
serverUrl = settingsPreferenceDataSource.getUrl(),
syncBookmarksRequestPayload = SyncBookmarksRequestPayload(
ids = updatedLocalBookmarkIds,
last_sync = currentLastSync,
page = currentPage + 1
)
).collect { result ->
if (result is Result.Success) {
result.data?.let {
handleSuccessfulSync(it, currentLastSync)
}
}
}
} else {
// Sync is complete, update last sync timestamp
val newLastSync = ZonedDateTime.now(ZoneId.systemDefault()).toEpochSecond() // Convert to seconds
settingsPreferenceDataSource.setLastSyncTimestamp(newLastSync)
}
} catch (e: Exception) {
Log.e("SyncBookmarksUseCase", "Error handling sync response: ${e.message}")
}
}
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/SystemLivenessUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import kotlinx.coroutines.flow.Flow
import com.desarrollodroide.common.result.Result
import com.desarrollodroide.data.repository.SystemRepository
import com.desarrollodroide.model.LivenessResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
class SystemLivenessUseCase(
private val systemRepository: SystemRepository,
) {
operator fun invoke(
serverUrl: String
): Flow> {
return systemRepository.liveness(serverUrl).flowOn(Dispatchers.IO)
}
}
================================================
FILE: domain/src/main/java/com/desarrollodroide/domain/usecase/UpdateBookmarkCacheUseCase.kt
================================================
package com.desarrollodroide.domain.usecase
import android.os.Build
import androidx.annotation.RequiresApi
import com.desarrollodroide.data.local.room.dao.BookmarksDao
import com.desarrollodroide.data.mapper.toEntityModel
import com.desarrollodroide.model.Bookmark
import com.desarrollodroide.data.repository.SyncWorks
import com.desarrollodroide.model.SyncOperationType
import com.desarrollodroide.model.UpdateCachePayload
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
class UpdateBookmarkCacheUseCase(
private val bookmarksDao: BookmarksDao,
private val syncManager: SyncWorks
) {
@RequiresApi(Build.VERSION_CODES.O)
suspend operator fun invoke(
updateCachePayload: UpdateCachePayload,
bookmark: Bookmark
) {
val updatedBookmark = bookmark.copy(
modified = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
)
bookmarksDao.updateBookmark(updatedBookmark.toEntityModel())
syncManager.scheduleSyncWork(
operationType = SyncOperationType.CACHE,
bookmark = updatedBookmark,
updateCachePayload = updateCachePayload
)
}
}
================================================
FILE: fastlane/metadata/android/de/full_description.txt
================================================
Entdecken Sie mit Pagekeeper eine neue Möglichkeit, Ihre Lieblingswebseiten zu speichern, zu organisieren und darauf zuzugreifen. Unsere App basiert auf der renommierten Shiori-Plattform und bringt die Lesezeichenverwaltung auf die nächste Ebene.
Shiori ist eine innovative Anwendung zur Verwaltung von Lesezeichen, die die Art und Weise revolutioniert, wie Benutzer ihre Lieblingswebseiten speichern, organisieren und darauf zugreifen. Basierend auf der robusten Shiori-Plattform bietet Shiori ein nahtloses Erlebnis auf allen Geräten.
Hauptmerkmale von Pagekeeper:
* Seiten einfach speichern: Erfassen Sie Webseiten, die Sie sofort entdecken, und greifen Sie jederzeit darauf zu, auch offline.
* Überlegene Organisation: Sortieren Sie Ihre Lesezeichen mit benutzerdefinierten Beschriftungen, Beschreibungen und Miniaturansichten zum schnellen Abrufen.
* Cloud-Synchronisierung: Halten Sie Ihre Lesezeichen auf allen Ihren Geräten synchronisiert, damit Sie nie eine wichtige Seite verlieren.
* Intuitive Benutzeroberfläche: Navigieren Sie durch Ihre Lesezeichen mit einer übersichtlichen und benutzerfreundlichen Oberfläche, die für ein nahtloses Benutzererlebnis konzipiert ist.
* Teilen und Entdecken: Teilen Sie Ihre Lieblingsseiten mit Freunden und entdecken Sie neue Seiten über die Pagekeeper-Community.
================================================
FILE: fastlane/metadata/android/de/short_description.txt
================================================
Lesezeichen-Manager
================================================
FILE: fastlane/metadata/android/en-US/changelogs/default.txt
================================================
================================================
FILE: fastlane/metadata/android/en-US/full_description.txt
================================================
Discover a new way to save, organize, and access your favorite web pages with Pagekeeper. Built on the renowned Shiori platform, our app takes bookmark management to the next level.
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, Shiori offers a seamless experience across all devices.
Pagekeeper Key Features:
* Save Pages Easily: Capture web pages you discover in an instant and access them anytime, even offline.
* Superior Organization: Sort your bookmarks with custom labels, descriptions, and thumbnails for quick retrieval.
* Cloud Synchronization: Keep your bookmarks synced across all your devices, so you never lose an important page.
* Intuitive Interface: Navigate through your bookmarks with a clean and user-friendly interface, designed for a seamless user experience.
* Share and Discover: Share your favorite pages with friends and discover new pages through the Pagekeeper community.
================================================
FILE: fastlane/metadata/android/en-US/short_description.txt
================================================
Android client for the Shiori Bookmark Manager
================================================
FILE: fastlane/metadata/android/en-US/title.txt
================================================
Shiori
================================================
FILE: gradle/libs.versions.toml
================================================
[versions]
datastorePreferences = "1.0.0"
junitJupiter = "5.8.1"
junitPlatformSuiteApi = "1.8.1"
koinAndroidxCompose = "3.4.2"
mockitoCore = "3.9.0"
mockitoKotlin = "3.2.0"
compose = "1.7.0"
composeMaterial3 = "1.2.1"
# gradlePlugin and lint need to be updated together
gradlePlugin = "7.3.1"
kotlin = "2.0.0"
coroutines = "1.8.1"
pagingCompose = "3.3.2"
protobuf = "3.21.9"
coil = "2.7.0"
koin = "3.3.3"
room = "2.6.1"
work = "2.9.1"
androidxnavigation = "2.7.7"
androidxLifecycle = "2.7.0"
[libraries]
compose-ui-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
compose-material-iconsext = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" }
compose-material3-material3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial3" }
compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata", version.ref = "compose" }
kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
androidx-core = "androidx.core:core-ktx:1.12.0"
androidx-activity-compose = "androidx.activity:activity-compose:1.8.2"
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-lifecycle-runtimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
androidx-preference = "androidx.preference:preference-ktx:1.2.0"
androidx-room = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
androidx-datastore-core = { module = "androidx.datastore:datastore-core", version.ref = "datastorePreferences" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxnavigation" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingCompose" }
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "pagingCompose" }
androidx-work = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiter" }
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiter" }
junit-platform-suite-api = { module = "org.junit.platform:junit-platform-suite-api", version.ref = "junitPlatformSuiteApi" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" }
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
accompanist-permissions = "com.google.accompanist:accompanist-permissions:0.20.3"
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-core-ext = { module = "io.insert-koin:koin-core-ext", version.ref = "koin" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koinAndroidxCompose" }
okhttp3-logging-interceptor = "com.squareup.okhttp3:logging-interceptor:4.9.1"
retrofit2-retrofit = "com.squareup.retrofit2:retrofit:2.9.0"
retrofit2-converter-gson = "com.squareup.retrofit2:converter-gson:2.9.0"
retrofit2-converter-scalars = "com.squareup.retrofit2:converter-scalars:2.1.0"
[bundles]
koin = ["koin-core", "koin-android", "koin-core-ext", "koin-androidx-compose"]
retrofit = ["okhttp3-logging-interceptor", "retrofit2-retrofit", "retrofit2-converter-gson", "retrofit2-converter-scalars"]
================================================
FILE: gradle/wrapper/gradle-wrapper.properties
================================================
#Mon Mar 25 12:51:42 CET 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
================================================
FILE: gradle.properties
================================================
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
compileSdkVersion=35
minSdkVersion=26
targetSdkVersion=35
versionCode=53
versionName=1.51.01
android.defaults.buildfeatures.buildconfig=true
android.nonFinalResIds=false
================================================
FILE: gradlew
================================================
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# 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
#
# https://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.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
================================================
FILE: gradlew.bat
================================================
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: model/.gitignore
================================================
/build
================================================
FILE: model/build.gradle.kts
================================================
plugins {
id("com.android.library")
id ("org.jetbrains.kotlin.android")
}
android {
namespace = "com.desarrollodroide.model"
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: model/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.
#
# 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: model/src/main/AndroidManifest.xml
================================================
================================================
FILE: model/src/main/java/com/desarrollodroide/model/Account.kt
================================================
package com.desarrollodroide.model
class Account(
val id: Int = -1,
val userName: String,
val password: String,
val owner: Boolean ,
val serverUrl: String,
) {
constructor() : this(
id = -1,
userName = "",
password = "",
owner = false,
serverUrl = "",
)
companion object {
val mock = Account(
id = 1,
userName = "user@example.com",
password = "securePassword123",
owner = true,
serverUrl = "https://api.example.com",
)
}
}
================================================
FILE: model/src/main/java/com/desarrollodroide/model/Bookmark.kt
================================================
package com.desarrollodroide.model
import android.webkit.URLUtil
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
data class Bookmark (
val id: Int,
val url: String,
val title: String,
val excerpt: String,
val author: String,
val public: Int,
val createAt: String,
val modified: String,
val imageURL: String,
val hasContent: Boolean,
val hasArchive: Boolean,
val hasEbook: Boolean,
val tags: List,
val createArchive: Boolean,
val createEbook: Boolean,
){
/**
* A bookmark is considered pending when it hasn't been fully processed by the server yet.
* This covers two cases:
* - The bookmark hasn't been sent to the server yet (temporary timestamp ID)
* - The server received it but hasn't finished processing content (no content, no image, no excerpt)
*/
val isPendingServerProcessing: Boolean
get() = isTemporaryId ||
(!hasContent && imageURL.isEmpty() && excerpt.isEmpty()) ||
(title.isNotEmpty() && URLUtil.isValidUrl(title))
/**
* 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.
*/
private val isTemporaryId: Boolean
get() = id > 1_000_000
constructor(
url: String,
tags: List,
public: Int,
createArchive: Boolean,
createEbook: Boolean,
title: String ,
) : this(
id = (System.currentTimeMillis() / 1000).toInt(),
url= url,
title = title,
excerpt = "",
author = "",
public = public,
createAt = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
modified = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
imageURL = "",
hasContent = false,
hasArchive = false,
hasEbook = false,
tags = tags,
createArchive = createArchive,
createEbook = createEbook,
)
companion object {
fun mock() = Bookmark(
id = -1,
url = "url",
title = "Bookmark title",
excerpt = "A detailed description of the bookmark, explaining its significance, context, and why it was saved.",
author = "John Doe",
public = 1,
createAt = "2024-09-25 05:53:11",
modified = "2024-03-19 15:44:40",
imageURL = "https://fastly.picsum.photos/id/12/2500/1667.jpg?hmac=Pe3284luVre9ZqNzv1jMFpLihFI6lwq7TPgMSsNXw2w",
hasContent = true,
hasArchive = true,
hasEbook = false,
createArchive = true,
createEbook = true,
tags = listOf(Tag(id = 1 ,name = "tag1"), Tag(id = 2, name = "tag2")),
)
}
}
================================================
FILE: model/src/main/java/com/desarrollodroide/model/Bookmarks.kt
================================================
package com.desarrollodroide.model
data class Bookmarks (
val error: String,
var maxPage: Int,
var page: Int,
var bookmarks: List,
) {
constructor(error: String): this(
error = error,
maxPage = 0,
page = 0,
bookmarks = emptyList()
)
}
================================================
FILE: model/src/main/java/com/desarrollodroide/model/LivenessResponse.kt
================================================
package com.desarrollodroide.model
class LivenessResponse (
val ok: Boolean,
val message: ReleaseInfo?
)
================================================
FILE: model/src/main/java/com/desarrollodroide/model/LoginResponseMessage.kt
================================================
package com.desarrollodroide.model
data class LoginResponseMessage(
val expires: Int,
val session: String,
val token: String
)
================================================
FILE: model/src/main/java/com/desarrollodroide/model/ModifiedBookmarks.kt
================================================
package com.desarrollodroide.model
data class ModifiedBookmarks(
val bookmarks: List,
val maxPage: Int,
val page: Int
)
================================================
FILE: model/src/main/java/com/desarrollodroide/model/PendingJob.kt
================================================
package com.desarrollodroide.model
data class PendingJob(
val operationType: SyncOperationType,
val state: String,
val bookmarkId: Int,
val bookmarkTitle: String
)
enum class SyncOperationType {
CREATE, UPDATE, DELETE, CACHE;
companion object {
fun fromString(value: String): SyncOperationType? =
entries.find { it.name == value.uppercase() }
}
}
================================================
FILE: model/src/main/java/com/desarrollodroide/model/ReadableContent.kt
================================================
package com.desarrollodroide.model
data class ReadableContent(
val ok: Boolean,
val message: ReadableMessage,
)
================================================
FILE: model/src/main/java/com/desarrollodroide/model/ReadableMessage.kt
================================================
package com.desarrollodroide.model
data class ReadableMessage(
val content: String,
val html: String
)
================================================
FILE: model/src/main/java/com/desarrollodroide/model/ReleaseInfo.kt
================================================
package com.desarrollodroide.model
data class ReleaseInfo(
val version: String,
val commit: String,
val date: String
)
================================================
FILE: model/src/main/java/com/desarrollodroide/model/SyncBookmarksRequestPayload.kt
================================================
package com.desarrollodroide.model
data class SyncBookmarksRequestPayload(
val ids: List,
val last_sync: Long,
val page: Int
)
================================================
FILE: model/src/main/java/com/desarrollodroide/model/SyncBookmarksResponse.kt
================================================
package com.desarrollodroide.model
data class SyncBookmarksResponse(
val deleted: List,
val modified: ModifiedBookmarks
)
================================================
FILE: model/src/main/java/com/desarrollodroide/model/Tag.kt
================================================
package com.desarrollodroide.model
data class Tag (
val id: Int,
val name: String,
var selected: Boolean,
val nBookmarks: Int
){
constructor(
id: Int,
name: String
) : this(id, name, false, 0)
}
================================================
FILE: model/src/main/java/com/desarrollodroide/model/UpdateCachePayload.kt
================================================
package com.desarrollodroide.model
data class UpdateCachePayload(
val createArchive : Boolean,
val createEbook : Boolean,
val ids: List,
val keepMetadata : Boolean,
val skipExist: Boolean
)
================================================
FILE: model/src/main/java/com/desarrollodroide/model/User.kt
================================================
package com.desarrollodroide.model
data class User(
val session: String,
val token: String,
val account: Account,
val error: String = ""
) {
fun hasSession() = session.isNotEmpty()
constructor(error: String) : this(
token = "",
session = "",
account = Account(),
error = error
)
companion object {
val mock = User(
session = "session123",
token = "token456",
account = Account.mock,
error = ""
)
val errorMock = User(
error = "Error occurred"
)
}
}
================================================
FILE: network/.gitignore
================================================
/build
================================================
FILE: network/README.md
================================================
# :core:network module

================================================
FILE: network/build.gradle.kts
================================================
plugins {
id ("com.android.library")
id ("org.jetbrains.kotlin.android")
}
android {
namespace = "com.desarrollodroide.network"
compileSdk = (findProperty("compileSdkVersion") as String).toInt()
defaultConfig {
minSdk = (findProperty("minSdkVersion") as String).toInt()
targetSdk = (findProperty("targetSdkVersion") as String).toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "21"
}
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
implementation(project(":common"))
implementation (libs.bundles.retrofit)
implementation (libs.koin.androidx.compose)
}
================================================
FILE: network/lint.xml
================================================
================================================
FILE: network/src/main/AndroidManifest.xml
================================================
================================================
FILE: network/src/main/java/com/desarrollodroide/network/di/NetworkingModule.kt
================================================
package com.desarrollodroide.network.di
import com.desarrollodroide.network.retrofit.NetworkLoggerInterceptor
import com.desarrollodroide.network.retrofit.RetrofitNetwork
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.util.concurrent.TimeUnit
fun networkingModule() = module {
single { NetworkLoggerInterceptor() }
single {
OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.connectTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val request = chain.request()
val sessionHeader = request.header("X-Session-Id")
if (sessionHeader != null && sessionHeader.isNotEmpty()) {
val newRequest = request.newBuilder()
.removeHeader("X-Session-Id")
.addHeader("Authorization", "Bearer $sessionHeader")
.build()
chain.proceed(newRequest)
} else {
chain.proceed(request)
}
}
.addInterceptor(get())
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
} // client
single {
Retrofit.Builder()
.addConverterFactory(ScalarsConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.baseUrl("https://google.com") //generic url
.client(get())
.build()
} // retrofit
single { get().create(RetrofitNetwork::class.java) } // api service
}
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/AccountDTO.kt
================================================
package com.desarrollodroide.network.model
import com.google.gson.annotations.SerializedName
data class AccountDTO(
@SerializedName("id")
val id: Int? = -1,
@SerializedName("username")
val userName: String? = null,
@SerializedName("password")
val password: String? = null,
@SerializedName("owner")
val isOwner: Boolean? = null,
@SerializedName("oldPassword")
val oldPassword: String? = null,
@SerializedName("newPassword")
val newPassword: String? = null,
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/ApiResponse.kt
================================================
package com.desarrollodroide.network.model
data class ApiResponse(
val success: Boolean,
val data: T? = null,
val error: String? = null
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/BookmarkDTO.kt
================================================
package com.desarrollodroide.network.model
import com.google.gson.annotations.SerializedName
data class BookmarkDTO (
val id: Int?,
val url: String?,
val title: String?,
val excerpt: String?,
val author: String?,
val public: Int?,
val createdAt: String?,
@SerializedName(value = "modified", alternate = ["modifiedAt"])
val modified: String?,
val imageURL: String?,
val hasContent: Boolean?,
val hasArchive: Boolean?,
val hasEbook: Boolean?,
val tags: List?,
@SerializedName("create_archive")
val createArchive: Boolean?,
@SerializedName("create_ebook")
val createEbook: Boolean?,
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/BookmarkResponseDTO.kt
================================================
package com.desarrollodroide.network.model
data class BookmarkResponseDTO (
val ok: Boolean?,
val message: List?,
)
/**
* Wrapper for single bookmark responses from Shiori v1.8.0+.
* The MessageResponseMiddleware wraps all responses in {"ok":bool,"message":data}.
* For addBookmark/editBookmark, the legacy handler returns a single BookmarkDTO,
* which gets wrapped as {"ok":true,"message":{id:...,url:...,...}}.
*
* Also handles legacy format (pre-middleware) where the BookmarkDTO is the root object.
*/
data class SingleBookmarkResponseDTO(
val ok: Boolean? = null,
val message: BookmarkDTO? = null,
// Legacy fallback fields (when response is not wrapped)
val id: Int? = null,
val url: String? = null,
val title: String? = null,
val excerpt: String? = null,
val author: String? = null,
val public: Int? = null,
val createdAt: String? = null,
val modified: String? = null,
val imageURL: String? = null,
val hasContent: Boolean? = null,
val hasArchive: Boolean? = null,
val hasEbook: Boolean? = null,
val tags: List? = null,
) {
fun resolvedBookmark(): BookmarkDTO? =
message ?: if (id != null) BookmarkDTO(
id = id, url = url, title = title, excerpt = excerpt,
author = author, public = public, createdAt = createdAt,
modified = modified, imageURL = imageURL, hasContent = hasContent,
hasArchive = hasArchive, hasEbook = hasEbook, tags = tags,
createArchive = null, createEbook = null
) else null
}
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/BookmarksDTO.kt
================================================
package com.desarrollodroide.network.model
data class BookmarksDTO (
val ok: Boolean? = null,
val message: BookmarksMessageDTO? = null,
val maxPage: Int? = null,
val page: Int? = null,
val bookmarks: List? = null,
) {
/** Resolves bookmarks from either v1.8+ (wrapped in message) or legacy format */
fun resolvedBookmarks(): List? = bookmarks ?: message?.bookmarks
fun resolvedPage(): Int? = page ?: message?.page
fun resolvedMaxPage(): Int? = maxPage ?: message?.maxPage
}
data class BookmarksMessageDTO(
val bookmarks: List?,
val maxPage: Int?,
val page: Int?,
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/LivenessResponseDTO.kt
================================================
package com.desarrollodroide.network.model
data class LivenessResponseDTO (
val ok: Boolean?,
val message: ReleaseInfoDTO?
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/LoginRequestPayload.kt
================================================
package com.desarrollodroide.network.model
import com.google.gson.annotations.SerializedName
data class LoginRequestPayload(
val username: String,
val password: String,
@SerializedName("remember_me")
val rememberMe: Boolean = true
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/LoginResponseDTO.kt
================================================
package com.desarrollodroide.network.model
data class LoginResponseDTO (
val ok: Boolean?,
val message: LoginResponseMessageDTO?,
val error: String?
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/LoginResponseMessageDTO.kt
================================================
package com.desarrollodroide.network.model
data class LoginResponseMessageDTO (
val expires: Int?, // Deprecated, used only for legacy APIs
val session: String?, // Deprecated, used only for legacy APIs
val token: String?,
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/ModifiedBookmarksDTO.kt
================================================
package com.desarrollodroide.network.model
data class ModifiedBookmarksDTO(
val bookmarks: List?,
val maxPage: Int?,
val page: Int?
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/ReadableContentResponseDTO.kt
================================================
package com.desarrollodroide.network.model
data class ReadableContentResponseDTO (
val ok: Boolean?,
val message: ReadableMessageDto?,
// v1.8.0 returns content and html at root level (no wrapper)
val content: String? = null,
val html: String? = null,
) {
fun resolvedMessage(): ReadableMessageDto? =
message ?: if (content != null || html != null) ReadableMessageDto(content, html) else null
}
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/ReadableMessageDto.kt
================================================
package com.desarrollodroide.network.model
data class ReadableMessageDto(
val content: String?,
val html: String?
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/ReleaseInfoDTO.kt
================================================
package com.desarrollodroide.network.model
data class ReleaseInfoDTO (
val version: String?,
val commit: String?,
val date: String?
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/SessionDTO.kt
================================================
package com.desarrollodroide.network.model
import com.google.gson.annotations.SerializedName
//import com.shiori.domain.model.Account
data class SessionDTO (
@SerializedName("session")
val session: String?,
@SerializedName("token")
val token: String?,
@SerializedName("account")
val account: AccountDTO?
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/SyncBookmarksMessageDTO.kt
================================================
package com.desarrollodroide.network.model
data class SyncBookmarksMessageDTO(
val deleted: List?,
val modified: ModifiedBookmarksDTO?
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/SyncBookmarksResponseDTO.kt
================================================
package com.desarrollodroide.network.model
data class SyncBookmarksResponseDTO(
val deleted: List?,
val message: SyncBookmarksMessageDTO
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/TagDTO.kt
================================================
package com.desarrollodroide.network.model
import com.google.gson.annotations.SerializedName
data class TagDTO (
@SerializedName("id")
val id: Int?,
@SerializedName("name")
val name: String?,
@SerializedName(value = "nBookmarks", alternate = ["bookmark_count"])
val nBookmarks: Int?,
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/TagsDTO.kt
================================================
package com.desarrollodroide.network.model
class TagsDTO (
val ok: Boolean?,
val message: List?
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/UpdateCachePayloadDTO.kt
================================================
package com.desarrollodroide.network.model
import com.google.gson.annotations.SerializedName
data class UpdateCachePayloadDTO(
@SerializedName("createArchive")
val createArchive : Boolean,
@SerializedName("createEbook")
val createEbook : Boolean?,
val ids: List,
@SerializedName("keepMetadata")
val keepMetadata : Boolean,
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/UpdateCachePayloadV1DTO.kt
================================================
package com.desarrollodroide.network.model
import com.google.gson.annotations.SerializedName
data class UpdateCachePayloadV1DTO(
@SerializedName("create_archive")
val createArchive : Boolean,
@SerializedName("create_ebook")
val createEbook : Boolean,
val ids: List,
@SerializedName("keep_metadata")
val keepMetadata : Boolean,
@SerializedName("skip_exist")
val skipExist : Boolean
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/model/util/NetworkChangeList.kt
================================================
package com.desarrollodroide.network.model.util
/**
* Network representation of a change list for a model.
*
* Change lists are a representation of a server-side map like data structure of model ids to
* metadata about that model. In a single change list, a given model id can only show up once.
*/
data class NetworkChangeList(
/**
* The id of the model that was changed
*/
val id: String,
/**
* Unique consecutive, monotonically increasing version number in the collection describing
* the relative point of change between models in the collection
*/
val changeListVersion: Int,
/**
* Summarizes the update to the model; whether it was deleted or updated.
* Updates include creations.
*/
val isDelete: Boolean,
)
================================================
FILE: network/src/main/java/com/desarrollodroide/network/retrofit/FileRemoteDataSource.kt
================================================
package com.desarrollodroide.network.retrofit
import android.content.Context
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
class FileRemoteDataSource {
fun downloadFile(
context: Context,
url: String,
fileName: String,
sessionId: String
): File {
val client = OkHttpClient.Builder().build()
val request = Request.Builder()
.url(url)
.addHeader("X-Session-Id", sessionId)
.build()
val response = client.newCall(request).execute()
val directory = context.getExternalFilesDir(null)
val downloadedFile = File(directory, "${cleanFileName(fileName)}.epub")
response.body?.byteStream().use { input ->
downloadedFile.outputStream().use { output ->
input?.copyTo(output)
}
}
return downloadedFile
}
private fun cleanFileName(fileName: String): String {
return fileName.replace(Regex("[^a-zA-Z0-9.,\\-\\s_\u0600-\u06FF]"), "_")
}
}
================================================
FILE: network/src/main/java/com/desarrollodroide/network/retrofit/NetworkBoundResource.kt
================================================
package com.desarrollodroide.network.retrofit
import android.util.Log
import androidx.annotation.MainThread
import androidx.annotation.WorkerThread
import com.desarrollodroide.common.result.ErrorHandler
import kotlinx.coroutines.flow.*
import retrofit2.Response
import com.desarrollodroide.common.result.Result
import kotlin.coroutines.cancellation.CancellationException
/**
* A generic class that can provide a resource backed by both the sqlite database and the network.
*
* Adapted from: Guide to app architecture
* https://developer.android.com/jetpack/guide
*
* @param Represents the domain model
* @param Represents the (converted) network > database model
*/
abstract class NetworkBoundResource(
private val errorHandler: ErrorHandler,
) {
fun asFlow() = flow {
emit(Result.Loading(null)) // start loading state immediately
val cachedData = fetchFromLocal().firstOrNull()
try {
if (shouldFetch(cachedData)) {
emit(Result.Loading(cachedData)) // update loading state with cached data
val apiResponse = fetchFromRemote()
val remoteResponse = apiResponse.body()
if (apiResponse.isSuccessful && remoteResponse != null) {
saveRemoteData(remoteResponse)
// Always fetch from local (Source of truth)
emitAll(fetchFromLocal().map {
Result.Success(it)
})
} else {
emit(Result.Error(errorHandler.getApiError(
statusCode = apiResponse.code(),
throwable = null,
message = apiResponse.errorBody()?.string())))
}
} else {
emit(Result.Success(cachedData))
}
} catch (e: Exception) {
if (e !is CancellationException) {
println("NetworkBoundResource: Error: ${e.message}")
emit(Result.Error(errorHandler.getError(e)))
}
}
}
@WorkerThread
protected abstract suspend fun saveRemoteData(response: RequestType)
@MainThread
protected abstract fun fetchFromLocal(): Flow
@MainThread
protected abstract suspend fun fetchFromRemote(): Response
@MainThread
protected abstract fun shouldFetch(data: ResultType?): Boolean
}
abstract class NetworkNoCacheResource(
private val errorHandler: ErrorHandler,
) {
fun asFlow() = flow {
emit(Result.Loading(null)) // start loading state immediately
try {
val apiResponse = fetchFromRemote()
val remoteResponse = apiResponse.body()
if (apiResponse.isSuccessful && remoteResponse != null) {
emitAll(fetchResult(remoteResponse).map { Result.Success(it) })
} else {
emit(Result.Error(errorHandler.getApiError(
statusCode = apiResponse.code(),
throwable = null,
message = apiResponse.errorBody()?.string())))
}
} catch (e: Exception) {
Log.v("NetworkNoCacheResource", "Error: ${e.message}")
emit(Result.Error(errorHandler.getError(e), null))
}
}
@MainThread
protected abstract suspend fun fetchFromRemote(): Response
@MainThread
protected abstract fun fetchResult(data: RequestType): Flow
}
================================================
FILE: network/src/main/java/com/desarrollodroide/network/retrofit/NetworkLoggerInterceptor.kt
================================================
package com.desarrollodroide.network.retrofit
import com.desarrollodroide.common.result.NetworkLogEntry
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import okhttp3.Interceptor
import okhttp3.Response
import java.text.SimpleDateFormat
import java.util.Locale
import kotlinx.coroutines.flow.asStateFlow
class NetworkLoggerInterceptor : Interceptor {
private val _logs = MutableStateFlow>(emptyList())
val logs: StateFlow> = _logs.asStateFlow()
fun clearLogs() {
_logs.value = emptyList()
}
private fun addLog(entry: NetworkLogEntry) {
_logs.update { currentLogs -> currentLogs + entry }
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val startTime = System.currentTimeMillis()
val timestamp = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.getDefault())
.format(startTime)
// Log request
addLog(
NetworkLogEntry(
timestamp = timestamp,
priority = "I",
url = request.url.toString(),
message = "${request.method} ${request.url.encodedPath}"
)
)
return try {
chain.proceed(request).also { response ->
val endTime = System.currentTimeMillis()
val duration = endTime - startTime
// Log response
addLog(
NetworkLogEntry(
timestamp = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.getDefault())
.format(endTime),
priority = if (response.isSuccessful) "S" else "E",
url = request.url.toString(),
message = "HTTP ${response.code} (${duration}ms)\n" +
response.peekBody(1024).string()
)
)
}
} catch (e: Exception) {
addLog(
NetworkLogEntry(
timestamp = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.getDefault())
.format(System.currentTimeMillis()),
priority = "E",
url = request.url.toString(),
message = e.message ?: "Unknown error"
)
)
throw e
}
}
}
================================================
FILE: network/src/main/java/com/desarrollodroide/network/retrofit/RetrofitNetwork.kt
================================================
package com.desarrollodroide.network.retrofit
import com.desarrollodroide.network.model.AccountDTO
import com.desarrollodroide.network.model.BookmarkDTO
import com.desarrollodroide.network.model.BookmarkResponseDTO
import com.desarrollodroide.network.model.SingleBookmarkResponseDTO
import com.desarrollodroide.network.model.BookmarksDTO
import com.desarrollodroide.network.model.LivenessResponseDTO
import com.desarrollodroide.network.model.LoginResponseDTO
import com.desarrollodroide.network.model.ReadableContentResponseDTO
import com.desarrollodroide.network.model.SessionDTO
import com.desarrollodroide.network.model.SyncBookmarksResponseDTO
import com.desarrollodroide.network.model.TagDTO
import com.desarrollodroide.network.model.TagsDTO
import retrofit2.Response
import retrofit2.http.*
interface RetrofitNetwork {
@GET()
suspend fun getBookmarks(
@Header("X-Session-Id") xSessionId: String,
@Url url: String
): Response
@GET()
suspend fun getPagingBookmarks(
@Header("X-Session-Id") xSessionId: String,
@Url url: String
): Response
@Headers("Content-Type: application/json")
@POST()
suspend fun sendLogin(
@Url url: String,
@Body jsonData: String
): Response
@Headers("Content-Type: application/json")
@POST()
suspend fun sendLoginV1(
@Url url: String,
@Body jsonData: String
): Response
@POST()
suspend fun sendLogout(
@Url url: String,
@Header("X-Session-Id") xSessionId: String,
): Response
@HTTP(method = "DELETE", hasBody = true)
suspend fun deleteBookmarks(
@Url url: String,
@Header("X-Session-Id") xSessionId: String,
@Body bookmarkIds: List
): Response
// Add Bookmark
@Headers("Content-Type: application/json")
@POST
suspend fun addBookmark(
@Url url: String,
@Header("X-Session-Id") xSessionId: String,
@Body body: String
): Response
@Headers("Content-Type: application/json")
@PUT()
suspend fun editBookmark(
@Url url: String,
@Header("X-Session-Id") xSessionId: String,
@Body body: String
): Response
@Headers("Content-Type: application/json")
@PUT()
suspend fun updateBookmarksCache(
@Url url: String,
@Header("X-Session-Id") xSessionId: String,
@Body body: String
): Response>
@Headers("Content-Type: application/json")
@PUT()
suspend fun updateBookmarksCacheV1(
@Url url: String,
@Header("Authorization") authorization: String,
@Body body: String
): Response
// Get tags
@GET()
suspend fun getTags(
@Url url: String,
@Header("Authorization") authorization: String,
): Response
// Rename tag
@PUT("/api/tags")
suspend fun renameTag(
@Header("X-Session-Id") xSessionId: String,
@Body tag: TagDTO
): Response
// List accounts
@GET("/api/accounts")
suspend fun listAccounts(
@Header("X-Session-Id") xSessionId: String
): Response>
// Create account
@POST("/api/accounts")
suspend fun createAccount(
@Header("X-Session-Id") xSessionId: String,
@Body account: AccountDTO
): Response
// Edit account
@PUT("/api/accounts")
suspend fun editAccount(
@Header("X-Session-Id") xSessionId: String,
@Body account: AccountDTO
): Response
// Delete accounts
@HTTP(method = "DELETE", path = "/api/accounts", hasBody = true)
suspend fun deleteAccounts(
@Header("X-Session-Id") xSessionId: String,
@Body accountNames: List
): Response
// Test system liveness
@GET()
suspend fun systemLiveness(
@Url url: String
): Response
@GET()
suspend fun getBookmarkReadableContent(
@Url url: String,
@Header("Authorization") authorization: String,
): Response
@Headers("Content-Type: application/json")
@POST()
suspend fun syncBookmarks(
@Url url: String,
@Header("Authorization") authorization: String,
@Body body: String
): Response
@GET
suspend fun getBookmark(
@Url url: String,
@Header("Authorization") authorization: String,
): Response