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

EhViewer
Shiori

GitHub Actions License Release Issues Commit Activity

Description | Screenshot | Features | Technologies Used | Download | License

## Description Shiori is an innovative bookmark management application that revolutionizes the way users save, organize, and access their favorite web pages. Built upon the robust [Shiori platform](https://github.com/go-shiori/shiori), Shiori offers a seamless experience across all devices. ## Screenshots | | | | | |:----------------------------------------------------:|:----------------------------------------------------:|:----------------------------------------------------:|:----------------------------------------------------:| | ![Screenshot 1](images/screenshots/Screenshot_1.png) | ![Screenshot 2](images/screenshots/Screenshot_2.png) | ![Screenshot 3](images/screenshots/Screenshot_3.png) | ![Screenshot 4](images/screenshots/Screenshot_4.png) | | ![Screenshot 5](images/screenshots/Screenshot_5.png) | ![Screenshot 6](images/screenshots/Screenshot_6.png) | ![Screenshot 7](images/screenshots/Screenshot_7.png) | ![Screenshot 8](images/screenshots/Screenshot_8.png) | ## Features - **Save Pages Easily**: Instantly capture and access web pages at any time, even offline. - **Superior Organization**: Custom labels, descriptions, and thumbnails for efficient bookmark sorting. - **Cloud Synchronization**: Sync your bookmarks across all devices. - **Intuitive Interface**: User-friendly navigation for a seamless experience. ## Technologies Used Shiori is built using a variety of modern and robust technologies to ensure scalability, maintainability, and performance: - **Clean Architecture**: Ensuring separation of concerns and modular design. - **Dependency Injection (DI)**: For managing dependencies effectively. - **Model-View-ViewModel (MVVM)**: For a responsive and powerful user interface. - **Use Cases**: Defining clear business logic. - **Repository Pattern**: For efficient data handling and abstraction. - **Protobuf (Proto)**: For efficient data serialization. - ## Development Status ⚠️ Please note that Shiori is currently under development. While we strive to provide a stable experience, you may encounter bugs or incomplete features. We encourage users to: - Report any issues you find on our [GitHub Issues page](https://github.com/DesarrolloAntonio/Shiori-Android-Client/issues) - Be aware that some features might be unstable or work in progress - Expect regular updates as we continue to improve the application ## Download Shiori is available for download on various platforms:

Get it on GitHub Get Shiori on Google Play Get Shiori on IzzyOnDroid Get it on F-Droid

## License This project is licensed under the Apache License - see the [LICENSE](LICENSE) file for details. ================================================ FILE: build.gradle ================================================ buildscript { ext { compose_ui_version = '1.1.1' } dependencies { classpath 'com.google.protobuf:protobuf-java:3.19.4' classpath "de.mannodermaus.gradle.plugins:android-junit5:1.10.2.0" } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '8.5.2' apply false id 'com.android.library' version '8.5.2' apply false id 'org.jetbrains.kotlin.android' version '2.0.0' apply false id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false } ================================================ FILE: common/.gitignore ================================================ /build ================================================ FILE: common/README.md ================================================ # :core:common module ![Dependency graph](../../docs/images/graphs/dep_graph_core_common.png) ================================================ FILE: common/build.gradle.kts ================================================ plugins { id("com.android.library") id ("org.jetbrains.kotlin.android") } android { namespace = "com.desarrollodroide.common" compileSdk = (findProperty("compileSdkVersion") as String).toInt() defaultConfig { minSdk = (findProperty("minSdkVersion") as String).toInt() targetSdk = (findProperty("targetSdkVersion") as String).toInt() } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { jvmTarget = "21" } } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } ================================================ FILE: common/src/main/AndroidManifest.xml ================================================ ================================================ 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 ![Dependency graph](../../docs/images/graphs/dep_graph_core_network.png) ================================================ 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 } ================================================ FILE: presentation/.gitignore ================================================ /build ================================================ FILE: presentation/build.gradle.kts ================================================ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("de.mannodermaus.android-junit5") id("org.jetbrains.kotlin.plugin.compose") } android { namespace = "com.desarrollodroide.pagekeeper" compileSdk = (findProperty("compileSdkVersion") as String).toInt() defaultConfig { applicationId = "com.desarrollodroide.pagekeeper" minSdk = (findProperty("minSdkVersion") as String).toInt() targetSdk = (findProperty("targetSdkVersion") as String).toInt() versionCode = (findProperty("versionCode") as String).toInt() versionName = findProperty("versionName") as String testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } signingConfigs { create("production") { keyAlias = System.getenv("RELEASE_KEY_ALIAS") keyPassword = System.getenv("RELEASE_KEY_PASSWORD") storeFile = file("${System.getenv("GITHUB_WORKSPACE")}/key_store.jks") storePassword = System.getenv("RELEASE_STORE_PASSWORD") } create("staging") { keyAlias = System.getenv("RELEASE_KEY_ALIAS") keyPassword = System.getenv("RELEASE_KEY_PASSWORD") storeFile = file("${System.getenv("GITHUB_WORKSPACE")}/key_store.jks") storePassword = System.getenv("RELEASE_STORE_PASSWORD") } } buildTypes { release { isMinifyEnabled = false } debug { isDebuggable = true } } flavorDimensions += "version" productFlavors { create("production") { dimension = "version" signingConfig = signingConfigs.getByName("production") } create("staging") { dimension = "version" applicationId = "com.desarrollodroide.pagekeeper.staging" signingConfig = signingConfigs.getByName("staging") versionNameSuffix = "-staging" resValue("string", "app_name", "Shiori-dev") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { jvmTarget = "21" } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.11" } packagingOptions { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } applicationVariants.configureEach { outputs.configureEach { val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl output?.outputFileName = "Shiori v$versionName.apk" } } dependenciesInfo { includeInApk = false includeInBundle = false } } dependencies { implementation(project(":data")) implementation(project(":domain")) implementation(project(":model")) implementation(project(":network")) implementation(project(":common")) implementation (libs.androidx.core) implementation (libs.androidx.lifecycle.runtime ) implementation (libs.androidx.activity.compose) implementation (libs.androidx.navigation.compose) implementation (libs.androidx.lifecycle.viewmodel.compose) implementation (libs.androidx.lifecycle.runtimeCompose) implementation (libs.androidx.preference) implementation (libs.androidx.paging.compose) implementation ("androidx.paging:paging-common-ktx:3.3.2") implementation (libs.compose.ui.ui) implementation (libs.compose.ui.tooling.preview) implementation (libs.compose.ui.tooling) implementation (libs.compose.material3.material3) implementation (libs.compose.material.iconsext) implementation (libs.compose.runtime.livedata) implementation (libs.bundles.retrofit) implementation (libs.accompanist.permissions) implementation (libs.koin.androidx.compose) implementation (libs.androidx.datastore.preferences) implementation (libs.coil.compose) // 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. } composeCompiler { enableStrongSkippingMode = true } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } ================================================ FILE: presentation/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: presentation/src/main/AndroidManifest.xml ================================================ ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ComposeSetup.kt ================================================ package com.desarrollodroide.pagekeeper import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.* import androidx.compose.ui.Modifier import com.desarrollodroide.data.helpers.ThemeMode import com.desarrollodroide.pagekeeper.helpers.ThemeManager import com.desarrollodroide.pagekeeper.ui.theme.ShioriTheme @Composable fun ComposeSetup( themeManager: ThemeManager, content: @Composable () -> Unit ) { val isDarkTheme = when (themeManager.themeMode.value) { ThemeMode.DARK -> true ThemeMode.LIGHT -> false ThemeMode.AUTO -> isSystemInDarkTheme() } ShioriTheme( dynamicColor = themeManager.useDynamicColors.value, darkTheme = isDarkTheme ) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { content() } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/MainActivity.kt ================================================ package com.desarrollodroide.pagekeeper import android.content.Context import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.annotation.RequiresApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import coil.ImageLoader import com.desarrollodroide.pagekeeper.extensions.logCacheDetails import com.desarrollodroide.pagekeeper.extensions.openUrlInBrowser import com.desarrollodroide.pagekeeper.helpers.ThemeManager import com.desarrollodroide.pagekeeper.navigation.Navigation import org.koin.android.ext.android.inject import com.desarrollodroide.pagekeeper.extensions.shareEpubFile import com.desarrollodroide.pagekeeper.extensions.shareText import com.desarrollodroide.pagekeeper.ui.bookmarkeditor.BookmarkEditorActivity import java.util.Locale class MainActivity : ComponentActivity() { private val themeManager: ThemeManager by inject() @RequiresApi(Build.VERSION_CODES.N) @OptIn(ExperimentalFoundationApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //val context = this.updateLocale(Locale("iw")) setContent { ComposeSetup(themeManager = themeManager) { Surface( modifier = Modifier .fillMaxSize() ){ Navigation( onFinish = { finish() }, openUrlInBrowser = { openUrlInBrowser(it) }, shareEpubFile = { shareEpubFile(it) }, shareText = { shareText(it) }, onAddManuallyClick = { startActivity(BookmarkEditorActivity.createManualIntent(this)) } ) } } } } override fun onResume() { super.onResume() Log.v("MainActivity", "onResume") // TODO: sync when endpoint is available } } fun Context.updateLocale(locale: Locale): Context { Locale.setDefault(locale) val resources = this.resources val config = resources.configuration config.setLocale(locale) return this.createConfigurationContext(config) } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ShioriApp.kt ================================================ package com.desarrollodroide.pagekeeper import android.app.Application import coil.ImageLoader import com.desarrollodroide.pagekeeper.di.presenterModule import com.desarrollodroide.pagekeeper.di.appModule import com.desarrollodroide.data.di.dataModule import com.desarrollodroide.data.di.databaseModule import com.desarrollodroide.data.helpers.CrashHandler import com.desarrollodroide.network.di.networkingModule import com.desarrollodroide.pagekeeper.extensions.logCacheDetails import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin class ShioriApplication : Application() { override fun onCreate() { super.onCreate() startKoin { androidContext(this@ShioriApplication) modules( listOf( networkingModule(), appModule(), presenterModule(), dataModule(), databaseModule() ) ) } // Show log disk cache statistics for debugging val imageLoader: ImageLoader by inject() imageLoader.logCacheDetails() val crashHandler: CrashHandler by inject() crashHandler.initialize() } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/di/AppModule.kt ================================================ package com.desarrollodroide.pagekeeper.di import android.content.Context import android.util.Log import coil.ImageLoader import coil.disk.DiskCache import com.desarrollodroide.pagekeeper.helpers.ThemeManager import com.desarrollodroide.pagekeeper.helpers.ThemeManagerImpl import com.desarrollodroide.data.repository.BookmarksRepository import com.desarrollodroide.data.repository.BookmarksRepositoryImpl import com.desarrollodroide.domain.usecase.AddBookmarkUseCase import com.desarrollodroide.domain.usecase.DeleteBookmarkUseCase import com.desarrollodroide.domain.usecase.DeleteLocalBookmarkUseCase import com.desarrollodroide.domain.usecase.DownloadFileUseCase import com.desarrollodroide.domain.usecase.EditBookmarkUseCase import com.desarrollodroide.domain.usecase.GetBookmarkReadableContentUseCase import com.desarrollodroide.domain.usecase.GetBookmarksUseCase import com.desarrollodroide.domain.usecase.GetLocalPagingBookmarksUseCase import com.desarrollodroide.domain.usecase.GetTagsUseCase import com.desarrollodroide.domain.usecase.SendLoginUseCase import com.desarrollodroide.domain.usecase.SendLogoutUseCase import com.desarrollodroide.domain.usecase.SyncBookmarksUseCase import com.desarrollodroide.domain.usecase.GetAllRemoteBookmarksUseCase import com.desarrollodroide.domain.usecase.SystemLivenessUseCase import com.desarrollodroide.domain.usecase.UpdateBookmarkCacheUseCase import com.desarrollodroide.domain.usecase.GetBookmarkByIdUseCase import okhttp3.OkHttpClient import org.koin.dsl.module fun appModule() = module { single { BookmarksRepositoryImpl( apiService = get(), bookmarksDao = get(), errorHandler = get(), ) as BookmarksRepository } single { GetBookmarksUseCase( bookmarksRepository = get() ) } single { GetLocalPagingBookmarksUseCase( bookmarksRepository = get() ) } single { DeleteBookmarkUseCase( bookmarksDao = get(), syncManager = get() ) } single { DeleteLocalBookmarkUseCase( bookmarksDao = get() ) } single { SendLoginUseCase( authRepository = get() ) } single { SendLogoutUseCase( authRepository = get(), syncManager = get(), settingsPreferenceDataSource = get(), bookmarksRepository = get() ) } single { AddBookmarkUseCase( bookmarksDao = get(), syncManager = get() ) } single { EditBookmarkUseCase( bookmarksDao = get(), tagsDao = get(), syncManager = get() ) } single { UpdateBookmarkCacheUseCase( bookmarksDao = get(), syncManager = get() ) } single { DownloadFileUseCase( fileRepository = get() ) } single { SystemLivenessUseCase( systemRepository = get() ) } single { GetTagsUseCase( tagsRepository = get() ) } single { GetBookmarkReadableContentUseCase( bookmarksRepository = get() ) } single { GetBookmarkByIdUseCase( bookmarksRepository = get() ) } single { GetAllRemoteBookmarksUseCase( bookmarksRepository = get() ) } single { SyncBookmarksUseCase( bookmarksRepository = get(), settingsPreferenceDataSource = get(), bookmarkDatabase = get() ) } single { ThemeManagerImpl(get()) as ThemeManager } single { ImageLoader.Builder(get()) .okHttpClient { OkHttpClient.Builder() .retryOnConnectionFailure(true) .addInterceptor { chain -> val request = chain.request() val response = chain.proceed(request) if (!response.isSuccessful) { Log.e("BookmarkImageView", "HTTP error: ${response.code}") } val newCacheControl = "public, max-age=31536000" response.newBuilder() .header("Cache-Control", newCacheControl) .build() } .build() } .diskCache { DiskCache.Builder() .directory(get().cacheDir.resolve("image_cache")) .maxSizeBytes(250L * 1024 * 1024) // 250MB .build() } .build() } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/di/PresenterModule.kt ================================================ package com.desarrollodroide.pagekeeper.di import com.desarrollodroide.pagekeeper.ui.feed.FeedViewModel import com.desarrollodroide.pagekeeper.ui.login.LoginViewModel import com.desarrollodroide.pagekeeper.ui.bookmarkeditor.BookmarkViewModel import com.desarrollodroide.pagekeeper.ui.feed.SearchViewModel import com.desarrollodroide.pagekeeper.ui.readablecontent.ReadableContentViewModel import com.desarrollodroide.pagekeeper.ui.settings.SettingsViewModel import com.desarrollodroide.pagekeeper.ui.settings.crash.CrashLogViewModel import com.desarrollodroide.pagekeeper.ui.settings.logcat.NetworkLogViewModel import org.koin.dsl.module import org.koin.androidx.viewmodel.dsl.viewModel fun presenterModule() = module { viewModel { LoginViewModel( loginUseCase = get(), settingsPreferenceDataSource = get(), livenessUseCase = get(), ) } viewModel { FeedViewModel( bookmarkDatabase = get(), settingsPreferenceDataSource = get(), getTagsUseCase = get(), deleteBookmarkUseCase = get(), updateBookmarkCacheUseCase = get(), getLocalPagingBookmarksUseCase = get(), downloadFileUseCase = get(), getAllRemoteBookmarksUseCase = get(), deleteLocalBookmarkUseCase = get(), syncBookmarksUseCase = get(), syncManager = get(), ) } viewModel { SettingsViewModel( settingsPreferenceDataSource = get(), bookmarksRepository = get(), sendLogoutUseCase = get(), themeManager = get(), getTagsUseCase = get(), imageLoader = get(), ) } viewModel { BookmarkViewModel( bookmarkDatabase = get(), bookmarksRepository = get(), bookmarkAdditionUseCase = get(), editBookmarkUseCase = get(), userPreferences = get(), settingsPreferenceDataSource = get(), ) } viewModel { SearchViewModel( getPagingBookmarksUseCase = get(), settingsPreferenceDataSource = get(), ) } viewModel { ReadableContentViewModel( getBookmarkReadableContentUseCase = get(), settingsPreferenceDataSource = get(), bookmarksDao = get(), bookmarkHtmlDao = get(), ) } viewModel { NetworkLogViewModel( logger = get(), ) } viewModel { CrashLogViewModel( settingsPreferenceDataSource = get(), ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/ContextExtensions.kt ================================================ package com.desarrollodroide.pagekeeper.extensions import android.content.Context import android.content.Intent import android.net.Uri import androidx.core.content.FileProvider import java.io.File fun Context.shareText(text: String) { val shareIntent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TEXT, text) type = "text/plain" } startActivity(Intent.createChooser(shareIntent, null)) } fun Context.openUrlInBrowser(url: String) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val chooser = Intent.createChooser(intent, "Open with") startActivity(chooser) } fun Context.shareEpubFile(file: File) { val uri = FileProvider.getUriForFile(this, "${applicationContext.packageName}.provider", file) val intent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_STREAM, uri) type = "application/epub+zip" addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } startActivity(Intent.createChooser(intent, "Share EPUB")) } fun Context.sendFeedbackEmail() { val emailIntent = Intent(Intent.ACTION_SENDTO).apply { data = Uri.parse("mailto:") putExtra(Intent.EXTRA_EMAIL, arrayOf("desarrollodroide@gmail.com")) } val chooserIntent = Intent.createChooser(emailIntent, "Choose an email app:") startActivity(chooserIntent) } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/ImageLoaderExtensions.kt ================================================ package com.desarrollodroide.pagekeeper.extensions import android.util.Log import coil.ImageLoader import coil.annotation.ExperimentalCoilApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @OptIn(ExperimentalCoilApi::class) fun ImageLoader.logCacheDetails() { diskCache?.let { cache -> val imageCount = cache.directory.toFile().listFiles()?.size ?: 0 Log.d("CoilCacheInfo", "Total images in disk cache: $imageCount") } ?: Log.d("CoilCacheInfo", "No disk cache configured") } @OptIn(ExperimentalCoilApi::class) suspend fun ImageLoader.clearCache() = withContext(Dispatchers.IO) { memoryCache?.clear() diskCache?.clear() } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/LongExtensions.kt ================================================ package com.desarrollodroide.pagekeeper.extensions fun Long.bytesToDisplaySize(): String { val kb = this / 1024.0 val mb = kb / 1024.0 val gb = mb / 1024.0 return when { gb >= 1.0 -> String.format("%.2f GB", gb) mb >= 1.0 -> String.format("%.2f MB", mb) kb >= 1.0 -> String.format("%.2f KB", kb) else -> "$this bytes" } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/extensions/StringExtensions.kt ================================================ package com.desarrollodroide.pagekeeper.extensions /** * Determines if a string contains more than half Arabic characters. * * @return True if the string contains more than half Arabic characters, false otherwise. */ fun String.isRTLText(): Boolean { // Take the first 20 characters of the string val textSample = this.take(100) // Count the number of Arabic characters in the sample val arabicCount = textSample.count { char -> // Check if the character is within the Arabic Unicode range char in '\u0600'..'\u06FF' || char in '\u0750'..'\u077F' || char in '\u08A0'..'\u08FF' || char in '\uFB50'..'\uFDFF' || char in '\uFE70'..'\uFEFF' } // Return true if the Arabic character count is greater than half the length of the sample return arabicCount > textSample.length / 2 } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/helpers/ThemeManager.kt ================================================ package com.desarrollodroide.pagekeeper.helpers import androidx.compose.runtime.MutableState import com.desarrollodroide.data.helpers.ThemeMode interface ThemeManager { var themeMode: MutableState var useDynamicColors: MutableState } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/helpers/ThemeManagerImpl.kt ================================================ package com.desarrollodroide.pagekeeper.helpers import androidx.compose.runtime.mutableStateOf import com.desarrollodroide.data.repository.SettingsRepository class ThemeManagerImpl( settingsRepository: SettingsRepository, ) : ThemeManager { override var themeMode = mutableStateOf(settingsRepository.getThemeMode()) override var useDynamicColors = mutableStateOf(settingsRepository.getUseDynamicColors()) } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/navigation/NavItem.kt ================================================ package com.desarrollodroide.pagekeeper.navigation import android.net.Uri import androidx.navigation.NavType import androidx.navigation.navArgument sealed class NavItem( internal val baseRoute: String, private val navArgs: List = emptyList() ) { data object LoginNavItem : NavItem("login") data object HomeNavItem : NavItem("home") data object SettingsNavItem : NavItem("settings") data object TermsOfUseNavItem : NavItem("termsOfUse") data object PrivacyPolicyNavItem : NavItem("privacyPolicy") data object ReadableContentNavItem : NavItem("readable_content/{bookmarkId}") { fun createRoute(bookmarkId: Int): String { return "readable_content/$bookmarkId" } } data object NetworkLoggerNavItem : NavItem("networkLogger") data object LastCrashNavItem: NavItem("lastCrash") val route = run { val argValues = navArgs.map { "{${it.key}}" } listOf(baseRoute) .plus(argValues) .joinToString("/") } val args = navArgs.map { navArgument(it.key) { type = it.navType } } } enum class NavArgs(val key: String, val navType: NavType<*>) ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/navigation/Navigation.kt ================================================ package com.desarrollodroide.pagekeeper.navigation import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.desarrollodroide.pagekeeper.ui.feed.FeedViewModel import com.desarrollodroide.pagekeeper.ui.home.HomeScreen import com.desarrollodroide.pagekeeper.ui.login.LoginScreen import com.desarrollodroide.pagekeeper.ui.login.LoginViewModel import org.koin.androidx.compose.get import java.io.File @RequiresApi(Build.VERSION_CODES.N) @ExperimentalFoundationApi @Composable fun Navigation( onFinish: () -> Unit, openUrlInBrowser: (String) -> Unit, onAddManuallyClick: () -> Unit, shareEpubFile: (File) -> Unit, shareText: (String) -> Unit ) { val navController = rememberNavController() val feedViewModel = get() val loginViewModel = get() NavHost( navController = navController, startDestination = NavItem.LoginNavItem.route ) { composable(NavItem.LoginNavItem) { backStackEntry -> LoginScreen( loginViewModel = loginViewModel, onSuccess = { navController.navigate(NavItem.HomeNavItem.route) } ) } composable(NavItem.HomeNavItem) { backStackEntry -> HomeScreen( feedViewModel = feedViewModel, goToLogin = { loginViewModel.clearState() feedViewModel.resetData() navController.navigate(NavItem.LoginNavItem.route) { popUpTo(NavItem.HomeNavItem.route) { inclusive = true } } }, onFinish = onFinish, openUrlInBrowser = openUrlInBrowser, shareEpubFile = shareEpubFile, shareText = shareText, onAddManuallyClick = onAddManuallyClick ) } } } private fun NavGraphBuilder.composable( navItem: NavItem, content: @Composable (NavBackStackEntry) -> Unit ) { composable( route = navItem.route, arguments = navItem.args ) { content(it) } } private inline fun NavBackStackEntry.findArg(key: String): T { val value = arguments?.get(key) requireNotNull(value) return value as T } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkEditorActivity.kt ================================================ package com.desarrollodroide.pagekeeper.ui.bookmarkeditor import android.content.Context import android.content.Intent import android.os.Bundle import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import com.desarrollodroide.pagekeeper.ui.theme.ShioriTheme import com.desarrollodroide.model.Bookmark import com.desarrollodroide.pagekeeper.MainActivity import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel class BookmarkEditorActivity : ComponentActivity() { private val bookmarkViewModel: BookmarkViewModel by viewModel() companion object { const val EXTRA_MODE = "extra_mode" fun createManualIntent(context: Context): Intent { return Intent(context, BookmarkEditorActivity::class.java).apply { putExtra(EXTRA_MODE, BookmarkEditorType.ADD_MANUALLY.name) } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val mode = intent.getStringExtra(EXTRA_MODE) if (mode == BookmarkEditorType.ADD_MANUALLY.name) { setupBookmarkEditor(BookmarkEditorType.ADD_MANUALLY, "", "") return } var sharedUrl = "" var title = "" intent?.let { intent -> if (intent.action == Intent.ACTION_SEND) { intent.extras?.keySet()?.forEach { key -> val value = intent.extras?.get(key) Log.v("Intent Extra", "$key: $value") } sharedUrl = intent.getStringExtra(Intent.EXTRA_TEXT) ?: "" Log.v("Shared link", sharedUrl) title = intent.getStringExtra(Intent.EXTRA_TITLE) ?: sharedUrl Log.v("Shared title", title) } else { Toast.makeText(this, "Invalid shared link", Toast.LENGTH_LONG).show() finish() } } lifecycleScope.launch { bookmarkViewModel.toastMessage.collect { message -> message?.let { Toast.makeText(this@BookmarkEditorActivity, it, Toast.LENGTH_LONG).show() finish() } } } lifecycleScope.launch { if (sharedUrl.isNotEmpty()) { if (bookmarkViewModel.userHasSession()) { if (bookmarkViewModel.autoAddBookmark) { bookmarkViewModel.autoAddBookmark(sharedUrl, title) Toast.makeText(this@BookmarkEditorActivity, "Bookmark saved", Toast.LENGTH_LONG).show() finish() } else { setupBookmarkEditor(BookmarkEditorType.ADD, sharedUrl, title) } } else { setContent { ShioriTheme { NotSessionScreen( onClickLogin = { startMainActivity() } ) } } } } else { Toast.makeText(this@BookmarkEditorActivity, "No shared URL found", Toast.LENGTH_LONG).show() finish() } } } private fun setupBookmarkEditor(type: BookmarkEditorType, url: String, title: String) { setContent { ShioriTheme { Surface( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.inverseOnSurface) ) { val makeArchivePublic = bookmarkViewModel.makeArchivePublic val createEbook = bookmarkViewModel.createEbook val createArchive = bookmarkViewModel.createArchive BookmarkEditorScreen( pageTitle = if (type == BookmarkEditorType.ADD_MANUALLY) "Add Manually" else "Add", bookmarkEditorType = type, bookmark = Bookmark( url = url, title = title, tags = emptyList(), public = if (makeArchivePublic) 1 else 0, createArchive = createArchive, createEbook = createEbook ), onBack = { finish() }, updateBookmark = { finish() }, showToast = { message -> Toast.makeText(this@BookmarkEditorActivity, message, Toast.LENGTH_LONG).show() }, startMainActivity = { startMainActivity() } ) } } } } private fun startMainActivity() { val intent = Intent(this, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK startActivity(intent) finish() } override fun onPause() { super.onPause() finish() } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkEditorScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.bookmarkeditor import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.desarrollodroide.pagekeeper.ui.components.ConfirmDialog import com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog import com.desarrollodroide.model.Bookmark import com.desarrollodroide.model.Tag import org.koin.androidx.compose.get @Composable fun BookmarkEditorScreen( pageTitle: String, bookmarkEditorType: BookmarkEditorType, bookmark: Bookmark, onBack: () -> Unit, updateBookmark: (Bookmark) -> Unit, showToast: (String) -> Unit = {}, startMainActivity: () -> Unit = {} ) { val bookmarkViewModel = get() val newTag = remember { mutableStateOf("") } val availableTags = bookmarkViewModel.availableTags.collectAsState() val bookmarkUiState = bookmarkViewModel.bookmarkUiState.collectAsState().value var currentUrl by remember { mutableStateOf(bookmark.url) } // No need to update values in settings var localCreateEbook by remember { mutableStateOf(bookmarkViewModel.createEbook) } var localCreateArchive by remember { mutableStateOf(bookmarkViewModel.createArchive) } val assignedTags: MutableState> = remember { mutableStateOf(bookmark.tags) } var localMakeArchivePublic by remember { mutableStateOf( when (bookmarkEditorType) { BookmarkEditorType.ADD, BookmarkEditorType.ADD_MANUALLY -> bookmarkViewModel.makeArchivePublic BookmarkEditorType.EDIT -> bookmark.public == 1 } ) } BackHandler { onBack() } if (bookmarkUiState.isLoading) { Log.v("BookmarkEditorScreen", "isLoading") InfiniteProgressDialog(onDismissRequest = {}) } if (!bookmarkUiState.error.isNullOrEmpty()) { Log.v("BookmarkEditorScreen", "Error") ConfirmDialog( icon = Icons.Default.Error, title = "Error", content = bookmarkUiState.error, dismissButton = if (bookmarkViewModel.sessionExpired) "Go to login" else "", confirmButton = "Accept", openDialog = remember { mutableStateOf(true) }, onDismiss = { if (bookmarkViewModel.sessionExpired) { startMainActivity() } }, onConfirm = {} ) } BookmarkEditorView( title = pageTitle, url = currentUrl, bookmarkEditorType = bookmarkEditorType, newTag = newTag, assignedTags = assignedTags, availableTags = availableTags, saveBookmark = { when (bookmarkEditorType) { BookmarkEditorType.ADD, BookmarkEditorType.ADD_MANUALLY -> { bookmarkViewModel.saveBookmark( url = currentUrl, title = bookmark.title, tags = assignedTags.value, createArchive = localCreateArchive, makeArchivePublic = localMakeArchivePublic, createEbook = localCreateEbook ) } BookmarkEditorType.EDIT -> { bookmarkViewModel.editBookmark( bookmark = bookmark.copy( tags = assignedTags.value, createEbook = bookmark.hasEbook, createArchive = bookmark.hasArchive, public = if (localMakeArchivePublic) 1 else 0, ) ) } } onBack() }, onBackClick = onBack, createArchive = localCreateArchive, makeArchivePublic = localMakeArchivePublic, onMakeArchivePublicChanged = { localMakeArchivePublic = it }, createEbook = localCreateEbook, onCreateEbookChanged = { localCreateEbook = it }, onCreateArchiveChanged = { localCreateArchive = it }, onUrlChange = { currentUrl = it } ) } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkEditorView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.bookmarkeditor import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.outlined.Save import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.desarrollodroide.pagekeeper.ui.components.Categories import com.desarrollodroide.pagekeeper.ui.components.CategoriesType import com.desarrollodroide.model.Tag enum class BookmarkEditorType { ADD, ADD_MANUALLY, EDIT } @Composable fun BookmarkEditorView( title: String, url: String, bookmarkEditorType: BookmarkEditorType, newTag: MutableState, assignedTags: MutableState>, availableTags: State>, saveBookmark: (BookmarkEditorType) -> Unit, onBackClick: () -> Unit, createArchive: Boolean, onCreateArchiveChanged: (Boolean) -> Unit, makeArchivePublic: Boolean, onMakeArchivePublicChanged: (Boolean) -> Unit, createEbook: Boolean, onCreateEbookChanged: (Boolean) -> Unit, onUrlChange: (String) -> Unit = {} ) { Column( modifier = Modifier .background(MaterialTheme.colorScheme.background) .padding(horizontal = 16.dp) .padding(top = 16.dp) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { IconButton(onClick = onBackClick) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } Text( text = title, fontSize = 24.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(CenterVertically) ) IconButton(onClick = { saveBookmark(bookmarkEditorType) }) { Icon(Icons.Outlined.Save, contentDescription = "Save") } } if (bookmarkEditorType == BookmarkEditorType.ADD_MANUALLY) { OutlinedTextField( value = url, onValueChange = onUrlChange, label = { Text("URL") }, modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), singleLine = true ) } else { Text( text = url, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceVariant) .padding(8.dp), maxLines = 3, overflow = TextOverflow.Ellipsis ) } if (bookmarkEditorType == BookmarkEditorType.ADD || bookmarkEditorType == BookmarkEditorType.ADD_MANUALLY) { Row(verticalAlignment = CenterVertically) { Checkbox( checked = createArchive, onCheckedChange = onCreateArchiveChanged ) Text("Create archive") } Row(verticalAlignment = CenterVertically) { Checkbox( checked = createEbook, onCheckedChange = onCreateEbookChanged ) Text("Create Ebook") } } Row(verticalAlignment = CenterVertically) { Checkbox( checked = makeArchivePublic, onCheckedChange = onMakeArchivePublicChanged ) Text("Make bookmark publicly available") } Row { OutlinedTextField( modifier = Modifier .weight(1f) .align(CenterVertically), value = newTag.value, onValueChange = { newTag.value = it }, label = { Text("Add Tag") }, singleLine = true, leadingIcon = { Icon(Icons.AutoMirrored.Filled.Label, contentDescription = "Tag") } ) Spacer(modifier = Modifier.width(10.dp)) Button( modifier = Modifier .align(CenterVertically) .padding(top = 4.dp), onClick = { val normalizedName = newTag.value.lowercase().trim() if (normalizedName.isNotBlank() && !assignedTags.value.any { it.name.lowercase() == normalizedName }) { assignedTags.value = assignedTags.value + Tag(id = -1, name = normalizedName) newTag.value = "" } } ) { Text(text = "Add") } } Spacer(modifier = Modifier.height(10.dp)) Column( Modifier .background(MaterialTheme.colorScheme.surfaceVariant) .heightIn(max = 145.dp) .fillMaxWidth() .border( BorderStroke(1.dp, MaterialTheme.colorScheme.primary), RoundedCornerShape(4.dp) ) .padding(horizontal = 6.dp) .verticalScroll(rememberScrollState()) ) { Categories( categoriesType = CategoriesType.REMOVEABLES, showCategories = true, uniqueCategories = assignedTags.value, selectedTags = assignedTags.value, onCategorySelected = { /* No se usa en modo REMOVEABLES */ }, onCategoryDeselected = { deselectedTag -> assignedTags.value = assignedTags.value.filter { it != deselectedTag } } ) } Spacer(modifier = Modifier.heightIn(10.dp)) Text( style = MaterialTheme.typography.titleMedium, text = "All Tags" ) Spacer(modifier = Modifier.heightIn(5.dp)) Column( Modifier .weight(1f) .verticalScroll(rememberScrollState()) ) { TagsSelectorView( availableTags = availableTags.value, onTagSelected = { if (!assignedTags.value.contains(it)) { assignedTags.value = assignedTags.value + it } } ) } } } @Composable @OptIn(ExperimentalLayoutApi::class) private fun TagsSelectorView( availableTags: List, onTagSelected: (Tag) -> Unit ) { FlowRow( ) { availableTags.forEach { category -> Text( color = Color.DarkGray, modifier = Modifier .padding(5.dp) .clip(RoundedCornerShape(18.dp)) .background(Color(0xFFEAEDED)) .clickable { onTagSelected(category) } .padding(vertical = 8.dp, horizontal = 16.dp), text = category.name ) } } } @Preview(showBackground = true) @Composable fun BookmarkEditorPreview() { val tag1 = Tag( id = 1, name = "tag1", selected = true, nBookmarks = 0 ) val tag2 = Tag( id = 2, name = "tag2", selected = false, nBookmarks = 0 ) val assignedTags = remember { mutableStateOf(listOf(tag1, tag2)) } val newTag = remember { mutableStateOf("")} BookmarkEditorView( title = "Add", url = "http://www.google.com", bookmarkEditorType = BookmarkEditorType.ADD, assignedTags = remember { mutableStateOf(generateRandomTags(100)) }, saveBookmark = {}, availableTags = assignedTags, newTag = newTag, onBackClick = {}, makeArchivePublic = true, createArchive = false, createEbook = false, onMakeArchivePublicChanged = {}, onCreateEbookChanged = {}, onCreateArchiveChanged = {} ) } private fun generateRandomTagName(length: Int): String { val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') return (1..length) .map { allowedChars.random() } .joinToString("") } private fun generateRandomTags(count: Int): List { return List(count) { Tag(id = count,name = generateRandomTagName(10)) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/BookmarkViewModel.kt ================================================ package com.desarrollodroide.pagekeeper.ui.bookmarkeditor import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.desarrollodroide.pagekeeper.ui.components.UiState import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource import com.desarrollodroide.data.local.room.dao.BookmarksDao import com.desarrollodroide.data.repository.BookmarksRepository import com.desarrollodroide.domain.usecase.AddBookmarkUseCase import com.desarrollodroide.domain.usecase.EditBookmarkUseCase import com.desarrollodroide.model.Bookmark import com.desarrollodroide.model.Tag import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking class BookmarkViewModel( bookmarkDatabase: BookmarksDao, private val bookmarkAdditionUseCase: AddBookmarkUseCase, private val bookmarksRepository: BookmarksRepository, private val editBookmarkUseCase: EditBookmarkUseCase, private val userPreferences: SettingsPreferenceDataSource, private val settingsPreferenceDataSource: SettingsPreferenceDataSource, ) : ViewModel() { var backendUrl = "" var sessionExpired = false var autoAddBookmark: Boolean = false private set private val _bookmarkUiState = MutableStateFlow(UiState(idle = true)) val bookmarkUiState = _bookmarkUiState.asStateFlow() private val _toastMessage = MutableStateFlow(null) val toastMessage = _toastMessage.asStateFlow() init { viewModelScope.launch { backendUrl = userPreferences.getUrl() initializePreferences() } } private suspend fun initializePreferences() { makeArchivePublic = settingsPreferenceDataSource.makeArchivePublicFlow.first() createEbook = settingsPreferenceDataSource.createEbookFlow.first() createArchive = settingsPreferenceDataSource.createArchiveFlow.first() autoAddBookmark = settingsPreferenceDataSource.autoAddBookmarkFlow.first() } var makeArchivePublic: Boolean = false var createEbook: Boolean = false var createArchive: Boolean = false val availableTags: StateFlow> = bookmarkDatabase.getAll() .map { bookmarks -> bookmarks.flatMap { it.tags }.distinct() } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = listOf() ) fun autoAddBookmark( url: String, title: String, ) = viewModelScope.launch { saveBookmark( url = url, title = title, tags = emptyList(), createArchive = createArchive, makeArchivePublic = makeArchivePublic, createEbook = createEbook ) } fun saveBookmark( url: String, title: String, tags: List, createArchive: Boolean, makeArchivePublic: Boolean, createEbook: Boolean ) = viewModelScope.launch { bookmarkAdditionUseCase.invoke( bookmark = Bookmark( url = url, title = title, tags = tags, createArchive = createArchive, createEbook = createEbook, public = if (makeArchivePublic) 1 else 0 ) ) } fun editBookmark(bookmark: Bookmark) = viewModelScope.launch { viewModelScope.launch { editBookmarkUseCase.invoke( bookmark = bookmark ) } } fun userHasSession() = runBlocking { userPreferences.getUser().first().hasSession() } private fun emitToastIfAutoAdd(message: String) { viewModelScope.launch { if (autoAddBookmark) { _toastMessage.value = message } } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/NotSessionScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.bookmarkeditor import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.desarrollodroide.pagekeeper.R import com.desarrollodroide.pagekeeper.ui.theme.ShioriTheme @Composable fun NotSessionScreen( onClickLogin: () -> Unit ) { Column( modifier = Modifier .fillMaxSize(), ) { Image( painter = painterResource(id = R.drawable.img_authentication_failed), contentDescription = null, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), contentScale = ContentScale.Inside, modifier = Modifier .fillMaxWidth() .padding(top = 100.dp) .height(200.dp) ) Spacer(modifier = Modifier.height(20.dp)) Text( modifier = Modifier.padding(40.dp), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.primary, text = "Session not found, please log in and try again.", style = MaterialTheme.typography.headlineLarge ) Spacer(modifier = Modifier.weight(1f)) Button( onClick = onClickLogin, modifier = Modifier.fillMaxWidth().padding(20.dp), content = { Text("Login") }, ) } } @Preview @Composable fun NotSessionScreenPreview() { ShioriTheme { NotSessionScreen( onClickLogin = { } ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/bookmarkeditor/ProgressButton.kt ================================================ package com.desarrollodroide.pagekeeper.ui.bookmarkeditor import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable fun ProgressButton( progress: Float, onClick: () -> Unit ) { Box( modifier = Modifier .fillMaxWidth() .height(48.dp) .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.primaryContainer) .clickable(onClick = onClick) ) { Box( modifier = Modifier .fillMaxHeight() .fillMaxWidth(progress) .background(MaterialTheme.colorScheme.primary) ) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = "Closing", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Medium ) } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/CategoriesView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.components import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Done import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.desarrollodroide.model.Tag enum class CategoriesType { SELECTABLES, REMOVEABLES } @Composable @OptIn(ExperimentalLayoutApi::class) fun Categories( categoriesType: CategoriesType = CategoriesType.SELECTABLES, showCategories: Boolean, uniqueCategories: List, selectedTags: List, onCategorySelected: (Tag) -> Unit, onCategoryDeselected: (Tag) -> Unit, singleSelection: Boolean = false ) { AnimatedVisibility(showCategories) { Column { FlowRow { uniqueCategories.forEach { category -> val selected = selectedTags.any { it.name == category.name } FilterChip( colors = FilterChipDefaults.filterChipColors( containerColor = MaterialTheme.colorScheme.surface, labelColor = MaterialTheme.colorScheme.onSurface, iconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f), disabledLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), selectedContainerColor = MaterialTheme.colorScheme.secondary, disabledSelectedContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f), selectedLabelColor = MaterialTheme.colorScheme.onSecondary, selectedLeadingIconColor = MaterialTheme.colorScheme.onSecondary ), selected = selected, label = { Text(category.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, modifier = Modifier.padding(horizontal = 4.dp), shape = RoundedCornerShape(12.dp), onClick = { when (categoriesType) { CategoriesType.SELECTABLES -> { if (singleSelection) { selectedTags.forEach { onCategoryDeselected(it) } if (!selected) { onCategorySelected(category) } } else { if (selected) { onCategoryDeselected(category) } else { onCategorySelected(category) } } } CategoriesType.REMOVEABLES -> { onCategoryDeselected(category) } } }, leadingIcon = { when(categoriesType){ CategoriesType.SELECTABLES -> { if (selected) { Icon( imageVector = Icons.Filled.Done, contentDescription = null, modifier = Modifier.size(FilterChipDefaults.IconSize) ) } } CategoriesType.REMOVEABLES -> { Icon( imageVector = Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(FilterChipDefaults.IconSize) ) } } } ) } } } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/Dialogs.kt ================================================ package com.desarrollodroide.pagekeeper.ui.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error import androidx.compose.material3.* import androidx.compose.material3.AlertDialog import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.desarrollodroide.pagekeeper.R @Composable fun SimpleDialog( title: String = "", content: String = "", icon: ImageVector? = null, confirmButtonText: String = "", dismissButtonText: String = "", openDialog: MutableState, onConfirm: (() -> Unit)? = null, onDismiss: (() -> Unit)? = null, properties: DialogProperties = DialogProperties(), ) { if (openDialog.value) { AlertDialog( onDismissRequest = { // Dismiss the dialog when the user clicks outside the dialog or on the back // button. If you want to disable that functionality, simply use an empty // onDismissRequest. openDialog.value = false }, icon = { if (icon != null) Icon(imageVector = icon, contentDescription = null) }, title = { if (title.isNotEmpty()) { Text(text = title) } }, text = { if (content.isNotEmpty()) { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { Text( modifier = Modifier .align(Alignment.Center), text = content ) } } }, confirmButton = { if (confirmButtonText.isNotEmpty()) { TextButton( onClick = { openDialog.value = false onConfirm?.invoke() } ) { Text(confirmButtonText) } } }, dismissButton = { if (dismissButtonText.isNotEmpty()) { TextButton( onClick = { openDialog.value = false onDismiss?.invoke() } ) { Text(dismissButtonText) } } }, properties = properties ) } } @Composable fun ConfirmDialog( title: String = "", content: String = "", confirmButton: String = "Accept", dismissButton: String = "", icon: ImageVector? = null, onConfirm: (() -> Unit)? = null, onDismiss: (() -> Unit)? = null, openDialog: MutableState, properties: DialogProperties = DialogProperties(), ) { SimpleDialog( title = title, content = content, icon = icon, onConfirm = onConfirm, onDismiss = onDismiss, confirmButtonText = confirmButton, dismissButtonText = dismissButton, openDialog = openDialog, properties = properties ) } @Composable fun InfiniteProgressDialog( title: String? = null, properties: DialogProperties = DialogProperties(), onDismissRequest: () -> Unit ) { Dialog( onDismissRequest = onDismissRequest, properties = properties ) { Column( horizontalAlignment = CenterHorizontally ) { Surface( modifier = Modifier.clip(CircleShape), ) { Column( modifier = Modifier .padding(dimensionResource(id = R.dimen.progressDialog_margin)) ) { CircularProgressIndicator( strokeWidth = dimensionResource(id = R.dimen.progressDialog_stroke), modifier = Modifier .height(dimensionResource(id = R.dimen.progressDialog_size)) .width(dimensionResource(id = R.dimen.progressDialog_size)) ) } } Spacer(modifier = Modifier.height(20.dp)) if (title != null) { Surface( modifier = Modifier .clip(RoundedCornerShape(20)), ) { Text( modifier = Modifier.padding(vertical = 10.dp, horizontal = 15.dp), text = title ) } } } } } @Composable fun ErrorDialog( title: String = "", content: String = "", openDialog: MutableState, onConfirm: (() -> Unit)? = null, ) { SimpleDialog( title = title, content = content, icon = Icons.Default.Error, confirmButtonText = "Accept", openDialog = openDialog, onConfirm = onConfirm, ) } @Composable fun UpdateCacheDialog( isLoading: Boolean, showDialog: MutableState, onConfirm: (keepOldTitle: Boolean, updateArchive: Boolean, updateEbook: Boolean) -> Unit, ) { if (showDialog.value) { var keepOldTitleChecked by remember { mutableStateOf(false) } var updateArchiveChecked by remember { mutableStateOf(false) } var updateEbookChecked by remember { mutableStateOf(false) } val wasLoading = remember { mutableStateOf(isLoading) } LaunchedEffect(isLoading) { if (wasLoading.value && !isLoading) { showDialog.value = false } wasLoading.value = isLoading } AlertDialog( onDismissRequest = { showDialog.value = false }, title = { Text("Update cache for selected bookmark? This action is irreversible.") }, text = { Column { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable(enabled = !isLoading) { keepOldTitleChecked = !keepOldTitleChecked } .padding(8.dp) ) { Checkbox( enabled = !isLoading, checked = keepOldTitleChecked, onCheckedChange = null ) Text("Keep the old title and excerpt", modifier = Modifier.padding(start = 8.dp)) } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable(enabled = !isLoading) { updateArchiveChecked = !updateArchiveChecked } .padding(8.dp) ) { Checkbox( enabled = !isLoading, checked = updateArchiveChecked, onCheckedChange = null ) Text("Update archive as well", modifier = Modifier.padding(start = 8.dp)) } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable(enabled = !isLoading) { updateEbookChecked = !updateEbookChecked } .padding(8.dp) ) { Checkbox( enabled = !isLoading, checked = updateEbookChecked, onCheckedChange = null ) Text("Update Ebook as well", modifier = Modifier.padding(start = 8.dp)) } } }, confirmButton = { LoadingButton( text = "Update", onClick = { onConfirm(keepOldTitleChecked, updateArchiveChecked, updateEbookChecked) }, loading = isLoading) }, dismissButton = { AnimatedVisibility ( enter = fadeIn(), exit = fadeOut(), visible = !isLoading ){ Button(onClick = { showDialog.value = false }) { Text("Cancel") } } }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true) ) } } @Composable fun EpubOptionsDialog( title: String = "", content: String = "", icon: ImageVector? = null, onClickOption: ((Int) -> Unit)? = null, properties: DialogProperties = DialogProperties(), showDialog: MutableState ) { if (showDialog.value) { AlertDialog( onDismissRequest = { showDialog.value = false }, icon = { if (icon != null) Icon(imageVector = icon, contentDescription = null) }, title = { if (title.isNotEmpty()) { Text(text = title) } }, text = { if (content.isNotEmpty()) { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { Text( modifier = Modifier .align(Alignment.Center), text = content ) } } }, confirmButton = { Row( modifier = Modifier.padding(all = 8.dp), horizontalArrangement = Arrangement.SpaceBetween ) { TextButton(onClick = { showDialog.value = false }) { Text("Cancel") } Spacer(modifier = Modifier.weight(1f)) TextButton(onClick = { showDialog.value = false onClickOption?.invoke(2) }) { Text("Share") } } }, properties = properties ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/LoadingButton.kt ================================================ package com.desarrollodroide.pagekeeper.ui.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Transition import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.spring import androidx.compose.animation.core.updateTransition import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @Composable fun LoadingButton( text: String, onClick: () -> Unit, loading: Boolean, ) { val transition = updateTransition( targetState = loading, label = "master transition", ) val horizontalContentPadding by transition.animateDp( transitionSpec = { spring( stiffness = SpringStiffness, ) }, targetValueByState = { toLoading -> if (toLoading) 12.dp else 24.dp }, label = "button's content padding", ) Button( onClick = onClick, modifier = Modifier.defaultMinSize(minWidth = 1.dp), contentPadding = PaddingValues( horizontal = horizontalContentPadding, vertical = 8.dp, ), ) { Box(contentAlignment = Alignment.Center) { LoadingContent( loadingStateTransition = transition, ) PrimaryContent( text = text, loadingStateTransition = transition, ) } } } @OptIn(ExperimentalAnimationApi::class) @Composable private fun LoadingContent( loadingStateTransition: Transition, ) { loadingStateTransition.AnimatedVisibility( visible = { loading -> loading }, enter = fadeIn(), exit = fadeOut( animationSpec = spring( stiffness = SpringStiffness, visibilityThreshold = 0.10f, ), ), ) { CircularProgressIndicator( modifier = Modifier.size(18.dp), color = LocalContentColor.current, strokeWidth = 1.5f.dp, strokeCap = StrokeCap.Round, ) } } @OptIn(ExperimentalAnimationApi::class) @Composable private fun PrimaryContent( loadingStateTransition: Transition, text: String, ) { loadingStateTransition.AnimatedVisibility( visible = { loading -> !loading }, enter = fadeIn() + expandHorizontally( animationSpec = spring( stiffness = SpringStiffness, dampingRatio = Spring.DampingRatioMediumBouncy, visibilityThreshold = IntSize.VisibilityThreshold, ), expandFrom = Alignment.CenterHorizontally, ), exit = fadeOut( animationSpec = spring( stiffness = SpringStiffness, visibilityThreshold = 0.10f, ), ) + shrinkHorizontally( animationSpec = spring( stiffness = SpringStiffness, // dampingRatio is not applicable here, size cannot become negative visibilityThreshold = IntSize.VisibilityThreshold, ), shrinkTowards = Alignment.CenterHorizontally, ), ) { Text( text = text, modifier = Modifier // so that bouncing button's width doesn't cut first and last letters .padding(horizontal = 4.dp), ) } } // use same spring stiffness so that all animations finish at about the same time private val SpringStiffness = Spring.StiffnessMediumLow @Preview @Composable private fun LoadingButtonPreview() { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { LoadingButton( text = "Login", onClick = {}, loading = false, ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/UiState.kt ================================================ package com.desarrollodroide.pagekeeper.ui.components import kotlinx.coroutines.flow.MutableStateFlow data class UiState( val isLoading: Boolean = false, val isUpdating: Boolean = false, val error: String? = null, val data: T? = null, val idle: Boolean = true ) fun UiState.success(data: T) = copy(isLoading = false, data = data, error = null, idle = false, isUpdating = false) fun UiState.error(error: String) = copy(isLoading = false, data = null, error = error, idle = false, isUpdating = false) fun MutableStateFlow>.success(data: T?) { value = value.copy(isLoading = false, data = data, error = null, idle = false, isUpdating = false) } fun MutableStateFlow>.error(errorMessage: String) { value = value.copy(isLoading = false, data = null, error = errorMessage, idle = false, isUpdating = false) } fun MutableStateFlow>.isLoading(isLoading: Boolean) { value = value.copy(isLoading = isLoading, data = null, error = null, idle = false, isUpdating = false) } fun MutableStateFlow>.idle(isIdle: Boolean) { value = value.copy(isLoading = false, data = null, error = null, idle = isIdle, isUpdating = false) } fun MutableStateFlow>.isUpdating(isUpdating: Boolean) { value = value.copy(isLoading = false, data = value.data, error = null, idle = false, isUpdating = isUpdating) } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefresh.kt ================================================ package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.platform.inspectable import androidx.compose.ui.unit.Velocity /** * A nested scroll modifier that provides scroll events to [state]. * * Note that this modifier must be added above a scrolling container, such as a lazy column, in * order to receive scroll events. For example: * * @sample androidx.compose.material.samples.PullRefreshSample * * @param state The [PullRefreshState] associated with this pull-to-refresh component. * The state will be updated by this modifier. * @param enabled If not enabled, all scroll delta and fling velocity will be ignored. */ // TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple. fun Modifier.pullRefresh( state: PullRefreshState, enabled: Boolean = true, ) = inspectable(inspectorInfo = debugInspectorInfo { name = "pullRefresh" properties["state"] = state properties["enabled"] = enabled }) { Modifier.pullRefresh(state::onPull, state::onRelease, enabled) } /** * A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom * pull refresh components. * * Note that this modifier must be added above a scrolling container, such as a lazy column, in * order to receive scroll events. For example: * * @sample androidx.compose.material.samples.CustomPullRefreshSample * * @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument. * Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling * down despite being at the top of a scrollable component), whereas negative delta (swiping up) is * dispatched first (in case it is needed to push the indicator back up), and then the unconsumed * delta is passed on to the child. The callback returns how much delta was consumed. * @param onRelease Callback for when drag is released, takes float flingVelocity as argument. * The callback returns how much velocity was consumed - in most cases this should only consume * velocity if pull refresh has been dragged already and the velocity is positive (the fling is * downwards), as an upwards fling should typically still scroll a scrollable component beneath the * pullRefresh. This is invoked before any remaining velocity is passed to the child. * @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither * [onPull] nor [onRelease] will be invoked. */ fun Modifier.pullRefresh( onPull: (pullDelta: Float) -> Float, onRelease: suspend (flingVelocity: Float) -> Float, enabled: Boolean = true, ) = inspectable(inspectorInfo = debugInspectorInfo { name = "pullRefresh" properties["onPull"] = onPull properties["onRelease"] = onRelease properties["enabled"] = enabled }) { Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled)) } private class PullRefreshNestedScrollConnection( private val onPull: (pullDelta: Float) -> Float, private val onRelease: suspend (flingVelocity: Float) -> Float, private val enabled: Boolean, ) : NestedScrollConnection { override fun onPreScroll( available: Offset, source: NestedScrollSource, ): Offset = when { !enabled -> Offset.Zero source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up else -> Offset.Zero } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset = when { !enabled -> Offset.Zero source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down else -> Offset.Zero } override suspend fun onPreFling(available: Velocity): Velocity { return Velocity(0f, onRelease(available.y)) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefreshIndicator.kt ================================================ package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh import androidx.compose.animation.Crossfade import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.pow /** * The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout. * * @sample androidx.compose.material.samples.PullRefreshSample * * @param refreshing A boolean representing whether a refresh is occurring. * @param state The [PullRefreshState] which controls where and how the indicator will be drawn. * @param modifier Modifiers for the indicator. * @param backgroundColor The color of the indicator's background. * @param contentColor The color of the indicator's arc and arrow. * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. */ @Composable // TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to // enable people to use this indicator with custom pull-to-refresh components. fun PullRefreshIndicator( refreshing: Boolean, state: PullRefreshState, modifier: Modifier = Modifier, backgroundColor: Color = MaterialTheme.colorScheme.surface, contentColor: Color = contentColorFor(backgroundColor), scale: Boolean = false, ) { val showElevation by remember(refreshing, state) { derivedStateOf { refreshing || state.position > 0.5f } } Surface( modifier = modifier .size(IndicatorSize) .pullRefreshIndicatorTransform(state, scale), shape = SpinnerShape, color = backgroundColor, shadowElevation = if (showElevation) Elevation else 0.dp, ) { Crossfade( targetState = refreshing, animationSpec = tween(durationMillis = CrossfadeDurationMs) ) { refreshing -> Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { val spinnerSize = (ArcRadius + StrokeWidth).times(2) if (refreshing) { CircularProgressIndicator( color = contentColor, strokeWidth = StrokeWidth, modifier = Modifier.size(spinnerSize), ) } else { CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) } } } } } /** * Modifier.size MUST be specified. */ @Composable private fun CircularArrowIndicator( state: PullRefreshState, color: Color, modifier: Modifier, ) { val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } val targetAlpha by remember(state) { derivedStateOf { if (state.progress >= 1f) MaxAlpha else MinAlpha } } val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) // Empty semantics for tests Canvas(modifier.semantics {}) { val values = ArrowValues(state.progress) val alpha = alphaState.value rotate(degrees = values.rotation) { val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f val arcBounds = Rect( size.center.x - arcRadius, size.center.y - arcRadius, size.center.x + arcRadius, size.center.y + arcRadius ) drawArc( color = color, alpha = alpha, startAngle = values.startAngle, sweepAngle = values.endAngle - values.startAngle, useCenter = false, topLeft = arcBounds.topLeft, size = arcBounds.size, style = Stroke( width = StrokeWidth.toPx(), cap = StrokeCap.Square ) ) drawArrow(path, arcBounds, color, alpha, values) } } } @Immutable private class ArrowValues( val rotation: Float, val startAngle: Float, val endAngle: Float, val scale: Float, ) private fun ArrowValues(progress: Float): ArrowValues { // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 // How far beyond the threshold pull has gone, as a percentage of the threshold. val overshootPercent = abs(progress) - 1.0f // Limit the overshoot to 200%. Linear between 0 and 200. val linearTension = overshootPercent.coerceIn(0f, 2f) // Non-linear tension. Increases with linearTension, but at a decreasing rate. val tensionPercent = linearTension - linearTension.pow(2) / 4 // Calculations based on SwipeRefreshLayout specification. val endTrim = adjustedPercent * MaxProgressArc val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f val startAngle = rotation * 360 val endAngle = (rotation + endTrim) * 360 val scale = min(1f, adjustedPercent) return ArrowValues(rotation, startAngle, endAngle, scale) } private fun DrawScope.drawArrow( arrow: Path, bounds: Rect, color: Color, alpha: Float, values: ArrowValues, ) { arrow.reset() arrow.moveTo(0f, 0f) // Move to left corner arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner // Line to tip of arrow arrow.lineTo( x = ArrowWidth.toPx() * values.scale / 2, y = ArrowHeight.toPx() * values.scale ) val radius = min(bounds.width, bounds.height) / 2f val inset = ArrowWidth.toPx() * values.scale / 2f arrow.translate( Offset( x = radius + bounds.center.x - inset, y = bounds.center.y + StrokeWidth.toPx() / 2f ) ) arrow.close() rotate(degrees = values.endAngle) { drawPath(path = arrow, color = color, alpha = alpha) } } private const val CrossfadeDurationMs = 100 private const val MaxProgressArc = 0.8f private val IndicatorSize = 40.dp private val SpinnerShape = CircleShape private val ArcRadius = 7.5.dp private val StrokeWidth = 2.5.dp private val ArrowWidth = 10.dp private val ArrowHeight = 5.dp private val Elevation = 6.dp // Values taken from SwipeRefreshLayout private const val MinAlpha = 0.3f private const val MaxAlpha = 1f private val AlphaTween = tween(300, easing = LinearEasing) ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefreshIndicatorTransform.kt ================================================ package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.platform.inspectable /** * A modifier for translating the position and scaling the size of a pull-to-refresh indicator * based on the given [PullRefreshState]. * * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample * * @param state The [PullRefreshState] which determines the position of the indicator. * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. */ // TODO: Consider whether the state parameter should be replaced with lambdas. fun Modifier.pullRefreshIndicatorTransform( state: PullRefreshState, scale: Boolean = false, ) = inspectable(inspectorInfo = debugInspectorInfo { name = "pullRefreshIndicatorTransform" properties["state"] = state properties["scale"] = scale }) { Modifier // Essentially we only want to clip the at the top, so the indicator will not appear when // the position is 0. It is preferable to clip the indicator as opposed to the layout that // contains the indicator, as this would also end up clipping shadows drawn by items in a // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE // for the other dimensions to allow for more room for elevation / arbitrary indicators - we // only ever really want to clip at the top edge. .drawWithContent { clipRect( top = 0f, left = -Float.MAX_VALUE, right = Float.MAX_VALUE, bottom = Float.MAX_VALUE ) { this@drawWithContent.drawContent() } } .graphicsLayer { translationY = state.position - size.height if (scale && !state.refreshing) { val scaleFraction = LinearOutSlowInEasing .transform(state.position / state.threshold) .coerceIn(0f, 1f) scaleX = scaleFraction scaleY = scaleFraction } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/components/pulltorefresh/PullRefreshState.kt ================================================ package com.desarrollodroide.pagekeeper.ui.components.pulltorefresh import androidx.compose.animation.core.animate import androidx.compose.foundation.MutatorMutex import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.pow /** * Creates a [PullRefreshState] that is remembered across compositions. * * Changes to [refreshing] will result in [PullRefreshState] being updated. * * @sample androidx.compose.material.samples.PullRefreshSample * * @param refreshing A boolean representing whether a refresh is currently occurring. * @param onRefresh The function to be called to trigger a refresh. * @param refreshThreshold The threshold below which, if a release * occurs, [onRefresh] will be called. * @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This * offset corresponds to the position of the bottom of the indicator. */ @Composable fun rememberPullRefreshState( refreshing: Boolean, onRefresh: () -> Unit, refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset, ): PullRefreshState { require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" } val scope = rememberCoroutineScope() val onRefreshState = rememberUpdatedState(onRefresh) val thresholdPx: Float val refreshingOffsetPx: Float with(LocalDensity.current) { thresholdPx = refreshThreshold.toPx() refreshingOffsetPx = refreshingOffset.toPx() } val state = remember(scope) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) } SideEffect { state.setRefreshing(refreshing) state.setThreshold(thresholdPx) state.setRefreshingOffset(refreshingOffsetPx) } return state } /** * A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh * behaviour to a scroll component. Based on Android's SwipeRefreshLayout. * * Provides [progress], a float representing how far the user has pulled as a percentage of the * refreshThreshold. Values of one or less indicate that the user has not yet pulled past the * threshold. Values greater than one indicate how far past the threshold the user has pulled. * * Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like * pull-to-refresh behaviour with a custom indicator. * * Should be created using [rememberPullRefreshState]. */ class PullRefreshState internal constructor( private val animationScope: CoroutineScope, private val onRefreshState: State<() -> Unit>, refreshingOffset: Float, threshold: Float, ) { /** * A float representing how far the user has pulled as a percentage of the refreshThreshold. * * If the component has not been pulled at all, progress is zero. If the pull has reached * halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has * gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to * two times the refreshThreshold. */ val progress get() = adjustedDistancePulled / threshold internal val refreshing get() = _refreshing internal val position get() = _position internal val threshold get() = _threshold private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier } private var _refreshing by mutableStateOf(false) private var _position by mutableStateOf(0f) private var distancePulled by mutableStateOf(0f) private var _threshold by mutableStateOf(threshold) private var _refreshingOffset by mutableStateOf(refreshingOffset) internal fun onPull(pullDelta: Float): Float { if (_refreshing) return 0f // Already refreshing, do nothing. val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f) val dragConsumed = newOffset - distancePulled distancePulled = newOffset _position = calculateIndicatorPosition() return dragConsumed } internal fun onRelease(velocity: Float): Float { if (refreshing) return 0f // Already refreshing, do nothing if (adjustedDistancePulled > threshold) { onRefreshState.value() } animateIndicatorTo(0f) val consumed = when { // We are flinging without having dragged the pull refresh (for example a fling inside // a list) - don't consume distancePulled == 0f -> 0f // If the velocity is negative, the fling is upwards, and we don't want to prevent the // the list from scrolling velocity < 0f -> 0f // We are showing the indicator, and the fling is downwards - consume everything else -> velocity } distancePulled = 0f return consumed } internal fun setRefreshing(refreshing: Boolean) { if (_refreshing != refreshing) { _refreshing = refreshing distancePulled = 0f animateIndicatorTo(if (refreshing) _refreshingOffset else 0f) } } internal fun setThreshold(threshold: Float) { _threshold = threshold } internal fun setRefreshingOffset(refreshingOffset: Float) { if (_refreshingOffset != refreshingOffset) { _refreshingOffset = refreshingOffset if (refreshing) animateIndicatorTo(refreshingOffset) } } // Make sure to cancel any existing animations when we launch a new one. We use this instead of // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra // overhead of running through the animation pipeline instead of directly mutating the state. private val mutatorMutex = MutatorMutex() private fun animateIndicatorTo(offset: Float) = animationScope.launch { mutatorMutex.mutate { animate(initialValue = _position, targetValue = offset) { value, _ -> _position = value } } } private fun calculateIndicatorPosition(): Float = when { // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled. adjustedDistancePulled <= threshold -> adjustedDistancePulled else -> { // How far beyond the threshold pull has gone, as a percentage of the threshold. val overshootPercent = abs(progress) - 1.0f // Limit the overshoot to 200%. Linear between 0 and 200. val linearTension = overshootPercent.coerceIn(0f, 2f) // Non-linear tension. Increases with linearTension, but at a decreasing rate. val tensionPercent = linearTension - linearTension.pow(2) / 4 // The additional offset beyond the threshold. val extraOffset = threshold * tensionPercent threshold + extraOffset } } } /** * Default parameter values for [rememberPullRefreshState]. */ object PullRefreshDefaults { /** * If the indicator is below this threshold offset when it is released, a refresh * will be triggered. */ val RefreshThreshold = 100.dp /** * The offset at which the indicator should be rendered whilst a refresh is occurring. */ val RefreshingOffset = 56.dp } /** * The distance pulled is multiplied by this value to give us the adjusted distance pulled, which * is used in calculating the indicator position (when the adjusted distance pulled is less than * the refresh threshold, it is the indicator position, otherwise the indicator position is * derived from the progress). */ private const val DragMultiplier = 0.5f ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/BookmarkViewer.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.viewinterop.AndroidView @OptIn(ExperimentalMaterial3Api::class) @Composable fun HtmlTextViewer( htmlString: String, onBack: () -> Unit ) { BackHandler { onBack() } Scaffold( topBar = { CenterAlignedTopAppBar( title = { Text("Viewer") }, navigationIcon = { IconButton(onClick = onBack) { Icon( Icons.Filled.ArrowBack, contentDescription = "Back" ) } }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.background ) ) }, containerColor = MaterialTheme.colorScheme.background ) { padding -> Column( modifier = Modifier.padding(padding) ) { val webViewClient = remember { WebViewClient() } // Remember and hold a reference to the WebView instance val webViewInstance = remember { mutableStateOf(null) } AndroidView( modifier = Modifier .fillMaxWidth() .weight(1f), factory = { context -> WebView(context).apply { this.webViewClient = webViewClient settings.loadWithOverviewMode = true loadDataWithBaseURL(null, htmlString, "text/html", "UTF-8", null) } }, update = { webView -> webViewInstance.value = webView // Update the remembered WebView instance } ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { IconButton(onClick = { webViewInstance.value?.goBack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } IconButton(onClick = { webViewInstance.value?.goForward() }) { Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Forward") } IconButton(onClick = { webViewInstance.value?.reload() }) { Icon(Icons.Filled.Refresh, contentDescription = "Refresh") } } } } } @Preview @Composable fun HtmlTextViewerPreview() { val html = """

The First Developer Preview of Android 15 🧑‍💻

We released the first Developer Preview of Android 15, which focuses on providing access to superior media capabilities, minimizing battery impact, maintaining buttery smooth app performance, and protecting user privacy/security — all while enabling a diverse ecosystem of devices.

Android 15 includes updates to Privacy Sandbox and Health Connect, while introducing new file integrity protection APIs. It provides enhanced camera controls and virtual MIDI 2.0 devices to help power creative applications. It expands the Android Dynamic Performance Framework to support a power-efficiency mode, report GPU work durations, and return Thermal Headroom thresholds. It adds quality of life focused OpenJDK APIs that will be updated on over a billion devices through Google Play system updates.

Get started today testing your app with Android 15 in the emulator, or by flashing a system image onto a Pixel 6+, Pixel Fold, or Pixel Tablet device.

Android Studio Iguana launched to stable🦎

We launched Android Studio Iguana 🦎 in the stable release channel to make it easier for you to create high quality apps.

Enhancements include Compose UI Check, which automatically audits Compose UI for accessibility and adaptive issues across different screen sizes. Progressive rendering in Compose Preview which speeds up iteration on complex layouts by lowering the detail of out-of-view previews. Iguana adds Version Control System support in App Quality Insights, built-in support to create Baseline Profiles, and enhanced support for Gradle Version Catalogs. The Espresso device API enables configuration change testing. The integrated IntelliJ 2023.2 update includes many enhancements such as support for GitLab and text search in Search Everywhere. The blog has information on all these changes and more.

Cloud photos now available in the Android photo picker☁️📷

Android’s photo picker now integrates cloud photos, giving apps a unified way to let users browse and grant access to selected local and cloud photos and videos. It’s currently available integrated with Google Photos and is open to other cloud media apps that meet the eligibility criteria. The cloud photos feature is currently rolling out with the February Google Play system update to devices running Android 12 and above.

Easily add document scanning capability to your app with the ML Kit Document Scanner API📃📷

We launched the ML Kit Document Scanner API, enabling you to easily integrate advanced document scanning capabilities into your apps.

The API offers a standardized and user-friendly interface for document scanning, includes precise corner and edge detection for accurate document capture, and allows users to further crop scanned documents, apply filters, and remove fingers or blemishes. It processes documents on the device, eliminates the need for camera permissions, and is supported on devices with Android API level 21 or above.

Android Developers Blog: Wear OS hybrid interface: Boosting power and performance

The WearOS powered OnePlus Watch 2 launched with a dual-chipset architecture that works with our hybrid interface to dramatically extend battery life up to 100 hours of Smart Mode regular use.

You can leverage existing Wear OS APIs to get the advantage of these optimizations, such as NotificationCompat, and Health Services on Wear OS. With Wear OS 4, we launched the Watch Face Format, and the new format helps future-proof watch faces to take advantage of emerging optimizations in future devices.

Articles📚

There are a bunch of other articles worth checking out.

Levi covered Nested Scrolling in Jetpack Compose, giving a deep dive into how you can implement custom nested behaviors, such as what the Material 3’s TopAppBar scrollBehavior parameter does.

Ben explained Jetpack Compose’s Strong Skipping Mode, an experimental feature in the Jetpack Compose Compiler 1.5.4+ that changes the rules for what composables can skip recomposition which should greatly reduce recomposition, improving performance.

Rebecca showed how you can use shapes in Compose to create a cool progress bar that morphs between two shapes using the graphics-shapes library, which has new documentation to help you add these effects into your apps.

Videos📹

Over in videos, #WeArePlay highlighted the developers behind We Spot Turtles!, whose app helps crowdsource pictures that a machine learning model uses to help collect extensive data on sea turtles in the wild.

There’s also an associated blog post if you’d rather read about them!

AndroidX releases 🚀

There was a bunch of activity over in Android Jetpack, including the first alphas of Annotation 1.8, Benchmark 1.3, Core-RemoteViews 1.1, Glance 1.1, ProfileInstaller 1.4, Lint 1.0, Wear Watchface 1.3, Webkit 1.11, and Compose Material 3 1.3. Highlights include:

  • Compose Material 3 1.3 includes more support for predictive back, and updates to the Slider and ProgressIndicator to improve accessibility.
  • The new Lint library is a set of lint checks for Gradle Plugin authors on projects that apply java-gradle-plugin to help catch mistakes in their code.
  • Glance 1.1 adds a new unit test library (that doesn’t require UI Automator), higher level components, new modifiers, and a new API for getting a flow of RemoteViews from a composition.

We also released Hilt Version 1.2 with assisted injection support for hiltViewModel() and hiltNavGraphViewModels() as well as Test Uiautomator 2.3, which adds support for multiple displays and custom wait conditions.

Android Developers Backstage🎙

In Episode 204: Fan’otations Tor, Romain, and Chet talk about one of Tor’s favorite topics: Lint! Specifically, they talk about Lint checks and the annotations that use them to enable better, more robust, and more self-documenting APIs.

As Chet says, “Lint: It’s not just for pockets anymore.” Thank you Chet for all you’ve done for Android and the community, and for helping us keep our sense of humor.

Now then… 👋

That’s it for this week with Android 15 developer preview 1, the stable release of Android Studio Iguana, cloud photos now available in Photo Picker, ML Kit Document Scanning, the Wear OS hybrid interface, nested scrolling/strong skipping/shape morphing in Compose, annotations with Lint, and more!

Check back soon for your next update from the Android developer universe! 🌌

""".trimIndent() MaterialTheme { HtmlTextViewer( htmlString = html, onBack = {} ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/CategoriesView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.outlined.Sell import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.desarrollodroide.model.Tag import com.desarrollodroide.pagekeeper.ui.components.Categories import com.desarrollodroide.pagekeeper.ui.components.CategoriesType @OptIn(ExperimentalMaterial3Api::class) @Composable fun CategoriesView( onDismiss: () -> Unit, uniqueCategories: List, tagToHide: Tag?, onFilterHiddenTag: (Boolean) -> Unit, selectedOptionIndex: Int, onSelectedOptionIndexChanged: (Int) -> Unit, selectedTags: List, onCategorySelected: (Tag) -> Unit, onCategoryDeselected: (Tag) -> Unit, onResetAll: () -> Unit ) { Box(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(bottom = 80.dp) .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { AnimatedVisibility( visible = tagToHide != null, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically() ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Filled.VisibilityOff, contentDescription = "Hidden tag", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp) ) Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( "Hidden: ${tagToHide?.name}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( "Bookmarks with this tag are currently hidden.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) } } Spacer(Modifier.height(8.dp)) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SegmentedButton( selected = selectedOptionIndex == 0, onClick = { onSelectedOptionIndexChanged(0) onFilterHiddenTag(false) }, shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), ) { Text("All", style = MaterialTheme.typography.titleMedium) } SegmentedButton( selected = selectedOptionIndex == 1, onClick = { onSelectedOptionIndexChanged(1) onFilterHiddenTag(true) }, shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), ) { Text("Hidden tag", style = MaterialTheme.typography.titleMedium) } } Spacer(Modifier.height(8.dp)) Text( modifier = Modifier.align(Alignment.CenterHorizontally), text = if (selectedOptionIndex == 0) "Filter by all bookmarks\n" else "Showing only bookmarks with the '${tagToHide?.name}' tag", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } AnimatedVisibility( visible = selectedOptionIndex == 0, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically() ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Text("Categories", style = MaterialTheme.typography.headlineSmall) Spacer(Modifier.height(8.dp)) if (uniqueCategories.isEmpty()) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .padding(vertical = 16.dp) .fillMaxWidth() ) { Icon( imageVector = Icons.Outlined.Sell, contentDescription = "No categories available", modifier = Modifier.size(24.dp) ) Spacer(Modifier.width(8.dp)) Text( "No categories available", style = MaterialTheme.typography.bodyLarge ) } } else { Categories( categoriesType = CategoriesType.SELECTABLES, showCategories = true, uniqueCategories = uniqueCategories, selectedTags = selectedTags, onCategorySelected = { tag -> onCategorySelected(tag) onFilterHiddenTag(false) }, onCategoryDeselected = { tag -> onCategoryDeselected(tag) onFilterHiddenTag(false) } ) } } } } // Fixed bottom buttons Surface( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth(), shadowElevation = 8.dp, color = MaterialTheme.colorScheme.surface ) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween ) { Button( enabled = selectedOptionIndex == 0, onClick = { onResetAll() onFilterHiddenTag(false) }, modifier = Modifier.weight(1f) ) { Text("Reset All") } Spacer(Modifier.width(8.dp)) Button( onClick = onDismiss, modifier = Modifier.weight(1f) ) { Text("Close") } } } } } @Preview(showBackground = true) @Composable fun SortAndFilterScreenPreview() { val regionOptions = listOf( Tag(id = 1, name = "Northern Europe"), Tag(id = 2, name = "Western Europe"), Tag(id = 3, name = "Southern Europe"), Tag(id = 4, name = "Southeast Europe"), Tag(id = 5, name = "Central Europe"), Tag(id = 6, name = "Eastern Europe") ) val selectedOptionIndex = remember { mutableStateOf(0) } val selectedTags = remember { mutableStateOf(listOf(Tag(id = 3, name = "Southern Europe"))) } MaterialTheme { CategoriesView( onDismiss = {}, uniqueCategories = regionOptions, tagToHide = Tag(id = 3, name = "Southeast Europe"), onFilterHiddenTag = {}, selectedOptionIndex = selectedOptionIndex.value, onSelectedOptionIndexChanged = { selectedOptionIndex.value = it }, selectedTags = selectedTags.value, onCategorySelected = { }, onCategoryDeselected = { }, onResetAll = { } ) } } @Preview(showBackground = true) @Composable fun SortAndFilterScreenPreview2() { val regionOptions = (1..200).map { Tag(id = it, name = "Category $it") } val selectedOptionIndex = remember { mutableStateOf(0) } val selectedTags = remember { mutableStateOf(emptyList()) } MaterialTheme { CategoriesView( onDismiss = {}, uniqueCategories = regionOptions, tagToHide = null, onFilterHiddenTag = {}, selectedOptionIndex = selectedOptionIndex.value, onSelectedOptionIndexChanged = { selectedOptionIndex.value = it }, selectedTags = selectedTags.value, onCategorySelected = { }, onCategoryDeselected = { }, onResetAll = { } ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/FeedContent.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.text.style.TextOverflow import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import com.desarrollodroide.data.helpers.BookmarkViewType import com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED import com.desarrollodroide.pagekeeper.ui.components.pulltorefresh.PullRefreshIndicator import com.desarrollodroide.pagekeeper.ui.components.pulltorefresh.pullRefresh import com.desarrollodroide.pagekeeper.ui.components.pulltorefresh.rememberPullRefreshState import com.desarrollodroide.model.Bookmark import com.desarrollodroide.model.Tag import com.desarrollodroide.pagekeeper.ui.feed.item.BookmarkActions import com.desarrollodroide.pagekeeper.ui.feed.item.BookmarkItem import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable fun FeedContent( actions: FeedActions, viewType: BookmarkViewType, serverURL: String, xSessionId: String, token: String, bookmarksPagingItems: LazyPagingItems, tagToHide: Tag?, showOnlyHiddenTag: Boolean ) { val refreshCoroutineScope = rememberCoroutineScope() var isRefreshing by remember { mutableStateOf(false) } val listState = rememberLazyListState() LaunchedEffect(bookmarksPagingItems.loadState.refresh) { if (bookmarksPagingItems.loadState.refresh is LoadState.NotLoading && isRefreshing) { listState.animateScrollToItem(0) delay(100) isRefreshing = false } } // Scroll to top when a new bookmark is added (item count increases) var previousItemCount by remember { mutableStateOf(bookmarksPagingItems.itemCount) } LaunchedEffect(bookmarksPagingItems.itemCount) { if (bookmarksPagingItems.itemCount > previousItemCount && previousItemCount > 0) { listState.animateScrollToItem(0) } previousItemCount = bookmarksPagingItems.itemCount } fun refreshBookmarks() = refreshCoroutineScope.launch { actions.onRefreshFeed.invoke() isRefreshing = true delay(1500) isRefreshing = false } val refreshState = rememberPullRefreshState(isRefreshing, ::refreshBookmarks) val coroutineScope = rememberCoroutineScope() Box( Modifier.fillMaxHeight() .padding(bottom = 10.dp) ) { //val listState = rememberLazyListState() LazyColumn( state = listState, modifier = Modifier .fillMaxHeight() .padding(horizontal = 10.dp) .pullRefresh(state = refreshState) .animateContentSize(), verticalArrangement = Arrangement.spacedBy(6.dp) ) { items( count = bookmarksPagingItems.itemCount, key = { index -> // Including 'modified' in the key ensures that when a bookmark's 'modified' field changes, // Compose recognizes it as a new item and recomposes it. This updates the UI immediately // after data changes val bookmark = bookmarksPagingItems[index] "${bookmark?.id}_${bookmark?.modified}" ?: index } ) { index -> val bookmark = bookmarksPagingItems[index] if (bookmark != null ) { BookmarkItem( getBookmark = { bookmark }, serverURL = serverURL, xSessionId = xSessionId, token = token, viewType = viewType, actions = BookmarkActions( onClickEdit = { getBookmark -> actions.onEditBookmark(getBookmark()) }, onClickDelete = { getBookmark -> actions.onDeleteBookmark(getBookmark()) }, onClickShare = { getBookmark -> actions.onShareBookmark(getBookmark()) }, onClickBookmark = { getBookmark -> actions.onBookmarkSelect(getBookmark()) }, onClickEpub = { getBookmark -> actions.onBookmarkEpub(getBookmark()) }, onClickSync = { getBookmark -> actions.onClickSync(getBookmark()) }, onClickCategory = { category -> } ), ) if (index < bookmarksPagingItems.itemCount - 1) { HorizontalDivider( modifier = Modifier .height(1.dp) .padding(horizontal = 6.dp), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) ) } } } bookmarksPagingItems.apply { when { loadState.refresh is LoadState.Loading -> { item { PageLoader(modifier = Modifier.fillParentMaxSize()) } } loadState.refresh is LoadState.Error -> { val error = loadState.refresh as LoadState.Error if (error.error.localizedMessage == SESSION_HAS_BEEN_EXPIRED) { actions.goToLogin() } else { item { ErrorMessage( modifier = Modifier.fillParentMaxSize(), message = error.error.localizedMessage ?: "Unknown error", onClickRetry = { retry() }) } } } loadState.append is LoadState.Loading -> { item { LoadingNextPageItem(modifier = Modifier) } } loadState.append is LoadState.Error -> { val error = loadState.append as LoadState.Error item { ErrorMessage( modifier = Modifier, message = error.error.localizedMessage ?: "Unknown error", onClickRetry = { retry() }) } } } } item { Spacer(modifier = Modifier.height(30.dp)) } } PullRefreshIndicator( modifier = Modifier.align(alignment = Alignment.TopCenter), refreshing = isRefreshing, state = refreshState, scale = true ) val showScrollToTopButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } AnimatedVisibility( visible = showScrollToTopButton, modifier = Modifier .align(Alignment.BottomEnd) .padding(16.dp), enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut() ) { FloatingActionButton( onClick = { coroutineScope.launch { listState.animateScrollToItem(0) } } ) { Icon(Icons.Filled.KeyboardArrowUp, contentDescription = "Scroll to top") } } } } @Composable fun BookmarkSuggestions( bookmarks: LazyPagingItems, onClickSuggestion: (Bookmark) -> Unit ) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(bookmarks.itemCount) { index -> val bookmark = bookmarks[index] if (bookmark != null) { ListItem( colors = ListItemDefaults.colors( containerColor = Color.Transparent ), modifier = Modifier .clickable { onClickSuggestion(bookmark) } .background(Color.Transparent), headlineContent = { Text( text = bookmark.title, style = MaterialTheme.typography.titleMedium ) }, supportingContent = { Text( overflow = TextOverflow.Ellipsis, text = bookmark.excerpt, maxLines = 3, style = MaterialTheme.typography.bodyMedium ) }, leadingContent = { Icon(Icons.Rounded.Bookmark, contentDescription = null) }, ) } } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/FeedScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed import android.media.MediaScannerConnection import android.util.Log import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.desarrollodroide.data.helpers.BookmarkViewType import com.desarrollodroide.data.helpers.SESSION_HAS_BEEN_EXPIRED import com.desarrollodroide.pagekeeper.extensions.shareText import com.desarrollodroide.pagekeeper.ui.bookmarkeditor.BookmarkEditorScreen import com.desarrollodroide.pagekeeper.ui.bookmarkeditor.BookmarkEditorType import com.desarrollodroide.pagekeeper.ui.components.ConfirmDialog import com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog import com.desarrollodroide.model.Bookmark import com.desarrollodroide.model.Tag import com.desarrollodroide.pagekeeper.ui.components.EpubOptionsDialog import com.desarrollodroide.pagekeeper.ui.components.UpdateCacheDialog import kotlinx.coroutines.launch import java.io.File @OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedScreen( feedViewModel: FeedViewModel, goToLogin: () -> Unit, goToReadableContent:(Bookmark) -> Unit, openUrlInBrowser: (String) -> Unit, shareEpubFile: (File) -> Unit, isCategoriesVisible: MutableState, isSearchBarVisible: MutableState, setShowTopBar: (Boolean) -> Unit, ) { val context = LocalContext.current val tagsState by feedViewModel.tagsState.collectAsState() val tagToHide by feedViewModel.tagToHide.collectAsState() val showOnlyHiddenTag by feedViewModel.showOnlyHiddenTag.collectAsState() LaunchedEffect(feedViewModel) { feedViewModel.initializeIfNeeded() } LaunchedEffect(isCategoriesVisible.value) { if (isCategoriesVisible.value) { // TODO remove when sync functionality is implemented //feedViewModel.getTags() } } val bookmarksPagingItems: LazyPagingItems = feedViewModel.bookmarksState.collectAsLazyPagingItems() LaunchedEffect(Unit) { snapshotFlow { bookmarksPagingItems.itemSnapshotList.items } .collect { updatedItems -> Log.d("FeedScreen", "Los bookmarks se han modificado: ${updatedItems.size} items") } } val bookmarksUiState = feedViewModel.bookmarksUiState.collectAsState().value val downloadUiState = feedViewModel.downloadUiState.collectAsState() val isCompactView by feedViewModel.compactView.collectAsState() val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) val sheetStateCategories = rememberModalBottomSheetState( skipPartiallyExpanded = false ) val actions = FeedActions( goToLogin = { goToLogin() }, onBookmarkSelect = { bookmark -> goToReadableContent(bookmark) }, onRefreshFeed = { feedViewModel.refreshFeed() }, onEditBookmark = { bookmark -> feedViewModel.bookmarkSelected.value = bookmark feedViewModel.showBookmarkEditorScreen.value = true }, onDeleteBookmark = { bookmark -> feedViewModel.bookmarkToDelete.value = bookmark feedViewModel.showDeleteConfirmationDialog.value = true }, onShareBookmark = { bookmark -> context.shareText(bookmark.url) }, onBookmarkEpub = { bookmark -> feedViewModel.downloadFile(bookmark) }, onClickSync = { bookmark -> feedViewModel.bookmarkToUpdateCache.value = bookmark feedViewModel.showSyncDialog.value = true }, onClearError = { feedViewModel.resetData() }, onCategoriesSelectedChanged = { categories -> }, ) LaunchedEffect(bookmarksPagingItems.loadState) { val loadState = bookmarksPagingItems.loadState.refresh if (loadState is LoadState.Error) { val error = loadState.error if (error.message == SESSION_HAS_BEEN_EXPIRED) { feedViewModel.handleLoadState(loadState) } } } FeedView( actions = actions, serverURL = feedViewModel.getServerUrl(), xSessionId = feedViewModel.getSession(), token = feedViewModel.getToken(), viewType = if (isCompactView) BookmarkViewType.SMALL else BookmarkViewType.FULL, bookmarksPagingItems = bookmarksPagingItems, tagToHide = tagToHide, showOnlyHiddenTag = showOnlyHiddenTag ) if (feedViewModel.showBookmarkEditorScreen.value && feedViewModel.bookmarkSelected.value != null) { Box( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectTapGestures { } } ) { feedViewModel.bookmarkSelected.value?.let { setShowTopBar(false) BookmarkEditorScreen( pageTitle = "Edit", bookmarkEditorType = BookmarkEditorType.EDIT, bookmark = it, onBack = { setShowTopBar(true) feedViewModel.showBookmarkEditorScreen.value = false }, updateBookmark = { bookMark -> setShowTopBar(true) feedViewModel.showBookmarkEditorScreen.value = false feedViewModel.refreshFeed() } ) } } } if (feedViewModel.showDeleteConfirmationDialog.value && feedViewModel.bookmarkToDelete.value != null) { ConfirmDialog( title = "Confirmation", content = "Are you sure you want to delete this bookmark?", confirmButton = "Delete", dismissButton = "Cancel", onConfirm = { feedViewModel.bookmarkToDelete.value?.let { feedViewModel.deleteLocalBookmark(it) feedViewModel.showDeleteConfirmationDialog.value = false } }, openDialog = feedViewModel.showDeleteConfirmationDialog, properties = DialogProperties( dismissOnClickOutside = true, dismissOnBackPress = true ) ) } if (bookmarksUiState.isLoading || downloadUiState.value.isLoading) { InfiniteProgressDialog(onDismissRequest = {}) } if (!bookmarksUiState.error.isNullOrEmpty()) { ConfirmDialog( icon = Icons.Default.Error, title = "Error", content = bookmarksUiState.error, openDialog = remember { mutableStateOf(true) }, onConfirm = { if (bookmarksUiState.error == SESSION_HAS_BEEN_EXPIRED){ actions.onClearError() actions.goToLogin.invoke() } }, properties = DialogProperties( dismissOnClickOutside = false, dismissOnBackPress = false ), ) Log.v("bookmarksUiState", "Error") } val isUpdating = feedViewModel.bookmarksUiState.collectAsState().value.isUpdating UpdateCacheDialog( isLoading = isUpdating, showDialog = feedViewModel.showSyncDialog ) { keepOldTitle, updateArchive, updateEbook -> feedViewModel.updateBookmarkCache( keepOldTitle = keepOldTitle, updateEbook = updateEbook, updateArchive = updateArchive, ) feedViewModel.showSyncDialog.value = false } if (!downloadUiState.value.error.isNullOrEmpty()) { ConfirmDialog( icon = Icons.Default.Error, title = "Download Error", content = downloadUiState.value.error?:"Unknown error", openDialog = remember { mutableStateOf(true) }, onConfirm = { }, properties = DialogProperties( dismissOnClickOutside = true, dismissOnBackPress = true ), ) } if (downloadUiState.value.data != null && feedViewModel.showEpubOptionsDialog.value) { MediaScannerConnection.scanFile( context, arrayOf(downloadUiState.value.data?.absolutePath), null ) { path, uri -> } EpubOptionsDialog( icon = Icons.Default.Error, title = "Success", content = "Epub file downloaded, would you like to share it?", onClickOption = { index -> when (index) { 2 -> { shareEpubFile.invoke(downloadUiState.value.data!!) } } }, properties = DialogProperties( dismissOnClickOutside = true, dismissOnBackPress = true ), showDialog = feedViewModel.showEpubOptionsDialog ) } if (isSearchBarVisible.value) { val scope = rememberCoroutineScope() ModalBottomSheet( modifier = Modifier.fillMaxSize(), shape = BottomSheetDefaults.ExpandedShape, onDismissRequest = { isSearchBarVisible.value = false }, sheetState = sheetState, dragHandle = null ) { SearchBar( onBookmarkClick = actions.onBookmarkSelect, onDismiss = { scope.launch { sheetState.hide() isSearchBarVisible.value = false } } ) } } if (isCategoriesVisible.value) { val scope = rememberCoroutineScope() ModalBottomSheet( shape = BottomSheetDefaults.ExpandedShape, onDismissRequest = { isCategoriesVisible.value = false }, sheetState = sheetStateCategories, ) { val selectedTags by feedViewModel.selectedTags.collectAsState() CategoriesView( onDismiss = { scope.launch { sheetStateCategories.hide() isCategoriesVisible.value = false } }, uniqueCategories = tagsState.data ?: emptyList(), tagToHide = tagToHide, onFilterHiddenTag = { value -> feedViewModel.showOnlyHiddenTag.value = value }, selectedOptionIndex = feedViewModel.selectedOptionIndex.value, onSelectedOptionIndexChanged = { newIndex -> feedViewModel.selectedOptionIndex.value = newIndex }, selectedTags = selectedTags, onCategoryDeselected = { tag -> feedViewModel.removeSelectedTag(tag) }, onCategorySelected = { tag -> feedViewModel.addSelectedTag(tag) }, onResetAll = { feedViewModel.resetTags() }, ) } } } @Composable private fun FeedView( actions: FeedActions, viewType: BookmarkViewType, serverURL: String, xSessionId: String, token: String, bookmarksPagingItems: LazyPagingItems, tagToHide: Tag?, showOnlyHiddenTag: Boolean ) { if (bookmarksPagingItems.itemCount > 0) { Column { Box( modifier = Modifier .fillMaxSize() .nestedScroll(rememberNestedScrollInteropConnection()), ) { FeedContent( actions = actions, serverURL = serverURL, xSessionId = xSessionId, token = token, viewType = viewType, bookmarksPagingItems = bookmarksPagingItems, tagToHide = tagToHide, showOnlyHiddenTag = showOnlyHiddenTag ) } } } else { EmptyView(actions) } } @Composable private fun EmptyView(actions: FeedActions) { Box( modifier = Modifier.fillMaxSize() ) { NoContentView( modifier = Modifier .padding(top = 100.dp) .align(Alignment.Center), onRefresh = actions.onRefreshFeed ) } } data class FeedActions( val goToLogin: () -> Unit, val onBookmarkSelect: (Bookmark) -> Unit, val onRefreshFeed: () -> Unit, val onEditBookmark: (Bookmark) -> Unit, val onDeleteBookmark: (Bookmark) -> Unit, val onShareBookmark: (Bookmark) -> Unit, val onBookmarkEpub: (Bookmark) -> Unit, val onClickSync: (Bookmark) -> Unit, val onClearError: () -> Unit, val onCategoriesSelectedChanged: (List) -> Unit, ) ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/FeedViewModel.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.LoadState import com.desarrollodroide.pagekeeper.ui.components.UiState import com.desarrollodroide.pagekeeper.ui.components.error import com.desarrollodroide.pagekeeper.ui.components.idle import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource import com.desarrollodroide.data.mapper.toProtoEntity import com.desarrollodroide.network.model.SessionDTO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import com.desarrollodroide.common.result.Result import com.desarrollodroide.data.extensions.removeTrailingSlash import com.desarrollodroide.domain.usecase.DeleteBookmarkUseCase import com.desarrollodroide.domain.usecase.DownloadFileUseCase import com.desarrollodroide.domain.usecase.GetLocalPagingBookmarksUseCase import com.desarrollodroide.domain.usecase.UpdateBookmarkCacheUseCase import com.desarrollodroide.model.Bookmark import com.desarrollodroide.model.Tag import com.desarrollodroide.model.UpdateCachePayload import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.runBlocking import java.io.File import androidx.paging.cachedIn import androidx.paging.PagingData 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.repository.SyncWorks import com.desarrollodroide.data.repository.SyncStatus import com.desarrollodroide.domain.usecase.DeleteLocalBookmarkUseCase import com.desarrollodroide.domain.usecase.GetTagsUseCase import com.desarrollodroide.domain.usecase.SyncBookmarksUseCase import com.desarrollodroide.domain.usecase.GetAllRemoteBookmarksUseCase import com.desarrollodroide.model.SyncBookmarksRequestPayload import com.desarrollodroide.model.SyncBookmarksResponse import com.desarrollodroide.pagekeeper.ui.components.success import kotlinx.coroutines.Job import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update class FeedViewModel( private val bookmarkDatabase: BookmarksDao, private val settingsPreferenceDataSource: SettingsPreferenceDataSource, private val getTagsUseCase: GetTagsUseCase, private val getLocalPagingBookmarksUseCase: GetLocalPagingBookmarksUseCase, private val deleteBookmarkUseCase: DeleteBookmarkUseCase, private val updateBookmarkCacheUseCase: UpdateBookmarkCacheUseCase, private val downloadFileUseCase: DownloadFileUseCase, private val getAllRemoteBookmarksUseCase: GetAllRemoteBookmarksUseCase, private val deleteLocalBookmarkUseCase: DeleteLocalBookmarkUseCase, private val syncBookmarksUseCase: SyncBookmarksUseCase, private val syncManager: SyncWorks, ) : ViewModel() { private val TAG = "FeedViewModel" private val _bookmarksUiState = MutableStateFlow(UiState>(idle = true)) val bookmarksUiState = _bookmarksUiState.asStateFlow() private val _downloadUiState = MutableStateFlow(UiState(idle = true)) val downloadUiState = _downloadUiState.asStateFlow() private val _bookmarksState: MutableStateFlow> = MutableStateFlow(value = PagingData.empty()) val bookmarksState: MutableStateFlow> get() = _bookmarksState private val _tagsState = MutableStateFlow(UiState>(idle = true)) val tagsState = _tagsState.asStateFlow() private val _currentBookmark = MutableStateFlow(null) val currentBookmark = _currentBookmark.asStateFlow() private var tagsJob: Job? = null private var serverUrl = "" private var xSessionId = "" private var token = "" val showBookmarkEditorScreen = mutableStateOf(false) val showDeleteConfirmationDialog = mutableStateOf(false) val showEpubOptionsDialog = mutableStateOf(false) val showSyncDialog = mutableStateOf(false) val bookmarkSelected = mutableStateOf(null) val bookmarkToDelete = mutableStateOf(null) val bookmarkToUpdateCache = mutableStateOf(null) val showOnlyHiddenTag = MutableStateFlow(false) val selectedOptionIndex = mutableStateOf(0) private var isInitialized = false val compactView: StateFlow = settingsPreferenceDataSource.compactViewFlow .stateIn(viewModelScope, SharingStarted.Eagerly, false) val tagToHide: StateFlow = settingsPreferenceDataSource.hideTagFlow .stateIn(viewModelScope, SharingStarted.Eagerly, null) val selectedTags: StateFlow> = combine( settingsPreferenceDataSource.selectedCategoriesFlow, _tagsState ) { selectedIds, tagsState -> val allTags = tagsState.data ?: emptyList() allTags.filter { it.id.toString() in selectedIds } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val _syncState = MutableStateFlow>(UiState(idle = true)) val syncState: StateFlow> = _syncState.asStateFlow() suspend fun initializeIfNeeded() { if (!isInitialized) { isInitialized = true loadInitialData() } } init { viewModelScope.launch { combine( selectedTags, showOnlyHiddenTag, tagToHide ) { selectedTags, showOnlyHidden, hiddenTag -> Triple(selectedTags, showOnlyHidden, hiddenTag) }.flatMapLatest { (selectedTags, showOnlyHidden, hiddenTag) -> getLocalPagingBookmarksUseCase.invoke( serverUrl = serverUrl, xSession = settingsPreferenceDataSource.getSession(), tags = if (showOnlyHidden) emptyList() else selectedTags, showOnlyHiddenTag = showOnlyHidden, tagToHide = hiddenTag ) }.cachedIn(viewModelScope) .collect { pagingData -> _bookmarksState.value = pagingData } } viewModelScope.launch { getTagsUseCase.getLocalTags() .distinctUntilChanged() .collect { localTags -> Log.d("FeedViewModel", "Tags updated: ${localTags.size}") if (localTags.isNotEmpty()) { _tagsState.success(localTags) } else { _tagsState.success(emptyList()) } } } } fun loadInitialData() { viewModelScope.launch { serverUrl = settingsPreferenceDataSource.getUrl() token = settingsPreferenceDataSource.getToken() xSessionId = settingsPreferenceDataSource.getSession() //getLocalTags() if (_tagsState.value.data.isNullOrEmpty()) { getRemoteTags() } if (bookmarkDatabase.isEmpty()) { settingsPreferenceDataSource.setCurrentTimeStamp() retrieveAllRemoteBookmarks() } refreshFeed() } } fun getLocalTags() { tagsJob?.cancel() tagsJob = viewModelScope.launch { getTagsUseCase.getLocalTags() .distinctUntilChanged() .collect { localTags -> Log.d("FeedViewModel", "Tags updated: ${localTags.size}") if (localTags.isNotEmpty()) { _tagsState.success(localTags) } else { _tagsState.success(emptyList()) } } } } fun syncBookmarks(ids: List, lastSync: Long, page: Int = 1) { viewModelScope.launch { _syncState.value = UiState(isLoading = true) val syncBookmarksRequestPayload = SyncBookmarksRequestPayload( ids = ids, last_sync = lastSync, page = page ) syncBookmarksUseCase( token = token, serverUrl = serverUrl, syncBookmarksRequestPayload = syncBookmarksRequestPayload ).collect { result -> when (result) { is Result.Success -> { result.data?.let { response -> Log.v(TAG, "Sync response: $response") _syncState.value = UiState(data = response) syncBookmarksUseCase.handleSuccessfulSync(response, lastSync) } ?: run { _syncState.value = UiState(error = "Sync response was null") Log.e(TAG, "Sync response was null") } } is Result.Error -> { //_syncState.value = UiState(error = result.error?.message) Log.e(TAG, "Error syncing bookmarks: ${result.error?.message}") } is Result.Loading -> {} } } } } private fun retrieveAllRemoteBookmarks() { Log.v(TAG, "Syncing bookmarks") viewModelScope.launch { getAllRemoteBookmarksUseCase.invoke( serverUrl = serverUrl, xSession = settingsPreferenceDataSource.getSession() ).collect { result -> result.fold( onSuccess = { status -> when (status) { is SyncStatus.Started -> { Log.v(TAG, "Sync started") } is SyncStatus.InProgress -> { Log.v(TAG, "Sync in progress") } is SyncStatus.Completed -> { Log.v(TAG, "Sync completed") } is SyncStatus.Error -> { Log.v(TAG, "Sync error") if (status.error is Result.ErrorType.SessionExpired) { Log.v(TAG, "Session expired") } handleSyncError(status.error) } SyncStatus.Started -> { } } }, onFailure = { throwable -> _bookmarksUiState.error(errorMessage = throwable.message.toString()) } ) } } } private fun handleSyncError(error: Result.ErrorType) { if (error is Result.ErrorType.SessionExpired) { _bookmarksUiState.error(errorMessage = SESSION_HAS_BEEN_EXPIRED) } else { Log.e(TAG, "Unhandled exception: ${error.message}") //_bookmarksUiState.error(errorMessage = "Unhandled exception: ${error.message}") } } fun refreshFeed() { viewModelScope.launch { val localBookmarkIds = bookmarkDatabase.getAllBookmarkIds() // TODO sync disabled until endpoint finished //syncBookmarks(localBookmarkIds, settingsPreferenceDataSource.getLastSyncTimestamp()) // TODO remove with sync is completed in backend retrieveAllRemoteBookmarks() } } fun getRemoteTags() { tagsJob?.cancel() tagsJob = viewModelScope.launch { getTagsUseCase.invoke( serverUrl = serverUrl, token = token, ) .distinctUntilChanged() .collect() { result -> when (result) { is Result.Error -> { Log.v("FeedViewModel", "Error getting tags: ${result.error?.message}") } is Result.Loading -> { Log.v("FeedViewModel", "Loading, updating tags from cache...") _tagsState.success(result.data) } is Result.Success -> { Log.v("FeedViewModel", "Tags loaded successfully.") _tagsState.success(result.data) } } } } } fun handleLoadState(loadState: LoadState) { if (loadState is LoadState.Error) { _bookmarksUiState.update { currentState -> currentState.copy(isLoading = false, error = loadState.error.message) } } } fun updateBookmarkCache( keepOldTitle: Boolean, updateArchive: Boolean, updateEbook: Boolean, ) { val updateCachePayload = UpdateCachePayload( ids = listOf(bookmarkToUpdateCache.value?.id ?: -1), createArchive = updateArchive, createEbook = updateEbook, keepMetadata = keepOldTitle, skipExist = false ) viewModelScope.launch { updateBookmarkCacheUseCase.invoke( bookmark = bookmarkToUpdateCache.value ?: return@launch, updateCachePayload = updateCachePayload ) } } fun resetData() { isInitialized = false _bookmarksUiState.idle(true) viewModelScope.launch { settingsPreferenceDataSource.saveUser( password = "", session = SessionDTO(null, null, null).toProtoEntity(), serverUrl = "" ) } } fun getUrl(bookmark: Bookmark) = if (bookmark.public == 1) "${serverUrl.removeTrailingSlash()}/bookmark/${bookmark.id}/content" else { bookmark.url } fun getEpubUrl(bookmark: Bookmark) = "${serverUrl.removeTrailingSlash()}/bookmark/${bookmark.id}/ebook" fun deleteBookmark(bookmark: Bookmark) { viewModelScope.launch { deleteBookmarkUseCase.invoke(bookmark = bookmark) } } fun deleteLocalBookmark(bookmark: Bookmark) { viewModelScope.launch { deleteLocalBookmarkUseCase(bookmark).collect { result -> if (result is Result.Success) { deleteBookmark(bookmark = bookmark) // TODO } else if (result is Result.Error){ Log.v("FeedViewModel","Error deleting local bookmark: ${result.error?.message}") _bookmarksUiState.error( errorMessage = result.error?.message ?: "Unknown error" ) } } } } fun downloadFile( bookmark: Bookmark, ) { viewModelScope.launch(Dispatchers.IO) { val sessionId = settingsPreferenceDataSource.getSession() _downloadUiState.value = UiState(isLoading = true) try { val downloadedFile = downloadFileUseCase.execute(getEpubUrl(bookmark), bookmark.title, sessionId) _downloadUiState.value = UiState(data = downloadedFile) showEpubOptionsDialog.value = true } catch (e: Exception) { _downloadUiState.value = UiState(error = e.message) } } } fun getServerUrl() = serverUrl fun getSession(): String = xSessionId fun getToken(): String = runBlocking { settingsPreferenceDataSource.getToken() } fun addSelectedTag(tag: Tag) { viewModelScope.launch { val currentTags = selectedTags.value if (tag !in currentTags) { settingsPreferenceDataSource.addSelectedCategory(tag) } } } fun removeSelectedTag(tag: Tag) { viewModelScope.launch { settingsPreferenceDataSource.removeSelectedCategory(tag) } } fun resetTags() { viewModelScope.launch { settingsPreferenceDataSource.setSelectedCategories(emptyList()) } } fun getPendingWorks() = syncManager.getPendingJobs() fun retryAllPendingJobs() { viewModelScope.launch { syncManager.retryAllPendingJobs() } } fun loadBookmarkById(id: Int) { viewModelScope.launch { _currentBookmark.value = bookmarkDatabase.getBookmarkById(id)?.toDomainModel() } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/ItemLazyLoad.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @Composable fun PageLoader(modifier: Modifier = Modifier) { Column( modifier = modifier, verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = "Fetching data from server", color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis ) CircularProgressIndicator(Modifier.padding(top = 10.dp)) } } @Composable fun LoadingNextPageItem(modifier: Modifier) { CircularProgressIndicator( modifier = modifier .fillMaxWidth() .padding(10.dp) .wrapContentWidth(Alignment.CenterHorizontally) ) } @Composable fun ErrorMessage( message: String, modifier: Modifier = Modifier, onClickRetry: () -> Unit ) { Row( modifier = modifier.padding(10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = message, color = MaterialTheme.colorScheme.error, modifier = Modifier.weight(1f), maxLines = 2 ) OutlinedButton(onClick = onClickRetry) { Text("Retry") } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/NoContentView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.desarrollodroide.pagekeeper.R @Composable fun NoContentView( modifier: Modifier = Modifier, onRefresh: () -> Unit, ) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Icon( modifier = Modifier.width(100.dp), tint = MaterialTheme.colorScheme.secondary, painter = painterResource(id = R.drawable.ic_empty_list), contentDescription = "No content image" ) Button( onClick = onRefresh, modifier = Modifier.padding(16.dp), ) { Text(text = "Refresh") } } } @Preview( showBackground = true, widthDp = 320, heightDp = 480 ) @Composable fun NoContentViewPreview() { NoContentView(onRefresh = {}) } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/SearchBarView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Cancel import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.paging.compose.collectAsLazyPagingItems import com.desarrollodroide.model.Bookmark import org.koin.androidx.compose.getViewModel @Composable @OptIn(ExperimentalMaterial3Api::class) fun SearchBar( onBookmarkClick: (Bookmark) -> Unit, onDismiss: () -> Unit, viewModel: SearchViewModel = getViewModel() ) { val searchText by viewModel.searchQuery.collectAsState() val isActive = rememberSaveable { mutableStateOf(true) } val context = LocalContext.current val filteredBookmarks = viewModel.bookmarksState.collectAsLazyPagingItems() Box( Modifier .fillMaxSize()) { androidx.compose.material3.SearchBar( modifier = Modifier .align(Alignment.TopCenter), query = searchText, onQueryChange = { viewModel.updateSearchQuery(it) }, onSearch = { Toast.makeText(context, "Select bookmark from list", Toast.LENGTH_SHORT).show() }, active = isActive.value, onActiveChange = { isActive.value = it }, placeholder = { Text("Search...") }, leadingIcon = { IconButton(onClick = { onDismiss() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go back") } }, trailingIcon = { Row() { Box(modifier = Modifier .padding(end = 8.dp) .clickable { viewModel.resetSearch() }) { Icon(Icons.Default.Cancel, contentDescription = null) } } }, ) { BookmarkSuggestions( bookmarks = filteredBookmarks, onClickSuggestion = onBookmarkClick ) } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/SearchViewModel.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource import com.desarrollodroide.domain.usecase.GetLocalPagingBookmarksUseCase import com.desarrollodroide.model.Bookmark import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @OptIn(FlowPreview::class) class SearchViewModel( private val getPagingBookmarksUseCase: GetLocalPagingBookmarksUseCase, private val settingsPreferenceDataSource: SettingsPreferenceDataSource, ) : ViewModel() { private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery private val _bookmarksState: MutableStateFlow> = MutableStateFlow(value = PagingData.empty()) val bookmarksState: MutableStateFlow> get() = _bookmarksState init { viewModelScope.launch { _searchQuery .debounce(1000) .distinctUntilChanged() .collectLatest { query -> if (query.isNotEmpty()) { getPagingBookmarks(query) } } } } fun updateSearchQuery(query: String) { _searchQuery.update { query } } suspend fun getPagingBookmarks( searchText: String ) { _bookmarksState.value = PagingData.empty() getPagingBookmarksUseCase.invoke( serverUrl = settingsPreferenceDataSource.getUrl(), xSession = settingsPreferenceDataSource.getSession(), searchText = searchText, tags = emptyList(), ) .cachedIn(viewModelScope) .collect { _bookmarksState.value = it } } fun resetSearch() { _searchQuery.value = "" } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/BookmarkImageView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed.item import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import coil.request.ImageRequest import okhttp3.Headers import android.graphics.Bitmap import androidx.compose.material3.Icon import androidx.compose.ui.platform.LocalInspectionMode import coil.ImageLoader import coil.size.Size import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.FilterQuality import coil.compose.AsyncImage import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Image import org.koin.androidx.compose.get @Composable fun BookmarkImageView( imageUrl: String, xSessionId: String, token: String, modifier: Modifier = Modifier, contentScale: ContentScale, loadAsThumbnail: Boolean ) { if (LocalInspectionMode.current) { Icon( imageVector = Icons.Default.Image, contentDescription = "Placeholder image", modifier = modifier ) } else { val context = LocalContext.current val imageLoader = get() AsyncImage( model = ImageRequest.Builder(context) .data(imageUrl) .bitmapConfig(Bitmap.Config.ARGB_8888) .apply { if (loadAsThumbnail) { size(Size(100, 100)) } else { size(Size.ORIGINAL) } } .headers( Headers.Builder().add("Authorization", "Bearer $token").build() ) .build(), contentDescription = "Bookmark image", imageLoader = imageLoader, modifier = modifier .heightIn(max = if (loadAsThumbnail) 100.dp else 200.dp) .fillMaxWidth(), alignment = Alignment.Center, contentScale = contentScale, alpha = 1.0f, colorFilter = null, filterQuality = FilterQuality.Medium, clipToBounds = true ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/BookmarkItem.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed.item import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.desarrollodroide.data.helpers.BookmarkViewType import com.desarrollodroide.model.Bookmark import com.desarrollodroide.model.Tag data class BookmarkActions( val onClickEdit: (GetBookmark) -> Unit, val onClickDelete: (GetBookmark) -> Unit, val onClickShare: (GetBookmark) -> Unit, val onClickCategory: (Tag) -> Unit, val onClickBookmark: (GetBookmark) -> Unit, val onClickEpub: (GetBookmark) -> Unit, val onClickSync: (GetBookmark) -> Unit ) typealias GetBookmark = () -> Bookmark @Composable fun BookmarkItem( getBookmark: GetBookmark, serverURL: String, xSessionId: String, token: String, actions: BookmarkActions, viewType: BookmarkViewType ) { val bookmark by remember { derivedStateOf(getBookmark) } Box(modifier = Modifier .padding(horizontal = 6.dp) .padding(bottom = if (viewType == BookmarkViewType.FULL) 0.dp else 6.dp) ) { Box( modifier = Modifier .fillMaxWidth() .clickable { actions.onClickBookmark(getBookmark) }, ) { when (viewType) { BookmarkViewType.FULL -> FullBookmarkView( getBookmark = getBookmark, serverURL = serverURL, xSessionId = xSessionId, token = token, actions = actions ) BookmarkViewType.SMALL -> SmallBookmarkView( getBookmark = getBookmark, serverURL = serverURL, xSessionId = xSessionId, token = token, actions = actions ) } } } } @Preview @Composable fun PreviewPost() { MaterialTheme { val mockBookmark = Bookmark.mock() val actions = BookmarkActions( onClickEdit = { }, onClickDelete = { }, onClickShare = { }, onClickCategory = { }, onClickBookmark = { }, onClickEpub = { }, onClickSync = { } ) Column { BookmarkItem( getBookmark = { mockBookmark }, serverURL = "", xSessionId = "", token = "", actions = actions, viewType = BookmarkViewType.FULL ) BookmarkItem( getBookmark = { mockBookmark }, serverURL = "", xSessionId = "", token = "", actions = actions, viewType = BookmarkViewType.SMALL ) } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/ButtonsView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed.item import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import com.desarrollodroide.data.extensions.isTimestampId import com.desarrollodroide.model.Bookmark import com.desarrollodroide.pagekeeper.R @Composable fun ButtonsView( getBookmark: GetBookmark, actions: BookmarkActions ) { val bookmark by remember { derivedStateOf(getBookmark) } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End ) { IconButton(onClick = { actions.onClickEdit(getBookmark) }) { Icon( imageVector = Icons.Filled.Edit, contentDescription = "Edit", tint = MaterialTheme.colorScheme.secondary ) } IconButton(onClick = { actions.onClickDelete(getBookmark) }) { Icon( imageVector = Icons.Filled.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.secondary ) } if (bookmark.hasEbook) { IconButton(onClick = { actions.onClickEpub(getBookmark) }) { Icon( painter = painterResource(id = R.drawable.ic_book), contentDescription = "Epub", tint = MaterialTheme.colorScheme.secondary ) } } IconButton(onClick = { actions.onClickShare(getBookmark) }) { Icon( imageVector = Icons.Filled.Share, contentDescription = "Share", tint = MaterialTheme.colorScheme.secondary ) } if (!bookmark.id.isTimestampId()){ IconButton(onClick = { actions.onClickSync(getBookmark) }) { Icon( imageVector = Icons.Filled.CloudUpload, contentDescription = "Sync", tint = MaterialTheme.colorScheme.secondary ) } } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/ClickableCategoriesView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed.item import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.desarrollodroide.model.Tag @Composable @OptIn(ExperimentalLayoutApi::class) fun ClickableCategoriesView( uniqueCategories: List, onClickCategory: (Tag) -> Unit ) { FlowRow( ) { uniqueCategories.forEach { category -> Text( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier .padding(5.dp) .clip(RoundedCornerShape(18.dp)) .background(MaterialTheme.colorScheme.secondaryContainer) .clickable { onClickCategory(category) } .padding(vertical = 8.dp, horizontal = 16.dp), text = category.name ) } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/FullBookmarkView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed.item import android.content.res.Configuration import android.util.Log import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.desarrollodroide.data.extensions.removeTrailingSlash import com.desarrollodroide.data.helpers.BookmarkViewType import com.desarrollodroide.model.Bookmark import com.desarrollodroide.pagekeeper.extensions.isRTLText @Composable fun FullBookmarkView( getBookmark: GetBookmark, serverURL: String, xSessionId: String, token: String, actions: BookmarkActions ) { val bookmark by remember { derivedStateOf(getBookmark) } val isArabic by remember { derivedStateOf { bookmark.title.isRTLText() || bookmark.excerpt.isRTLText() } } //val imageUrl by remember { derivedStateOf { "${serverURL.removeTrailingSlash()}${bookmark.imageURL}?lastUpdated=${bookmark.modified}" } } val imageUrl by remember { derivedStateOf { "${serverURL.removeTrailingSlash()}${bookmark.imageURL}" } } Column { if (bookmark.isPendingServerProcessing) { PendingSyncBanner() } if (bookmark.imageURL.isNotEmpty()) { BookmarkImageView( imageUrl = imageUrl, xSessionId = xSessionId, token = token, modifier = Modifier .fillMaxWidth() .padding(top = 16.dp) .clip(RoundedCornerShape(12.dp)), contentScale = ContentScale.FillWidth, loadAsThumbnail = false ) } Column( modifier = Modifier.padding(16.dp) ) { CompositionLocalProvider(LocalLayoutDirection provides if (isArabic) LayoutDirection.Rtl else LayoutDirection.Ltr) { Text( modifier = Modifier.fillMaxWidth(), text = if (bookmark.title.isNullOrEmpty()) bookmark.url else bookmark.title, style = MaterialTheme.typography.titleLarge, overflow = TextOverflow.Ellipsis, maxLines = 2 ) Text( modifier = Modifier .fillMaxWidth() .padding(top = 5.dp), text = bookmark.excerpt, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, overflow = TextOverflow.Ellipsis, maxLines = 3 ) } Text( modifier = Modifier.padding(top = 8.dp), text = bookmark.modified, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary, maxLines = 1 ) Spacer(modifier = Modifier.height(8.dp)) ClickableCategoriesView( uniqueCategories = bookmark.tags, onClickCategory = actions.onClickCategory ) Spacer(modifier = Modifier.height(8.dp)) ButtonsView(getBookmark = getBookmark, actions = actions) } } } @Preview @Composable private fun FullBookmarkViewPreview() { MaterialTheme { val mockBookmark = Bookmark.mock() val actions = BookmarkActions( onClickEdit = {}, onClickDelete = {}, onClickShare = {}, onClickCategory = {}, onClickBookmark = {}, onClickEpub = {}, onClickSync = {} ) Column { BookmarkItem( getBookmark = { mockBookmark }, serverURL = "", xSessionId = "", token = "", actions = actions, viewType = BookmarkViewType.FULL ) BookmarkItem( getBookmark = { mockBookmark }, serverURL = "", xSessionId = "", token = "", actions = actions, viewType = BookmarkViewType.SMALL ) } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/PendingSyncBanner.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed.item import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.HourglassTop import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun PendingSyncBanner() { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) .background( color = MaterialTheme.colorScheme.tertiaryContainer, shape = RoundedCornerShape(8.dp) ) .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Filled.HourglassTop, contentDescription = "Pending", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer ) Spacer(modifier = Modifier.width(8.dp)) Column { Text( text = "Pending server processing", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer ) Text( text = "Pull to refresh to update", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.7f) ) } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/feed/item/SmallBookmarkView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.feed.item import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.desarrollodroide.data.extensions.isTimestampId import com.desarrollodroide.data.extensions.removeTrailingSlash import com.desarrollodroide.model.Bookmark import com.desarrollodroide.pagekeeper.R import com.desarrollodroide.pagekeeper.extensions.isRTLText @Composable fun SmallBookmarkView( getBookmark: GetBookmark, serverURL: String, xSessionId: String, token: String, actions: BookmarkActions ) { val bookmark by remember { derivedStateOf(getBookmark) } val imageUrl by remember { derivedStateOf { "${serverURL.removeTrailingSlash()}${bookmark.imageURL}" }} val modifier = if (bookmark.imageURL.isNotEmpty()) Modifier.height(90.dp) else Modifier.wrapContentHeight() val isArabic by remember { derivedStateOf { bookmark.title.isRTLText() || bookmark.excerpt.isRTLText() } } Column { if (bookmark.isPendingServerProcessing) { PendingSyncBanner() } Row( modifier = modifier .padding(vertical = 8.dp) .padding(start = 8.dp) ) { if (bookmark.imageURL.isNotEmpty()) { BookmarkImageView( imageUrl = imageUrl, xSessionId = xSessionId, token = token, modifier = Modifier .aspectRatio(1f) .clip( RoundedCornerShape(8.dp) ), contentScale = ContentScale.Crop, loadAsThumbnail = true ) Spacer(modifier = Modifier.width(8.dp)) } Spacer(modifier = Modifier.width(8.dp)) Row( modifier = Modifier .fillMaxWidth() .padding(top = 4.dp), horizontalArrangement = Arrangement.SpaceBetween ) { Column( modifier = Modifier.weight(1f) ) { CompositionLocalProvider(LocalLayoutDirection provides if (isArabic) LayoutDirection.Rtl else LayoutDirection.Ltr) { Text( text = if (bookmark.title.isNullOrEmpty()) bookmark.url else bookmark.title, style = MaterialTheme.typography.titleMedium, overflow = TextOverflow.Ellipsis, maxLines = 2 ) Spacer(modifier = Modifier.height(8.dp)) Text( text = bookmark.modified, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary, maxLines = 1 ) } } Column { val expanded = remember { mutableStateOf(false) } IconButton(onClick = { expanded.value = true }) { Icon( imageVector = Icons.Filled.MoreVert, contentDescription = "Edit", tint = MaterialTheme.colorScheme.secondary ) } DropdownMenu( modifier = Modifier .align(alignment = Alignment.End), offset = DpOffset((8).dp, 0.dp), expanded = expanded.value, onDismissRequest = { expanded.value = false } ) { DropdownMenuItem( text = { Text("Edit") }, onClick = { expanded.value = false actions.onClickEdit(getBookmark) }, leadingIcon = { Icon( Icons.Outlined.Edit, contentDescription = null ) }) DropdownMenuItem( text = { Text("Delete") }, onClick = { expanded.value = false actions.onClickDelete(getBookmark) }, leadingIcon = { Icon( Icons.Outlined.Delete, contentDescription = null ) }) if (bookmark.hasEbook) { DropdownMenuItem( text = { Text("Epub") }, onClick = { expanded.value = false actions.onClickEpub(getBookmark) }, leadingIcon = { Icon( painter = painterResource(id = R.drawable.ic_book), contentDescription = "Epub", tint = MaterialTheme.colorScheme.secondary ) }) } DropdownMenuItem( text = { Text("Share") }, onClick = { expanded.value = false actions.onClickShare(getBookmark) }, leadingIcon = { Icon( Icons.Outlined.Share, contentDescription = null ) }) if (!bookmark.id.isTimestampId()){ DropdownMenuItem( text = { Text("Update") }, onClick = { expanded.value = false actions.onClickSync(getBookmark) }, leadingIcon = { Icon( Icons.Outlined.CloudUpload, contentDescription = null ) }) } } } } } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/home/BottomNavItem.kt ================================================ package com.desarrollodroide.pagekeeper.ui.home import androidx.compose.ui.graphics.vector.ImageVector import com.desarrollodroide.pagekeeper.navigation.NavItem data class BottomNavItem( val name: String, val navItem: NavItem, val icon: ImageVector, ) ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/home/HomeScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.home import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.ui.Modifier import org.koin.androidx.compose.get import androidx.compose.runtime.* import androidx.compose.material3.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.outlined.Sell import androidx.compose.material3.Badge import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.paging.compose.collectAsLazyPagingItems import com.desarrollodroide.data.helpers.SHIORI_ANDROID_CLIENT_GITHUB_URL import com.desarrollodroide.model.PendingJob import com.desarrollodroide.model.SyncOperationType import com.desarrollodroide.pagekeeper.navigation.NavItem import com.desarrollodroide.pagekeeper.ui.feed.FeedScreen import com.desarrollodroide.pagekeeper.ui.feed.FeedViewModel import com.desarrollodroide.pagekeeper.ui.settings.PrivacyPolicyScreen import com.desarrollodroide.pagekeeper.ui.settings.SettingsScreen import com.desarrollodroide.pagekeeper.ui.settings.TermsOfUseScreen import java.io.File import com.desarrollodroide.pagekeeper.R import com.desarrollodroide.pagekeeper.extensions.isRTLText import com.desarrollodroide.pagekeeper.ui.readablecontent.ReadableContentScreen import com.desarrollodroide.pagekeeper.ui.settings.crash.CrashLogScreen import com.desarrollodroide.pagekeeper.ui.settings.logcat.NetworkLogScreen import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( feedViewModel: FeedViewModel, goToLogin: () -> Unit, onFinish: () -> Unit, openUrlInBrowser: (String) -> Unit, onAddManuallyClick: () -> Unit, shareEpubFile: (File) -> Unit, shareText: (String) -> Unit ) { val navController = rememberNavController() val isCategoriesVisible = remember { mutableStateOf(false) } val isSearchBarVisible = remember { mutableStateOf(false) } val (showTopBar, setShowTopBar) = remember { mutableStateOf(true) } val hasBookmarks = feedViewModel.bookmarksState.collectAsLazyPagingItems().itemCount > 0 val selectedTags by feedViewModel.selectedTags.collectAsState() val showOnlyHiddenTag by feedViewModel.showOnlyHiddenTag.collectAsState() val coroutineScope = rememberCoroutineScope() val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false, confirmValueChange = { true }) val showBottomSheet = remember { mutableStateOf(false) } BackHandler { onFinish() } val pendingJobs by feedViewModel.getPendingWorks().collectAsState(initial = emptyList()) if (showBottomSheet.value) { ModalBottomSheet( sheetState = bottomSheetState, onDismissRequest = { showBottomSheet.value = false } ) { SyncJobsBottomSheetContent( pendingJobs = pendingJobs, onDismiss = { showBottomSheet.value = false }, onRetryAll = { feedViewModel.retryAllPendingJobs() } ) } } NavHost( navController = navController, startDestination = NavItem.HomeNavItem.route ) { composable(NavItem.HomeNavItem.route) { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val pendingJobsCount by feedViewModel.getPendingWorks().collectAsState(initial = emptyList()) val pendingJobs by feedViewModel.getPendingWorks().collectAsState(initial = emptyList()) Scaffold( containerColor = MaterialTheme.colorScheme.background, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { AnimatedVisibility (showTopBar) { TopBar( toggleCategoryVisibility = { isCategoriesVisible.value = !isCategoriesVisible.value }, toggleSearchBarVisibility = { isSearchBarVisible.value = !isSearchBarVisible.value }, onSettingsClick = { navController.navigate(NavItem.SettingsNavItem.route) }, scrollBehavior = scrollBehavior, hasBookmarks = hasBookmarks, selectedTagsCount = selectedTags.size, showOnlyHiddenTag = showOnlyHiddenTag, pendingJobsCount = pendingJobsCount.size, onSyncButtonClick = { coroutineScope.launch { showBottomSheet.value = true bottomSheetState.show() } }, pendingJobs = pendingJobs, onAddManuallyClick = onAddManuallyClick, ) } } ) { paddingValues -> Box( modifier = Modifier .padding(paddingValues) ) { FeedScreen( feedViewModel = feedViewModel, isCategoriesVisible = isCategoriesVisible, goToLogin = goToLogin, openUrlInBrowser = openUrlInBrowser, shareEpubFile = shareEpubFile, isSearchBarVisible = isSearchBarVisible, setShowTopBar = setShowTopBar, goToReadableContent = { bookmark-> navController.navigate(NavItem.ReadableContentNavItem.createRoute( bookmarkId = bookmark.id, )) }, ) } } } composable(NavItem.SettingsNavItem.route) { SettingsScreen( settingsViewModel = get(), goToLogin = goToLogin, onNavigateToPrivacyPolicy = { navController.navigate(NavItem.PrivacyPolicyNavItem.route) }, onNavigateToTermsOfUse = { navController.navigate(NavItem.TermsOfUseNavItem.route) }, onBack = { navController.navigateUp() }, onNavigateToSourceCode = { openUrlInBrowser.invoke(SHIORI_ANDROID_CLIENT_GITHUB_URL) }, onNavigateToLogs = { navController.navigate(NavItem.NetworkLoggerNavItem.route) }, onViewLastCrash = { navController.navigate(NavItem.LastCrashNavItem.route) } ) } composable(NavItem.TermsOfUseNavItem.route) { TermsOfUseScreen( onBack = { navController.navigateUp() } ) } composable(NavItem.PrivacyPolicyNavItem.route) { PrivacyPolicyScreen( onBack = { navController.navigateUp() } ) } composable(NavItem.NetworkLoggerNavItem.route) { NetworkLogScreen( onBack = { navController.navigateUp() }, onShare = shareText ) } composable(NavItem.LastCrashNavItem.route) { CrashLogScreen( onBack = { navController.navigateUp() }, onShare = shareText ) } composable( route = NavItem.ReadableContentNavItem.route, arguments = listOf( navArgument("bookmarkId") { type = NavType.IntType } ) ) { backStackEntry -> val bookmarkId = backStackEntry.arguments?.getInt("bookmarkId") ?: 0 val bookmark by feedViewModel.currentBookmark.collectAsState() LaunchedEffect(bookmarkId) { feedViewModel.loadBookmarkById(bookmarkId) } bookmark?.let { ReadableContentScreen( readableContentViewModel = get(), bookmarkId = bookmarkId, bookmarkUrl = it.url, onBack = { navController.navigateUp() }, openUrlInBrowser = openUrlInBrowser, bookmarkDate = it.modified, bookmarkTitle = it.title, isRtl = it.title.isRTLText() || it.excerpt.isRTLText() ) } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopBar( toggleCategoryVisibility: () -> Unit, toggleSearchBarVisibility: () -> Unit, onAddManuallyClick: () -> Unit, onSettingsClick: () -> Unit, onSyncButtonClick: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, hasBookmarks: Boolean, selectedTagsCount: Int, showOnlyHiddenTag: Boolean, pendingJobsCount: Int, pendingJobs: List ) { var showTooltip by remember { mutableStateOf(false) } val hasRunningJobs = pendingJobs.any { it.state.uppercase() == "RUNNING" } val rotation by remember { mutableStateOf(Animatable(0f)) } LaunchedEffect(hasRunningJobs) { if (hasRunningJobs) { rotation.animateTo( targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(2000, easing = LinearEasing), repeatMode = RepeatMode.Restart ) ) } else { rotation.snapTo(0f) } } TopAppBar( scrollBehavior = scrollBehavior, title = { Box(modifier = Modifier.fillMaxWidth()) { Text( color = MaterialTheme.colorScheme.primary, text = "Shiori", modifier = Modifier.align(Alignment.CenterStart), style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold, fontSize = 28.sp) ) } }, navigationIcon = { Image( painter = painterResource(id = R.drawable.logo_pagekeeper), contentDescription = "Menu", modifier = Modifier .width(45.dp) .padding(8.dp) ) }, actions = { IconButton(onClick = onAddManuallyClick) { Icon( imageVector = Icons.Default.Add, contentDescription = "Add Manually", tint = MaterialTheme.colorScheme.secondary, ) } IconButton(onClick = { toggleSearchBarVisibility() }) { Icon( imageVector = Icons.Filled.Search, contentDescription = "Search", tint = MaterialTheme.colorScheme.secondary, ) } Box(contentAlignment = Alignment.TopEnd) { IconButton(onClick = { toggleCategoryVisibility() }) { Icon( imageVector = if (showOnlyHiddenTag) Icons.Default.VisibilityOff else Icons.Outlined.Sell, contentDescription = if (showOnlyHiddenTag) "Hidden Tags" else "Filter", tint = MaterialTheme.colorScheme.secondary, ) } this@TopAppBar.AnimatedVisibility( visible = selectedTagsCount > 0 && !showOnlyHiddenTag, enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut() ) { Badge(modifier = Modifier.padding(2.dp)) { Text( text = selectedTagsCount.toString(), style = MaterialTheme.typography.labelSmall ) } } } Box(contentAlignment = Alignment.TopEnd) { IconButton( onClick = { showTooltip = !showTooltip onSyncButtonClick() } ) { Icon( imageVector = Icons.Default.Sync, contentDescription = "Sync", tint = MaterialTheme.colorScheme.secondary, modifier = Modifier.graphicsLayer { rotationZ = rotation.value } ) } this@TopAppBar.AnimatedVisibility( visible = pendingJobsCount > 0, enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut() ) { Badge(modifier = Modifier.padding(2.dp)) { Text( text = pendingJobsCount.toString(), style = MaterialTheme.typography.labelSmall ) } } } IconButton(onClick = onSettingsClick) { Icon( imageVector = Icons.Filled.Settings, contentDescription = "Settings", tint = MaterialTheme.colorScheme.secondary ) } }, colors = TopAppBarDefaults.smallTopAppBarColors( containerColor = MaterialTheme.colorScheme.background, // Sets the background color of the TopAppBar titleContentColor = MaterialTheme.colorScheme.primary, // Optional: Set the title color if needed navigationIconContentColor = MaterialTheme.colorScheme.primary, // Optional: Set the navigation icon color if needed actionIconContentColor = MaterialTheme.colorScheme.primary // Optional: Set the action icons color if needed ) ) } @Composable fun SyncJobsBottomSheetContent( pendingJobs: List, onDismiss: () -> Unit, onRetryAll: () -> Unit ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = 26.dp) ) { Text( modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.CenterHorizontally), text = "Pending Sync Jobs", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(16.dp)) if (pendingJobs.isEmpty()) { Text( modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.CenterHorizontally), text = "No pending jobs", style = MaterialTheme.typography.bodyMedium ) } else { HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)) pendingJobs.forEach { job -> Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = job.operationType.name, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium ) if (job.state.uppercase() == "RUNNING") { CircularProgressIndicator( modifier = Modifier.size(12.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary ) } } Text( text = job.bookmarkTitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis ) } Text( text = job.state, style = MaterialTheme.typography.labelSmall, color = when (job.state.uppercase()) { "RUNNING", "ENQUEUED" -> MaterialTheme.colorScheme.primary "BLOCKED", "FAILED" -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) } ) } HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)) } } Spacer(modifier = Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Button( onClick = onRetryAll, modifier = Modifier.weight(1f) ) { Text("Retry All") } Button( onClick = onDismiss, modifier = Modifier.weight(1f) ) { Text("Close") } } } } @Preview(showBackground = true) @Composable fun SyncJobsBottomSheetContentPreview() { SyncJobsBottomSheetContent( pendingJobs = listOf( PendingJob(operationType = SyncOperationType.CREATE, state = "Pending", bookmarkId = 1, "Bookmark 1"), PendingJob(operationType = SyncOperationType.UPDATE, state = "Failed", bookmarkId = 2, "Bookmark 2"), PendingJob(operationType = SyncOperationType.DELETE, state = "In Progress", bookmarkId = 3, "Bookmark 3") ), onDismiss = {}, onRetryAll = {} ) } @OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable fun TopBarPreview() { MaterialTheme { TopBar( toggleCategoryVisibility = { }, toggleSearchBarVisibility = { }, onSettingsClick = { }, onAddManuallyClick = { }, scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), hasBookmarks = true, selectedTagsCount = 2, showOnlyHiddenTag = false, pendingJobsCount = 0, onSyncButtonClick = { }, pendingJobs = emptyList(), ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/LoginButton.kt ================================================ package com.desarrollodroide.pagekeeper.ui.login import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier @Composable fun LoginButton( user: MutableState, userErrorState: MutableState, password: MutableState, passwordErrorState: MutableState, onClickLoginButton: () -> Unit, serverErrorState: MutableState ) { Button( onClick = { if (user.value.isEmpty()) { userErrorState.value = true } if (password.value.isEmpty()) { passwordErrorState.value = true } if (user.value.isNotEmpty() && password.value.isNotEmpty() && !serverErrorState.value) { passwordErrorState.value = false userErrorState.value = false onClickLoginButton.invoke() } }, modifier = Modifier.fillMaxWidth(), content = { Text("Login") }, ) } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/LoginScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.login import android.content.res.Configuration import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.desarrollodroide.pagekeeper.R import com.desarrollodroide.pagekeeper.ui.components.ConfirmDialog import com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog import com.desarrollodroide.pagekeeper.ui.theme.ShioriTheme import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.desarrollodroide.pagekeeper.ui.components.UiState import com.desarrollodroide.model.User import androidx.compose.runtime.getValue import com.desarrollodroide.data.helpers.SHIORI_GITHUB_URL import com.desarrollodroide.model.LivenessResponse import com.desarrollodroide.pagekeeper.ui.settings.LinkableText @Composable fun LoginScreen( loginViewModel: LoginViewModel, onSuccess: (User) -> Unit, ) { val loginUiState: UiState by loginViewModel.userUiState.collectAsStateWithLifecycle() val livenessUiState: UiState by loginViewModel.livenessUiState.collectAsStateWithLifecycle() val serverAvailabilityUiState: UiState by loginViewModel.serverAvailabilityUiState.collectAsStateWithLifecycle() Box( modifier = Modifier .fillMaxSize() ) { LoginContent( loginUiState = loginUiState, checked = loginViewModel.rememberSession, userErrorState = loginViewModel.userNameError, passwordErrorState = loginViewModel.passwordError, urlErrorState = loginViewModel.urlError, onClickLoginButton = { loginViewModel.checkSystemLiveness() }, onCheckedRememberSessionChange = { loginViewModel.rememberSession.value = it }, onSuccess = { loginViewModel.clearState() onSuccess.invoke(it) }, user = loginViewModel.userName, password = loginViewModel.password, serverUrl = loginViewModel.serverUrl, onClearError = { loginViewModel.clearState() }, livenessUiState = livenessUiState, serverAvailabilityUiState = serverAvailabilityUiState, onClickTestButton = { loginViewModel.checkServerAvailability() }, resetServerAvailabilityState = { loginViewModel.resetServerAvailabilityUiState() } ) } } @Composable fun LoginContent( user: MutableState, password: MutableState, serverUrl: MutableState, checked: MutableState, urlErrorState: MutableState, userErrorState: MutableState, passwordErrorState: MutableState, onSuccess: (User) -> Unit, onClickLoginButton: () -> Unit, onClickTestButton: () -> Unit, onClearError: () -> Unit, onCheckedRememberSessionChange: (Boolean) -> Unit, loginUiState: UiState, livenessUiState: UiState, serverAvailabilityUiState: UiState, resetServerAvailabilityState: () -> Unit ) { if (loginUiState.isLoading || livenessUiState.isLoading) { InfiniteProgressDialog(onDismissRequest = {}) } if (!livenessUiState.error.isNullOrEmpty()) { ConfirmDialog( icon = Icons.Default.Error, title = "Error", content = livenessUiState.error, openDialog = remember { mutableStateOf(true) }, onConfirm = { onClearError.invoke() } ) Log.v("loginUiState", "Error") } if (!loginUiState.error.isNullOrEmpty()) { ConfirmDialog( icon = Icons.Default.Error, title = "Error", content = loginUiState.error, openDialog = remember { mutableStateOf(true) }, onConfirm = { onClearError.invoke() } ) Log.v("loginUiState", "Error") } else if (loginUiState.data == null && !loginUiState.idle) { ContentViews( serverUrl = serverUrl, urlErrorState = urlErrorState, user = user, userErrorState = userErrorState, password = password, passwordErrorState = passwordErrorState, onClickLoginButton = onClickLoginButton, checked = checked, onCheckedRememberSessionChange = onCheckedRememberSessionChange, isTestingServer = serverAvailabilityUiState.isLoading, onClickTestButton = onClickTestButton, serverAvailabilityUiState = serverAvailabilityUiState, serverVersion = serverAvailabilityUiState.data?.message?.version ?: "", resetServerAvailabilityState = resetServerAvailabilityState ) } else if (loginUiState.data != null) { LaunchedEffect(Unit) { onSuccess.invoke(loginUiState.data) } } } @Composable private fun ContentViews( serverUrl: MutableState, urlErrorState: MutableState, user: MutableState, userErrorState: MutableState, password: MutableState, passwordErrorState: MutableState, isTestingServer: Boolean, onClickLoginButton: () -> Unit, onClickTestButton: () -> Unit, checked: MutableState, onCheckedRememberSessionChange: (Boolean) -> Unit, serverAvailabilityUiState: UiState, serverVersion: String, resetServerAvailabilityState: () -> Unit ) { Box(modifier = Modifier.fillMaxSize()) { Image( painter = painterResource(id = R.drawable.ic_logo), contentDescription = null, contentScale = ContentScale.FillHeight, modifier = Modifier .fillMaxWidth() .padding(top = 20.dp) .height(120.dp) ) Image( painter = painterResource(id = R.drawable.curved_wave_bottom), contentDescription = null, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)), contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() .padding(top = 10.dp) .height(150.dp) .align(Alignment.BottomCenter) ) Column( modifier = Modifier .padding(16.dp) .fillMaxWidth() .align(Alignment.Center), verticalArrangement = Arrangement.Bottom, ) { ServerUrlTextField( modifier = Modifier, serverUrl = serverUrl, serverErrorState = urlErrorState, serverAvailabilityUiState = serverAvailabilityUiState, serverVersion = serverVersion, resetServerAvailabilityState = resetServerAvailabilityState, onClick = onClickTestButton, isTestingServer = isTestingServer ) Spacer(modifier = Modifier.height(10.dp)) UserTextField( user = user, userErrorState = userErrorState ) Spacer(modifier = Modifier.height(10.dp)) PasswordTextField( password = password, passwordErrorState = passwordErrorState ) Spacer(Modifier.size(14.dp)) LoginButton( user = user, userErrorState = userErrorState, password = password, passwordErrorState = passwordErrorState, onClickLoginButton = onClickLoginButton, serverErrorState = urlErrorState ) RememberSessionSection( checked = checked, onCheckedChange = onCheckedRememberSessionChange ) Box( modifier = Modifier .fillMaxWidth() .padding(10.dp), contentAlignment = Alignment.Center ) { LinkableText( text = "Server Setup Guide", url = SHIORI_GITHUB_URL ) } } } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showSystemUi = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true) @Composable fun DefaultPreview() { ShioriTheme( dynamicColor = false ) { LoginContent( user = remember { mutableStateOf("User") }, password = remember { mutableStateOf("Pass") }, serverUrl = remember { mutableStateOf("ServerUrl") }, checked = remember { mutableStateOf(true) }, urlErrorState = remember { mutableStateOf(true) }, userErrorState = remember { mutableStateOf(true) }, passwordErrorState = remember { mutableStateOf(true) }, onSuccess = {}, onClickLoginButton = {}, onCheckedRememberSessionChange = {}, onClearError = {}, loginUiState = UiState(data = null, idle = false), livenessUiState = UiState(false), serverAvailabilityUiState = UiState(data = null, idle = false), onClickTestButton = {}, resetServerAvailabilityState = {} ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/LoginViewModel.kt ================================================ package com.desarrollodroide.pagekeeper.ui.login import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.desarrollodroide.pagekeeper.ui.components.UiState import com.desarrollodroide.pagekeeper.ui.components.error import com.desarrollodroide.pagekeeper.ui.components.isLoading import com.desarrollodroide.pagekeeper.ui.components.success import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource import com.desarrollodroide.domain.usecase.SendLoginUseCase import com.desarrollodroide.model.User import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import com.desarrollodroide.common.result.Result import com.desarrollodroide.domain.usecase.SystemLivenessUseCase import com.desarrollodroide.model.LivenessResponse import com.desarrollodroide.pagekeeper.ui.components.idle import kotlinx.coroutines.delay class LoginViewModel( private val settingsPreferenceDataSource: SettingsPreferenceDataSource, private val loginUseCase: SendLoginUseCase, private val livenessUseCase: SystemLivenessUseCase, ) : ViewModel() { var rememberSession = mutableStateOf(false) // Oracle // var userName = mutableStateOf("Test") // var password = mutableStateOf("Test") // var serverUrl = mutableStateOf("https://shiori.desarrollodroide.es/") // v1.6 // var userName = mutableStateOf("Test") // var password = mutableStateOf("Test") // var serverUrl = mutableStateOf("http://192.168.1.12:8080/") // Synology // var userName = mutableStateOf("Test") // var password = mutableStateOf("Test") // var serverUrl = mutableStateOf("http://192.168.1.68:18080/") // localhost // var userName = mutableStateOf("shiori") // var password = mutableStateOf("gopher") // var serverUrl = mutableStateOf("http://192.168.1.12:8080/") var serverUrl = mutableStateOf("") var userName = mutableStateOf("") var password = mutableStateOf("") val userNameError = mutableStateOf(false) val passwordError = mutableStateOf(false) val urlError = mutableStateOf(false) private val _userUiState = MutableStateFlow(UiState(idle = true)) val userUiState = _userUiState.asStateFlow() private val _livenessUiState = MutableStateFlow(UiState(idle = true)) val livenessUiState = _livenessUiState.asStateFlow() private val _serverAvailabilityUiState = MutableStateFlow(UiState(idle = true)) val serverAvailabilityUiState = _serverAvailabilityUiState.asStateFlow() init { viewModelScope.launch { getUser() getRememberUser() } } fun sendLogin() { viewModelScope.launch { loginUseCase.invoke( username = userName.value, password = password.value, serverUrl = serverUrl.value, ) .collect { result -> when (result) { is Result.Error -> { val error = result.error?.throwable?.message?:result.error?.message?:"Unknown error" _userUiState.error( errorMessage = error ) } is Result.Loading -> { _userUiState.isLoading(true) } is Result.Success -> { if (result.data != null && result.data?.hasSession() == true) { if (rememberSession.value) { settingsPreferenceDataSource.saveRememberUser( url = serverUrl.value, userName = userName.value, password = password.value ) } else { userName.value = "" password.value = "" serverUrl.value = "" settingsPreferenceDataSource.resetRememberUser() } _userUiState.success(result.data) } else { settingsPreferenceDataSource.resetData() } } } } } } fun checkSystemLiveness(){ viewModelScope.launch { livenessUseCase.invoke(serverUrl.value) .collect { result -> when (result) { is Result.Error -> { if (result.error?.statusCode == 404){ // Liveness not supported, versión < 1.6 sendLogin() Log.v("LoginViewModel", "Liveness not supported") } else if (result.error is Result.ErrorType.IOError) { // Error connecting to server Log.v("LoginViewModel", "Error connecting to server") val error = result.error?.throwable?.message?:result.error?.message?:"Unknown error" _livenessUiState.error(errorMessage = error) } } is Result.Loading -> { _livenessUiState.isLoading(true) } is Result.Success -> { Log.v("LoginViewModel", "Liveness: ${result.data}") settingsPreferenceDataSource.setServerVersion(result.data?.message?.version?:"") _livenessUiState.success(result.data) sendLogin() } } } } } fun checkServerAvailability(){ viewModelScope.launch { livenessUseCase.invoke(serverUrl.value) .collect { result -> when (result) { is Result.Error -> { Log.v("LoginViewModel", "Server Availability error") val error = result.error?.throwable?.message?:result.error?.message?:"Unknown error" _serverAvailabilityUiState.error(errorMessage = error) } is Result.Loading -> { _serverAvailabilityUiState.isLoading(true) } is Result.Success -> { Log.v("LoginViewModel", "Server Availability: ${result.data}") delay(1000) _serverAvailabilityUiState.success(result.data) } } } } } fun clearState() { _userUiState.success(null) _livenessUiState.success(null) } private suspend fun getUser() { val user = settingsPreferenceDataSource.getUser().first() if (user.hasSession()) { _userUiState.success(user) } else { _userUiState.success(null) } } private suspend fun getRememberUser() { val rememberUser = settingsPreferenceDataSource.getRememberUser().first() if (rememberUser.userName.isNotEmpty() && rememberUser.password.isNotEmpty()) { serverUrl.value = rememberUser.serverUrl userName.value = rememberUser.userName password.value = rememberUser.password rememberSession.value = true } } fun resetServerAvailabilityUiState() { _serverAvailabilityUiState.idle(true) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/PasswordTextField.kt ================================================ package com.desarrollodroide.pagekeeper.ui.login import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @Composable fun PasswordTextField( password: MutableState, passwordErrorState: MutableState ) { Column() { val passwordVisibility = remember { mutableStateOf(true) } OutlinedTextField( value = password.value, leadingIcon = { Icon(imageVector = Icons.Filled.Lock, contentDescription = null) }, onValueChange = { if (passwordErrorState.value) { passwordErrorState.value = false } password.value = it }, isError = passwordErrorState.value, modifier = Modifier.fillMaxWidth(), label = { Text(text = "Password") }, trailingIcon = { IconButton(onClick = { passwordVisibility.value = !passwordVisibility.value }) { Icon( imageVector = if (passwordVisibility.value) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = "visibility", tint = Color.Gray ) } }, visualTransformation = if (passwordVisibility.value) PasswordVisualTransformation() else VisualTransformation.None ) if (passwordErrorState.value) { Text( text = "Required", color = Color.Red, modifier = Modifier.align(Alignment.End) ) } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/RememberSessionSection.kt ================================================ package com.desarrollodroide.pagekeeper.ui.login import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Checkbox import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun RememberSessionSection( checked: MutableState, onCheckedChange: ((Boolean) -> Unit), ) { Row( modifier = Modifier .fillMaxWidth() .clickable { onCheckedChange(!checked.value) } .padding(horizontal = 10.dp), verticalAlignment = Alignment.CenterVertically ) { Checkbox( checked = checked.value, onCheckedChange = { onCheckedChange.invoke(it) } ) Text( text = "Remember", modifier = Modifier .padding(horizontal = 10.dp) ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/ServerUrlTextField.kt ================================================ package com.desarrollodroide.pagekeeper.ui.login import android.webkit.URLUtil import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Link import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.desarrollodroide.model.LivenessResponse import com.desarrollodroide.pagekeeper.ui.components.UiState @Composable fun ServerUrlTextField( modifier: Modifier, serverAvailabilityUiState: UiState, serverUrl: MutableState, serverErrorState: MutableState, serverVersion: String, resetServerAvailabilityState: () -> Unit, onClick: () -> Unit, isTestingServer: Boolean ) { val serverUrlAvailable = serverAvailabilityUiState.data?.ok == true var isFocused by remember { mutableStateOf(false) } Column( modifier = modifier ) { OutlinedTextField( value = serverUrl.value, leadingIcon = { Icon( imageVector = Icons.Filled.Link, contentDescription = null ) }, trailingIcon = { if (isTestingServer) { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp ) } else if (serverUrlAvailable) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = "visibility", ) } }, onValueChange = { serverErrorState.value = !URLUtil.isValidUrl(it) serverUrl.value = it if (serverUrlAvailable || serverAvailabilityUiState.error != null) { resetServerAvailabilityState() } }, isError = serverErrorState.value, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> if (isFocused && !focusState.isFocused && URLUtil.isValidUrl(serverUrl.value)) { onClick() } isFocused = focusState.isFocused }, label = { Text(text = "Server url") }, singleLine = true, maxLines = 1 ) AnimatedVisibility(visible = serverErrorState.value) { Text( modifier = Modifier.align(Alignment.End), color = Color.Red, text = "Invalid url" ) } AnimatedVisibility(visible = serverUrlAvailable && serverVersion.isNotEmpty()) { Text( modifier = Modifier.align(Alignment.Start), text = "Server v$serverVersion" ) } AnimatedVisibility(visible = serverAvailabilityUiState.error != null) { Text( modifier = Modifier.align(Alignment.End), color = Color.Red, text = serverAvailabilityUiState.error ?: "" ) } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/login/UserTextField.kt ================================================ package com.desarrollodroide.pagekeeper.ui.login import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @Composable fun UserTextField( user: MutableState, userErrorState: MutableState ) { Column { OutlinedTextField( value = user.value, leadingIcon = { Icon(imageVector = Icons.Filled.Person, contentDescription = null) }, onValueChange = { if (userErrorState.value) { userErrorState.value = false } user.value = it }, isError = userErrorState.value, modifier = Modifier.fillMaxWidth(), label = { Text(text = "UserName") }, ) if (userErrorState.value) { Text( modifier = Modifier.align(Alignment.End), color = Color.Red, text = "Invalid username" ) } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/readablecontent/ErrorView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.readablecontent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable fun ErrorView(errorMessage: String) { Box( modifier = Modifier .fillMaxSize() .padding(16.dp), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( imageVector = Icons.Default.Error, contentDescription = null, modifier = Modifier.size(64.dp) ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "Error", fontSize = 24.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(8.dp)) Text( text = errorMessage, fontSize = 16.sp, textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(16.dp)) } } } @Preview(showBackground = true) @Composable private fun ErrorViewPreview() { MaterialTheme { ErrorView(errorMessage = "Something went wrong. Please try again later.") } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/readablecontent/ReadableContentScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.readablecontent import android.content.Intent import android.os.Build import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import com.desarrollodroide.data.helpers.ThemeMode import com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog @RequiresApi(Build.VERSION_CODES.N) @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReadableContentScreen( readableContentViewModel: ReadableContentViewModel, onBack: () -> Unit, bookmarkUrl: String, bookmarkId: Int, openUrlInBrowser: (String) -> Unit, bookmarkDate: String, bookmarkTitle: String, isRtl: Boolean ) { BackHandler { onBack() } LaunchedEffect(Unit) { readableContentViewModel.loadInitialData() readableContentViewModel.getBookmarkReadableContent(bookmarkId = bookmarkId, bookmarkUrl = bookmarkUrl) } val themeMode by readableContentViewModel.themeMode.collectAsState() val isDarkTheme = when (themeMode) { ThemeMode.DARK -> true ThemeMode.LIGHT -> false ThemeMode.AUTO -> isSystemInDarkTheme() } val themeCss = if (isDarkTheme) DARK_THEME_CSS else LIGHT_THEME_CSS val directionCss = if (isRtl) RTL_CSS else LTR_CSS val readableContentState by readableContentViewModel.readableContentState.collectAsState() Scaffold( topBar = { CenterAlignedTopAppBar( title = { Text("Content", style = MaterialTheme.typography.titleLarge) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.background ) ) }, containerColor = MaterialTheme.colorScheme.background ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues).fillMaxWidth()) { if (readableContentState.isLoading) { InfiniteProgressDialog(onDismissRequest = {}) } else { LazyColumn(modifier = Modifier.fillMaxWidth()) { item { TopSection( title = bookmarkTitle, date = bookmarkDate, onClick = { openUrlInBrowser(bookmarkUrl) } ) } item { readableContentState.error?.let { error -> ErrorView(errorMessage = error) } ?: readableContentState.data?.let { readableMessage -> AndroidView(factory = { context -> WebView(context).apply { webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) val css = """ (function() { var style = document.createElement('style'); style.innerHTML = ` img { max-width: 100%; height: auto; } $directionCss $themeCss `; document.head.appendChild(style); })(); """.trimIndent() view?.evaluateJavascript(css, null) } override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { request?.url?.let { url -> val intent = Intent(Intent.ACTION_VIEW, url) context.startActivity(intent) return true } return false } } settings.javaScriptEnabled = true setBackgroundColor(if (isDarkTheme) 0xFF121212.toInt() else 0xFFFFFFFF.toInt()) loadDataWithBaseURL(null, readableMessage.html, "text/html", "UTF-8", null) } }) } } } } } } } private const val DARK_THEME_CSS = """ body { background-color: #121212; color: #ffffff; } a { color: #bb86fc; } """ private const val LIGHT_THEME_CSS = """ body { background-color: #ffffff; color: #000000; } a { color: #1a0dab; } """ private const val RTL_CSS = """ body { direction: rtl; text-align: right; } """ private const val LTR_CSS = """ body { direction: ltr; text-align: left; } """ ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/readablecontent/ReadableContentViewModel.kt ================================================ package com.desarrollodroide.pagekeeper.ui.readablecontent import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.desarrollodroide.common.result.Result import com.desarrollodroide.data.helpers.ThemeMode import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource import com.desarrollodroide.data.local.room.dao.BookmarkHtmlDao import com.desarrollodroide.data.local.room.dao.BookmarksDao import com.desarrollodroide.data.local.room.entity.BookmarkHtmlEntity import com.desarrollodroide.domain.usecase.GetBookmarkReadableContentUseCase import com.desarrollodroide.model.ReadableMessage import com.desarrollodroide.pagekeeper.ui.components.UiState import com.desarrollodroide.pagekeeper.ui.components.error import com.desarrollodroide.pagekeeper.ui.components.isLoading import com.desarrollodroide.pagekeeper.ui.components.success import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch class ReadableContentViewModel( private val settingsPreferenceDataSource: SettingsPreferenceDataSource, private val getBookmarkReadableContentUseCase: GetBookmarkReadableContentUseCase, private val bookmarksDao: BookmarksDao, private val bookmarkHtmlDao: BookmarkHtmlDao ) : ViewModel() { private var serverUrl = "" private var token = "" private val _readableContentState = MutableStateFlow(UiState(idle = true)) val readableContentState = _readableContentState.asStateFlow() val themeMode = MutableStateFlow(ThemeMode.AUTO) fun loadInitialData() { viewModelScope.launch { serverUrl = settingsPreferenceDataSource.getUrl() token = settingsPreferenceDataSource.getToken() themeMode.value = settingsPreferenceDataSource.getThemeMode() } } fun getBookmarkReadableContent( bookmarkId: Int, bookmarkUrl: String, ) { viewModelScope.launch { getBookmarkReadableContentUseCase.invoke( serverUrl = serverUrl, token = token, bookmarkId = bookmarkId ) .distinctUntilChanged() .collect() { result -> when (result) { is Result.Error -> { Log.v( "ReadableContent","Error getting bookmark readable content: ${result.error?.message}") getLocalHtmlContent(bookmarkId) } is Result.Loading -> { Log.v( "ReadableContent","Loading, getting bookmark readable content...") _readableContentState.isLoading(true) } is Result.Success -> { Log.v("ReadableContent", "Get bookmark readable content successfully.") result.data?.let { _readableContentState.success(it.message) saveHtmlContent( bookmarkId = bookmarkId, url = bookmarkUrl, html = it.message.html) } } else -> {} } } } } private fun saveHtmlContent(bookmarkId: Int, url: String, html: String) { viewModelScope.launch { val bookmarkHtml = BookmarkHtmlEntity(id = bookmarkId, url = url, readableContentHtml = html) bookmarkHtmlDao.insertOrUpdate(bookmarkHtml) } } private fun getLocalHtmlContent(bookmarkId: Int) { viewModelScope.launch { val bookmarkHtml = bookmarkHtmlDao.getBookmarkHtml(bookmarkId) if (bookmarkHtml != null) { val readableMessage = ReadableMessage(content = "", html = bookmarkHtml.readableContentHtml) _readableContentState.success(readableMessage) } else { _readableContentState.error(errorMessage = "No local content available") } } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/readablecontent/TopSection.kt ================================================ package com.desarrollodroide.pagekeeper.ui.readablecontent import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable fun TopSection( title: String, date: String, onClick: () -> Unit ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) ) { Text( text = date, fontSize = 14.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(8.dp)) Text( text = title, fontSize = 24.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { Button( onClick = onClick, ) { Text("View Original", color = Color.White) } } HorizontalDivider(modifier = Modifier.padding(vertical = 10.dp)) } } @RequiresApi(Build.VERSION_CODES.N) @Preview(showBackground = true) @Composable fun TopSectionPreview() { MaterialTheme { TopSection( title = "A Developer’s Roadmap to Predictive Back (Views)", date = "Added 27 May 2024, 16:41:09", onClick = {} ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/AccountSection.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Feedback import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.Security import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun AccountSection( serverUrl: String, onLogout: () -> Unit, onNavigateToTermsOfUse: () -> Unit, onNavigateToPrivacyPolicy: () -> Unit, onNavigateToSeverSettings: () -> Unit, onSendFeedbackEmail: () -> Unit, onNavigateToSourceCode: () -> Unit ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(top = 12.dp, bottom = 5.dp) ) { Text( text = "Account", style = MaterialTheme.typography.titleSmall ) Spacer(modifier = Modifier.height(5.dp)) ClickableOption( Item( title = "Logout", subtitle = serverUrl, icon = Icons.AutoMirrored.Filled.Logout, onClick = onLogout ), ) ClickableOption( Item( title = "Server Settings Guide", icon = Icons.Filled.Dns, onClick = onNavigateToSeverSettings ) ) ClickableOption( item = Item( title = "Source Code", icon = Icons.Filled.Code, onClick = onNavigateToSourceCode ) ) ClickableOption( Item( title = "Send Feedback", icon = Icons.Filled.Feedback, onClick = onSendFeedbackEmail ) ) ClickableOption( Item( title = "Terms of Use", icon = Icons.Filled.Gavel, onClick = onNavigateToTermsOfUse ) ) ClickableOption( Item( title = "Privacy policy", icon = Icons.Filled.Security, onClick = onNavigateToPrivacyPolicy ) ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/ClickableOption.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @Composable fun ClickableOption( item: Item, ) { Row( modifier = Modifier .fillMaxWidth() .clickable { item.onClick() }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start ) { Icon(item.icon, contentDescription = item.title) Column( modifier = Modifier .weight(1f) .padding(vertical = 10.dp) ) { Text( modifier = Modifier .padding(horizontal = 10.dp), text = item.title ) if (item.subtitle.isNotEmpty()){ Text( modifier = Modifier .padding(horizontal = 10.dp), text = item.subtitle, style = MaterialTheme.typography.labelSmall) } } } } @Composable fun ClickableOption( title: String, icon: ImageVector, subtitle: String = "", onClick: () -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start ) { Icon(icon, contentDescription = title) Column( modifier = Modifier .weight(1f) .padding(vertical = 10.dp) ) { Text( modifier = Modifier .padding(horizontal = 10.dp), text = title ) if (subtitle.isNotEmpty()) { Text( modifier = Modifier .padding(horizontal = 10.dp), text = subtitle, style = MaterialTheme.typography.labelSmall ) } } } } @Preview(showBackground = true) @Composable fun ClickableOptionPreviewWithSubtitle() { MaterialTheme { ClickableOption( item = Item( title = "Settings", subtitle = "Set your preferences", icon = Icons.Default.Settings, onClick = {} ) ) } } @Preview(showBackground = true) @Composable fun ClickableOptionPreviewWithoutSubtitle() { MaterialTheme { ClickableOption( item = Item( title = "Profile", icon = Icons.Default.Person, onClick = {} ) ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/DataSection.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow @Composable fun DataSection( cacheSize: StateFlow, onClearCache: () -> Unit ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(top = 12.dp, bottom = 5.dp) ) { Text(text = "Data", style = MaterialTheme.typography.titleSmall) Spacer(modifier = Modifier.height(8.dp)) val currentCacheSize by cacheSize.collectAsState() ClickableOption( title = "Clear Image Cache", icon = Icons.Default.Delete, subtitle = "Cache size: $currentCacheSize", onClick = onClearCache ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/DebugSection.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun DebugSection( onNavigateToLogs: () -> Unit, onViewLastCrash: () -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(top = 12.dp, bottom = 5.dp) ) { Text(text = "Debug", style = MaterialTheme.typography.titleSmall) Spacer(modifier = Modifier.height(8.dp)) ClickableOption( title = "View network logs", icon = Icons.Default.Code, onClick = onNavigateToLogs ) ClickableOption( title = "View last crash", icon = Icons.Default.BugReport, onClick = onViewLastCrash ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/DefaultsSection.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.BookmarkAdd import androidx.compose.material.icons.filled.Public import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun DefaultsSection( makeArchivePublic: Boolean, onMakeArchivePublicChanged: (Boolean) -> Unit, createEbook: Boolean, onCreateEbookChanged: (Boolean) -> Unit, createArchive: Boolean, onCreateArchiveChanged: (Boolean) -> Unit, autoAddBookmark: Boolean, onAutoAddBookmarkChanged: (Boolean) -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(top = 12.dp, bottom = 5.dp) ) { Text(text = "Defaults", style = MaterialTheme.typography.titleSmall) Spacer(modifier = Modifier.height(5.dp)) SwitchOption( title = "Make bookmark publicly available", icon = Icons.Filled.Public, checked = makeArchivePublic, onCheckedChange = onMakeArchivePublicChanged ) SwitchOption( title = "Create archive", icon = Icons.Filled.Archive, checked = createArchive, onCheckedChange = onCreateArchiveChanged ) SwitchOption( title = "Create Ebook", icon = Icons.Filled.Book, checked = createEbook, onCheckedChange = onCreateEbookChanged ) SwitchOption( title = "Add bookmark automatically", icon = Icons.Filled.BookmarkAdd, checked = autoAddBookmark, onCheckedChange = onAutoAddBookmarkChanged ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/FeedSection.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Sell import androidx.compose.material.icons.filled.ViewCompactAlt import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.desarrollodroide.model.Tag import com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog import com.desarrollodroide.pagekeeper.ui.components.UiState import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedSection( compactView: Boolean, onCompactViewChanged: (Boolean) -> Unit, onClickHideDialogOption: () -> Unit, onHideTagChanged: (Tag?) -> Unit, tagsUiState: UiState>, hideTag: Tag?, ) { val isCategoriesVisible = remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(top = 12.dp, bottom = 5.dp) ) { Text( text = "Bookmark list", style = MaterialTheme.typography.titleSmall ) Spacer(modifier = Modifier.height(5.dp)) SwitchOption( title = "Compact view", icon = Icons.Filled.ViewCompactAlt, checked = compactView, onCheckedChange = onCompactViewChanged ) ClickableOption( title = "Hide tag", icon = Icons.Filled.Sell, subtitle = hideTag?.name ?: "None", onClick = onClickHideDialogOption ) if (tagsUiState.isLoading) { InfiniteProgressDialog(onDismissRequest = {}) } val sheetStateCategories = rememberModalBottomSheetState( skipPartiallyExpanded = false ) LaunchedEffect(tagsUiState) { if (tagsUiState.data != null) { isCategoriesVisible.value = true } } if (isCategoriesVisible.value) { val scope = rememberCoroutineScope() ModalBottomSheet( shape = BottomSheetDefaults.ExpandedShape, onDismissRequest = { isCategoriesVisible.value = false }, sheetState = sheetStateCategories, ) { val categories: List = tagsUiState.data ?: emptyList() HideCategoryOptionView( hideTag = hideTag, uniqueCategories = categories, onApply = { selectedTag -> scope.launch { sheetStateCategories.hide() isCategoriesVisible.value = false onHideTagChanged(selectedTag) } }, ) } } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/HideCategoryOptionView.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Sell import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.desarrollodroide.model.Tag import com.desarrollodroide.pagekeeper.ui.components.Categories import com.desarrollodroide.pagekeeper.ui.components.CategoriesType @Composable fun HideCategoryOptionView( onApply: (Tag?) -> Unit, uniqueCategories: List, hideTag: Tag? ) { var selectedTag by remember { mutableStateOf(hideTag) } Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text("Select category to hide", style = MaterialTheme.typography.headlineSmall) Spacer(Modifier.height(8.dp)) if (uniqueCategories.isEmpty()) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .padding(vertical = 16.dp) .fillMaxWidth() ) { Icon( imageVector = Icons.Outlined.Sell, contentDescription = "No categories available", modifier = Modifier.size(24.dp) ) Spacer(Modifier.width(8.dp)) Text( "No categories available", style = MaterialTheme.typography.bodyLarge ) } } else { Categories( categoriesType = CategoriesType.SELECTABLES, showCategories = true, uniqueCategories = uniqueCategories, selectedTags = listOfNotNull(selectedTag), onCategorySelected = { tag -> selectedTag = tag }, onCategoryDeselected = { selectedTag = null }, singleSelection = true ) } Spacer(Modifier.height(24.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Button( onClick = { selectedTag = null onApply(null) }, modifier = Modifier.weight(1f) ) { Text("None") } Spacer(Modifier.width(8.dp)) Button( onClick = { onApply(selectedTag) }, modifier = Modifier.weight(1f), enabled = uniqueCategories.isNotEmpty() ) { Text("Apply") } } Spacer(modifier = Modifier.height(20.dp)) } } @Preview(showBackground = true) @Composable fun SortAndFilterScreenPreview() { val regionOptions = listOf( Tag(id = 1, name = "Northern Europe"), Tag(id = 2, name = "Western Europe"), Tag(id = 3, name = "Southern Europe"), Tag(id = 4, name = "Southeast Europe"), Tag(id = 5, name = "Central Europe"), Tag(id = 6, name = "Eastern Europe") ) MaterialTheme { HideCategoryOptionView( onApply = {}, uniqueCategories = regionOptions, hideTag = null ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/LinkableText.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.sp import com.desarrollodroide.pagekeeper.extensions.openUrlInBrowser @Composable fun LinkableText( text: String, url: String ) { val annotatedText = buildAnnotatedString { pushStyle( style = SpanStyle( color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline, fontSize = 18.sp, fontWeight = FontWeight.Bold ) ) append(text) pop() } val context = LocalContext.current ClickableText( text = annotatedText, onClick = { context.openUrlInBrowser(url) } ) } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/PrivacyPolicyScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyPolicyScreen( onBack: () -> Unit ) { BackHandler { onBack() } Scaffold( topBar = { CenterAlignedTopAppBar( title = { Text("Privacy policy") }, navigationIcon = { IconButton(onClick = onBack) { Icon( Icons.Filled.ArrowBack, contentDescription = "Back" ) } }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.background ) ) }, containerColor = MaterialTheme.colorScheme.background ) { padding -> Box( modifier = Modifier .fillMaxSize() .padding(padding) ) { Column( modifier = Modifier .padding(start = 16.dp, end = 16.dp, top = 16.dp) .verticalScroll(rememberScrollState()) ) { val annotatedText = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Privacy Policy of Shiori\n\n") } append("Effective as of [Insert Date Here].\n\n") append("This Privacy Policy outlines our policies and procedures on the collection, use, and disclosure of personal information. Shiori values your privacy and is committed to protecting it through our compliance with this policy. Our app is designed not to collect or store any personal data from our users, ensuring your privacy and security.\n\n") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Data Collection and Use\n\n") } append("As a commitment to your privacy, Shiori does not gather, store, or process any personal data. This includes but is not limited to personal identifiers, contact details, usage data, and location information.\n\n") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Third-Party Services\n\n") } append("Shiori does not share any personal data with third parties as no personal data is collected. However, users should be aware that third-party services used by the app may collect their own data.\n\n") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Security\n\n") } append("The security of your personal information is important to us. As we do not collect personal data, there is no risk of your personal information being accessed from our app.\n\n") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Changes to This Privacy Policy\n\n") } append("We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the 'Effective as of' date at the top of this policy. We may also provide notice to you in other ways in our discretion, such as through contact information you have provided.\n\n") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Contact Us\n\n") } append("For questions or concerns about this Privacy Policy, please contact us via email at desarrollodroide@gmail.com or through other communication channels as provided in our app.\n") } Text( text = annotatedText, style = MaterialTheme.typography.bodyLarge, ) } } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/SettingsScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Smartphone import androidx.compose.material.icons.filled.Storage import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.desarrollodroide.data.helpers.SHIORI_GITHUB_URL import com.desarrollodroide.data.helpers.ThemeMode import com.desarrollodroide.model.Tag import com.desarrollodroide.pagekeeper.extensions.openUrlInBrowser import com.desarrollodroide.pagekeeper.ui.components.ErrorDialog import com.desarrollodroide.pagekeeper.ui.components.InfiniteProgressDialog import com.desarrollodroide.pagekeeper.ui.components.UiState import kotlinx.coroutines.flow.MutableStateFlow import com.desarrollodroide.pagekeeper.BuildConfig import com.desarrollodroide.pagekeeper.extensions.sendFeedbackEmail import kotlinx.coroutines.flow.StateFlow @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( settingsViewModel: SettingsViewModel, onNavigateToTermsOfUse: () -> Unit, onNavigateToPrivacyPolicy: () -> Unit, onNavigateToSourceCode: () -> Unit, onNavigateToLogs: () -> Unit, onViewLastCrash: () -> Unit, goToLogin: () -> Unit, onBack: () -> Unit ) { BackHandler { onBack() } val settingsUiState by settingsViewModel.settingsUiState.collectAsStateWithLifecycle() val tagsUiState by settingsViewModel.tagsState.collectAsStateWithLifecycle() val tagToHide by settingsViewModel.tagToHide.collectAsStateWithLifecycle() val compactView by settingsViewModel.compactView.collectAsStateWithLifecycle() val makeArchivePublic by settingsViewModel.makeArchivePublic.collectAsStateWithLifecycle() val createEbook by settingsViewModel.createEbook.collectAsStateWithLifecycle() val autoAddBookmark by settingsViewModel.autoAddBookmark.collectAsStateWithLifecycle() val createArchive by settingsViewModel.createArchive.collectAsStateWithLifecycle() Scaffold( topBar = { CenterAlignedTopAppBar( title = { Text("Settings", style = MaterialTheme.typography.titleLarge) }, navigationIcon = { IconButton(onClick = onBack) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back" ) } }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.background ) ) }, containerColor = MaterialTheme.colorScheme.background ) { paddingValues -> Box( modifier = Modifier .padding(paddingValues) ) { SettingsContent( settingsUiState = settingsUiState, tagsUiState = tagsUiState, onLogout = { settingsViewModel.logout() }, goToLogin = { settingsViewModel.clearImageCache() goToLogin.invoke() }, themeMode = settingsViewModel.themeMode, makeArchivePublic = makeArchivePublic, onMakeArchivePublicChanged = { isPublic -> settingsViewModel.setMakeArchivePublic(isPublic) }, createEbook = createEbook, onCreateEbookChanged = { isEbook -> settingsViewModel.setCreateEbook(isEbook) }, createArchive = createArchive, onCreateArchiveChanged = { isArchive -> settingsViewModel.setCreateArchive(isArchive) }, compactView = compactView, onCompactViewChanged = { isCompact -> settingsViewModel.setCompactView(isCompact) }, autoAddBookmark = autoAddBookmark, onAutoAddBookmarkChanged = { isAuto -> settingsViewModel.setAutoAddBookmark(isAuto) }, onNavigateToTermsOfUse = onNavigateToTermsOfUse, onNavigateToPrivacyPolicy = onNavigateToPrivacyPolicy, onNavigateToSourceCode = onNavigateToSourceCode, onNavigateToLogs = onNavigateToLogs, onViewLastCrash = onViewLastCrash, useDynamicColors = settingsViewModel.useDynamicColors, onClickHideDialogOption = settingsViewModel::getTags, onHideTagChanged = settingsViewModel::setHideTag, hideTag = tagToHide, cacheSize = settingsViewModel.cacheSize, onClearCache = settingsViewModel::clearImageCache, serverVersion = settingsViewModel.getServerVersion(), serverUrl = settingsViewModel.getServerUrl() ) } } } @Composable fun SettingsContent( settingsUiState: UiState, makeArchivePublic: Boolean, onMakeArchivePublicChanged: (Boolean) -> Unit, createEbook: Boolean, onCreateEbookChanged: (Boolean) -> Unit, createArchive: Boolean, onCreateArchiveChanged: (Boolean) -> Unit, autoAddBookmark: Boolean, onAutoAddBookmarkChanged: (Boolean) -> Unit, compactView: Boolean, onCompactViewChanged: (Boolean) -> Unit, onLogout: () -> Unit, onNavigateToSourceCode: () -> Unit, onNavigateToTermsOfUse: () -> Unit, onNavigateToPrivacyPolicy: () -> Unit, onNavigateToLogs: () -> Unit, onViewLastCrash: () -> Unit, themeMode: MutableStateFlow, goToLogin: () -> Unit, useDynamicColors: MutableStateFlow, tagsUiState: UiState>, onClickHideDialogOption: () -> Unit, onHideTagChanged: (Tag?) -> Unit, hideTag: Tag?, cacheSize: StateFlow, onClearCache: () -> Unit, serverVersion: String, serverUrl: String, ) { val context = LocalContext.current if (settingsUiState.isLoading) { InfiniteProgressDialog(onDismissRequest = {}) Log.v("SettingsContent!!", "settingsUiState.isLoading") } if (!settingsUiState.error.isNullOrEmpty()) { ErrorDialog( title = "Error", content = settingsUiState.error, openDialog = remember { mutableStateOf(true) }, onConfirm = { goToLogin() } ) Log.v("SettingsContent!!", settingsUiState.error) } else if (settingsUiState.data == null) { Log.v("SettingsContent!!", "settingsUiState.data is null") } else { Log.v("SettingsContent!!", "settingsUiState.data is not null") LaunchedEffect(Unit) { goToLogin() } } LazyColumn( modifier = Modifier.padding(horizontal = 16.dp) ) { item { Spacer(modifier = Modifier.height(8.dp)) VisualSection( themeMode = themeMode, dynamicColors = useDynamicColors ) HorizontalDivider() Spacer(modifier = Modifier.height(18.dp)) FeedSection( compactView = compactView, onCompactViewChanged = onCompactViewChanged, tagsUiState = tagsUiState, onHideTagChanged = onHideTagChanged, onClickHideDialogOption = onClickHideDialogOption, hideTag = hideTag ) HorizontalDivider() Spacer(modifier = Modifier.height(18.dp)) DefaultsSection( makeArchivePublic = makeArchivePublic, onMakeArchivePublicChanged = onMakeArchivePublicChanged, createEbook = createEbook, onCreateEbookChanged = onCreateEbookChanged, createArchive = createArchive, onCreateArchiveChanged = onCreateArchiveChanged, autoAddBookmark = autoAddBookmark, onAutoAddBookmarkChanged = onAutoAddBookmarkChanged ) HorizontalDivider() Spacer(modifier = Modifier.height(18.dp)) DataSection( cacheSize = cacheSize, onClearCache = onClearCache ) if (BuildConfig.FLAVOR == "staging") { HorizontalDivider() Spacer(modifier = Modifier.height(18.dp)) DebugSection ( onNavigateToLogs = onNavigateToLogs, onViewLastCrash = onViewLastCrash ) } HorizontalDivider() Spacer(modifier = Modifier.height(18.dp)) AccountSection( serverUrl = serverUrl, onLogout = onLogout, onNavigateToTermsOfUse = onNavigateToTermsOfUse, onNavigateToPrivacyPolicy = onNavigateToPrivacyPolicy, onNavigateToSeverSettings = { context.openUrlInBrowser(SHIORI_GITHUB_URL) }, onSendFeedbackEmail = { context.sendFeedbackEmail() }, onNavigateToSourceCode = onNavigateToSourceCode ) Spacer(modifier = Modifier.height(18.dp)) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp), horizontalArrangement = Arrangement.SpaceBetween ) { if (serverVersion.isNotEmpty()) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start ) { Icon( imageVector = Icons.Default.Storage, contentDescription = "Server version", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.width(4.dp)) Text( text = "Server v${serverVersion}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { Icon( imageVector = Icons.Default.Smartphone, contentDescription = "App version", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.width(4.dp)) Text( text = "App v${BuildConfig.VERSION_NAME}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } Spacer(modifier = Modifier.height(18.dp)) } } } @Composable private fun HorizontalDivider(){ HorizontalDivider( modifier = Modifier .height(1.dp) .padding(horizontal = 6.dp,), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) ) } data class Item( val title: String, val icon: ImageVector, val subtitle: String = "", val onClick: () -> Unit = {}, val switchState: MutableStateFlow = MutableStateFlow(false) ) @Preview(showBackground = true) @Composable fun SettingsScreenPreview() { SettingsContent( settingsUiState = UiState(isLoading = false), makeArchivePublic = false, onMakeArchivePublicChanged = {}, createEbook = false, onCreateEbookChanged = {}, createArchive = false, onCreateArchiveChanged = {}, autoAddBookmark = false, onAutoAddBookmarkChanged = { }, compactView = false, onCompactViewChanged = {}, onLogout = {}, onNavigateToSourceCode = {}, onNavigateToTermsOfUse = {}, onNavigateToPrivacyPolicy = {}, onNavigateToLogs = {}, onViewLastCrash = {}, themeMode = remember { MutableStateFlow(ThemeMode.AUTO)}, goToLogin = {}, useDynamicColors = remember { MutableStateFlow(false) }, tagsUiState = UiState(isLoading = false), onClickHideDialogOption = {}, onHideTagChanged = {}, hideTag = null, cacheSize = MutableStateFlow("Calculating..."), onClearCache = {}, serverVersion = "1.0.0", serverUrl = "192.168.1.66:8888" ) } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/SettingsSectionState.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import com.desarrollodroide.data.helpers.ThemeMode import com.desarrollodroide.model.Tag import com.desarrollodroide.pagekeeper.ui.components.UiState sealed class SettingsSectionState { /** * Represents the visual settings section. */ data class Visual( val themeMode: ThemeMode, val useDynamicColors: Boolean ) : SettingsSectionState() /** * Represents the feed settings section. */ data class Feed( val compactView: Boolean, val hideTag: Tag?, val tagsUiState: UiState> ) : SettingsSectionState() /** * Represents the defaults settings section. */ data class Defaults( val makeArchivePublic: Boolean, val createEbook: Boolean, val createArchive: Boolean, val autoAddBookmark: Boolean ) : SettingsSectionState() /** * Represents the data settings section. */ data class Data( val cacheSize: String ) : SettingsSectionState() /** * Represents an error state within the settings. */ data class Error(val message: String) : SettingsSectionState() /** * Represents a loading state within the settings. */ object Loading : SettingsSectionState() } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/SettingsViewModel.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import coil.ImageLoader import coil.annotation.ExperimentalCoilApi import com.desarrollodroide.pagekeeper.helpers.ThemeManager import com.desarrollodroide.pagekeeper.ui.components.UiState import com.desarrollodroide.pagekeeper.ui.components.error import com.desarrollodroide.pagekeeper.ui.components.isLoading import com.desarrollodroide.pagekeeper.ui.components.success import com.desarrollodroide.common.result.Result import com.desarrollodroide.data.helpers.ThemeMode import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource import com.desarrollodroide.data.repository.BookmarksRepository import com.desarrollodroide.domain.usecase.GetTagsUseCase import com.desarrollodroide.domain.usecase.SendLogoutUseCase import com.desarrollodroide.model.Tag import com.desarrollodroide.pagekeeper.extensions.bytesToDisplaySize import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class SettingsViewModel( private val sendLogoutUseCase: SendLogoutUseCase, private val bookmarksRepository: BookmarksRepository, private val settingsPreferenceDataSource: SettingsPreferenceDataSource, private val themeManager: ThemeManager, private val getTagsUseCase: GetTagsUseCase, private val imageLoader: ImageLoader, ) : ViewModel() { private val _settingsUiState = MutableStateFlow(UiState(isLoading = false)) val settingsUiState = _settingsUiState.asStateFlow() private val _tagsState = MutableStateFlow(UiState>(idle = true)) val tagsState = _tagsState.asStateFlow() private val _cacheSize = MutableStateFlow("Calculating...") val cacheSize: StateFlow = _cacheSize.asStateFlow() val useDynamicColors = MutableStateFlow(false) val themeMode = MutableStateFlow(ThemeMode.AUTO) private var _token = "" private var _serverVersion = "" private var _serverUrl: String = "" val compactView: StateFlow = settingsPreferenceDataSource.compactViewFlow .stateIn(viewModelScope, SharingStarted.Eagerly, false) val makeArchivePublic: StateFlow = settingsPreferenceDataSource.makeArchivePublicFlow .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val createEbook: StateFlow = settingsPreferenceDataSource.createEbookFlow .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val autoAddBookmark: StateFlow = settingsPreferenceDataSource.autoAddBookmarkFlow .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val createArchive: StateFlow = settingsPreferenceDataSource.createArchiveFlow .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val tagToHide: StateFlow = settingsPreferenceDataSource.hideTagFlow .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) fun setAutoAddBookmark(value: Boolean) { viewModelScope.launch { settingsPreferenceDataSource.setAutoAddBookmark(value) } } fun setCompactView(isCompact: Boolean) { viewModelScope.launch { settingsPreferenceDataSource.setCompactView(isCompact) } } fun setMakeArchivePublic(isPublic: Boolean) { viewModelScope.launch { settingsPreferenceDataSource.setMakeArchivePublic(isPublic) } } fun setCreateEbook(ebook: Boolean) { viewModelScope.launch { settingsPreferenceDataSource.setCreateEbook(ebook) } } fun setCreateArchive(archive: Boolean) { viewModelScope.launch { settingsPreferenceDataSource.setCreateArchive(archive) } } fun setHideTag(tag: Tag?) { viewModelScope.launch { settingsPreferenceDataSource.setHideTag(tag) } } init { loadSettings() observeDefaultsSettings() updateCacheSize() } fun logout() { viewModelScope.launch { sendLogoutUseCase( serverUrl = settingsPreferenceDataSource.getUrl(), xSession = settingsPreferenceDataSource.getSession() ).collect { result -> when (result) { is Result.Error -> { _settingsUiState.error(errorMessage = result.error?.throwable?.message?: "") } is Result.Loading -> { _settingsUiState.isLoading(true) } is Result.Success -> { _settingsUiState.success(result.data) } } } } } private fun loadSettings() { viewModelScope.launch { useDynamicColors.value = settingsPreferenceDataSource.getUseDynamicColors() themeMode.value = settingsPreferenceDataSource.getThemeMode() _token = settingsPreferenceDataSource.getToken() _serverVersion = settingsPreferenceDataSource.getServerVersion() _serverUrl = settingsPreferenceDataSource.getUrl() } } fun getTags() { viewModelScope.launch { getTagsUseCase.invoke( serverUrl = settingsPreferenceDataSource.getUrl(), token = _token, ) .distinctUntilChanged() .collect { result -> when (result) { is Result.Error -> { Log.v("FeedViewModel", "Error getting tags: ${result.error?.message}") } is Result.Loading -> { Log.v("FeedViewModel", "Loading, updating tags from cache...") _tagsState.isLoading(true) } is Result.Success -> { Log.v("FeedViewModel", "Tags loaded successfully.") _tagsState.success(result.data) } } } } } @OptIn(ExperimentalCoilApi::class) private fun updateCacheSize() { viewModelScope.launch { val size = imageLoader.diskCache?.size ?: 0L _cacheSize.value = size.bytesToDisplaySize() } } @OptIn(ExperimentalCoilApi::class) fun clearImageCache() { viewModelScope.launch { imageLoader.memoryCache?.clear() imageLoader.diskCache?.clear() updateCacheSize() } } private fun observeDefaultsSettings() { viewModelScope.launch { useDynamicColors.collect { newValue -> settingsPreferenceDataSource.setUseDynamicColors(newValue) themeManager.useDynamicColors.value = newValue } } viewModelScope.launch { themeMode.collect { newValue -> settingsPreferenceDataSource.setTheme(newValue) themeManager.themeMode.value = newValue } } } fun getServerUrl(): String = _serverUrl fun getServerVersion(): String = _serverVersion } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/SwitchOption.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.MutableStateFlow @Composable fun SwitchOption( item: Item, switchState: MutableStateFlow, ) { val switchValue by switchState.collectAsState() Row( modifier = Modifier .fillMaxWidth() .clickable { switchState.value = !switchValue }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start ) { Icon(item.icon, contentDescription = item.title) Spacer(modifier = Modifier.width(12.dp)) Text(text = item.title, modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.width(5.dp)) Switch( checked = switchValue, onCheckedChange = { newValue -> switchState.value = newValue } ) } } @Composable fun SwitchOption( title: String, icon: ImageVector, checked: Boolean, onCheckedChange: (Boolean) -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .clickable { onCheckedChange(!checked) } // Permite hacer clic en cualquier parte de la fila .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = icon, contentDescription = title ) Spacer(modifier = Modifier.width(16.dp)) Text( text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f) ) Switch( checked = checked, onCheckedChange = onCheckedChange ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/TermsOfUseScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun TermsOfUseScreen( onBack: () -> Unit ) { BackHandler { onBack() } Scaffold( topBar = { CenterAlignedTopAppBar( title = { Text("Terms of Use") }, navigationIcon = { IconButton(onClick = onBack) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back" ) } }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.background ) ) }, containerColor = MaterialTheme.colorScheme.background ) { padding -> Box( modifier = Modifier .fillMaxSize() .padding(padding) ) { Column( modifier = Modifier .padding(start = 16.dp, end = 16.dp, top = 16.dp) .verticalScroll(rememberScrollState()) ) { val termsText = buildAnnotatedString { append("1. ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Acceptance of Terms\n\n") } append("By accessing and using Shiori, you agree to be bound by these Terms of Use.\n\n") append("2. ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("License for the App\n\n") } append("Shiori is provided under the Apache 2.0 License, allowing personal and commercial use, redistribution, and modification under the terms specified in the LICENSE file included with the source code.\n\n") append("3. ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Disclaimer\n\n") } append("Shiori is provided 'as is', without any warranties, expressed or implied, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose.\n\n") append("4. ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Limitations of Liability\n\n") } append("In no event shall Shiori, or its contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.\n\n") append("5. ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Modifications to Terms\n\n") } append("We may revise these terms of use for Shiori at any time without notice. By using this app, you are agreeing to be bound by the then current version of these terms of use.\n\n") append("6. ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append("Governing Law\n\n") } append("Any claim relating to Shiori shall be governed by the laws of the app owner's jurisdiction without regard to its conflict of law provisions.\n") } Text( text = termsText, style = MaterialTheme.typography.bodyLarge, ) } } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/VisualSection.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.FormatColorFill import androidx.compose.material.icons.filled.HdrAuto import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.Palette import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.desarrollodroide.data.helpers.ThemeMode import kotlinx.coroutines.flow.MutableStateFlow @Composable fun VisualSection( themeMode: MutableStateFlow, dynamicColors: MutableStateFlow, ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(top = 12.dp, bottom = 5.dp) ) { Text(text = "Visual", style = MaterialTheme.typography.titleSmall) Spacer(modifier = Modifier.height(5.dp)) ThemeOption( item = Item("Theme", Icons.Filled.Palette, onClick = {}), initialThemeMode = themeMode ) val dynamicColorItem = Item( title = "Use dynamic colors", icon = Icons.Filled.FormatColorFill, switchState = dynamicColors ) SwitchOption( item = dynamicColorItem, switchState = dynamicColors ) } } @Composable fun ThemeOption( item: Item, initialThemeMode: MutableStateFlow, ) { val themeMode by initialThemeMode.collectAsState() Row( modifier = Modifier .fillMaxWidth() .clickable { val newMode = when (themeMode) { ThemeMode.DARK -> ThemeMode.LIGHT ThemeMode.LIGHT -> ThemeMode.AUTO ThemeMode.AUTO -> ThemeMode.DARK } initialThemeMode.value = newMode item.onClick() }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start ) { Icon(item.icon, contentDescription = "Change theme") Spacer(modifier = Modifier.width(12.dp)) Text( text = item.title, modifier = Modifier .weight(1f) .padding(vertical = 10.dp) ) val themeIcon = when (themeMode) { ThemeMode.DARK -> Icons.Filled.DarkMode ThemeMode.LIGHT -> Icons.Filled.LightMode ThemeMode.AUTO -> Icons.Filled.HdrAuto } Icon(themeIcon, contentDescription = "Current theme icon") } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/crash/CrashLogScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings.crash import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.androidx.compose.getViewModel import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.Alignment @OptIn(ExperimentalMaterial3Api::class) @Composable fun CrashLogScreen( onBack: () -> Unit, onShare:(String) -> Unit ) { val viewModel: CrashLogViewModel = getViewModel() val crashLog by viewModel.crashLog.collectAsStateWithLifecycle() BackHandler { onBack() } Scaffold( topBar = { CenterAlignedTopAppBar( title = { Text("Last Crash Log") }, navigationIcon = { IconButton(onClick = onBack) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back" ) } }, actions = { IconButton(onClick = { viewModel.clearCrashLog() }) { Icon( Icons.Default.Delete, contentDescription = "Clear crash log" ) } IconButton(onClick = { onShare.invoke(viewModel.shareCrashLog()) }) { Icon( Icons.Default.Share, contentDescription = "Share crash log" ) } }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.background ) ) }, containerColor = MaterialTheme.colorScheme.background ) { padding -> Box( modifier = Modifier .fillMaxSize() .padding(padding) ) { if (crashLog == null || crashLog?.isEmpty() == true) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = "No crash log available", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } else { LazyColumn( modifier = Modifier.padding(16.dp) ) { item { CrashLogContent(crashLog = crashLog ?: "") } } } } } } @Composable fun CrashLogContent( crashLog: String ) { Column( modifier = Modifier .fillMaxWidth() .background( MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(8.dp) ) .padding(8.dp) ) { Text( text = crashLog, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/crash/CrashLogViewModel.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings.crash import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.desarrollodroide.data.local.preferences.SettingsPreferenceDataSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class CrashLogViewModel( private val settingsPreferenceDataSource: SettingsPreferenceDataSource, ) : ViewModel() { private val _crashLog = MutableStateFlow(null) val crashLog = _crashLog.asStateFlow() init { loadLastCrash() } private fun loadLastCrash() { viewModelScope.launch { _crashLog.value = settingsPreferenceDataSource.getLastCrashLog() } } fun clearCrashLog() { viewModelScope.launch { settingsPreferenceDataSource.clearLastCrashLog() _crashLog.value = "" } } fun shareCrashLog(): String = crashLog.value ?: "" } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/logcat/NetworkLogScreen.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings.logcat import androidx.activity.compose.BackHandler import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.desarrollodroide.common.result.NetworkLogEntry import org.koin.androidx.compose.getViewModel import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.foundation.lazy.items import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.style.TextOverflow @OptIn(ExperimentalMaterial3Api::class) @Composable fun NetworkLogScreen( onBack: () -> Unit, onShare:(String) -> Unit ) { val viewModel: NetworkLogViewModel = getViewModel() val logs by viewModel.logs.collectAsStateWithLifecycle() BackHandler { onBack() } Scaffold( topBar = { CenterAlignedTopAppBar( title = { Text("Network Logs") }, navigationIcon = { IconButton(onClick = onBack) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back" ) } }, actions = { IconButton(onClick = { viewModel.clearLogs() }) { Icon( Icons.Default.Delete, contentDescription = "Clear logs" ) } IconButton(onClick = { onShare.invoke(viewModel.shareLogs()) }) { Icon( Icons.Default.Share, contentDescription = "Share logs" ) } }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.background ) ) }, containerColor = MaterialTheme.colorScheme.background ) { padding -> Box( modifier = Modifier .fillMaxSize() .padding(padding) ) { LazyColumn( modifier = Modifier .padding(start = 16.dp, end = 16.dp, top = 16.dp), reverseLayout = true ) { items(logs) { logEntry -> NetworkLogEntryItem(logEntry) Spacer(modifier = Modifier.height(8.dp)) } } } } } @Composable fun NetworkLogEntryItem( logEntry: NetworkLogEntry ) { var isExpanded by remember { mutableStateOf(false) } var textLayoutResult by remember { mutableStateOf(null) } var hasMoreContent by remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxWidth() .background( MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(8.dp) ) .padding(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = logEntry.timestamp, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = when(logEntry.priority) { "I" -> "→" "S" -> "←" else -> "⚠" }, style = MaterialTheme.typography.labelSmall, color = when(logEntry.priority) { "I" -> MaterialTheme.colorScheme.primary "S" -> MaterialTheme.colorScheme.secondary else -> MaterialTheme.colorScheme.error } ) } Spacer(modifier = Modifier.height(4.dp)) Text( text = logEntry.url, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(2.dp)) Column { Text( text = logEntry.message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = if (isExpanded) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis, onTextLayout = { textLayoutResult = it if (!isExpanded) { hasMoreContent = it.hasVisualOverflow } }, modifier = Modifier.animateContentSize() ) if (!isExpanded && hasMoreContent) { Text( text = "View more...", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier .padding(top = 4.dp) .clickable { isExpanded = true } ) } else if (isExpanded && hasMoreContent) { Text( text = "Show less", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier .padding(top = 4.dp) .clickable { isExpanded = false } ) } } } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/settings/logcat/NetworkLogViewModel.kt ================================================ package com.desarrollodroide.pagekeeper.ui.settings.logcat import androidx.lifecycle.ViewModel import com.desarrollodroide.network.retrofit.NetworkLoggerInterceptor class NetworkLogViewModel( private val logger: NetworkLoggerInterceptor, ) : ViewModel() { val logs = logger.logs fun clearLogs() { logger.clearLogs() } fun shareLogs() = logs.value.joinToString("\n") { "${it.timestamp} ${it.priority}/${it.url}: ${it.message}" } } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/theme/Color.kt ================================================ package com.desarrollodroide.pagekeeper.ui.theme import androidx.compose.ui.graphics.Color val md_theme_light_primary = Color(0xFF006B5E) val md_theme_light_onPrimary = Color(0xFFFFFFFF) val md_theme_light_primaryContainer = Color(0xFF76F8E1) val md_theme_light_onPrimaryContainer = Color(0xFF00201B) val md_theme_light_secondary = Color(0xFF4A635E) val md_theme_light_onSecondary = Color(0xFFFFFFFF) val md_theme_light_secondaryContainer = Color(0xFFCDE8E1) val md_theme_light_onSecondaryContainer = Color(0xFF06201B) val md_theme_light_tertiary = Color(0xFF446279) val md_theme_light_onTertiary = Color(0xFFFFFFFF) val md_theme_light_tertiaryContainer = Color(0xFFCAE6FF) val md_theme_light_onTertiaryContainer = Color(0xFF001E30) val md_theme_light_error = Color(0xFFBA1A1A) val md_theme_light_errorContainer = Color(0xFFFFDAD6) val md_theme_light_onError = Color(0xFFFFFFFF) val md_theme_light_onErrorContainer = Color(0xFF410002) val md_theme_light_background = Color(0xFFFAFDFA) val md_theme_light_onBackground = Color(0xFF191C1B) val md_theme_light_surface = Color(0xFFFAFDFA) val md_theme_light_onSurface = Color(0xFF191C1B) val md_theme_light_surfaceVariant = Color(0xFFDAE5E1) val md_theme_light_onSurfaceVariant = Color(0xFF3F4946) val md_theme_light_outline = Color(0xFF6F7976) val md_theme_light_inverseOnSurface = Color(0xFFEFF1EF) val md_theme_light_inverseSurface = Color(0xFF2D3130) val md_theme_light_inversePrimary = Color(0xFF56DBC5) val md_theme_light_shadow = Color(0xFF000000) val md_theme_light_surfaceTint = Color(0xFF006B5E) val md_theme_light_outlineVariant = Color(0xFFBEC9C5) val md_theme_light_scrim = Color(0xFF000000) val md_theme_dark_primary = Color(0xFF56DBC5) val md_theme_dark_onPrimary = Color(0xFF003730) val md_theme_dark_primaryContainer = Color(0xFF005046) val md_theme_dark_onPrimaryContainer = Color(0xFF76F8E1) val md_theme_dark_secondary = Color(0xFFB1CCC5) val md_theme_dark_onSecondary = Color(0xFF1C3530) val md_theme_dark_secondaryContainer = Color(0xFF334B46) val md_theme_dark_onSecondaryContainer = Color(0xFFCDE8E1) val md_theme_dark_tertiary = Color(0xFFACCAE5) val md_theme_dark_onTertiary = Color(0xFF133348) val md_theme_dark_tertiaryContainer = Color(0xFF2C4A60) val md_theme_dark_onTertiaryContainer = Color(0xFFCAE6FF) val md_theme_dark_error = Color(0xFFFFB4AB) val md_theme_dark_errorContainer = Color(0xFF93000A) val md_theme_dark_onError = Color(0xFF690005) val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) val md_theme_dark_background = Color(0xFF191C1B) val md_theme_dark_onBackground = Color(0xFFE0E3E1) val md_theme_dark_surface = Color(0xFF191C1B) val md_theme_dark_onSurface = Color(0xFFE0E3E1) val md_theme_dark_surfaceVariant = Color(0xFF3F4946) val md_theme_dark_onSurfaceVariant = Color(0xFFBEC9C5) val md_theme_dark_outline = Color(0xFF899390) val md_theme_dark_inverseOnSurface = Color(0xFF191C1B) val md_theme_dark_inverseSurface = Color(0xFFE0E3E1) val md_theme_dark_inversePrimary = Color(0xFF006B5E) val md_theme_dark_shadow = Color(0xFF000000) val md_theme_dark_surfaceTint = Color(0xFF56DBC5) val md_theme_dark_outlineVariant = Color(0xFF3F4946) val md_theme_dark_scrim = Color(0xFF000000) val seed = Color(0xFF00584D) ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/theme/Shape.kt ================================================ package com.desarrollodroide.pagekeeper.ui.theme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp val shapes = Shapes( extraSmall = RoundedCornerShape(4.dp), small = RoundedCornerShape(8.dp), medium = RoundedCornerShape(16.dp), large = RoundedCornerShape(24.dp), extraLarge = RoundedCornerShape(32.dp) ) ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/theme/Theme.kt ================================================ package com.desarrollodroide.pagekeeper.ui.theme import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat private val lightColors = lightColorScheme( primary = md_theme_light_primary, onPrimary = md_theme_light_onPrimary, primaryContainer = md_theme_light_primaryContainer, onPrimaryContainer = md_theme_light_onPrimaryContainer, secondary = md_theme_light_secondary, onSecondary = md_theme_light_onSecondary, secondaryContainer = md_theme_light_secondaryContainer, onSecondaryContainer = md_theme_light_onSecondaryContainer, tertiary = md_theme_light_tertiary, onTertiary = md_theme_light_onTertiary, tertiaryContainer = md_theme_light_tertiaryContainer, onTertiaryContainer = md_theme_light_onTertiaryContainer, error = md_theme_light_error, errorContainer = md_theme_light_errorContainer, onError = md_theme_light_onError, onErrorContainer = md_theme_light_onErrorContainer, background = md_theme_light_background, onBackground = md_theme_light_onBackground, surface = md_theme_light_surface, onSurface = md_theme_light_onSurface, surfaceVariant = md_theme_light_surfaceVariant, onSurfaceVariant = md_theme_light_onSurfaceVariant, outline = md_theme_light_outline, inverseOnSurface = md_theme_light_inverseOnSurface, inverseSurface = md_theme_light_inverseSurface, inversePrimary = md_theme_light_inversePrimary, surfaceTint = md_theme_light_surfaceTint, outlineVariant = md_theme_light_outlineVariant, scrim = md_theme_light_scrim, ) private val darkColors = darkColorScheme( primary = md_theme_dark_primary, onPrimary = md_theme_dark_onPrimary, primaryContainer = md_theme_dark_primaryContainer, onPrimaryContainer = md_theme_dark_onPrimaryContainer, secondary = md_theme_dark_secondary, onSecondary = md_theme_dark_onSecondary, secondaryContainer = md_theme_dark_secondaryContainer, onSecondaryContainer = md_theme_dark_onSecondaryContainer, tertiary = md_theme_dark_tertiary, onTertiary = md_theme_dark_onTertiary, tertiaryContainer = md_theme_dark_tertiaryContainer, onTertiaryContainer = md_theme_dark_onTertiaryContainer, error = md_theme_dark_error, errorContainer = md_theme_dark_errorContainer, onError = md_theme_dark_onError, onErrorContainer = md_theme_dark_onErrorContainer, background = md_theme_dark_background, onBackground = md_theme_dark_onBackground, surface = md_theme_dark_surface, onSurface = md_theme_dark_onSurface, surfaceVariant = md_theme_dark_surfaceVariant, onSurfaceVariant = md_theme_dark_onSurfaceVariant, outline = md_theme_dark_outline, inverseOnSurface = md_theme_dark_inverseOnSurface, inverseSurface = md_theme_dark_inverseSurface, inversePrimary = md_theme_dark_inversePrimary, surfaceTint = md_theme_dark_surfaceTint, outlineVariant = md_theme_dark_outlineVariant, scrim = md_theme_dark_scrim, ) @Composable fun ShioriTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable() () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> darkColors else -> lightColors } val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window window.statusBarColor = colorScheme.background.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } } MaterialTheme( colorScheme = colorScheme, typography = shioriTypography, shapes = shapes, content = content ) } ================================================ FILE: presentation/src/main/java/com/desarrollodroide/pagekeeper/ui/theme/Type.kt ================================================ package com.desarrollodroide.pagekeeper.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp val shioriTypography = Typography( headlineLarge = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.sp ), headlineMedium = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.sp ), headlineSmall = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.sp ), titleLarge = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp ), titleMedium = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp ), titleSmall = TextStyle( fontWeight = FontWeight.Bold, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp ), bodyLarge = TextStyle( fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp ), bodyMedium = TextStyle( fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp ), bodySmall = TextStyle( fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp ), labelLarge = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp ), labelMedium = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp ), labelSmall = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp ) ) ================================================ FILE: presentation/src/main/res/drawable/curved_wave_bottom.xml ================================================ ================================================ FILE: presentation/src/main/res/drawable/curved_wave_top.xml ================================================ ================================================ FILE: presentation/src/main/res/drawable/ic_book.xml ================================================ ================================================ FILE: presentation/src/main/res/drawable/ic_empty_list.xml ================================================ ================================================ FILE: presentation/src/main/res/drawable/img_authentication_failed.xml ================================================ ================================================ FILE: presentation/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: presentation/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: presentation/src/main/res/values/dimens.xml ================================================ // Progress Dialog 70dp 20dp 6dp ================================================ FILE: presentation/src/main/res/values/strings.xml ================================================ Shiori ================================================ FILE: presentation/src/main/res/values/themes.xml ================================================