diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..5655ee8c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,134 @@ +# indicate this is the root of the project +root = true + +[*] +charset = utf-8 +end_of_line = lf +# indent_size = 2 +indent_style = space +indent_size = 4 + +[{*.yml,*.yaml}] +indent_size = 2 + +[*.{kt,java,gradle,md}] +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 +# tab_width = 2 +# ij_continuation_indent_size = 4 +# ij_formatter_off_tag = @formatter:off +# ij_formatter_on_tag = @formatter:on +# ij_formatter_tags_enabled = true +# ij_smart_tabs = false +# ij_visual_guides = +# ij_wrap_on_typing = false + +[*.xml] +# ij_continuation_indent_size = 4 +ij_xml_align_attributes = true +# ij_xml_align_text = false +# ij_xml_attribute_wrap = normal +# ij_xml_block_comment_add_space = false +# ij_xml_block_comment_at_first_column = true +ij_xml_keep_blank_lines = 2 +# ij_xml_keep_indents_on_empty_lines = false +# ij_xml_keep_line_breaks = false +# ij_xml_keep_line_breaks_in_text = true +# ij_xml_keep_whitespaces = false +# ij_xml_keep_whitespaces_around_cdata = preserve +# ij_xml_keep_whitespaces_inside_cdata = false +# ij_xml_line_comment_at_first_column = true +# ij_xml_space_after_tag_name = false +# ij_xml_space_around_equals_in_attribute = false +# ij_xml_space_inside_empty_tag = true +ij_xml_text_wrap = off +# ij_xml_use_custom_settings = true + +[{*.kt,*.kts}] +tab_width = 4 +# ij_continuation_indent_size = 8 +# ij_kotlin_align_in_columns_case_branch = false +# ij_kotlin_align_multiline_binary_operation = false +# ij_kotlin_align_multiline_extends_list = false +# ij_kotlin_align_multiline_method_parentheses = false +# ij_kotlin_align_multiline_parameters = true +# ij_kotlin_align_multiline_parameters_in_calls = false +# ij_kotlin_allow_trailing_comma = false +# ij_kotlin_allow_trailing_comma_on_call_site = false +# ij_kotlin_assignment_wrap = normal +# ij_kotlin_blank_lines_after_class_header = 0 +# ij_kotlin_blank_lines_around_block_when_branches = 0 +# ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +# ij_kotlin_block_comment_add_space = false +# ij_kotlin_block_comment_at_first_column = true +# ij_kotlin_call_parameters_new_line_after_left_paren = true +# ij_kotlin_call_parameters_right_paren_on_new_line = true +# ij_kotlin_call_parameters_wrap = on_every_item +# ij_kotlin_catch_on_new_line = false +# ij_kotlin_class_annotation_wrap = split_into_lines +# ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +# ij_kotlin_continuation_indent_for_chained_calls = false +# ij_kotlin_continuation_indent_for_expression_bodies = false +# ij_kotlin_continuation_indent_in_argument_lists = false +# ij_kotlin_continuation_indent_in_elvis = false +# ij_kotlin_continuation_indent_in_if_conditions = false +# ij_kotlin_continuation_indent_in_parameter_lists = false +# ij_kotlin_continuation_indent_in_supertype_lists = false +# ij_kotlin_else_on_new_line = false +# ij_kotlin_enum_constants_wrap = off +# ij_kotlin_extends_list_wrap = normal +# ij_kotlin_field_annotation_wrap = split_into_lines +# ij_kotlin_finally_on_new_line = false +# ij_kotlin_if_rparen_on_new_line = true +# ij_kotlin_import_nested_classes = false +ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ +# ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +# ij_kotlin_keep_blank_lines_before_right_brace = 2 +# ij_kotlin_keep_blank_lines_in_code = 2 +# ij_kotlin_keep_blank_lines_in_declarations = 2 +# ij_kotlin_keep_first_column_comment = true +# ij_kotlin_keep_indents_on_empty_lines = false +# ij_kotlin_keep_line_breaks = true +# ij_kotlin_lbrace_on_next_line = false +# ij_kotlin_line_break_after_multiline_when_entry = true +# ij_kotlin_line_comment_add_space = false +# ij_kotlin_line_comment_add_space_on_reformat = false +# ij_kotlin_line_comment_at_first_column = true +# ij_kotlin_method_annotation_wrap = split_into_lines +# ij_kotlin_method_call_chain_wrap = normal +# ij_kotlin_method_parameters_new_line_after_left_paren = true +# ij_kotlin_method_parameters_right_paren_on_new_line = true +# ij_kotlin_method_parameters_wrap = on_every_item +ij_kotlin_name_count_to_use_star_import = 1000 +ij_kotlin_name_count_to_use_star_import_for_members = 1000 +# ij_kotlin_packages_to_use_import_on_demand = +# ij_kotlin_parameter_annotation_wrap = off +# ij_kotlin_space_after_comma = true +# ij_kotlin_space_after_extend_colon = true +# ij_kotlin_space_after_type_colon = true +# ij_kotlin_space_before_catch_parentheses = true +# ij_kotlin_space_before_comma = false +# ij_kotlin_space_before_extend_colon = true +# ij_kotlin_space_before_for_parentheses = true +# ij_kotlin_space_before_if_parentheses = true +# ij_kotlin_space_before_lambda_arrow = true +# ij_kotlin_space_before_type_colon = false +# ij_kotlin_space_before_when_parentheses = true +# ij_kotlin_space_before_while_parentheses = true +# ij_kotlin_spaces_around_additive_operators = true +# ij_kotlin_spaces_around_assignment_operators = true +# ij_kotlin_spaces_around_equality_operators = true +# ij_kotlin_spaces_around_function_type_arrow = true +# ij_kotlin_spaces_around_logical_operators = true +# ij_kotlin_spaces_around_multiplicative_operators = true +# ij_kotlin_spaces_around_range = false +# ij_kotlin_spaces_around_relational_operators = true +# ij_kotlin_spaces_around_unary_operator = false +# ij_kotlin_spaces_around_when_arrow = true +# ij_kotlin_use_custom_formatting_for_modifiers = true +# ij_kotlin_variable_annotation_wrap = off +# ij_kotlin_while_on_new_line = false +# ij_kotlin_wrap_elvis_expressions = 1 +# ij_kotlin_wrap_expression_body_functions = 1 +# ij_kotlin_wrap_first_method_in_call_chain = false diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6fdfc73c..aae6fb5a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,5 +1,4 @@ -# Contributing to Quillnote - +# Contributing to Quillpad ## 🌎 Translations @@ -16,16 +15,16 @@ | French (`fr`) | [@locness3](https://github.com/locness3) | Complete | | Spanish (`es`) | [@urizev](https://github.com/urizev) | Complete | -You can help Quillnote grow by translating it in languages it does not support yet or by improving existing translations. +You can help Quillpad grow by translating it in languages it does not support yet or by improving existing translations. ### How to translate 1. Fork the repository. -2. Create a new branch from the `develop` branch. Give it a good name, like `translation-FR` for a French translation. -3. Inside `app/src/main/res` create a folder named `values-COUNTRY_CODE` where `COUNTRY_CODE` is the code for the language you're translating Quillnote in. For example, the Greek translation lies inside the `values-el` folder. +2. Create a new branch from the `master` branch. Give it a good name, like `translation-FR` for a French translation. +3. Inside `app/src/main/res` create a folder named `values-COUNTRY_CODE` where `COUNTRY_CODE` is the code for the language you're translating Quillpad in. For example, the Greek translation lies inside the `values-el` folder. 4. Copy `app/src/main/res/values/strings.xml` inside the folder you just created. 5. Edit the file by translating the strings between the XML tags and not the tags themselves. For example, in this line `All Notes` you should only translate `All Notes`. 6. When done, commit your changes. -7. Finally, create a new pull request [here](https://github.com/msoultanidis/quillnote/pulls). +7. Finally, create a new pull request [here](https://github.com/quillpad/quillpad/pulls). -**Thanks for your interest in contributing to Quillnote!** +**Thanks for your interest in contributing to Quillpad!** diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index ed7f886b..cd0afcbc 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -liberapay: Quillnote +buy_me_a_coffee: quillpad diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..4b022155 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,162 @@ +name: Android Build Release + +on: + push: + tags: + - v* + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + add-job-summary-as-pr-comment: on-failure # Valid values are 'never' (default), 'always', and 'on-failure' + build-scan-publish: true + build-scan-terms-of-use-url: "https://gradle.com/terms-of-service" + build-scan-terms-of-use-agree: "yes" + + - name: Setup keystore + run: | + echo "${{secrets.KEYSTORE}}" > keystore.jks.asc + gpg -d --batch --passphrase "${{secrets.GPG_PASS}}" --output app/keystore.jks keystore.jks.asc + + - name: Build App + run: | + chmod +x gradlew + ./gradlew assembleRelease \ + -Pkeystore=keystore.jks \ + -Pstorepass=${{secrets.STORE_PASS}} \ + -Pkeypass=${{secrets.KEY_PASS}} \ + -Pkeyalias=${{secrets.KEY_NAME}} + + - name: Build App Bundle + if: ${{ github.ref_type == 'tag' }} + run: | + chmod +x gradlew + ./gradlew bundleRelease \ + -Pkeystore=keystore.jks \ + -Pstorepass=${{secrets.STORE_PASS}} \ + -Pkeypass=${{secrets.KEY_PASS}} \ + -Pkeyalias=${{secrets.KEY_NAME}} + + - name: Delete keystore + run: rm -f keystore.jks keystore.jks.asc + + - name: Archive APK + uses: actions/upload-artifact@v4 + with: + name: Archive Quillpad APK + path: app/build/outputs/apk/release/app-release.apk + + - name: Archive Bundle + if: ${{ github.ref_type == 'tag' }} + uses: actions/upload-artifact@v4 + with: + name: Google PlayStore Bundle + path: app/build/outputs/bundle/release/app-release.aab + + - name: Archive ProGuard Mapping + if: ${{ github.ref_type == 'tag' }} + uses: actions/upload-artifact@v4 + with: + name: ProGuard Mapping File + path: app/build/outputs/mapping/release/mapping.txt + + - name: Rename app to release version + if: ${{ github.ref_type == 'tag' }} + env: + VERSION: ${{ github.ref_name }} + run: | + echo version = ${VERSION:1} + echo "APK_VERSION=${VERSION:1}" >> $GITHUB_ENV + cp -v app/build/outputs/apk/release/app-release.apk quillpad-${VERSION:1}.apk + + - name: Do Release + uses: softprops/action-gh-release@v2 + if: ${{ github.ref_type == 'tag' }} + with: + files: quillpad-${{env.APK_VERSION}}.apk + draft: true + + test_lab: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + add-job-summary-as-pr-comment: on-failure + build-scan-publish: true + build-scan-terms-of-use-url: "https://gradle.com/terms-of-service" + build-scan-terms-of-use-agree: "yes" + + - name: Setup keystore + run: | + echo "${{secrets.KEYSTORE}}" > keystore.jks.asc + gpg -d --batch --passphrase "${{secrets.GPG_PASS}}" --output app/keystore.jks keystore.jks.asc + + - name: Build Test APK + run: | + chmod +x gradlew + ./gradlew assembleRelease -PTESTLAB_BUILD=true \ + -Pkeystore=keystore.jks \ + -Pstorepass=${{secrets.STORE_PASS}} \ + -Pkeypass=${{secrets.KEY_PASS}} \ + -Pkeyalias=${{secrets.KEY_NAME}} + + - name: Delete keystore + run: rm -f app/keystore.jks keystore.jks.asc + + - name: Archive Test APK + uses: actions/upload-artifact@v4 + with: + name: Test APK + path: app/build/outputs/apk/release/app-release.apk + + - name: Setup Google Cloud Auth + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Run Firebase Test Lab Tests + run: | + gcloud firebase test android run \ + --type robo \ + --app app/build/outputs/apk/release/app-release.apk \ + --device=model=MediumPhone.arm,version=35 \ + --device=model=MediumPhone.arm,version=26 \ + --results-dir=test-results + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-lab-results + path: test-results/ diff --git a/.github/workflows/dev-android.yml b/.github/workflows/dev-android.yml new file mode 100644 index 00000000..0db0020c --- /dev/null +++ b/.github/workflows/dev-android.yml @@ -0,0 +1,51 @@ +name: Android Debug build + +on: + push: + branches: + - dev-* + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + add-job-summary-as-pr-comment: on-failure # Valid values are 'never' (default), 'always', and 'on-failure' + build-scan-publish: true + build-scan-terms-of-use-url: "https://gradle.com/terms-of-service" + build-scan-terms-of-use-agree: "yes" + + - name: Setup keystore + run: | + echo "${{secrets.KEYSTORE}}" > keystore.jks.asc + gpg -d --batch --passphrase "${{secrets.GPG_PASS}}" --output app/keystore.jks keystore.jks.asc + + - name: Build App + run: | + chmod +x gradlew + ./gradlew assembleDebug \ + -Pkeystore=keystore.jks \ + -Pstorepass=${{secrets.STORE_PASS}} \ + -Pkeypass=${{secrets.KEY_PASS}} \ + -Pkeyalias=${{secrets.KEY_NAME}} + + - name: Delete keystore + run: rm -f keystore.jks keystore.jks.asc + + - name: Archive APK + uses: actions/upload-artifact@v4 + with: + name: Quillpad Debug APKs + path: | + app/build/outputs/apk/debug/app-debug.apk diff --git a/.github/workflows/validate_pr.yml b/.github/workflows/validate_pr.yml new file mode 100644 index 00000000..f1864017 --- /dev/null +++ b/.github/workflows/validate_pr.yml @@ -0,0 +1,42 @@ +name: Validate PR build + +on: + pull_request: + branches: [ "master" ] +permissions: + pull-requests: write + +jobs: + build: + runs-on: ubuntu-latest + environment: Test + + steps: + - uses: actions/checkout@v4 + + - name: set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + add-job-summary-as-pr-comment: on-failure # Valid values are 'never' (default), 'always', and 'on-failure' + build-scan-publish: true + build-scan-terms-of-use-url: "https://gradle.com/terms-of-service" + build-scan-terms-of-use-agree: "yes" + + - name: Generate Debug build + run: | + chmod +x gradlew + ./gradlew \ + "-Porg.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g" \ + assembleDebug + + - name: Archive APK + uses: actions/upload-artifact@v4 + with: + name: Archive Debug APKs + path: app/build/outputs/apk/debug/app-*.apk diff --git a/.gitignore b/.gitignore index a07a74ca..f5ce1978 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle +.kotlin/ /local.properties /.idea/ /.idea/caches @@ -13,3 +14,10 @@ /captures .externalNativeBuild .cxx +hs_err_pid*.log +output-metadata.json +*.apk +*.aab +/build-cache/ +app/build/ +buildSrc/build/ diff --git a/README.md b/README.md index 0a92559a..c859429f 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,20 @@ -# Quillnote - - - - +[![Gitter](https://badges.gitter.im/quillpad/community.svg)](https://gitter.im/quillpad/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) +[![Android Build Release](https://github.com/quillpad/quillpad/actions/workflows/android.yml/badge.svg?branch=master)](https://github.com/quillpad/quillpad/actions/workflows/android.yml) +# Quillpad +Quillpad is fully free and open-source. It will never show you ads, ask you for unnecessary permissions or upload your notes anywhere without you knowing. Take beautiful markdown notes whenever you feel inspired. Place them in notebooks and tag them accordingly. Stay organized by making task lists, set reminders and keep everything in one place by attaching related files. -Quillnote is fully free and open-source. It will never show you ads, ask you for unnecessary permissions or upload your notes anywhere without you knowing. +## Available at - - Get it on F-Droid - -Get it on Google Play + +Get it on F-Droid + + +Get it on Google Play ## Features -With Quillnote, you can: +With Quillpad, you can: - Take notes with Markdown support - Make task lists @@ -28,25 +26,104 @@ With Quillnote, you can: - Add tags to notes - Archive notes you want out of your way - Search through notes -- Sync with Nextcloud (experimental) +- Sync notes to the file system (store notes as markdown files in a local or cloud folder) +- Sync with Nextcloud (experimental, requires the Nextcloud Notes app installed on the Nextcloud server used for syncing) - Backup your notes to a zip file which you can restore at a later time - Toggle between Light and Dark mode - Choose between multiple color schemes -## License -``` -Copyright (C) 2021 Michael Soultanidis +### Support + +If you like the work and would like to support me, + + +Buy Me A Coffee + + +### Matrix + +Join the conversation at [Matrix](https://matrix.to/#/#quillpad_community:gitter.im) +or [Gitter](https://gitter.im/quillpad/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) + +### Fork Info + +Quillpad is a fork of an original app called [Quillnote](https://github.com/msoultanidis/quillnote). The development +stopped on the original app and PR backlogs were not cleared up. The community showed much interest in the app for +continued development and so this fork was +created. https://github.com/msoultanidis/quillnote/issues/177 https://github.com/msoultanidis/quillnote/issues/209 + + + +## Migration +Backups from [Notally](https://github.com/OmGodse/Notally) can be converted into Quillpad compatible backups using this [python script](https://gist.github.com/nWestie/224d14a6efd00661b5c93040c7511816) + +Notes from [Google Keep](https://www.google.com/keep/) can be converted to Quillpad compatible backups using [gk2qp](https://github.com/l0f3n/gk2qp). + +## Translations + +Please help with translations using the [Weblate](https://toolate.othing.xyz/projects/quillpad/). + + +Translation status + + +You may also manually change translation files. +Follow these steps to add a new translation: + +1. Create an account on Github or use your Github account if you have one that already exists. +2. Create a new folder under [quillpad/app/src/main/res](https://github.com/quillpad/quillpad/tree/master/app/src/main/res) named values-LANGCODE (e.g the Arabic folder will be named values-ar). +3. Copy the file [quillpad/app/src/main/res/values/strings.xml](https://github.com/quillpad/quillpad/blob/master/app/src/main/res/values/strings.xml) and put it in the values-LANGCODE folder. +4. Now you can translate the strings.xml file in your language value folder. + +## Roadmap + +The major features that are currently planned for this app are listed below in a series of milestones. In additional to +these major features, there will be bug fixes and other enhancements will be added as we go. + +### Milestone 1.5 (General cloud syncing) ✓ + +- ✓ Store the notes as markdown files in the device. +- ✓ If the folder chosen is under a cloud storage provider, Android will sync the markdown files with the respective + cloud. For e.g. ~~Google Drive, Dropbox~~, pCloud and even Nextcloud. Syncing this way to the cloud does not deal with + the Nextcloud API. +- ✓ This will be the easiest way for users to sync the notes without having to self-host a NextCloud instance. +- ✓ Update to the latest version of Android API and Dependency Libraries. + +This milestone has been completed. Users can now select file storage as a sync mechanism and sync their notes to a +folder of their choice, which can be a local folder or a cloud folder in the files app. + +### Milestone 1.6 (Jetpack Compose) +~~- Introduce Compose and Kotlin multiplatform. The main app views like the list of notes, edit view and the todo list view will be migrated to Compose.~~ + +The app uses the current XML based UI and a bunch of Android based libraries. +- **Migrating the UI to compose**. These libraries are Android UI and would need to be replaced with Compose Multiplatform libraries. The entire UI will be re-written in Compose. + - `androidx.appcompat:appcompat` + - `androidx.recyclerview:recyclerview` + - `androidx.fragment:fragment-ktx` + - `androidx.palette:palette-ktx` + - `androidx.media:media` + - `androidx.swiperefreshlayout:swiperefreshlayout` + - `androidx.navigation:navigation-ui-ktx ` +- **Hilt + Dagger** will need to be replaced with koin. Given that the dependency injection is already deeply integrated with the UI, this will be a major change. +- **Room Database** will need to be replaced with SQLDelight. This is a major change as the entire database schema will need to be re-written. All the migrations will need to be re-written and tested if an earlier version of the app with Room DB be able to newer versions with SQLDelight. +- **Markwon** will need to be replaced with Compose compatible Markdown library. Given that the app is a markdown editor, this is a major change and I don't know if there is a Compose and Multiplatform compatible markdown library. +- **Retrofit, Security-crypto, ExoPlayer**, and other libraries will need to be replaced with Multiplatform compatible libraries. + +Given the extensive changes required for the migration to Compose Multiplatform, it would be more practical to start a new project. This approach allows us to build the app with multiplatform compatibility in mind from the ground up, rather than trying to retrofit it into an existing Android-specific codebase. + +Furthermore, this approach ensures that the current app remains stable and usable for users during the migration process. We can gradually port features from the old app to the new one. + + +### Milestone 1.7 (Desktop App) +~~- Introduce desktop app. With the main views available in compose, try making a desktop app with help of Compose for desktop.~~ -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. +### Milestone 1.8 (iOS App) +~~- Try an iOS version since the kotlin multiplatform code does the heavy lifting of notes management and syncing. Leverage the same storage API equivalent in iOS.~~ -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. +### Milestone 2.0 (Encryption) -You should have received a copy of the GNU General Public License -along with this program. If not, see . -``` +- Now we have the ability to sync notes using cloud providers like ~~Google Drive and Dropbox~~ pCloud. The cloud + providers _may_ to go through the notes and _may_ index them and _may_ profile the user. This is the primary reason + for the encryption feature. Which means, the notes won't be staying as markdown files, and cannot be edited by other + text editors. +- Encryption will be optional. The user can switch between having the notes encrypted vs stored them as plain markdown files. diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100755 index 5e493b30..00000000 --- a/app/build.gradle +++ /dev/null @@ -1,159 +0,0 @@ -plugins { - id "com.android.application" - id "kotlin-android" - id "kotlin-kapt" - id "androidx.navigation.safeargs" - id "kotlinx-serialization" - id "kotlin-parcelize" - id "dagger.hilt.android.plugin" -} - -android { - compileSdkVersion 31 - buildToolsVersion "30.0.3" - - defaultConfig { - applicationId "org.qosp.notes" - minSdkVersion 21 - targetSdkVersion 31 - versionCode 8 - versionName "1.4.3" - - testInstrumentationRunner "org.qosp.notes.TestRunner" - } - - buildTypes { - release { - minifyEnabled true - shrinkResources true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - flavorDimensions "versions" - productFlavors { - googleFlavor { - dimension "versions" - buildConfigField "boolean", "IS_GOOGLE", "true" - } - defaultFlavor { - dimension "versions" - buildConfigField "boolean", "IS_GOOGLE", "false" - } - } - - kotlinOptions { - jvmTarget = "1.8" - } - - compileOptions { - coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - buildFeatures { - viewBinding true - } - - kapt { - javacOptions { - option("-Adagger.fastInit=ENABLED") - option("-Adagger.hilt.android.internal.disableAndroidSuperclassValidation=true") - } - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation fileTree(dir: "libs", include: ["*.jar"]) - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" - testImplementation "junit:junit:4.13.2" - androidTestImplementation "androidx.test.ext:junit:1.1.3" - androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" - coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5" - - // AndroidX - implementation "androidx.core:core-ktx:1.7.0" - implementation "androidx.appcompat:appcompat:1.4.1" - implementation "androidx.constraintlayout:constraintlayout:2.1.3" - implementation "androidx.coordinatorlayout:coordinatorlayout:1.2.0" - implementation "androidx.recyclerview:recyclerview:1.2.1" - implementation "androidx.fragment:fragment-ktx:1.4.1" - implementation "androidx.palette:palette-ktx:1.0.0" - implementation "androidx.media:media:1.5.0" - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" - - // Navigation Component - implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" - implementation "androidx.navigation:navigation-ui-ktx:$nav_version" - - // Material Components - implementation "com.google.android.material:material:$material_version" - - // Room - kapt "androidx.room:room-compiler:$room_version" - implementation "androidx.room:room-runtime:$room_version" - implementation "androidx.room:room-ktx:$room_version" - testImplementation "androidx.room:room-testing:$room_version" - - // Lifecycle - implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-service:$lifecycle_version" - - // Security - implementation "androidx.security:security-crypto:1.1.0-alpha03" - - // Flow Preferences - implementation "com.github.tfcporciuncula.flow-preferences:flow-preferences:1.4.0" - - // DataStore Preferences - implementation "androidx.datastore:datastore-preferences:$datastore_version" - implementation "me.msoul:datastoreext:1.0.0" - - // Markwon - implementation "io.noties.markwon:core:$markwon_version" - implementation "io.noties.markwon:editor:$markwon_version" - implementation "io.noties.markwon:linkify:$markwon_version" - implementation "io.noties.markwon:ext-strikethrough:$markwon_version" - implementation "io.noties.markwon:ext-tables:$markwon_version" - implementation "io.noties.markwon:ext-tasklist:$markwon_version" - implementation "me.saket:better-link-movement-method:2.2.0" - - // Work Manager - implementation "androidx.work:work-runtime-ktx:$work_version" - androidTestImplementation "androidx.work:work-testing:$work_version" - - // Coil - implementation "io.coil-kt:coil:$coil_version" - implementation "io.coil-kt:coil-video:$coil_version" - implementation "io.coil-kt:coil-gif:$coil_version" - - // PhotoView - implementation "com.github.chrisbanes:PhotoView:$photoview_version" - - // Hilt - implementation "androidx.hilt:hilt-work:1.0.0" - implementation "com.google.dagger:hilt-android:$hilt_version" - androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" - kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version" - kapt "com.google.dagger:hilt-compiler:$hilt_version" - kapt "androidx.hilt:hilt-compiler:1.0.0" - - // ExoPlayer - implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version" - implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version" - - // Retrofit - implementation "com.squareup.retrofit2:retrofit:$retrofit_version" - implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0" - - // LeakCanary - debugImplementation "com.squareup.leakcanary:leakcanary-android:$leakcanary_version" -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 00000000..f7895a8f --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,236 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.kotlinParcelize) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.ksp) + alias(libs.plugins.navigationSafeArgs) +} + +android { + compileSdk = 35 + namespace = "org.qosp.notes" + + defaultConfig { + applicationId = "io.github.quillpad" + minSdk = 24 + targetSdk = 35 + versionCode = 48 + versionName = "1.5.6" + + testInstrumentationRunner = "org.qosp.notes.TestRunner" + + // Enable per-app language preferences + androidResources { + localeFilters += listOf( + "ar", + "ca", + "cs", + "de", + "el", + "en", + "es", + "fr", + "it", + "nb-rNO", + "nl", + "pl", + "pt-rBR", + "ru", + "tr", + "uk", + "vi", + "zh-rCN", + "zh-rTW" + ) + + } + } + + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false + } + + if (project.hasProperty("keystore")) { + signingConfigs { + create("release") { + val keystoreFileArg = project.property("keystore").toString() + val storePassArg = project.property("storepass").toString() + val keyName = project.property("keyalias").toString() + val keypassArg = project.property("keypass").toString() + storeFile = file(keystoreFileArg) + storePassword = storePassArg + keyAlias = keyName + keyPassword = keypassArg + } + } + } + + buildTypes { + val testLabBuild = project.findProperty("TESTLAB_BUILD")?.toString() ?: "false" + + debug { + buildConfigField("boolean", "TESTLAB_BUILD", testLabBuild) + } + release { + isMinifyEnabled = true + isShrinkResources = true + isCrunchPngs = false + if (project.hasProperty("keystore")) { + signingConfig = signingConfigs.getByName("release") + } + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + ndk { + debugSymbolLevel = "FULL" + } + buildConfigField("boolean", "TESTLAB_BUILD", testLabBuild) + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + buildFeatures { + viewBinding = true + compose = true + buildConfig = true + } + + packaging { + resources.excludes.addAll( + listOf( + "META-INF/LICENSE.md", + "META-INF/LICENSE-notice.md", + ) + ) + } + sourceSets { + // Adds exported schema location as test app assets. + getByName("androidTest").assets.srcDirs(files("$projectDir/schemas")) + } +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + } +} + +// export schema +// https://stackoverflow.com/a/44645943/4594587 +ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("KOIN_CONFIG_CHECK", "true") + arg("KOIN_DEFAULT_MODULE", "true") +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + androidTestImplementation(platform(libs.androidx.compose.bom)) + + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + implementation(libs.monitor) + implementation(libs.junit.ktx) + coreLibraryDesugaring(libs.coreLibraryDesugaring) + + // Test + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) + testImplementation(libs.roomTesting) + testImplementation(libs.koin.test) + testImplementation(libs.koin.test.junit4) + androidTestImplementation(libs.roomTesting) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.mockk.agent) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.workTesting) + + // AndroidX + implementation(libs.bundles.kotlin.androidX) + + implementation(libs.bundles.kotlin.deps) + + // Material Components + implementation(libs.material) + implementation(libs.androidx.material) + // Optional - Integration with activities + implementation(libs.androidx.activity.compose) + // Optional - Integration with ViewModels + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Room + ksp(libs.roomCompiler) + implementation(libs.roomRuntime) + implementation(libs.roomKtx) + + // Lifecycle + implementation(libs.bundles.kotlin.lifecycle) + + // Security + implementation(libs.securityCrypto) + + // Flow Preferences + implementation(libs.flowPreferences) + + // DataStore Preferences + implementation(libs.datastorePreferences) + implementation(libs.datastoreext) + + // Markwon + implementation(libs.bundles.markwon) + + // Work Manager + implementation(libs.workRuntimeKtx) + + // Koin + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.koin.core.coroutines) + implementation(libs.koin.android) + implementation(libs.koin.android.compat) + implementation(libs.koin.androidx.workmanager) + implementation(libs.koin.androidx.navigation) + + // Yaml parsing + implementation(libs.yamlkt) + + // Coil + implementation(libs.coil) + implementation(libs.coilVideo) + implementation(libs.coilGif) + + // PhotoView + implementation(libs.photoview) + + // ExoPlayer + implementation(libs.exoplayerCore) + implementation(libs.exoplayerUi) + + // Retrofit + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.retrofit) + implementation(libs.retrofit2Convertor) + + // Software Quality + debugImplementation(libs.leakcanaryAndroid) + implementation(libs.bundles.acra) +} diff --git a/app/schemas/org.qosp.notes.data.AppDatabase/2.json b/app/schemas/org.qosp.notes.data.AppDatabase/2.json new file mode 100644 index 00000000..832217a5 --- /dev/null +++ b/app/schemas/org.qosp.notes.data.AppDatabase/2.json @@ -0,0 +1,395 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "4c616a3aa1faf4611482e94c9448c5c7", + "entities": [ + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `content` TEXT NOT NULL, `isList` INTEGER NOT NULL, `taskList` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isHidden` INTEGER NOT NULL, `isMarkdownEnabled` INTEGER NOT NULL, `isLocalOnly` INTEGER NOT NULL, `isCompactPreview` INTEGER NOT NULL, `creationDate` INTEGER NOT NULL, `modifiedDate` INTEGER NOT NULL, `deletionDate` INTEGER, `attachments` TEXT NOT NULL, `color` TEXT NOT NULL, `notebookId` INTEGER, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`notebookId`) REFERENCES `notebooks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isList", + "columnName": "isList", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskList", + "columnName": "taskList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMarkdownEnabled", + "columnName": "isMarkdownEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocalOnly", + "columnName": "isLocalOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCompactPreview", + "columnName": "isCompactPreview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationDate", + "columnName": "creationDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDate", + "columnName": "modifiedDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletionDate", + "columnName": "deletionDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notebookId", + "columnName": "notebookId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_notes_notebookId", + "unique": false, + "columnNames": [ + "notebookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_notebookId` ON `${TABLE_NAME}` (`notebookId`)" + } + ], + "foreignKeys": [ + { + "table": "notebooks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "notebookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "note_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tagId` INTEGER NOT NULL, `noteId` INTEGER NOT NULL, PRIMARY KEY(`noteId`, `tagId`), FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "noteId", + "tagId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_note_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_note_tags_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_noteId` ON `${TABLE_NAME}` (`noteId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tags", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "tagId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "notebooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notebookName` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "name", + "columnName": "notebookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "reminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `noteId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_reminders_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_noteId` ON `${TABLE_NAME}` (`noteId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "cloud_ids", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mappingId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localNoteId` INTEGER NOT NULL, `remoteNoteId` INTEGER, `provider` TEXT, `extras` TEXT, `isDeletedLocally` INTEGER NOT NULL, `isBeingUpdated` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "mappingId", + "columnName": "mappingId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localNoteId", + "columnName": "localNoteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteNoteId", + "columnName": "remoteNoteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDeletedLocally", + "columnName": "isDeletedLocally", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBeingUpdated", + "columnName": "isBeingUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "mappingId" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4c616a3aa1faf4611482e94c9448c5c7')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.qosp.notes.data.AppDatabase/3.json b/app/schemas/org.qosp.notes.data.AppDatabase/3.json new file mode 100644 index 00000000..578c3ca9 --- /dev/null +++ b/app/schemas/org.qosp.notes.data.AppDatabase/3.json @@ -0,0 +1,401 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "933b90be6a1662313ff8511dcd759c6d", + "entities": [ + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `content` TEXT NOT NULL, `isList` INTEGER NOT NULL, `taskList` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isHidden` INTEGER NOT NULL, `isMarkdownEnabled` INTEGER NOT NULL, `isLocalOnly` INTEGER NOT NULL, `isCompactPreview` INTEGER NOT NULL, `screenAlwaysOn` INTEGER NOT NULL, `creationDate` INTEGER NOT NULL, `modifiedDate` INTEGER NOT NULL, `deletionDate` INTEGER, `attachments` TEXT NOT NULL, `color` TEXT NOT NULL, `notebookId` INTEGER, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`notebookId`) REFERENCES `notebooks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isList", + "columnName": "isList", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskList", + "columnName": "taskList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMarkdownEnabled", + "columnName": "isMarkdownEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocalOnly", + "columnName": "isLocalOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCompactPreview", + "columnName": "isCompactPreview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "screenAlwaysOn", + "columnName": "screenAlwaysOn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationDate", + "columnName": "creationDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDate", + "columnName": "modifiedDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletionDate", + "columnName": "deletionDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notebookId", + "columnName": "notebookId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_notes_notebookId", + "unique": false, + "columnNames": [ + "notebookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_notebookId` ON `${TABLE_NAME}` (`notebookId`)" + } + ], + "foreignKeys": [ + { + "table": "notebooks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "notebookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "note_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tagId` INTEGER NOT NULL, `noteId` INTEGER NOT NULL, PRIMARY KEY(`noteId`, `tagId`), FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "noteId", + "tagId" + ] + }, + "indices": [ + { + "name": "index_note_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_note_tags_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_noteId` ON `${TABLE_NAME}` (`noteId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tags", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "tagId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "notebooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notebookName` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "name", + "columnName": "notebookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "reminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `noteId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_reminders_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_noteId` ON `${TABLE_NAME}` (`noteId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "cloud_ids", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mappingId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localNoteId` INTEGER NOT NULL, `remoteNoteId` INTEGER, `provider` TEXT, `extras` TEXT, `isDeletedLocally` INTEGER NOT NULL, `isBeingUpdated` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "mappingId", + "columnName": "mappingId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localNoteId", + "columnName": "localNoteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteNoteId", + "columnName": "remoteNoteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDeletedLocally", + "columnName": "isDeletedLocally", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBeingUpdated", + "columnName": "isBeingUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "mappingId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '933b90be6a1662313ff8511dcd759c6d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.qosp.notes.data.AppDatabase/4.json b/app/schemas/org.qosp.notes.data.AppDatabase/4.json new file mode 100644 index 00000000..8c25e943 --- /dev/null +++ b/app/schemas/org.qosp.notes.data.AppDatabase/4.json @@ -0,0 +1,407 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "741ef6485bcdc7321a082165402a7fcf", + "entities": [ + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `content` TEXT NOT NULL, `isList` INTEGER NOT NULL, `taskList` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isHidden` INTEGER NOT NULL, `isMarkdownEnabled` INTEGER NOT NULL, `isLocalOnly` INTEGER NOT NULL, `isCompactPreview` INTEGER NOT NULL, `screenAlwaysOn` INTEGER NOT NULL, `creationDate` INTEGER NOT NULL, `modifiedDate` INTEGER NOT NULL, `deletionDate` INTEGER, `attachments` TEXT NOT NULL, `color` TEXT NOT NULL, `notebookId` INTEGER, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`notebookId`) REFERENCES `notebooks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isList", + "columnName": "isList", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskList", + "columnName": "taskList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMarkdownEnabled", + "columnName": "isMarkdownEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocalOnly", + "columnName": "isLocalOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCompactPreview", + "columnName": "isCompactPreview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "screenAlwaysOn", + "columnName": "screenAlwaysOn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationDate", + "columnName": "creationDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDate", + "columnName": "modifiedDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletionDate", + "columnName": "deletionDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notebookId", + "columnName": "notebookId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_notes_notebookId", + "unique": false, + "columnNames": [ + "notebookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_notebookId` ON `${TABLE_NAME}` (`notebookId`)" + } + ], + "foreignKeys": [ + { + "table": "notebooks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "notebookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "note_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tagId` INTEGER NOT NULL, `noteId` INTEGER NOT NULL, PRIMARY KEY(`noteId`, `tagId`), FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "noteId", + "tagId" + ] + }, + "indices": [ + { + "name": "index_note_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_note_tags_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_noteId` ON `${TABLE_NAME}` (`noteId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tags", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "tagId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "notebooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notebookName` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "name", + "columnName": "notebookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "reminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `noteId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_reminders_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_noteId` ON `${TABLE_NAME}` (`noteId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "cloud_ids", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mappingId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localNoteId` INTEGER NOT NULL, `remoteNoteId` INTEGER, `provider` TEXT, `extras` TEXT, `isDeletedLocally` INTEGER NOT NULL, `isBeingUpdated` INTEGER NOT NULL, `storageUri` TEXT)", + "fields": [ + { + "fieldPath": "mappingId", + "columnName": "mappingId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localNoteId", + "columnName": "localNoteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteNoteId", + "columnName": "remoteNoteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDeletedLocally", + "columnName": "isDeletedLocally", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBeingUpdated", + "columnName": "isBeingUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageUri", + "columnName": "storageUri", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "mappingId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '741ef6485bcdc7321a082165402a7fcf')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.qosp.notes.data.AppDatabase/5.json b/app/schemas/org.qosp.notes.data.AppDatabase/5.json new file mode 100644 index 00000000..4858458f --- /dev/null +++ b/app/schemas/org.qosp.notes.data.AppDatabase/5.json @@ -0,0 +1,427 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "39f0553b6701ada1e9c87863b6de49a0", + "entities": [ + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `content` TEXT NOT NULL, `isList` INTEGER NOT NULL, `taskList` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isHidden` INTEGER NOT NULL, `isMarkdownEnabled` INTEGER NOT NULL, `isLocalOnly` INTEGER NOT NULL, `isCompactPreview` INTEGER NOT NULL, `screenAlwaysOn` INTEGER NOT NULL, `creationDate` INTEGER NOT NULL, `modifiedDate` INTEGER NOT NULL, `deletionDate` INTEGER, `attachments` TEXT NOT NULL, `color` TEXT NOT NULL, `notebookId` INTEGER, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`notebookId`) REFERENCES `notebooks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isList", + "columnName": "isList", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskList", + "columnName": "taskList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMarkdownEnabled", + "columnName": "isMarkdownEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocalOnly", + "columnName": "isLocalOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCompactPreview", + "columnName": "isCompactPreview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "screenAlwaysOn", + "columnName": "screenAlwaysOn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationDate", + "columnName": "creationDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDate", + "columnName": "modifiedDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletionDate", + "columnName": "deletionDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notebookId", + "columnName": "notebookId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_notes_notebookId", + "unique": false, + "columnNames": [ + "notebookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_notebookId` ON `${TABLE_NAME}` (`notebookId`)" + } + ], + "foreignKeys": [ + { + "table": "notebooks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "notebookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "note_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tagId` INTEGER NOT NULL, `noteId` INTEGER NOT NULL, PRIMARY KEY(`noteId`, `tagId`), FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "noteId", + "tagId" + ] + }, + "indices": [ + { + "name": "index_note_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_note_tags_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_noteId` ON `${TABLE_NAME}` (`noteId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tags", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "tagId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "notebooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notebookName` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "name", + "columnName": "notebookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "reminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `noteId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_reminders_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_noteId` ON `${TABLE_NAME}` (`noteId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "cloud_ids", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mappingId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localNoteId` INTEGER NOT NULL, `remoteNoteId` INTEGER, `provider` TEXT, `extras` TEXT, `isDeletedLocally` INTEGER NOT NULL, `isBeingUpdated` INTEGER NOT NULL, `storageUri` TEXT)", + "fields": [ + { + "fieldPath": "mappingId", + "columnName": "mappingId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localNoteId", + "columnName": "localNoteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteNoteId", + "columnName": "remoteNoteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDeletedLocally", + "columnName": "isDeletedLocally", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBeingUpdated", + "columnName": "isBeingUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageUri", + "columnName": "storageUri", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "mappingId" + ] + }, + "indices": [ + { + "name": "cloud_ids_id_index", + "unique": false, + "columnNames": [ + "localNoteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `cloud_ids_id_index` ON `${TABLE_NAME}` (`localNoteId`)" + }, + { + "name": "cloud_ids_id_provider_index", + "unique": false, + "columnNames": [ + "localNoteId", + "provider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `cloud_ids_id_provider_index` ON `${TABLE_NAME}` (`localNoteId`, `provider`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '39f0553b6701ada1e9c87863b6de49a0')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/qosp/notes/TestRunner.kt b/app/src/androidTest/java/org/qosp/notes/TestRunner.kt index de50a5f3..395080fb 100644 --- a/app/src/androidTest/java/org/qosp/notes/TestRunner.kt +++ b/app/src/androidTest/java/org/qosp/notes/TestRunner.kt @@ -3,10 +3,48 @@ package org.qosp.notes import android.app.Application import android.content.Context import androidx.test.runner.AndroidJUnitRunner -import dagger.hilt.android.testing.HiltTestApplication +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.context.startKoin +import org.koin.core.logger.Level +import org.qosp.notes.di.MarkwonModule +import org.qosp.notes.di.NextcloudModule +import org.qosp.notes.di.PreferencesModule +import org.qosp.notes.di.RepositoryModule +import org.qosp.notes.di.SyncModule +import org.qosp.notes.di.TestUtilModule +import org.qosp.notes.di.UIModule +import org.qosp.notes.di.UtilModule class TestRunner : AndroidJUnitRunner() { - override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { - return super.newApplication(cl, HiltTestApplication::class.java.name, context) + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context? + ): Application { + return super.newApplication(cl, TestApplication::class.java.name, context) + } +} + +@OptIn(KoinExperimentalAPI::class) +class TestApplication : Application() { + + override fun onCreate() { + super.onCreate() + startKoin { + androidLogger(level = Level.DEBUG) + androidContext(this@TestApplication) + modules( + TestUtilModule.module, + MarkwonModule.markwonModule, + NextcloudModule.nextcloudModule, + PreferencesModule.prefModule, + RepositoryModule.repoModule, + SyncModule.syncModule, + UIModule.uiModule, + UtilModule.utilModule, + ) + } } } diff --git a/app/src/androidTest/java/org/qosp/notes/data/DatabaseMigrationTest.kt b/app/src/androidTest/java/org/qosp/notes/data/DatabaseMigrationTest.kt new file mode 100644 index 00000000..6cf7fa59 --- /dev/null +++ b/app/src/androidTest/java/org/qosp/notes/data/DatabaseMigrationTest.kt @@ -0,0 +1,169 @@ +package org.qosp.notes.data + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +/** + * Test class for verifying database migrations. + * + * These tests ensure that all migrations in the AppDatabase can be applied successfully. + * The approach is to create a database with the migrations applied and verify that it can be opened without errors. + * + * While these tests don't validate the specific changes made by each migration (like adding a column or creating an index), + * they do ensure that the migrations don't cause any errors when applied to a database, which is the most important aspect + * of migration testing. + */ +@RunWith(AndroidJUnit4::class) +class DatabaseMigrationTest { + + /** + * Tests that all migrations can be applied together. + * + * This test creates a database and applies all migrations from version 1 to version 5. + * It verifies that the database can be opened successfully after all migrations are applied. + */ + @Test + @Throws(IOException::class) + fun testAllMigrations() { + // Create a database with all migrations + val context = ApplicationProvider.getApplicationContext() + val db = Room.databaseBuilder( + context, + AppDatabase::class.java, + "migration-test" + ) + .addMigrations( + AppDatabase.MIGRATION_1_2, + AppDatabase.MIGRATION_2_3, + AppDatabase.MIGRATION_3_4, + AppDatabase.MIGRATION_4_5 + ) + .build() + + // Verify that the database can be opened + db.openHelper.writableDatabase + + // Close the database + db.close() + } + + /** + * Tests the migration from version 2 to version 3. + * + * This migration adds the 'screenAlwaysOn' column to the 'notes' table. + * The test verifies that the database can be opened successfully after the migration is applied. + */ + @Test + @Throws(IOException::class) + fun testMigration2To3() { + // Create a database with migration 2 to 3 + val context = ApplicationProvider.getApplicationContext() + val db = Room.databaseBuilder( + context, + AppDatabase::class.java, + "migration-2-3-test" + ) + .addMigrations(AppDatabase.MIGRATION_2_3) + .build() + + // Verify that the database can be opened + db.openHelper.writableDatabase + + // Close the database + db.close() + } + + /** + * Tests the migration from version 3 to version 4. + * + * This migration adds the 'storageUri' column to the 'cloud_ids' table. + * The test verifies that the database can be opened successfully after the migration is applied. + */ + @Test + @Throws(IOException::class) + fun testMigration3To4() { + // Create a database with migration 3 to 4 + val context = ApplicationProvider.getApplicationContext() + val db = Room.databaseBuilder( + context, + AppDatabase::class.java, + "migration-3-4-test" + ) + .addMigrations(AppDatabase.MIGRATION_3_4) + .build() + + // Verify that the database can be opened + db.openHelper.writableDatabase + + // Close the database + db.close() + } + + /** + * Tests the migration from version 4 to version 5. + * + * This migration creates two indices on the 'cloud_ids' table: + * - cloud_ids_id_index on localNoteId + * - cloud_ids_id_provider_index on localNoteId and provider + * + * The test verifies that the database can be opened successfully after the migration is applied. + */ + @Test + @Throws(IOException::class) + fun testMigration4To5() { + // Create a database with migration 4 to 5 + val context = ApplicationProvider.getApplicationContext() + val db = Room.databaseBuilder( + context, + AppDatabase::class.java, + "migration-4-5-test" + ) + .addMigrations(AppDatabase.MIGRATION_4_5) + .build() + + // Verify that the database can be opened + db.openHelper.writableDatabase + + // Close the database + db.close() + } + + /** + * Tests the migration from version 3 to version 5. + * + * This test verifies that a database at version 3 can be successfully upgraded to version 5 + * by applying both migrations in sequence: + * 1. Migration 3 to 4: Adds the 'storageUri' column to the 'cloud_ids' table + * 2. Migration 4 to 5: Creates two indices on the 'cloud_ids' table + * + * This simulates a user upgrading from an app with database version 3 directly to an app + * with database version 5, skipping version 4. + */ + @Test + @Throws(IOException::class) + fun testMigration3To5() { + // Create a database with migrations from 3 to 5 + val context = ApplicationProvider.getApplicationContext() + val db = Room.databaseBuilder( + context, + AppDatabase::class.java, + "migration-3-5-test" + ) + .addMigrations( + AppDatabase.MIGRATION_3_4, + AppDatabase.MIGRATION_4_5 + ) + .build() + + // Verify that the database can be opened + db.openHelper.writableDatabase + + // Close the database + db.close() + } +} diff --git a/app/src/androidTest/java/org/qosp/notes/di/TestDatabaseModule.kt b/app/src/androidTest/java/org/qosp/notes/di/TestDatabaseModule.kt deleted file mode 100644 index b280bdbd..00000000 --- a/app/src/androidTest/java/org/qosp/notes/di/TestDatabaseModule.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.qosp.notes.di - -import android.content.Context -import androidx.room.Room -import dagger.Module -import dagger.Provides -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dagger.hilt.testing.TestInstallIn -import org.qosp.notes.data.AppDatabase -import javax.inject.Singleton - -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [DatabaseModule::class], -) -object TestDatabaseModule { - @Provides - @Singleton - fun provideRoomDatabase( - @ApplicationContext context: Context, - ): AppDatabase { - return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() - } -} diff --git a/app/src/androidTest/java/org/qosp/notes/di/TestUtilModule.kt b/app/src/androidTest/java/org/qosp/notes/di/TestUtilModule.kt index 290d4474..70e88418 100644 --- a/app/src/androidTest/java/org/qosp/notes/di/TestUtilModule.kt +++ b/app/src/androidTest/java/org/qosp/notes/di/TestUtilModule.kt @@ -1,83 +1,69 @@ package org.qosp.notes.di import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dagger.hilt.testing.TestInstallIn -import kotlinx.coroutines.GlobalScope -import org.qosp.notes.BuildConfig +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import kotlinx.coroutines.DelicateCoroutinesApi +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module +import org.qosp.notes.BuildConfig.VERSION_CODE import org.qosp.notes.components.MediaStorageManager import org.qosp.notes.components.backup.BackupManager +import org.qosp.notes.data.AppDatabase import org.qosp.notes.data.repo.IdMappingRepository import org.qosp.notes.data.repo.NoteRepository import org.qosp.notes.data.repo.NotebookRepository import org.qosp.notes.data.repo.ReminderRepository import org.qosp.notes.data.repo.TagRepository -import org.qosp.notes.data.sync.core.SyncManager -import org.qosp.notes.data.sync.nextcloud.NextcloudManager -import org.qosp.notes.preferences.PreferenceRepository import org.qosp.notes.ui.reminders.ReminderManager -import org.qosp.notes.ui.utils.ConnectionManager -import javax.inject.Singleton const val TEST_MEDIA_FOLDER = "test_media" -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [UtilModule::class], -) object TestUtilModule { - @Provides - @Singleton - fun provideMediaStorageManager( - @ApplicationContext context: Context, - noteRepository: NoteRepository, - ) = MediaStorageManager(context, noteRepository, TEST_MEDIA_FOLDER) - - @Provides - @Singleton - fun provideReminderManager( - @ApplicationContext context: Context, - reminderRepository: ReminderRepository, - ) = ReminderManager(context, reminderRepository) - - @Provides - @Singleton - fun provideSyncManager( - @ApplicationContext context: Context, - preferenceRepository: PreferenceRepository, - idMappingRepository: IdMappingRepository, - nextcloudManager: NextcloudManager, - ) = SyncManager( - preferenceRepository, - idMappingRepository, - ConnectionManager(context), - nextcloudManager, - GlobalScope, - ) - - @Provides - @Singleton - fun provideBackupManager( - noteRepository: NoteRepository, - notebookRepository: NotebookRepository, - tagRepository: TagRepository, - reminderRepository: ReminderRepository, - idMappingRepository: IdMappingRepository, - reminderManager: ReminderManager, - @ApplicationContext context: Context, - ) = BackupManager( - BuildConfig.VERSION_CODE, - noteRepository, - notebookRepository, - tagRepository, - reminderRepository, - idMappingRepository, - reminderManager, - context - ) + // Manual syncModule definition to ensure all dependencies are included + @OptIn(DelicateCoroutinesApi::class) + val module = module { + single { + MediaStorageManager( + context = get(), + noteRepository = get(), + mediaFolder = TEST_MEDIA_FOLDER + ) + } + single { + ReminderManager( + context = get(), + reminderRepository = get(), + noteRepository = get(), + ) + } + single { + BackupManager( + currentVersion = VERSION_CODE, + noteRepository = get(), + notebookRepository = get(), + tagRepository = get(), + reminderRepository = get(), + idMappingRepository = get(), + reminderManager = get(), + context = get() + ) + } + single { + Room.inMemoryDatabaseBuilder(androidContext(), AppDatabase::class.java) + .addMigrations(AppDatabase.MIGRATION_1_2) + .addMigrations(AppDatabase.MIGRATION_2_3) + .addMigrations(AppDatabase.MIGRATION_3_4) + .addMigrations(AppDatabase.MIGRATION_4_5) + .build() + } + single { + MigrationTestHelper( + instrumentation = getInstrumentation(), + databaseClass = AppDatabase::class.java, + ) + } + } } diff --git a/app/src/androidTest/java/org/qosp/notes/tests/BinCleaningWorkerTest.kt b/app/src/androidTest/java/org/qosp/notes/tests/BinCleaningWorkerTest.kt index 3d1d5243..224cdb0e 100644 --- a/app/src/androidTest/java/org/qosp/notes/tests/BinCleaningWorkerTest.kt +++ b/app/src/androidTest/java/org/qosp/notes/tests/BinCleaningWorkerTest.kt @@ -1,74 +1,99 @@ package org.qosp.notes.tests import android.content.Context -import androidx.hilt.work.HiltWorkerFactory -import androidx.work.testing.TestListenableWorkerBuilder -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule import org.junit.Test +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.qosp.notes.components.workers.BinCleaningWorker import org.qosp.notes.data.model.Note import org.qosp.notes.data.repo.NoteRepository import org.qosp.notes.preferences.NoteDeletionTime import org.qosp.notes.preferences.PreferenceRepository import java.time.Instant -import javax.inject.Inject +import kotlin.time.Duration.Companion.days -@HiltAndroidTest -class BinCleaningWorkerTest { - private lateinit var worker: BinCleaningWorker - @Inject @ApplicationContext - lateinit var context: Context - @Inject - lateinit var preferenceRepository: PreferenceRepository - @Inject - lateinit var noteRepository: NoteRepository - @Inject - lateinit var workerFactory: HiltWorkerFactory +class BinCleaningWorkerTest : KoinComponent { + val worker: BinCleaningWorker by inject() - @get:Rule - var hiltRule = HiltAndroidRule(this) + val context: Context by inject() - @Before - fun prepare() { - hiltRule.inject() - worker = TestListenableWorkerBuilder(context) - .setWorkerFactory(workerFactory) - .build() - } + val preferenceRepository: PreferenceRepository by inject() + + val noteRepository: NoteRepository by inject() @Test @Throws(Exception::class) - fun workerShouldDeleteNotesAfterCertainInterval() = runBlocking { + fun test1WeekDelete() = runBlocking { val pref = NoteDeletionTime.WEEK - val interval = pref.interval + preferenceRepository.set(pref) + setupNotes() + val allNotes = noteRepository.getAll().first() + worker.doWork() + val actual = noteRepository.getAll().first() + val expected = allNotes.filter { it.title.toInt() < 7 } + assertTrue("Notes were not deleted properly", actual == expected) + } + + @Test + @Throws(Exception::class) + fun test2WeeksDelete() = runBlocking { + val pref = NoteDeletionTime.TWO_WEEKS preferenceRepository.set(pref) + setupNotes() + val allNotes = noteRepository.getAll().first() + worker.doWork() - // Create and persist the notes - val notes = listOf( - Note(isDeleted = true, deletionDate = Instant.now().epochSecond), - Note(isDeleted = true, deletionDate = Instant.now().epochSecond - interval / 2), - Note(isDeleted = true, deletionDate = Instant.now().epochSecond - (interval + 1)), // Should get deleted permanently - ) - .map { note -> - val id = noteRepository.insertNote(note) - note.copy(id = id) - } + val actual = noteRepository.getAll().first() + val expected = allNotes.filter { it.title.toInt() < 14 } + assertTrue("Notes were not deleted properly", actual == expected) + } + @Test + @Throws(Exception::class) + fun test1MonthDelete() = runBlocking { + val pref = NoteDeletionTime.MONTH + preferenceRepository.set(pref) + setupNotes() val allNotes = noteRepository.getAll().first() + worker.doWork() + val actual = noteRepository.getAll().first() + val expected = allNotes.filter { it.title.toInt() < 30 } + assertTrue("Notes were not deleted properly", actual == expected) + } + + @Test + @Throws(Exception::class) + fun testNeverDelete() = runBlocking { + val pref = NoteDeletionTime.NEVER + preferenceRepository.set(pref) + setupNotes() + val allNotes = noteRepository.getAll().first() worker.doWork() val actual = noteRepository.getAll().first() - val expected = allNotes.filterNot { it.id == notes[2].id } + assertTrue("Notes were not deleted properly", actual == allNotes) + } - assertTrue("Notes were not deleted properly", actual == expected) + private suspend fun setupNotes(): List { + // Create and persist the notes + val now = Instant.now().epochSecond + val notes = listOf( + Note(isDeleted = true, title = "0", deletionDate = now), + Note(isDeleted = true, title = "3", deletionDate = now - 3.days.inWholeSeconds), + Note(isDeleted = true, title = "8", deletionDate = now - 8.days.inWholeSeconds), + Note(isDeleted = true, title = "18", deletionDate = now - 18.days.inWholeSeconds), + Note(isDeleted = true, title = "31", deletionDate = now - 31.days.inWholeSeconds), + Note(isDeleted = true, title = "58", deletionDate = now - 58.days.inWholeSeconds), + ) + .map { note -> + val id = noteRepository.insertNote(note) + note.copy(id = id) + } + return notes } } diff --git a/app/src/androidTest/java/org/qosp/notes/tests/MediaStorageManagerTest.kt b/app/src/androidTest/java/org/qosp/notes/tests/MediaStorageManagerTest.kt index 29c2e2eb..159e85d9 100644 --- a/app/src/androidTest/java/org/qosp/notes/tests/MediaStorageManagerTest.kt +++ b/app/src/androidTest/java/org/qosp/notes/tests/MediaStorageManagerTest.kt @@ -1,35 +1,21 @@ package org.qosp.notes.tests -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertTrue import org.junit.Assert.fail -import org.junit.Before -import org.junit.Rule import org.junit.Test +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.qosp.notes.components.MediaStorageManager import org.qosp.notes.data.model.Attachment import org.qosp.notes.data.model.Note import org.qosp.notes.data.repo.NoteRepository import java.io.IOException -import javax.inject.Inject -@HiltAndroidTest -class MediaStorageManagerTest { - @Inject - lateinit var noteRepository: NoteRepository - @Inject - lateinit var mediaStorageManager: MediaStorageManager - - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @Before - fun init() { - hiltRule.inject() - } +class MediaStorageManagerTest: KoinComponent { + val noteRepository: NoteRepository by inject() + val mediaStorageManager: MediaStorageManager by inject() @Test @Throws(Exception::class) diff --git a/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderCancelTest.kt b/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderCancelTest.kt index be833b99..2f362190 100644 --- a/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderCancelTest.kt +++ b/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderCancelTest.kt @@ -1,28 +1,15 @@ package org.qosp.notes.tests.reminders -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.runBlocking import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule import org.junit.Test +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.qosp.notes.ui.reminders.ReminderManager import java.time.Instant -import javax.inject.Inject -@HiltAndroidTest -class ReminderCancelTest { - @Inject - lateinit var reminderManager: ReminderManager - - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @Before - fun init() { - hiltRule.inject() - } +class ReminderCancelTest : KoinComponent { + val reminderManager: ReminderManager by inject() @Test @Throws(Exception::class) diff --git a/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderDeletionTest.kt b/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderDeletionTest.kt index e04b9e25..6d425c87 100644 --- a/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderDeletionTest.kt +++ b/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderDeletionTest.kt @@ -1,36 +1,20 @@ package org.qosp.notes.tests.reminders -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule import org.junit.Test +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.qosp.notes.data.model.Note import org.qosp.notes.data.model.Reminder import org.qosp.notes.data.repo.NoteRepository import org.qosp.notes.data.repo.ReminderRepository -import org.qosp.notes.di.NO_SYNC import java.time.Instant -import javax.inject.Inject -import javax.inject.Named -@HiltAndroidTest -class ReminderDeletionTest { - @Inject @Named(NO_SYNC) - lateinit var noteRepository: NoteRepository - @Inject - lateinit var reminderRepository: ReminderRepository - - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @Before - fun init() { - hiltRule.inject() - } +class ReminderDeletionTest : KoinComponent { + val noteRepository: NoteRepository by inject() + val reminderRepository: ReminderRepository by inject() @Test @Throws(Exception::class) diff --git a/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderScheduleTest.kt b/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderScheduleTest.kt index 6b77f654..6b809642 100644 --- a/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderScheduleTest.kt +++ b/app/src/androidTest/java/org/qosp/notes/tests/reminders/ReminderScheduleTest.kt @@ -1,27 +1,14 @@ package org.qosp.notes.tests.reminders -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule import org.junit.Test +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.qosp.notes.ui.reminders.ReminderManager import java.time.Instant -import javax.inject.Inject -@HiltAndroidTest -class ReminderScheduleTest { - @Inject - lateinit var reminderManager: ReminderManager - - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @Before - fun init() { - hiltRule.inject() - } +class ReminderScheduleTest : KoinComponent { + private val reminderManager: ReminderManager by inject() @Test @Throws(Exception::class) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0cdc315..fdfb196a 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,87 +1,137 @@ - - - - - - - - + xmlns:tools="http://schemas.android.com/tools"> - - + android:name=".App" + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config" + android:localeConfig="@xml/locales_config" + android:theme="@style/AppTheme"> - - - + + + + + + + - + + + + + + + + + + + - + + + + + + + + + + + + + + + + + - + android:name=".ui.launcher.LauncherActivity" + android:exported="true"> + + + + - - - + + + + + + + + + android:name=".ui.reminders.ReminderReceiver" + android:exported="false"> - + - - - + - + - - + - \ No newline at end of file + + + + + + + + + + + + + diff --git a/app/src/main/assets/whatsnew.yaml b/app/src/main/assets/whatsnew.yaml new file mode 100644 index 00000000..7cd032bc --- /dev/null +++ b/app/src/main/assets/whatsnew.yaml @@ -0,0 +1,12 @@ +updates: + - id: 3 + title: What's New in Quillpad + items: + - Quickly report issues using the new "Send Logs" option in the About section. + - The app now gives clearer, more specific error messages regarding Nextcloud Sync. + - Fixed sync issues with remotely deleted notes and local updates not reflecting on the server. + - New translation updates for French, German, and Russian! + - id: 2 + title: Bug Fixes + items: + - The bugs are fixed diff --git a/app/src/main/java/org/qosp/notes/App.kt b/app/src/main/java/org/qosp/notes/App.kt index df99b1d7..42564364 100644 --- a/app/src/main/java/org/qosp/notes/App.kt +++ b/app/src/main/java/org/qosp/notes/App.kt @@ -3,12 +3,12 @@ package org.qosp.notes import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager +import android.content.Context import android.os.Build import android.os.Build.VERSION.SDK_INT import android.os.StrictMode +import android.util.Log import androidx.core.content.ContextCompat -import androidx.hilt.work.HiltWorkerFactory -import androidx.work.Configuration import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType @@ -18,39 +18,44 @@ import coil.ImageLoader import coil.ImageLoaderFactory import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder -import coil.util.CoilUtils -import dagger.hilt.android.HiltAndroidApp -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import okhttp3.OkHttpClient +import coil.decode.VideoFrameDecoder +import coil.disk.DiskCache +import coil.memory.MemoryCache +import org.acra.ReportField +import org.acra.config.dialog +import org.acra.config.mailSender +import org.acra.data.StringFormat +import org.acra.ktx.initAcra +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.androidx.workmanager.koin.workManagerFactory +import org.koin.core.context.startKoin import org.qosp.notes.components.workers.BinCleaningWorker import org.qosp.notes.components.workers.SyncWorker +import org.qosp.notes.di.MarkwonModule +import org.qosp.notes.di.NextcloudModule +import org.qosp.notes.di.PreferencesModule +import org.qosp.notes.di.RepositoryModule +import org.qosp.notes.di.SyncModule +import org.qosp.notes.di.UIModule +import org.qosp.notes.di.UtilModule import java.util.concurrent.TimeUnit -import javax.inject.Inject -@HiltAndroidApp -class App : Application(), ImageLoaderFactory, Configuration.Provider { - val syncingScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - @Inject - lateinit var workerFactory: HiltWorkerFactory - - override fun getWorkManagerConfiguration() = - Configuration.Builder() - .setWorkerFactory(workerFactory) - .build() +class App : Application(), ImageLoaderFactory { override fun newImageLoader(): ImageLoader { return ImageLoader.Builder(applicationContext) .crossfade(true) - .okHttpClient { - OkHttpClient.Builder() - .cache(CoilUtils.createDefaultCache(applicationContext)) - .build() + .memoryCache { + MemoryCache.Builder(applicationContext).maxSizePercent(0.05).build() } - .componentRegistry { - if (SDK_INT >= 28) add(ImageDecoderDecoder(applicationContext)) else add(GifDecoder()) + .diskCache( + DiskCache.Builder().directory(applicationContext.cacheDir.resolve("img_cache")) + .maxSizePercent(0.02).build() + ) + .components { + if (SDK_INT >= 28) add(ImageDecoderDecoder.Factory()) else add(GifDecoder.Factory()) + add(VideoFrameDecoder.Factory()) } .build() } @@ -60,13 +65,70 @@ class App : Application(), ImageLoaderFactory, Configuration.Provider { enableStrictMode() } super.onCreate() + + startKoin { + androidLogger() + androidContext(this@App) + workManagerFactory() + modules( + listOf( + MarkwonModule.markwonModule, + NextcloudModule.nextcloudModule, + PreferencesModule.prefModule, + RepositoryModule.repoModule, + UIModule.uiModule, + UtilModule.utilModule, + SyncModule.syncModule, + ) + ) + } + createNotificationChannels() enqueueWorkers() } + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + Log.d("App", "attachBaseContext: Initializing ACRA") + initAcra { + buildConfigClass = BuildConfig::class.java + reportFormat = StringFormat.JSON + reportContent = listOf( + ReportField.REPORT_ID, + ReportField.APP_VERSION_CODE, + ReportField.APP_VERSION_NAME, + ReportField.ANDROID_VERSION, + ReportField.PRODUCT, + ReportField.BRAND, + ReportField.PHONE_MODEL, + ReportField.BUILD_CONFIG, + ReportField.CUSTOM_DATA, + ReportField.STACK_TRACE, + ReportField.USER_COMMENT, + ReportField.USER_APP_START_DATE, + ReportField.USER_CRASH_DATE, + ReportField.LOGCAT, + ) + logcatFilterByPid = true + + dialog { + text = getString(R.string.error_report_description) + title = getString(R.string.error_report_title) + commentPrompt = getString(R.string.error_report_comment) + } + + mailSender { + mailTo = getString(R.string.error_report_email) + reportAsFile = true + reportFileName = "error_report.json" + } + } + } + private fun createNotificationChannels() { if (SDK_INT < Build.VERSION_CODES.O) return - val notificationManager = ContextCompat.getSystemService(this, NotificationManager::class.java) ?: return + val notificationManager = + ContextCompat.getSystemService(this, NotificationManager::class.java) ?: return listOf( NotificationChannel( @@ -94,7 +156,9 @@ class App : Application(), ImageLoaderFactory, Configuration.Provider { "BIN_CLEAN" to PeriodicWorkRequestBuilder(5, TimeUnit.HOURS) .build(), "SYNC" to PeriodicWorkRequestBuilder(1, TimeUnit.HOURS) - .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + ) .build(), ) @@ -106,20 +170,20 @@ class App : Application(), ImageLoaderFactory, Configuration.Provider { private fun enableStrictMode() { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy.Builder() - .detectDiskReads() - .detectDiskWrites() .detectNetwork() .penaltyLog() .build() ) - StrictMode.setVmPolicy( - StrictMode.VmPolicy.Builder() - .detectLeakedSqlLiteObjects() - .detectLeakedClosableObjects() - .penaltyLog() - .penaltyDeath() - .build() - ) + val vmPolicyBuilder = StrictMode.VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .penaltyLog() + + if (SDK_INT >= 31) { + vmPolicyBuilder.detectUnsafeIntentLaunch() + } + + StrictMode.setVmPolicy(vmPolicyBuilder.build()) } companion object { diff --git a/app/src/main/java/org/qosp/notes/Config.kt b/app/src/main/java/org/qosp/notes/Config.kt new file mode 100644 index 00000000..0be4ff05 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/Config.kt @@ -0,0 +1,7 @@ +package org.qosp.notes + +import kotlin.time.Duration.Companion.milliseconds + +object Config { + val RemoteUpdateDebounceTime = 500.milliseconds +} diff --git a/app/src/main/java/org/qosp/notes/components/MediaStorageManager.kt b/app/src/main/java/org/qosp/notes/components/MediaStorageManager.kt index ea1ecb35..43d61f65 100644 --- a/app/src/main/java/org/qosp/notes/components/MediaStorageManager.kt +++ b/app/src/main/java/org/qosp/notes/components/MediaStorageManager.kt @@ -59,6 +59,7 @@ class MediaStorageManager( val prefix = when (type) { MediaType.IMAGE -> "img_" MediaType.AUDIO -> "audio_" + MediaType.VIDEO -> "video_" } val file = File.createTempFile(prefix, extension, directory) @@ -68,6 +69,8 @@ class MediaStorageManager( } enum class MediaType(val defaultExtension: String) { - IMAGE(".jpg"), AUDIO(".mp3"); + IMAGE(".jpg"), + VIDEO(".mp4"), + AUDIO(".mp3") } } diff --git a/app/src/main/java/org/qosp/notes/components/backup/BackupManager.kt b/app/src/main/java/org/qosp/notes/components/backup/BackupManager.kt index 3c7ff6b0..65b005c3 100644 --- a/app/src/main/java/org/qosp/notes/components/backup/BackupManager.kt +++ b/app/src/main/java/org/qosp/notes/components/backup/BackupManager.kt @@ -165,7 +165,7 @@ class BackupManager( ZipInputStream(BufferedInputStream(context.contentResolver.openInputStream(uri))).use { input -> while (true) { - val entry = input.nextEntry ?: break + val entry = runCatching { input.nextEntry }.getOrNull() ?: break when (entry.name) { "backup.json" -> { // Create backup class diff --git a/app/src/main/java/org/qosp/notes/components/backup/BackupService.kt b/app/src/main/java/org/qosp/notes/components/backup/BackupService.kt index 126736bc..35f9a836 100644 --- a/app/src/main/java/org/qosp/notes/components/backup/BackupService.kt +++ b/app/src/main/java/org/qosp/notes/components/backup/BackupService.kt @@ -10,21 +10,19 @@ import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.qosp.notes.App import org.qosp.notes.R import org.qosp.notes.data.model.Note import org.qosp.notes.preferences.BackupStrategy import org.qosp.notes.preferences.PreferenceRepository -import javax.inject.Inject -@AndroidEntryPoint class BackupService : LifecycleService() { private var nextId = 0 get() { @@ -34,11 +32,8 @@ class BackupService : LifecycleService() { private val jobs = mutableListOf() - @Inject - lateinit var preferenceRepository: PreferenceRepository - - @Inject - lateinit var backupManager: BackupManager + val preferenceRepository: PreferenceRepository by inject() + val backupManager: BackupManager by inject() private var notificationManager: NotificationManager? = null @@ -49,16 +44,16 @@ class BackupService : LifecycleService() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - intent?.let { intent -> - val action = intent.extras?.getSerializable(ACTION) as? Action ?: return@let - val uri = intent.extras?.getParcelable(URI_EXTRA) ?: return@let + intent?.let { i -> + val action = i.extras?.getSerializable(ACTION) as? Action ?: return@let + val uri = i.extras?.getParcelable(URI_EXTRA) ?: return@let when (action) { Action.RESTORE -> { restoreNotes(uri) } Action.BACKUP -> { - val notes = intent.extras?.getParcelableArrayList(NOTES)?.toSet() + val notes = i.extras?.getParcelableArrayList(NOTES)?.toSet() backupNotes(notes, uri) } } @@ -66,6 +61,12 @@ class BackupService : LifecycleService() { return START_STICKY } + override fun onTimeout(startId: Int) { + super.onTimeout(startId) + jobs.forEach { it.cancel() } + stopSelf() + } + override fun onDestroy() { super.onDestroy() lifecycleScope.cancel() diff --git a/app/src/main/java/org/qosp/notes/components/backup/Handlers.kt b/app/src/main/java/org/qosp/notes/components/backup/Handlers.kt index 0baafab1..e012606c 100644 --- a/app/src/main/java/org/qosp/notes/components/backup/Handlers.kt +++ b/app/src/main/java/org/qosp/notes/components/backup/Handlers.kt @@ -4,7 +4,6 @@ import android.content.Context import android.net.Uri import org.qosp.notes.data.model.Attachment import org.qosp.notes.ui.attachments.getAttachmentFilename -import kotlin.collections.set interface ProgressHandler { fun onProgressChanged(current: Int, max: Int) diff --git a/app/src/main/java/org/qosp/notes/components/workers/BinCleaningWorker.kt b/app/src/main/java/org/qosp/notes/components/workers/BinCleaningWorker.kt index cd633422..e99f22c6 100644 --- a/app/src/main/java/org/qosp/notes/components/workers/BinCleaningWorker.kt +++ b/app/src/main/java/org/qosp/notes/components/workers/BinCleaningWorker.kt @@ -1,11 +1,8 @@ package org.qosp.notes.components.workers import android.content.Context -import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @@ -15,17 +12,17 @@ import org.qosp.notes.preferences.NoteDeletionTime import org.qosp.notes.preferences.PreferenceRepository import java.time.Instant -@HiltWorker -class BinCleaningWorker @AssistedInject constructor( - @Assisted context: Context, - @Assisted params: WorkerParameters, +class BinCleaningWorker( + context: Context, + params: WorkerParameters, private val preferenceRepository: PreferenceRepository, private val noteRepository: NoteRepository, private val mediaStorageManager: MediaStorageManager, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val deletionTime = preferenceRepository.get().first().interval + val deletionTime = preferenceRepository.get().first().interval.takeIf { it > 0 } + ?: return@withContext Result.success() val now = Instant.now() val toBeDeleted = noteRepository.getDeleted().first() .filter { note -> diff --git a/app/src/main/java/org/qosp/notes/components/workers/SyncWorker.kt b/app/src/main/java/org/qosp/notes/components/workers/SyncWorker.kt index a7398055..66a4c535 100644 --- a/app/src/main/java/org/qosp/notes/components/workers/SyncWorker.kt +++ b/app/src/main/java/org/qosp/notes/components/workers/SyncWorker.kt @@ -1,25 +1,21 @@ package org.qosp.notes.components.workers import android.content.Context -import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import org.qosp.notes.data.repo.NoteRepository import org.qosp.notes.data.sync.core.Success -import org.qosp.notes.data.sync.core.SyncManager import org.qosp.notes.preferences.BackgroundSync import org.qosp.notes.preferences.PreferenceRepository -@HiltWorker -class SyncWorker @AssistedInject constructor( - @Assisted context: Context, - @Assisted params: WorkerParameters, +class SyncWorker( private val preferenceRepository: PreferenceRepository, - private val syncManager: SyncManager, + private val noteRepository: NoteRepository, + context: Context, + params: WorkerParameters, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) { @@ -27,7 +23,7 @@ class SyncWorker @AssistedInject constructor( if (preferenceRepository.get().first() == BackgroundSync.DISABLED) return@withContext Result.failure() - when (syncManager.sync()) { + when (noteRepository.syncNotes()) { Success -> Result.success() else -> Result.failure() } diff --git a/app/src/main/java/org/qosp/notes/data/AppDatabase.kt b/app/src/main/java/org/qosp/notes/data/AppDatabase.kt index 33843933..916b2fae 100755 --- a/app/src/main/java/org/qosp/notes/data/AppDatabase.kt +++ b/app/src/main/java/org/qosp/notes/data/AppDatabase.kt @@ -3,6 +3,8 @@ package org.qosp.notes.data import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import org.qosp.notes.data.dao.IdMappingDao import org.qosp.notes.data.dao.NoteDao import org.qosp.notes.data.dao.NoteTagDao @@ -25,7 +27,8 @@ import org.qosp.notes.data.model.Tag Reminder::class, IdMapping::class, ], - version = 1, + version = 5, + exportSchema = true ) @TypeConverters(DatabaseConverters::class) abstract class AppDatabase : RoomDatabase() { @@ -39,5 +42,36 @@ abstract class AppDatabase : RoomDatabase() { companion object { const val DB_NAME = "notes_database" + + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.apply { + execSQL("ALTER TABLE notes ADD COLUMN isCompactPreview INTEGER NOT NULL DEFAULT (0)") + } + } + } + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.apply { + execSQL("ALTER TABLE notes ADD COLUMN screenAlwaysOn INTEGER NOT NULL DEFAULT (0)") + } + } + } + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.apply { + execSQL("ALTER TABLE cloud_ids ADD COLUMN storageUri TEXT") + } + } + } + + val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.apply { + execSQL("CREATE INDEX IF NOT EXISTS cloud_ids_id_index ON cloud_ids (localNoteId)") + execSQL("CREATE INDEX IF NOT EXISTS cloud_ids_id_provider_index ON cloud_ids (localNoteId, provider)") + } + } + } } } diff --git a/app/src/main/java/org/qosp/notes/data/Backup.kt b/app/src/main/java/org/qosp/notes/data/Backup.kt index d90e3b50..050ad446 100644 --- a/app/src/main/java/org/qosp/notes/data/Backup.kt +++ b/app/src/main/java/org/qosp/notes/data/Backup.kt @@ -1,8 +1,8 @@ package org.qosp.notes.data +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.qosp.notes.data.model.IdMapping import org.qosp.notes.data.model.Note @@ -12,6 +12,7 @@ import org.qosp.notes.data.model.Reminder import org.qosp.notes.data.model.Tag @Serializable +@Parcelize data class Backup( val version: Int, val notes: Set = setOf(), @@ -20,7 +21,7 @@ data class Backup( val tags: Set = setOf(), val joins: Set = setOf(), val idMappings: Set = setOf(), -) { +) : Parcelable { fun serialize() = Json.encodeToString(this) companion object { diff --git a/app/src/main/java/org/qosp/notes/data/DatabaseConverters.kt b/app/src/main/java/org/qosp/notes/data/DatabaseConverters.kt index f5d4d23a..42f5def7 100755 --- a/app/src/main/java/org/qosp/notes/data/DatabaseConverters.kt +++ b/app/src/main/java/org/qosp/notes/data/DatabaseConverters.kt @@ -1,8 +1,6 @@ package org.qosp.notes.data import androidx.room.TypeConverter -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.qosp.notes.data.model.Attachment import org.qosp.notes.data.model.NoteColor diff --git a/app/src/main/java/org/qosp/notes/data/WhatsNew.kt b/app/src/main/java/org/qosp/notes/data/WhatsNew.kt new file mode 100644 index 00000000..a30c395b --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/WhatsNew.kt @@ -0,0 +1,15 @@ +package org.qosp.notes.data + +import kotlinx.serialization.Serializable + +@Serializable +data class WhatsNew( + val updates: List +) + +@Serializable +data class WhatsNewItem( + val id: Int, + val title: String, + val items: List +) diff --git a/app/src/main/java/org/qosp/notes/data/dao/IdMappingDao.kt b/app/src/main/java/org/qosp/notes/data/dao/IdMappingDao.kt index c979b95f..f72f2cf8 100644 --- a/app/src/main/java/org/qosp/notes/data/dao/IdMappingDao.kt +++ b/app/src/main/java/org/qosp/notes/data/dao/IdMappingDao.kt @@ -1,7 +1,6 @@ package org.qosp.notes.data.dao import androidx.room.Dao -import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -17,8 +16,8 @@ interface IdMappingDao { @Update suspend fun update(vararg mappings: IdMapping) - @Delete - suspend fun delete(vararg mappings: IdMapping) + @Query("UPDATE cloud_ids SET extras = :extras WHERE localNoteId = :localId AND provider = :cloudService") + suspend fun updateNoteExtras(localId: Long, cloudService: CloudService, extras: String?) @Query("DELETE FROM cloud_ids WHERE localNoteId IN (:ids)") suspend fun deleteByLocalId(vararg ids: Long) @@ -26,8 +25,8 @@ interface IdMappingDao { @Query("UPDATE cloud_ids SET isDeletedLocally = 1 WHERE localNoteId IN (:ids)") suspend fun setNotesToBeDeleted(vararg ids: Long) - @Query("SELECT * FROM cloud_ids WHERE remoteNoteId = :remoteId AND provider = :provider LIMIT 1") - suspend fun getByRemoteId(remoteId: Long, provider: CloudService): IdMapping? + @Query("DELETE from cloud_ids WHERE provider = :cloudService") + suspend fun deleteAllMappingsFor(cloudService: CloudService) @Query("SELECT * FROM cloud_ids WHERE localNoteId = :localId AND provider = :provider LIMIT 1") suspend fun getByLocalIdAndProvider(localId: Long, provider: CloudService): IdMapping? @@ -35,24 +34,12 @@ interface IdMappingDao { @Query("SELECT * FROM cloud_ids WHERE localNoteId = :localId AND provider IS NULL LIMIT 1") suspend fun getNonRemoteByLocalId(localId: Long): IdMapping? - @Query("UPDATE cloud_ids SET remoteNoteId = NULL, provider = NULL WHERE isDeletedLocally = 0 AND remoteNoteId NOT IN (:idsInUse) AND provider = :provider") - suspend fun unassignProviderFromRemotelyDeletedNotes(idsInUse: List, provider: CloudService) - - @Query("DELETE FROM cloud_ids WHERE remoteNoteId IN (:remoteIds) AND provider = :provider") - suspend fun deleteByRemoteId(provider: CloudService, vararg remoteIds: Long) - - @Query("UPDATE cloud_ids SET provider = :provider WHERE localNoteId = :localId AND provider IS NULL") - suspend fun assignProviderToNote(localId: Long, provider: CloudService) - - @Query("UPDATE cloud_ids SET provider = NULL, remoteNoteId = NULL WHERE localNoteId = :localId AND provider = :provider") - suspend fun unassignProviderFromNote(localId: Long, provider: CloudService) - - @Query("DELETE FROM cloud_ids WHERE localNoteId NOT IN (:ids)") - suspend fun deleteIfLocalIdNotIn(ids: List) - @Query("SELECT * FROM cloud_ids WHERE localNoteId = :localId AND provider IS NOT NULL AND remoteNoteId IS NOT NULL") suspend fun getAllByLocalId(localId: Long): List - @Query("UPDATE cloud_ids SET isBeingUpdated = :isBeingUpdated WHERE localNoteId = :id") - suspend fun setNoteIsBeingUpdated(id: Long, isBeingUpdated: Boolean) + @Query("SELECT * FROM cloud_ids WHERE provider = :provider") + suspend fun getAllByCloudService(provider: CloudService): List + + @Query("SELECT count(mappingId) FROM cloud_ids WHERE provider = :provider") + suspend fun getCountByCloudService(provider: CloudService): Int } diff --git a/app/src/main/java/org/qosp/notes/data/dao/NoteDao.kt b/app/src/main/java/org/qosp/notes/data/dao/NoteDao.kt index 2c639e4b..832d84ea 100755 --- a/app/src/main/java/org/qosp/notes/data/dao/NoteDao.kt +++ b/app/src/main/java/org/qosp/notes/data/dao/NoteDao.kt @@ -23,6 +23,7 @@ import org.qosp.notes.preferences.SortMethod.MODIFIED_ASC import org.qosp.notes.preferences.SortMethod.MODIFIED_DESC import org.qosp.notes.preferences.SortMethod.TITLE_ASC import org.qosp.notes.preferences.SortMethod.TITLE_DESC +import java.time.Instant @Dao interface NoteDao { @@ -32,6 +33,10 @@ interface NoteDao { @Update suspend fun update(vararg notes: NoteEntity) + @Transaction + @Query("UPDATE notes SET modifiedDate = :modified WHERE id = :id") + suspend fun updateLastModified(id: Long, modified: Long = Instant.now().epochSecond) + @Delete suspend fun delete(vararg notes: NoteEntity) @@ -45,7 +50,7 @@ interface NoteDao { @Query( """ UPDATE notes SET isDeleted = 1 WHERE id IN ( - SELECT localNoteId FROM cloud_ids + SELECT localNoteId FROM cloud_ids WHERE remoteNoteId IS NOT NULL AND isDeletedLocally = 0 AND remoteNoteId NOT IN (:idsInUse) AND provider = :provider )""" @@ -68,10 +73,10 @@ interface NoteDao { return rawGetQuery( SimpleSQLiteQuery( """ - SELECT * FROM notes + SELECT * FROM notes WHERE isDeleted = 0 AND isLocalOnly = 0 AND id NOT IN ( - SELECT localNoteId FROM cloud_ids + SELECT localNoteId FROM cloud_ids WHERE provider = '${provider.name}' ) ORDER BY isPinned DESC, $column $order @@ -85,7 +90,7 @@ interface NoteDao { return rawGetQuery( SimpleSQLiteQuery( """ - SELECT * FROM notes WHERE isDeleted = 1 + SELECT * FROM notes WHERE isDeleted = 1 ORDER BY isPinned DESC, $column $order """ ) @@ -97,7 +102,7 @@ interface NoteDao { return rawGetQuery( SimpleSQLiteQuery( """ - SELECT * FROM notes WHERE isArchived = 1 AND isDeleted = 0 + SELECT * FROM notes WHERE isArchived = 1 AND isDeleted = 0 ORDER BY isPinned DESC, $column $order """ ) @@ -109,7 +114,7 @@ interface NoteDao { return rawGetQuery( SimpleSQLiteQuery( """ - SELECT * FROM notes WHERE isDeleted = 0 + SELECT * FROM notes WHERE isDeleted = 0 ORDER BY isPinned DESC, $column $order """ ) @@ -121,7 +126,7 @@ interface NoteDao { return rawGetQuery( SimpleSQLiteQuery( """ - SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0 + SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0 ORDER BY isPinned DESC, $column $order """ ) @@ -139,13 +144,22 @@ interface NoteDao { ) ) } + fun getAllBlankTitleNotes(): Flow> { + return rawGetQuery( + SimpleSQLiteQuery( + """ + SELECT * FROM notes WHERE title IS NULL OR trim(title) = '' + """ + ) + ) + } fun getByNotebook(notebookId: Long, sortMethod: SortMethod): Flow> { val (column, order) = getOrderByMethod(sortMethod) return rawGetQuery( SimpleSQLiteQuery( """ - SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0 AND notebookId = $notebookId + SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0 AND notebookId = $notebookId ORDER BY isPinned DESC, $column $order """ ) @@ -170,7 +184,7 @@ interface NoteDao { return rawGetQuery( SimpleSQLiteQuery( """ - SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0 AND notebookId IS NULL + SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0 AND notebookId IS NULL ORDER BY isPinned DESC, $column $order """ ) diff --git a/app/src/main/java/org/qosp/notes/data/model/IdMapping.kt b/app/src/main/java/org/qosp/notes/data/model/IdMapping.kt index 9732a187..66249182 100644 --- a/app/src/main/java/org/qosp/notes/data/model/IdMapping.kt +++ b/app/src/main/java/org/qosp/notes/data/model/IdMapping.kt @@ -1,12 +1,20 @@ package org.qosp.notes.data.model +import android.os.Parcelable import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.qosp.notes.preferences.CloudService @Serializable -@Entity(tableName = "cloud_ids") +@Parcelize +@Entity( + tableName = "cloud_ids", + indices = [Index(value = ["localNoteId"], name = "cloud_ids_id_index"), + Index(value = ["localNoteId", "provider"], name = "cloud_ids_id_provider_index")], +) data class IdMapping( @PrimaryKey(autoGenerate = true) val mappingId: Long = 0L, @@ -16,4 +24,5 @@ data class IdMapping( val extras: String?, val isDeletedLocally: Boolean, val isBeingUpdated: Boolean = false, -) + val storageUri: String? = null, +) : Parcelable diff --git a/app/src/main/java/org/qosp/notes/data/model/Note.kt b/app/src/main/java/org/qosp/notes/data/model/Note.kt index 026f6b87..991ea11f 100755 --- a/app/src/main/java/org/qosp/notes/data/model/Note.kt +++ b/app/src/main/java/org/qosp/notes/data/model/Note.kt @@ -34,6 +34,8 @@ data class NoteEntity( val isHidden: Boolean, val isMarkdownEnabled: Boolean, val isLocalOnly: Boolean, + val isCompactPreview: Boolean, + val screenAlwaysOn: Boolean, val creationDate: Long, val modifiedDate: Long, val deletionDate: Long?, @@ -58,6 +60,8 @@ data class Note( val isHidden: Boolean = false, val isMarkdownEnabled: Boolean = true, val isLocalOnly: Boolean = false, + val isCompactPreview: Boolean = false, + val screenAlwaysOn: Boolean = false, val creationDate: Long = Instant.now().epochSecond, val modifiedDate: Long = Instant.now().epochSecond, val deletionDate: Long? = null, @@ -84,7 +88,8 @@ data class Note( ) : Parcelable { fun isEmpty(): Boolean { - val baseCondition = title.isBlank() && attachments.isEmpty() && reminders.isEmpty() && tags.isEmpty() + val baseCondition = + title.isBlank() && attachments.isEmpty() && reminders.isEmpty() && tags.isEmpty() return when { isList -> baseCondition && taskList.isEmpty() else -> baseCondition && content.isBlank() @@ -99,13 +104,40 @@ data class Note( .map { NoteTask(nextId++, it.trim(), false) } } + fun mdToTaskList(content: String): List { + val regex = Regex("^\\s*[-+*] *\\[([ xX])](.*)$") + val tasks = mutableListOf() + content.lines().forEachIndexed { index, line -> + val result = regex.find(line) + val task = result?.let { + val checked = it.groupValues[1].lowercase() == "x" + NoteTask(id = index.toLong(), content = it.groupValues[2].trim(), isDone = checked) + } ?: tasks.removeLastOrNull()?.let { t -> t.copy(content = t.content + line.trim()) } + task?.let { tasks.add(it) } + } + return tasks.toList() + } + + fun toStorableContent(): String { + return when { + isList -> taskListToMd() + else -> content + } + } + + fun taskListToMd(): String { + return taskList.joinToString("\n") { + val prefix = if (it.isDone) "- [x]" else "- [ ]" + "$prefix ${it.content.trim()}" + } + } + fun taskListToString(withCheckmarks: Boolean = false): String { return taskList.joinToString("\n") { val prefix = when { withCheckmarks -> if (it.isDone) "☑ " else "☐ " else -> "" } - "$prefix${it.content.trim()}" } } @@ -121,6 +153,8 @@ data class Note( isHidden = isHidden, isMarkdownEnabled = isMarkdownEnabled, isLocalOnly = isLocalOnly, + isCompactPreview = isCompactPreview, + screenAlwaysOn = screenAlwaysOn, creationDate = creationDate, modifiedDate = modifiedDate, deletionDate = deletionDate, diff --git a/app/src/main/java/org/qosp/notes/data/model/NoteTagJoin.kt b/app/src/main/java/org/qosp/notes/data/model/NoteTagJoin.kt index eb93933d..44e95ec7 100755 --- a/app/src/main/java/org/qosp/notes/data/model/NoteTagJoin.kt +++ b/app/src/main/java/org/qosp/notes/data/model/NoteTagJoin.kt @@ -1,9 +1,11 @@ package org.qosp.notes.data.model +import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.ForeignKey.CASCADE +import androidx.room.ForeignKey.Companion.CASCADE +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @Entity( @@ -25,10 +27,11 @@ import kotlinx.serialization.Serializable ) ], ) +@Parcelize @Serializable data class NoteTagJoin( @ColumnInfo(index = true) val tagId: Long = 0L, @ColumnInfo(index = true) val noteId: Long = 0L -) +) : Parcelable diff --git a/app/src/main/java/org/qosp/notes/data/repo/IdMappingRepository.kt b/app/src/main/java/org/qosp/notes/data/repo/IdMappingRepository.kt index 5fc020a2..41051d79 100644 --- a/app/src/main/java/org/qosp/notes/data/repo/IdMappingRepository.kt +++ b/app/src/main/java/org/qosp/notes/data/repo/IdMappingRepository.kt @@ -10,8 +10,6 @@ class IdMappingRepository(private val idMappingDao: IdMappingDao) { suspend fun update(vararg mappings: IdMapping) = idMappingDao.update(*mappings) - suspend fun delete(vararg mappings: IdMapping) = idMappingDao.delete(*mappings) - suspend fun assignProviderToNote(mapping: IdMapping) { val unassignedMappingId = idMappingDao.getNonRemoteByLocalId(mapping.localNoteId)?.mappingId @@ -24,27 +22,8 @@ class IdMappingRepository(private val idMappingDao: IdMappingDao) { idMappingDao.insert(mapping) } - suspend fun deleteByRemoteId(provider: CloudService, vararg remoteIds: Long) { - idMappingDao.deleteByRemoteId(provider, *remoteIds) - } - suspend fun getAllByLocalId(localId: Long) = idMappingDao.getAllByLocalId(localId) - suspend fun getByLocalIdAndProvider(localId: Long, provider: CloudService): IdMapping? { - return idMappingDao.getByLocalIdAndProvider(localId, provider) - } - - suspend fun getByRemoteId(remoteId: Long, provider: CloudService): IdMapping? { - return idMappingDao.getByRemoteId(remoteId, provider) - } - - suspend fun unassignProviderFromRemotelyDeletedNotes(idsInUse: List, provider: CloudService) { - idMappingDao.unassignProviderFromRemotelyDeletedNotes(idsInUse, provider) - } - - suspend fun unassignProviderFromNote(provider: CloudService, localId: Long) { - idMappingDao.unassignProviderFromNote(localId, provider) - } + suspend fun getAllByProvider(provider: CloudService) = idMappingDao.getAllByCloudService(provider) - suspend fun deleteIfLocalIdNotIn(ids: List) = idMappingDao.deleteIfLocalIdNotIn(ids) } diff --git a/app/src/main/java/org/qosp/notes/data/repo/NoteRepository.kt b/app/src/main/java/org/qosp/notes/data/repo/NoteRepository.kt index ed0b1a92..afbea872 100644 --- a/app/src/main/java/org/qosp/notes/data/repo/NoteRepository.kt +++ b/app/src/main/java/org/qosp/notes/data/repo/NoteRepository.kt @@ -1,192 +1,32 @@ package org.qosp.notes.data.repo import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import me.msoul.datastore.defaultOf -import org.qosp.notes.data.dao.IdMappingDao -import org.qosp.notes.data.dao.NoteDao -import org.qosp.notes.data.dao.ReminderDao import org.qosp.notes.data.model.IdMapping import org.qosp.notes.data.model.Note -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.data.sync.core.BaseResult import org.qosp.notes.preferences.CloudService import org.qosp.notes.preferences.SortMethod -import java.time.Instant -class NoteRepository( - private val noteDao: NoteDao, - private val idMappingDao: IdMappingDao, - private val reminderDao: ReminderDao, - private val syncManager: SyncManager?, -) { - - private suspend fun cleanMappingsForLocalNotes(vararg notes: Note) { - notes - .filter { it.isLocalOnly } - .also { notes -> - idMappingDao.setNotesToBeDeleted( - *notes - .map { it.id } - .toLongArray() - ) - } - } - - suspend fun insertNote(note: Note, shouldSync: Boolean = true): Long { - val id = noteDao.insert(note.toEntity()) - - if (!note.isLocalOnly && shouldSync) { - idMappingDao.insert( - IdMapping( - localNoteId = id, - remoteNoteId = null, - provider = null, - isDeletedLocally = false, - extras = null, - ) - ) - - if (syncManager == null) return id - - syncManager.syncingScope.launch { - syncManager.createNote(note.copy(id = id)) - } - } - - return id - } - - suspend fun updateNotes(vararg notes: Note, shouldSync: Boolean = true) { - val array = notes - .map { it.toEntity() } - .toTypedArray() - noteDao.update(*array) - - if (shouldSync && syncManager != null) { - syncManager.syncingScope.launch { - notes - .filterNot { it.isLocalOnly } - .forEach { - idMappingDao.setNoteIsBeingUpdated(it.id, true) - syncManager.updateOrCreate(it) - } - } - } - } - - suspend fun moveNotesToBin(vararg notes: Note, shouldSync: Boolean = true) { - val array = notes - .map { it.toEntity().copy(isDeleted = true, deletionDate = Instant.now().epochSecond) } - .toTypedArray() - noteDao.update(*array) - - reminderDao.deleteIfNoteIdIn(notes.map { it.id }) - cleanMappingsForLocalNotes(*notes) - - if (shouldSync && syncManager != null) { - syncManager.syncingScope.launch { - notes - .filterNot { it.isLocalOnly } - .forEach { syncManager.moveNoteToBin(it) } - } - } - } - - suspend fun restoreNotes(vararg notes: Note, shouldSync: Boolean = true) { - val array = notes - .map { it.toEntity().copy(isDeleted = false, deletionDate = null) } - .toTypedArray() - noteDao.update(*array) - - cleanMappingsForLocalNotes(*notes) - - if (shouldSync && syncManager != null) { - syncManager.syncingScope.launch { - notes - .filterNot { it.isLocalOnly } - .forEach { syncManager.restoreNote(it) } - } - } - } - - suspend fun deleteNotes(vararg notes: Note, shouldSync: Boolean = true) { - val array = notes - .map { it.toEntity() } - .toTypedArray() - noteDao.delete(*array) - - idMappingDao.setNotesToBeDeleted(*notes.map { it.id }.toLongArray()) - - if (shouldSync && syncManager != null) { - syncManager.syncingScope.launch { - notes - .filterNot { it.isLocalOnly } - .forEach { - syncManager.deleteNote(it) - } - } - } - } - - suspend fun discardEmptyNotes(): Boolean { - val notes = noteDao.getAll(defaultOf()) - .first() - .filter { it.isEmpty() } - .toTypedArray() - - deleteNotes(*notes) - - return notes.isNotEmpty() - } - - suspend fun permanentlyDeleteNotesInBin() { - val noteIds = noteDao.getDeleted(defaultOf()) - .first() - .map { it.id } - .toLongArray() - - idMappingDao.setNotesToBeDeleted(*noteIds) - noteDao.permanentlyDeleteNotesInBin() - } - - fun getById(noteId: Long): Flow { - return noteDao.getById(noteId) - } - - fun getDeleted(sortMethod: SortMethod = defaultOf()): Flow> { - return noteDao.getDeleted(sortMethod) - } - - fun getArchived(sortMethod: SortMethod = defaultOf()): Flow> { - return noteDao.getArchived(sortMethod) - } - - fun getNonDeleted(sortMethod: SortMethod = defaultOf()): Flow> { - return noteDao.getNonDeleted(sortMethod) - } - - fun getNonDeletedOrArchived(sortMethod: SortMethod = defaultOf()): Flow> { - return noteDao.getNonDeletedOrArchived(sortMethod) - } - - fun getAll(sortMethod: SortMethod = defaultOf()): Flow> { - return noteDao.getAll(sortMethod) - } - - fun getByNotebook(notebookId: Long, sortMethod: SortMethod = defaultOf()): Flow> { - return noteDao.getByNotebook(notebookId, sortMethod) - } - - fun getNonRemoteNotes(provider: CloudService, sortMethod: SortMethod = defaultOf()): Flow> { - return noteDao.getNonRemoteNotes(sortMethod, provider) - } - - fun getNotesWithoutNotebook(sortMethod: SortMethod = defaultOf()): Flow> { - return noteDao.getNotesWithoutNotebook(sortMethod) - } - - suspend fun moveRemotelyDeletedNotesToBin(idsInUse: List, provider: CloudService) { - noteDao.moveRemotelyDeletedNotesToBin(idsInUse, provider) - } +interface NoteRepository { + suspend fun insertNote(note: Note, sync: Boolean = true): Long + suspend fun updateNotes(vararg notes: Note, sync: Boolean = true) + suspend fun moveNotesToBin(vararg notes: Note, sync: Boolean = true) + suspend fun restoreNotes(vararg notes: Note) + suspend fun deleteNotes(vararg notes: Note, sync: Boolean = true) + suspend fun discardEmptyNotes(): Boolean + suspend fun permanentlyDeleteNotesInBin() + + suspend fun syncNotes(): BaseResult + fun getById(noteId: Long): Flow + fun getDeleted(sortMethod: SortMethod = defaultOf()): Flow> + fun getArchived(sortMethod: SortMethod = defaultOf()): Flow> + fun getNonDeleted(sortMethod: SortMethod = defaultOf()): Flow> + fun getNonDeletedOrArchived(sortMethod: SortMethod = defaultOf()): Flow> + fun getAll(sortMethod: SortMethod = defaultOf()): Flow> + fun getByNotebook(notebookId: Long, sortMethod: SortMethod = defaultOf()): Flow> + fun getNonRemoteNotes(provider: CloudService, sortMethod: SortMethod = defaultOf()): Flow> + fun getNotesWithoutNotebook(sortMethod: SortMethod = defaultOf()): Flow> + suspend fun getNotesByCloudService(provider: CloudService): Map + suspend fun deleteIdMappingsForCloudService(cloudService: CloudService) } diff --git a/app/src/main/java/org/qosp/notes/data/repo/NoteRepositoryImpl.kt b/app/src/main/java/org/qosp/notes/data/repo/NoteRepositoryImpl.kt new file mode 100644 index 00000000..44e06ed1 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/repo/NoteRepositoryImpl.kt @@ -0,0 +1,274 @@ +package org.qosp.notes.data.repo + +import android.util.Log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import me.msoul.datastore.defaultOf +import org.qosp.notes.data.dao.IdMappingDao +import org.qosp.notes.data.dao.NoteDao +import org.qosp.notes.data.dao.ReminderDao +import org.qosp.notes.data.model.IdMapping +import org.qosp.notes.data.model.Note +import org.qosp.notes.data.model.NoteEntity +import org.qosp.notes.data.sync.core.BackendProvider +import org.qosp.notes.data.sync.core.BaseResult +import org.qosp.notes.data.sync.core.GenericError +import org.qosp.notes.data.sync.core.ISyncBackend +import org.qosp.notes.data.sync.core.NoteAction +import org.qosp.notes.data.sync.core.ProcessRemoteActions +import org.qosp.notes.data.sync.core.RemoteOperation.Create +import org.qosp.notes.data.sync.core.RemoteOperation.Delete +import org.qosp.notes.data.sync.core.RemoteOperation.Update +import org.qosp.notes.data.sync.core.Success +import org.qosp.notes.data.sync.core.SyncMethod +import org.qosp.notes.data.sync.core.SyncNotesResult +import org.qosp.notes.data.sync.core.SynchronizeNotes +import org.qosp.notes.data.sync.getMapping +import org.qosp.notes.data.sync.toLocalNote +import org.qosp.notes.di.SyncScope +import org.qosp.notes.preferences.CloudService +import org.qosp.notes.preferences.SortMethod +import java.time.Instant + +class NoteRepositoryImpl( + private val noteDao: NoteDao, + private val idMappingDao: IdMappingDao, + private val reminderDao: ReminderDao, + private val backendProvider: BackendProvider, + private val synchronizeNotes: SynchronizeNotes, + private val processRemoteActions: ProcessRemoteActions, + private val syncingScope: SyncScope +) : NoteRepository { + + private val tag = NoteRepositoryImpl::class.java.simpleName + + private suspend fun cleanMappingsForLocalNotes(vararg notes: Note) { + val n = notes.filter { it.isLocalOnly } + Log.d(tag, "cleanMappingsForLocalNotes: Cleaning ${n.size} local-only notes from ${notes.size} total") + idMappingDao.setNotesToBeDeleted(*n.map { it.id }.toLongArray()) + } + + override suspend fun syncNotes(): BaseResult { + Log.d(tag, "syncNotes: Starting synchronization") + + val syncProvider = backendProvider.syncProvider.value + if (syncProvider == null || !backendProvider.isSyncing) { + Log.i(tag, "syncNotes: Sync not available or disabled") + return Success + } + val syncMethod = + if (idMappingDao.getCountByCloudService(syncProvider.type) == 0) + SyncMethod.TITLE else SyncMethod.MAPPING + try { + // Get all local notes (excluding local-only ones) + val localNotes = getAll().first().filterNot { it.isLocalOnly || it.isDeleted } + + // Get all remote notes and convert to metadata + val allRemoteNotes = syncProvider.getAll() + Log.d( + tag, "syncNotes: Syncing by $syncMethod. " + + "Found ${allRemoteNotes.size} remote notes, and ${localNotes.size} local notes" + ) + + // Use SynchronizeNotes to determine what updates are needed + val syncResult = + synchronizeNotes(localNotes, allRemoteNotes, service = syncProvider.type, syncMethod) + Log.d(tag, "sync updates: ${syncResult.localUpdates.size} local, ${syncResult.remoteUpdates.size} remote") + + if (syncMethod == SyncMethod.TITLE) applyMappingChanges(syncResult, syncProvider) // Initial import + applyLocalUpdates(syncResult.localUpdates, syncProvider) + applyRemoteUpdates(syncResult.remoteUpdates) + Log.i(tag, "syncNotes: Synchronization completed successfully") + } catch (e: Exception) { + Log.e(tag, "syncNotes: Synchronization failed: ${e.message}", e) + return GenericError(e.message ?: "Unknown error") + } + return Success + } + + private suspend fun applyLocalUpdates(localUpdates: List, syncProvider: ISyncBackend) { + for (action in localUpdates) { + try { + when (action) { + is NoteAction.Create -> { + val syncNote = action.remoteNote + val noteId = insertNote(syncNote.toLocalNote(), sync = false) + idMappingDao.insert(syncNote.getMapping(noteId, syncProvider.type)) + } + + is NoteAction.Update -> { + val localNote = action.remoteNote.toLocalNote() + val note = if (action.note.isList) { + val tasks = localNote.mdToTaskList(localNote.content) + localNote.copy(id = action.note.id, content = "", taskList = tasks, isList = true) + } else { + localNote.copy(id = action.note.id) + } + idMappingDao.updateNoteExtras( + localId = action.note.id, + cloudService = syncProvider.type, + extras = action.remoteNote.extra + ) + updateNote(note, sync = false) + } + + is NoteAction.Delete -> { + moveNotesToBin(action.note, sync = false) + } + } + } catch (e: Exception) { + Log.e(tag, "applyLocalUpdates: Failed to apply action $action: ${e.message}") + } + } + } + + private fun applyRemoteUpdates(remoteUpdates: List) { + for (action in remoteUpdates) { + try { + when (action) { + is NoteAction.Create -> processRemoteActions(action.note.id, Create(action.note)) + is NoteAction.Update -> processRemoteActions(action.note.id, Update(action.note)) + is NoteAction.Delete -> processRemoteActions(action.note.id, Delete(action.note)) + } + } catch (e: Exception) { + Log.e(tag, "applyRemoteUpdates: Failed to apply action $action: ${e.message}") + } + } + } + + private suspend fun applyMappingChanges(syncResult: SyncNotesResult, syncProvider: ISyncBackend) { + syncResult.newMappings.forEach { idMappingDao.insert(it) } + syncResult.localUpdates.filterIsInstance().map { + it.remoteNote.getMapping(it.note.id, syncProvider.type) + }.forEach { idMappingDao.insert(it) } + syncResult.remoteUpdates.filterIsInstance().map { + it.remoteNote.getMapping(it.note.id, syncProvider.type) + }.forEach { idMappingDao.insert(it) } + } + + override suspend fun insertNote(note: Note, sync: Boolean): Long { + Log.d(tag, "insertNote: Creating note '${note.title}', isLocalOnly=${note.isLocalOnly}") + val noteId = noteDao.insert(note.toEntity()) + if (note.isLocalOnly.not() && backendProvider.isSyncing && sync) { + val note1 = note.copy(id = noteId) + processRemoteActions(note1.id, Create(note1)) + } + return noteId + } + + override suspend fun updateNotes(vararg notes: Note, sync: Boolean) = notes.forEach { updateNote(it, sync) } + + private suspend fun updateNote(note: Note, sync: Boolean) { + Log.d(tag, "updateNote: Updating note ID=${note.id}, title='${note.title}'") + noteDao.update(note.toEntity()) + if (note.isLocalOnly.not() && backendProvider.isSyncing && sync) { + processRemoteActions(note.id, Update(note)) + } + } + + override suspend fun moveNotesToBin(vararg notes: Note, sync: Boolean) { + Log.d(tag, "moveNotesToBin: Moving ${notes.size} notes to bin") + val entities = notes.map { it.toEntity().copy(isDeleted = true, deletionDate = Instant.now().epochSecond) } + .toTypedArray() + + noteDao.update(*entities) + reminderDao.deleteIfNoteIdIn(notes.map { it.id }) + cleanMappingsForLocalNotes(*notes) + notes.filterNot { it.isLocalOnly }.forEach { + if (sync) processRemoteActions(it.id, Delete(it)) + } + } + + override suspend fun restoreNotes(vararg notes: Note) { + Log.d(tag, "restoreNotes: Restoring ${notes.size} notes from bin") + val array = notes + .map { it.toEntity().copy(isDeleted = false, deletionDate = null) } + .toTypedArray() + noteDao.update(*array) + cleanMappingsForLocalNotes(*notes) + if (backendProvider.isSyncing) { + backendProvider.syncProvider.value?.let { syncProvider -> + syncingScope.launch { + val syncableNotes = notes.filterNot { it.isLocalOnly } + Log.d(tag, "restoreNotes: Re-syncing ${syncableNotes.size} restored notes to ${syncProvider.type}") + syncableNotes + .associateWith { syncProvider.createNote(it) } + .forEach { (n, syncNote) -> + idMappingDao.insert(syncNote.getMapping(n.id, syncProvider.type)) + noteDao.updateLastModified(n.id, syncNote.lastModified) + } + } + } + } + } + + override suspend fun deleteNotes(vararg notes: Note, sync: Boolean) { + Log.d(tag, "deleteNotes: Permanently deleting ${notes.size} notes") + val array = notes.map { it.toEntity() }.toTypedArray() + noteDao.delete(*array) + if (sync) notes.filterNot { it.isLocalOnly }.forEach { + processRemoteActions(it.id, Delete(it)) + } + } + + override suspend fun discardEmptyNotes(): Boolean { + val notes = noteDao.getAllBlankTitleNotes().first().filter { it.isEmpty() }.toTypedArray() + Log.d(tag, "discardEmptyNotes: Found ${notes.size} empty notes to discard") + deleteNotes(*notes) + return notes.isNotEmpty() + } + + override suspend fun permanentlyDeleteNotesInBin() { + val noteIds = noteDao.getDeleted(defaultOf()).first().map { it.id }.toLongArray() + Log.d(tag, "permanentlyDeleteNotesInBin: Permanently deleting ${noteIds.size} notes from bin") + idMappingDao.deleteByLocalId(*noteIds) + noteDao.permanentlyDeleteNotesInBin() + } + + override fun getById(noteId: Long): Flow { + return noteDao.getById(noteId) + } + + override fun getDeleted(sortMethod: SortMethod): Flow> { + return noteDao.getDeleted(sortMethod) + } + + override fun getArchived(sortMethod: SortMethod): Flow> { + return noteDao.getArchived(sortMethod) + } + + override fun getNonDeleted(sortMethod: SortMethod): Flow> { + return noteDao.getNonDeleted(sortMethod) + } + + override fun getNonDeletedOrArchived(sortMethod: SortMethod): Flow> { + return noteDao.getNonDeletedOrArchived(sortMethod) + } + + override fun getAll(sortMethod: SortMethod): Flow> { + return noteDao.getAll(sortMethod) + } + + override fun getByNotebook(notebookId: Long, sortMethod: SortMethod): Flow> { + return noteDao.getByNotebook(notebookId, sortMethod) + } + + override fun getNonRemoteNotes(provider: CloudService, sortMethod: SortMethod): Flow> { + return noteDao.getNonRemoteNotes(sortMethod, provider) + } + + override fun getNotesWithoutNotebook(sortMethod: SortMethod): Flow> { + return noteDao.getNotesWithoutNotebook(sortMethod) + } + + override suspend fun getNotesByCloudService(provider: CloudService): Map { + val allNotes = getAll().first().associateBy { it.id } + val mappings = idMappingDao.getAllByCloudService(provider) + Log.d(tag, "getNotesByCloudService: Found ${mappings.size} mappings for $provider") + return mappings.associateWith { allNotes[it.localNoteId] } + } + + override suspend fun deleteIdMappingsForCloudService(cloudService: CloudService) = + idMappingDao.deleteAllMappingsFor(cloudService) +} diff --git a/app/src/main/java/org/qosp/notes/data/repo/NotebookRepository.kt b/app/src/main/java/org/qosp/notes/data/repo/NotebookRepository.kt index babe3178..ac03dd05 100755 --- a/app/src/main/java/org/qosp/notes/data/repo/NotebookRepository.kt +++ b/app/src/main/java/org/qosp/notes/data/repo/NotebookRepository.kt @@ -2,48 +2,29 @@ package org.qosp.notes.data.repo import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import org.qosp.notes.data.dao.NotebookDao import org.qosp.notes.data.model.Notebook -import org.qosp.notes.data.sync.core.SyncManager class NotebookRepository( private val notebookDao: NotebookDao, private val noteRepository: NoteRepository, - private val syncManager: SyncManager?, ) { suspend fun insert(notebook: Notebook): Long { return notebookDao.insert(notebook) } - suspend fun delete(vararg notebooks: Notebook, shouldSync: Boolean = true) { + suspend fun delete(vararg notebooks: Notebook) { val affectedNotes = notebooks .map { noteRepository.getByNotebook(it.id).first() } .flatten() .filterNot { it.isLocalOnly } notebookDao.delete(*notebooks) - - if (shouldSync && syncManager != null) { - syncManager.syncingScope.launch { - affectedNotes.forEach { syncManager.updateNote(it) } - } - } } suspend fun update(vararg notebooks: Notebook, shouldSync: Boolean = true) { notebookDao.update(*notebooks) - - if (shouldSync && syncManager != null) { - syncManager.syncingScope.launch { - notebooks - .map { noteRepository.getByNotebook(it.id).first() } - .flatten() - .filterNot { it.isLocalOnly } - .forEach { syncManager.updateNote(it) } - } - } } fun getById(notebookId: Long): Flow { diff --git a/app/src/main/java/org/qosp/notes/data/repo/TagRepository.kt b/app/src/main/java/org/qosp/notes/data/repo/TagRepository.kt index 2cdc5cec..ba36dab5 100644 --- a/app/src/main/java/org/qosp/notes/data/repo/TagRepository.kt +++ b/app/src/main/java/org/qosp/notes/data/repo/TagRepository.kt @@ -7,13 +7,13 @@ import org.qosp.notes.data.dao.NoteTagDao import org.qosp.notes.data.dao.TagDao import org.qosp.notes.data.model.NoteTagJoin import org.qosp.notes.data.model.Tag -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.di.SyncScope class TagRepository( private val tagDao: TagDao, private val noteTagDao: NoteTagDao, private val noteRepository: NoteRepository, - private val syncManager: SyncManager, + private val syncScope: SyncScope, ) { fun getAll(): Flow> { @@ -45,8 +45,8 @@ class TagRepository( tagDao.delete(*tags) if (shouldSync) { - syncManager.syncingScope.launch { - affectedNotes.forEach { syncManager.updateNote(it) } + syncScope.launch { + affectedNotes.forEach { noteRepository.updateNotes(it) } } } } @@ -59,9 +59,9 @@ class TagRepository( noteTagDao.insert(NoteTagJoin(tagId, noteId)) if (shouldSync) { - syncManager.syncingScope.launch { + syncScope.launch { val note = noteRepository.getById(noteId).first()?.takeUnless { it.isLocalOnly } ?: return@launch - syncManager.updateNote(note) + noteRepository.updateNotes(note) } } } @@ -70,9 +70,9 @@ class TagRepository( noteTagDao.delete(NoteTagJoin(tagId, noteId)) if (shouldSync) { - syncManager.syncingScope.launch { + syncScope.launch { val note = noteRepository.getById(noteId).first()?.takeUnless { it.isLocalOnly } ?: return@launch - syncManager.updateNote(note) + noteRepository.updateNotes(note) } } } diff --git a/app/src/main/java/org/qosp/notes/data/sync/Converters.kt b/app/src/main/java/org/qosp/notes/data/sync/Converters.kt new file mode 100644 index 00000000..5cec43e4 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/Converters.kt @@ -0,0 +1,39 @@ +package org.qosp.notes.data.sync + +import org.qosp.notes.data.model.IdMapping +import org.qosp.notes.data.model.Note +import org.qosp.notes.data.sync.core.SyncNote +import org.qosp.notes.data.sync.nextcloud.NextcloudNote +import org.qosp.notes.preferences.CloudService + +fun NextcloudNote.asSyncNote() = SyncNote( + id = id, + idStr = id.toString(), + content = content, + title = title, + lastModified = modified, // Nextcloud already uses epoch seconds. + extra = etag, + category = category, + favorite = favorite, + readOnly = readOnly == true, +) + +// Convert SyncNote to local Note with full content +fun SyncNote.toLocalNote() = Note( + id = 0L, // Will be assigned by a database + title = title, + content = content ?: "", + isPinned = favorite, + modifiedDate = lastModified, + notebookId = null, // TODO: Handle category to notebook conversion if needed + isMarkdownEnabled = true // Default to Markdown enabled +) + +fun SyncNote.getMapping(noteId: Long, service: CloudService) = IdMapping( + localNoteId = noteId, + remoteNoteId = id, + provider = service, + extras = extra, + isDeletedLocally = false, + storageUri = idStr +) diff --git a/app/src/main/java/org/qosp/notes/data/sync/core/BackendProvider.kt b/app/src/main/java/org/qosp/notes/data/sync/core/BackendProvider.kt new file mode 100644 index 00000000..20906819 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/core/BackendProvider.kt @@ -0,0 +1,49 @@ +package org.qosp.notes.data.sync.core + +import android.content.Context +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.qosp.notes.data.sync.fs.StorageBackend +import org.qosp.notes.data.sync.fs.StorageConfig +import org.qosp.notes.data.sync.nextcloud.NextcloudAPIProvider +import org.qosp.notes.data.sync.nextcloud.NextcloudBackend +import org.qosp.notes.data.sync.nextcloud.NextcloudConfig +import org.qosp.notes.di.SyncScope +import org.qosp.notes.preferences.AppPreferences +import org.qosp.notes.preferences.CloudService +import org.qosp.notes.preferences.PreferenceRepository +import org.qosp.notes.ui.utils.ConnectionManager + +class BackendProvider( + private val context: Context, + private val nextcloudApiProvider: NextcloudAPIProvider, + preferenceRepository: PreferenceRepository, + syncingScope: SyncScope, + private val connectionManager: ConnectionManager, +) { + private val syncService: Flow = preferenceRepository.getAll().map { it.cloudService } + private val pref: StateFlow = + preferenceRepository.getAll().stateIn(syncingScope, SharingStarted.Eagerly, null) + + val syncProvider: StateFlow = combine( + syncService, + NextcloudConfig.fromPreferences(preferenceRepository), + StorageConfig.storageLocation(preferenceRepository) + ) { service, nextcloudConfig, storageConfig -> + when (service) { + CloudService.DISABLED -> null + CloudService.NEXTCLOUD -> nextcloudConfig?.let { NextcloudBackend(nextcloudApiProvider, it) } + CloudService.FILE_STORAGE -> storageConfig?.let { StorageBackend(context, it) } + } + }.stateIn(syncingScope, SharingStarted.Eagerly, null) + + val isSyncing: Boolean + get() = syncProvider.value != null && connectionManager.isConnectionAvailable( + syncMode = pref.value?.syncMode, + cloudService = syncProvider.value?.type + ) +} diff --git a/app/src/main/java/org/qosp/notes/data/sync/core/BaseResult.kt b/app/src/main/java/org/qosp/notes/data/sync/core/BaseResult.kt index a06e640b..ff3c577a 100644 --- a/app/src/main/java/org/qosp/notes/data/sync/core/BaseResult.kt +++ b/app/src/main/java/org/qosp/notes/data/sync/core/BaseResult.kt @@ -1,9 +1,11 @@ package org.qosp.notes.data.sync.core -sealed class BaseResult(val message: String? = null) +sealed class BaseResult(val message: String? = null) { + override fun toString(): String = this::class.java.simpleName +} object Success : BaseResult() -object OperationNotSupported : BaseResult() +data class OperationNotSupported(val msg: String?) : BaseResult(msg) object NoConnectivity : BaseResult() object SyncingNotEnabled : BaseResult() @@ -14,5 +16,5 @@ object Unauthorized : BaseResult() class ApiError(msg: String, val code: Int) : BaseResult(msg) class GenericError(msg: String) : BaseResult(msg) - +class SecurityError(msg: String?): BaseResult(msg) object ServerNotSupportedException : Exception() diff --git a/app/src/main/java/org/qosp/notes/data/sync/core/ISyncBackend.kt b/app/src/main/java/org/qosp/notes/data/sync/core/ISyncBackend.kt new file mode 100644 index 00000000..99678e3e --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/core/ISyncBackend.kt @@ -0,0 +1,14 @@ +package org.qosp.notes.data.sync.core + +import org.qosp.notes.data.model.IdMapping +import org.qosp.notes.data.model.Note +import org.qosp.notes.preferences.CloudService + +interface ISyncBackend { + val type: CloudService + suspend fun createNote(note: Note): SyncNote + suspend fun updateNote(note: Note, mapping: IdMapping): IdMapping + suspend fun deleteNote(mapping: IdMapping): Boolean + suspend fun getNote(mapping: IdMapping): SyncNote? + suspend fun getAll(): List +} diff --git a/app/src/main/java/org/qosp/notes/data/sync/core/ProcessRemoteActions.kt b/app/src/main/java/org/qosp/notes/data/sync/core/ProcessRemoteActions.kt new file mode 100644 index 00000000..33023405 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/core/ProcessRemoteActions.kt @@ -0,0 +1,118 @@ +package org.qosp.notes.data.sync.core + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.qosp.notes.Config +import org.qosp.notes.data.dao.IdMappingDao +import org.qosp.notes.data.dao.NoteDao +import org.qosp.notes.data.model.Note +import org.qosp.notes.data.sync.core.RemoteOperation.Create +import org.qosp.notes.data.sync.core.RemoteOperation.Delete +import org.qosp.notes.data.sync.core.RemoteOperation.Update +import org.qosp.notes.data.sync.getMapping +import org.qosp.notes.di.SyncScope +import org.qosp.notes.ui.utils.Toaster +import java.util.concurrent.ConcurrentHashMap + +class ProcessRemoteActions( + private val syncingScope: SyncScope, + private val backendProvider: BackendProvider, + private val idMappingDao: IdMappingDao, + private val noteDao: NoteDao, + private val toaster: Toaster, +) { + private val tag = "ProcessRemoteActions" + + // Thread-safe map to store the latest operation for each noteId + private val operationQueue = ConcurrentHashMap() + + // Map to track pending remote operation jobs by noteId + private val pendingJobs = ConcurrentHashMap() + + /** + * Process a remote operation for a note. + * This method ensures that only the latest operation for each noteId is processed, + * and earlier operations are ignored. + */ + operator fun invoke(noteId: Long, operation: RemoteOperation) { + if (!backendProvider.isSyncing) return // Not syncing now + + // Store the operation in the queue, replacing any existing operation for this noteId + operationQueue[noteId] = operation + + if (pendingJobs[noteId] != null) return // If there's an existing job, let it finish. + val job = syncingScope.launch { // Create a new job to process the operation + var last: String = "" + try { + // Add debounce delay to allow for batching of rapid operations + delay(Config.RemoteUpdateDebounceTime) + + // Get the latest operation for this noteId + var latestAction = operationQueue.remove(noteId) + while (latestAction != null) { + // Process the operation based on its type + when (latestAction) { + is Create -> { + last = latestAction.note.content.takeLast(4) + Log.d(tag, "insertRemoteNote: for note ID=$noteId ending ...$last") + backendProvider.syncProvider.value?.let { insertRemote(it, latestAction.note) } + } + + is Update -> { + last = latestAction.note.content.takeLast(4) + Log.d(tag, "updateRemoteNote: for note ID=$noteId ending ...$last") + backendProvider.syncProvider.value?.let { updateRemote(latestAction.note, it) } + } + + is Delete -> { + last = latestAction.note.content.takeLast(4) + Log.d(tag, "deleteRemoteNotes: for $noteId ending ...$last") + backendProvider.syncProvider.value?.let { deleteRemoteNotes(latestAction.note, it) } + } + } + latestAction = operationQueue.remove(noteId) + } + } catch (_: CancellationException) { + // Job was canceled, which is expected when a newer operation comes in + } catch (e: Exception) { + Log.e(tag, "processRemoteOperation: Error processing operation: ${e.message}", e) + } finally { + // Remove the job from the maps when done + pendingJobs.remove(noteId) + Log.d(tag, "Completed ...$last") + } + } + // Store the job in the map + pendingJobs[noteId] = job + } + + private suspend fun deleteRemoteNotes(note: Note, syncProvider: ISyncBackend) = try { + idMappingDao.getByLocalIdAndProvider(note.id, syncProvider.type)?.let { mapping -> + syncProvider.deleteNote(mapping = mapping) + idMappingDao.deleteByLocalId(note.id) + } + } catch (e: Exception) { + Log.e(tag, "processRemoteOperation: Failed to delete notes: ${e.message}", e) + } + + private suspend fun insertRemote(syncProvider: ISyncBackend, note: Note) = try { + val created = syncProvider.createNote(note) + idMappingDao.insert(created.getMapping(note.id, syncProvider.type)) + noteDao.updateLastModified(note.id, created.lastModified) + } catch (e: Exception) { + Log.e(tag, "processRemoteOperation: Failed to create note ID=${note.id}: ${e.message}", e) + toaster.showShort(e.message ?: "Failed to create note") + } + + private suspend fun updateRemote(note: Note, syncProvider: ISyncBackend) = try { + idMappingDao.getByLocalIdAndProvider(note.id, syncProvider.type)?.let { + val updatedMapping = syncProvider.updateNote(note, it) + idMappingDao.update(updatedMapping) + } + } catch (e: Exception) { + Log.e(tag, "processRemoteOperation: Failed to update note ID=${note.id}: ${e.message}", e) + } +} diff --git a/app/src/main/java/org/qosp/notes/data/sync/core/ProviderConfig.kt b/app/src/main/java/org/qosp/notes/data/sync/core/ProviderConfig.kt deleted file mode 100644 index 6c3aa9fc..00000000 --- a/app/src/main/java/org/qosp/notes/data/sync/core/ProviderConfig.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.qosp.notes.data.sync.core - -import org.qosp.notes.preferences.CloudService - -interface ProviderConfig { - val remoteAddress: String - val username: String - val provider: CloudService - val authenticationHeaders: Map -} diff --git a/app/src/main/java/org/qosp/notes/data/sync/core/SyncDtos.kt b/app/src/main/java/org/qosp/notes/data/sync/core/SyncDtos.kt new file mode 100644 index 00000000..5deae6ea --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/core/SyncDtos.kt @@ -0,0 +1,27 @@ +package org.qosp.notes.data.sync.core + +import org.qosp.notes.data.model.Note + +// Sealed class to represent remote operations +sealed class RemoteOperation { + data class Create(val note: Note, val import: Boolean = false) : RemoteOperation() + data class Update(val note: Note) : RemoteOperation() + data class Delete(val note: Note) : RemoteOperation() +} + +enum class SyncMethod { + MAPPING, + TITLE, +} + +data class SyncNote( + val id: Long, + val idStr: String, + val content: String?, + val title: String, + val lastModified: Long, // Epoch seconds + val extra: String? = null, + val category: String = "", + val favorite: Boolean = false, + val readOnly: Boolean = false, +) diff --git a/app/src/main/java/org/qosp/notes/data/sync/core/SyncManager.kt b/app/src/main/java/org/qosp/notes/data/sync/core/SyncManager.kt deleted file mode 100644 index 34ad663b..00000000 --- a/app/src/main/java/org/qosp/notes/data/sync/core/SyncManager.kt +++ /dev/null @@ -1,140 +0,0 @@ -package org.qosp.notes.data.sync.core - -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.ObsoleteCoroutinesApi -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import org.qosp.notes.data.model.Note -import org.qosp.notes.data.repo.IdMappingRepository -import org.qosp.notes.data.sync.nextcloud.NextcloudConfig -import org.qosp.notes.data.sync.nextcloud.NextcloudManager -import org.qosp.notes.preferences.CloudService -import org.qosp.notes.preferences.PreferenceRepository -import org.qosp.notes.preferences.SyncMode -import org.qosp.notes.ui.utils.ConnectionManager - -class SyncManager( - private val preferenceRepository: PreferenceRepository, - private val idMappingRepository: IdMappingRepository, - val connectionManager: ConnectionManager, - private val nextcloudManager: NextcloudManager, - val syncingScope: CoroutineScope, -) { - - @OptIn(ExperimentalCoroutinesApi::class) - val prefs: Flow = preferenceRepository.getAll().flatMapLatest { prefs -> - when (prefs.cloudService) { - CloudService.DISABLED -> flowOf(SyncPrefs(false, null, prefs.syncMode, null)) - CloudService.NEXTCLOUD -> { - NextcloudConfig.fromPreferences(preferenceRepository).map { config -> - SyncPrefs(true, nextcloudManager, prefs.syncMode, config) - } - } - } - } - - val config = prefs.map { prefs -> prefs.config } - .stateIn(syncingScope, SharingStarted.WhileSubscribed(5000), null) - - @OptIn(ObsoleteCoroutinesApi::class) - private val actor = syncingScope.actor { - - for (msg in channel) { - with(msg) { - val result = when (this) { - is CreateNote -> provider.createNote(note, config) - is DeleteNote -> provider.deleteNote(note, config) - is MoveNoteToBin -> provider.moveNoteToBin(note, config) - is RestoreNote -> provider.restoreNote(note, config) - is Sync -> provider.sync(config) - is UpdateNote -> provider.updateNote(note, config) - is Authenticate -> provider.authenticate(config) - is IsServerCompatible -> provider.isServerCompatible(config) - is UpdateOrCreateNote -> { - val exists = idMappingRepository.getByLocalIdAndProvider(note.id, config.provider) != null - if (exists) provider.updateNote(note, config) else provider.createNote(note, config) - } - } - - deferred.complete(result) - } - } - } - - suspend inline fun ifSyncing( - customConfig: ProviderConfig? = null, - fallback: () -> Unit = {}, - block: (SyncProvider, ProviderConfig) -> BaseResult, - ): BaseResult { - val (isEnabled, provider, mode, prefConfig) = prefs.first() - val config = customConfig ?: prefConfig - - return when { - !isEnabled -> SyncingNotEnabled.also { fallback() } - provider == null || config == null -> InvalidConfig.also { fallback() } - !connectionManager.isConnectionAvailable(mode) -> NoConnectivity.also { fallback() } - else -> block(provider, config) - } - } - - private suspend inline fun sendMessage( - customConfig: ProviderConfig? = null, - crossinline block: suspend (SyncProvider, ProviderConfig) -> Message, - ): BaseResult { - return ifSyncing(customConfig) { provider, config -> - val message = block(provider, config) - actor.send(message) - message.deferred.await() - } - } - - suspend fun sync() = sendMessage { provider, config -> Sync(provider, config) } - - suspend fun createNote(note: Note) = sendMessage { provider, config -> CreateNote(note, provider, config) } - - suspend fun deleteNote(note: Note) = sendMessage { provider, config -> DeleteNote(note, provider, config) } - - suspend fun moveNoteToBin(note: Note) = sendMessage { provider, config -> MoveNoteToBin(note, provider, config) } - - suspend fun restoreNote(note: Note) = sendMessage { provider, config -> RestoreNote(note, provider, config) } - - suspend fun updateNote(note: Note) = sendMessage { provider, config -> UpdateNote(note, provider, config) } - - suspend fun updateOrCreate(note: Note) = sendMessage { provider, config -> UpdateOrCreateNote(note, provider, config) } - - suspend fun isServerCompatible(customConfig: ProviderConfig? = null) = sendMessage(customConfig) { provider, config -> - IsServerCompatible(provider, config) - } - - suspend fun authenticate(customConfig: ProviderConfig? = null) = sendMessage(customConfig) { provider, config -> - Authenticate(provider, config) - } -} - -data class SyncPrefs( - val isEnabled: Boolean, - val provider: SyncProvider?, - val mode: SyncMode, - val config: ProviderConfig?, -) - -private sealed class Message(val provider: SyncProvider, val config: ProviderConfig) { - val deferred: CompletableDeferred = CompletableDeferred() -} -private class CreateNote(val note: Note, provider: SyncProvider, config: ProviderConfig) : Message(provider, config) -private class UpdateNote(val note: Note, provider: SyncProvider, config: ProviderConfig) : Message(provider, config) -private class UpdateOrCreateNote(val note: Note, provider: SyncProvider, config: ProviderConfig) : Message(provider, config) -private class DeleteNote(val note: Note, provider: SyncProvider, config: ProviderConfig) : Message(provider, config) -private class RestoreNote(val note: Note, provider: SyncProvider, config: ProviderConfig) : Message(provider, config) -private class MoveNoteToBin(val note: Note, provider: SyncProvider, config: ProviderConfig) : Message(provider, config) -private class Sync(provider: SyncProvider, config: ProviderConfig) : Message(provider, config) -private class Authenticate(provider: SyncProvider, config: ProviderConfig) : Message(provider, config) -private class IsServerCompatible(provider: SyncProvider, config: ProviderConfig) : Message(provider, config) diff --git a/app/src/main/java/org/qosp/notes/data/sync/core/SyncProvider.kt b/app/src/main/java/org/qosp/notes/data/sync/core/SyncProvider.kt deleted file mode 100644 index b31f2428..00000000 --- a/app/src/main/java/org/qosp/notes/data/sync/core/SyncProvider.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.qosp.notes.data.sync.core - -import org.qosp.notes.data.model.Note - -interface SyncProvider { - suspend fun sync(config: ProviderConfig): BaseResult - suspend fun createNote(note: Note, config: ProviderConfig): BaseResult - suspend fun deleteNote(note: Note, config: ProviderConfig): BaseResult - suspend fun updateNote(note: Note, config: ProviderConfig): BaseResult - - suspend fun moveNoteToBin(note: Note, config: ProviderConfig): BaseResult - suspend fun restoreNote(note: Note, config: ProviderConfig): BaseResult - - suspend fun authenticate(config: ProviderConfig): BaseResult - suspend fun isServerCompatible(config: ProviderConfig): BaseResult -} diff --git a/app/src/main/java/org/qosp/notes/data/sync/core/SynchronizeNotes.kt b/app/src/main/java/org/qosp/notes/data/sync/core/SynchronizeNotes.kt new file mode 100644 index 00000000..f8bea091 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/core/SynchronizeNotes.kt @@ -0,0 +1,186 @@ +package org.qosp.notes.data.sync.core + +import android.util.Log +import org.qosp.notes.data.model.IdMapping +import org.qosp.notes.data.model.Note +import org.qosp.notes.data.repo.IdMappingRepository +import org.qosp.notes.data.sync.getMapping +import org.qosp.notes.preferences.CloudService +import kotlin.math.abs + +class SynchronizeNotes(private val idMappingRepository: IdMappingRepository) { + private val tag = SynchronizeNotes::class.java.simpleName + + private fun logDebug(message: String) = Log.d(tag, message) + + suspend operator fun invoke( + localNotes: List, + remoteNotes: List, + service: CloudService, + method: SyncMethod = SyncMethod.MAPPING + ): SyncNotesResult { + logDebug("SynchronizeNotes: Starting synchronization with method: $method") + + val localUpdates = mutableListOf() + val remoteUpdates = mutableListOf() + val mappingUpdates = mutableListOf() + + when (method) { + SyncMethod.MAPPING -> { + // Fetch all mappings for this service at once to minimize idMapping queries (requirement #1) + val allMappings = idMappingRepository.getAllByProvider(service) + + // Create maps for faster lookups + val localNotesMap = localNotes.associateBy { it.id } + val remoteNotesMap = remoteNotes.associateBy { it.idStr } + + // Maps for local note ID to remote note ID and vice versa + val localToRemoteMap = allMappings.associateBy { it.localNoteId } + val remoteToLocalMap = allMappings.associateBy { + if (service == CloudService.NEXTCLOUD) it.remoteNoteId?.toString() ?: "" else it.storageUri ?: "" + }.filterKeys { it != "" } + + // Process local notes + for (localNote in localNotes) { + val mapping = localToRemoteMap[localNote.id] + + if (mapping != null) { + // Local note has a mapping to a remote note + val remoteNote = when (service) { + CloudService.NEXTCLOUD -> mapping.remoteNoteId.toString() + CloudService.FILE_STORAGE -> mapping.storageUri + else -> null + }?.let { remoteNotesMap[it] } + + if (remoteNote != null) { + // Both local and remote notes exist, compare last modified times + if (abs(localNote.modifiedDate - remoteNote.lastModified) <= 1) { + // no action + } else if (localNote.modifiedDate > remoteNote.lastModified) { + // Local note is newer, update remote + logDebug("Local note (${localNote.title}) is newer ${localNote.modifiedDate}, ${remoteNote.lastModified}") + remoteUpdates.add(NoteAction.Update(localNote, remoteNote)) + } else if (localNote.modifiedDate < remoteNote.lastModified) { + // Remote note is newer, update local + logDebug("Remote note(${remoteNote.title}) is newer ${remoteNote.lastModified}, ${localNote.modifiedDate}") + localUpdates.add(NoteAction.Update(localNote, remoteNote)) + } + // If equal, no action needed + } else { + // Remote note doesn't exist + // This happens when the remote note was deleted + val syncNote = SyncNote( + title = localNote.title, + lastModified = localNote.modifiedDate, + content = localNote.content, + idStr = "", // Empty ID for new remote notes + id = 0, + ) + logDebug("May be deleted remotely: $syncNote") + localUpdates.add(NoteAction.Delete(localNote, syncNote)) + } + + } else { + // Local note has no mapping, create a new remote note + val remoteNoteMetaData = SyncNote( + id = 0, // Empty ID for new remote notes + idStr = "", content = localNote.content, + title = localNote.title, lastModified = localNote.modifiedDate + ) + logDebug("New local note: ${localNote.title}") + remoteUpdates.add(NoteAction.Create(localNote, remoteNoteMetaData)) + } + } + + // Process remote notes + for (remoteNote in remoteNotes) { + val mapping = remoteToLocalMap[remoteNote.idStr] + if (mapping != null) { + // Remote note has a mapping to a local note + val localNote = localNotesMap[mapping.localNoteId] + + if (localNote == null || mapping.isDeletedLocally) { + // Local note doesn't exist anymore, delete the remote note + val dummyLocalNote = Note(id = mapping.localNoteId) + logDebug("Local note deleted. Deleting remotely: ${remoteNote.title}") + remoteUpdates.add(NoteAction.Delete(dummyLocalNote, remoteNote)) + } + // If the local note exists, it was already handled in the local notes loop + } else { + // Remote note has no mapping, create a new local note + val newLocalNote = Note( + title = remoteNote.title, modifiedDate = remoteNote.lastModified + ) + logDebug("New Remote note: ${remoteNote.title}") + localUpdates.add(NoteAction.Create(newLocalNote, remoteNote)) + } + } + } + + SyncMethod.TITLE -> { + // Create maps for faster lookups based on title + val localNotesMap = localNotes.associateBy { it.title } + val remoteNotesMap = remoteNotes.associateBy { it.title } + + // Process local notes + for (localNote in localNotes) { + val remoteNote = remoteNotesMap[localNote.title] + + if (remoteNote != null) { + // Both local and remote notes exist with the same title, compare last modified times + if (abs(localNote.modifiedDate - remoteNote.lastModified) <= 1) { + // About the same note, just create mapping + mappingUpdates.add(remoteNote.getMapping(localNote.id, service)) + } else if (localNote.modifiedDate > remoteNote.lastModified) { + // Local note is newer, update remote + logDebug("Local note (${localNote.title}) is newer ${localNote.modifiedDate}, ${remoteNote.lastModified}") + remoteUpdates.add(NoteAction.Update(localNote, remoteNote)) + } else if (localNote.modifiedDate < remoteNote.lastModified) { + // Remote note is newer, update local + logDebug("Remote note(${remoteNote.title}) is newer ${remoteNote.lastModified}, ${localNote.modifiedDate}") + localUpdates.add(NoteAction.Update(localNote, remoteNote)) + } + // If equal, no action needed + } else { + // No remote note with this title, create a new remote note + val syncNote = SyncNote( + id = 0, // Empty ID for new remote notes + idStr = "", content = localNote.content, + title = localNote.title, lastModified = localNote.modifiedDate + ) + logDebug("New local note: ${localNote.title}") + remoteUpdates.add(NoteAction.Create(localNote, syncNote)) + } + } + + // Process remote notes + for (remoteNote in remoteNotes) { + val localNote = localNotesMap[remoteNote.title] + + if (localNote == null) { + // No local note with this title, create a new local note + val newLocalNote = Note( + title = remoteNote.title, modifiedDate = remoteNote.lastModified + ) + logDebug("New Remote note: ${remoteNote.title}") + localUpdates.add(NoteAction.Create(newLocalNote, remoteNote)) + } + // If the local note exists, it was already handled in the local notes loop + } + } + } + return SyncNotesResult(localUpdates, remoteUpdates, mappingUpdates) + } +} + +sealed interface NoteAction { + data class Create(val note: Note, val remoteNote: SyncNote) : NoteAction + data class Update(val note: Note, val remoteNote: SyncNote) : NoteAction + data class Delete(val note: Note, val remoteNote: SyncNote) : NoteAction +} + +data class SyncNotesResult( + val localUpdates: List, + val remoteUpdates: List, + val newMappings: List = emptyList() +) diff --git a/app/src/main/java/org/qosp/notes/data/sync/fs/StorageBackend.kt b/app/src/main/java/org/qosp/notes/data/sync/fs/StorageBackend.kt new file mode 100644 index 00000000..df10f619 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/fs/StorageBackend.kt @@ -0,0 +1,182 @@ +package org.qosp.notes.data.sync.fs + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import android.util.Log +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import org.qosp.notes.data.model.IdMapping +import org.qosp.notes.data.model.Note +import org.qosp.notes.data.sync.core.ISyncBackend +import org.qosp.notes.data.sync.core.SyncNote +import org.qosp.notes.data.sync.nextcloud.BackendValidationResult +import org.qosp.notes.preferences.CloudService +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import kotlin.time.measureTimedValue + +class StorageBackend(private val context: Context, private val config: StorageConfig) : ISyncBackend { + + companion object { + private const val TAG = "StorageBackend" + } + + override val type: CloudService = CloudService.FILE_STORAGE + + private val Note.filename: String + get() { + val titleToSet = title.ifBlank { "Untitled" } + val ext = if (isMarkdownEnabled) "md" else "txt" + return "${titleToSet.trim()}.$ext" + } + + override suspend fun createNote(note: Note): SyncNote { + val root = getRootDocumentFile() ?: throw IOException("Unable to access storage location") + + Log.d(TAG, "createNote: $note") + val mimeType = if (note.isMarkdownEnabled) "text/markdown" else "text/plain" + val newDoc = root.createFile(mimeType, note.filename) + + return newDoc?.let { + writeNoteToFile(it, note.toStorableContent()) + SyncNote( + idStr = newDoc.uri.toString(), + title = note.title, + content = note.toStorableContent(), + lastModified = newDoc.lastModified() / 1000, // Epoch milliseconds to seconds + id = 0 + ) + } ?: throw IOException("Unable to create file for ${note.filename}") + } + + override suspend fun updateNote(note: Note, mapping: IdMapping): IdMapping { + val uri = mapping.storageUri?.toUri() ?: throw IllegalArgumentException("URI cannot be null") + val rootDoc = getRootDocumentFile() ?: throw IOException("Unable to access storage location") + val file = DocumentFile.fromSingleUri(context, uri) ?: throw FileNotFoundException("URI not found") + + writeNoteToFile(file, content = note.toStorableContent()) + val newUri = if (note.filename != file.name) renameFile(file, note.filename, rootDoc) else uri + return mapping.copy(storageUri = newUri.toString()) + } + + override suspend fun deleteNote(mapping: IdMapping): Boolean { + val uri = mapping.storageUri?.toUri() ?: return false + val deletionResult = inStorage { + val result = DocumentsContract.deleteDocument(context.contentResolver, uri) + Log.d(TAG, "deleteNote: Deleted (${result}) the file: ${uri.pathSegments.last()}") + result + } + return deletionResult == true + } + + override suspend fun getNote(mapping: IdMapping): SyncNote? { + return try { + val uri = mapping.storageUri?.toUri() ?: return null + val file = DocumentFile.fromSingleUri(context, uri) ?: return null + getFile(file) + } catch (e: Exception) { + Log.e(TAG, "getNote: Error getting note with id ${mapping.localNoteId}", e) + null + } + } + + private fun getFile(file: DocumentFile) = SyncNote( + id = 0, + idStr = file.uri.toString(), + content = readFileContent(file), + title = getTitleFromUri(file.uri), + lastModified = file.lastModified() / 1000, // Milliseconds to seconds + ) + + override suspend fun getAll(): List { + val root = getRootDocumentFile() ?: return emptyList() + return try { + val files = root.listFiles() + .flatMap { + if (it.isDirectory) { + if (it.name?.startsWith(".") == true) emptyList()// Skip hidden directories + else it.listFiles().toList() + } else listOf(it) + } + .filter { it.name?.startsWith(".") != true } // Skip hidden files + .filter { it.name?.endsWith(".md") == true || it.name?.endsWith(".txt") == true } + files.map { file -> getFile(file) } + } catch (e: Exception) { + Log.e(TAG, "getAll: Error listing files", e) + emptyList() + } + } + + suspend fun validateConfig(): BackendValidationResult { + return try { + val root = getRootDocumentFile() + if (root == null || !hasPermissionsAt(config.location)) { + BackendValidationResult.InvalidConfig + } else { + BackendValidationResult.Success + } + } catch (e: Exception) { + Log.e(TAG, "validateConfig: Error validating config", e) + BackendValidationResult.InvalidConfig + } + } + + private fun getRootDocumentFile(): DocumentFile? { + return DocumentFile.fromTreeUri(context, config.location) + } + + private fun hasPermissionsAt(uri: Uri): Boolean { + val perm = context.contentResolver.persistedUriPermissions.firstOrNull { it.uri == uri } + return perm?.let { it.isReadPermission && it.isWritePermission } ?: false + } + + private fun writeNoteToFile(file: DocumentFile, content: String) { + context.contentResolver.openOutputStream(file.uri, "w")?.use { output -> + (output as? FileOutputStream)?.let { + output.channel.truncate(0) + val bytesWritten = content.encodeToByteArray().inputStream().copyTo(output) + Log.d(TAG, "writeNote: Wrote $bytesWritten bytes to ${file.name}") + } ?: run { + Log.e(TAG, "writeNoteToDocument: ${file.name} is not a file. URI:${file.uri}") + } + } + } + + private fun readFileContent(file: DocumentFile): String? { + Log.d(TAG, "readFileContent: ${file.name}") + return context.contentResolver.openInputStream(file.uri)?.use { it.bufferedReader().readText() } + } + + private fun getTitleFromUri(uri: Uri): String { + val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "" + return when { + fileName.endsWith(".md") -> fileName.removeSuffix(".md") + fileName.endsWith(".txt") -> fileName.removeSuffix(".txt") + else -> fileName + } + } + + private fun renameFile(file: DocumentFile, newName: String, root: DocumentFile): String { + Log.d(TAG, "renameFile: Renaming ${file.name} to $newName") + val foundFile = root.listFiles().firstOrNull { it.name == file.name } + ?: throw FileNotFoundException("File ${file.name} not found") + val succeeded = foundFile.renameTo(newName) + Log.d(TAG, "renameFile: Renaming ${foundFile.name}, succeeded? $succeeded") + return foundFile.uri.toString() + } + + private inline fun inStorage(block: () -> T): T? { + return try { + val duration = measureTimedValue { + block() + } + Log.i(TAG, "inStorage: That took ${duration.duration} to complete") + duration.value + } catch (e: Exception) { + Log.e(TAG, "Exception while storing: ${e.message}", e) + null + } + } +} diff --git a/app/src/main/java/org/qosp/notes/data/sync/fs/StorageConfig.kt b/app/src/main/java/org/qosp/notes/data/sync/fs/StorageConfig.kt new file mode 100644 index 00000000..2a4938e1 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/fs/StorageConfig.kt @@ -0,0 +1,40 @@ +package org.qosp.notes.data.sync.fs + +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.qosp.notes.preferences.PreferenceRepository + +data class StorageConfig(val location: Uri) { + + companion object { + fun storageLocation(prefRepo: PreferenceRepository): Flow = + prefRepo.getEncryptedString(PreferenceRepository.STORAGE_LOCATION).map { + runCatching { it.toUri() }.getOrNull()?.let { l -> StorageConfig(l) } + } + } +} + +fun Uri.toFriendlyString(context: Context): String { + // Get the provider name (app name) + val packageManager = context.packageManager + val providerName = packageManager.getInstalledPackages(PackageManager.GET_PROVIDERS) + ?.firstOrNull { it?.providers?.any { p -> p?.authority == this.authority } ?: false } + ?.applicationInfo + ?.let { packageManager.getApplicationLabel(it) }?.toString() ?: this.authority ?: "Unknown" + + // Try to get the directory name using DocumentFile + val documentFile = DocumentFile.fromTreeUri(context, this) + val directoryName = documentFile?.name ?: this.lastPathSegment?.substringAfterLast('/') ?: "" + + // Combine provider name and directory name + return if (directoryName.isNotEmpty()) { + "$providerName: $directoryName" + } else { + providerName + } +} diff --git a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudAPI.kt b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudAPI.kt index 5de74be3..3617be23 100644 --- a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudAPI.kt +++ b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudAPI.kt @@ -1,13 +1,10 @@ package org.qosp.notes.data.sync.nextcloud +import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.jsonObject -import okhttp3.ResponseBody -import org.qosp.notes.data.sync.nextcloud.model.NextcloudCapabilities -import org.qosp.notes.data.sync.nextcloud.model.NextcloudNote +import org.qosp.notes.data.sync.nextcloud.model.NextcloudCapabilitiesResult +import org.qosp.notes.data.sync.nextcloud.model.NextcloudNotesCapabilities import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET @@ -61,28 +58,23 @@ interface NextcloudAPI { suspend fun getAllCapabilitiesAPI( @Url url: String, @Header("Authorization") auth: String, - ): ResponseBody + ): NextcloudCapabilitiesResult } -suspend fun NextcloudAPI.getNotesCapabilities(config: NextcloudConfig): NextcloudCapabilities? { +suspend fun NextcloudAPI.getNotesCapabilities(config: NextcloudConfig): NextcloudNotesCapabilities? { val endpoint = "ocs/v2.php/cloud/capabilities" val fullUrl = config.remoteAddress + endpoint val response = withContext(Dispatchers.IO) { - getAllCapabilitiesAPI(url = fullUrl, auth = config.credentials).string() + getAllCapabilitiesAPI(url = fullUrl, auth = config.credentials) } - - val element = Json - .parseToJsonElement(response).jsonObject["ocs"]?.jsonObject - ?.get("data")?.jsonObject - ?.get("capabilities")?.jsonObject - ?.get("notes") - return element?.let { Json.decodeFromJsonElement(it) } + Log.d("NextcloudAPI", "getNotesCapabilities: $response") + return response.ocs.data.capabilities.notes } -suspend fun NextcloudAPI.deleteNote(note: NextcloudNote, config: NextcloudConfig) { +suspend fun NextcloudAPI.deleteNote(noteId: Long, config: NextcloudConfig) { deleteNoteAPI( - url = config.remoteAddress + baseURL + "notes/${note.id}", + url = config.remoteAddress + baseURL + "notes/${noteId}", auth = config.credentials, ) } @@ -91,7 +83,7 @@ suspend fun NextcloudAPI.updateNote(note: NextcloudNote, etag: String, config: N return updateNoteAPI( note = note, url = config.remoteAddress + baseURL + "notes/${note.id}", - etag = etag, + etag = "\"$etag\"", auth = config.credentials, ) } @@ -104,16 +96,17 @@ suspend fun NextcloudAPI.createNote(note: NextcloudNote, config: NextcloudConfig ) } -suspend fun NextcloudAPI.getNotes(config: NextcloudConfig): List { - return getNotesAPI( - url = config.remoteAddress + baseURL + "notes", - auth = config.credentials, +suspend fun NextcloudAPI.getNote(id: Long, config: NextcloudConfig): NextcloudNote { + return getNoteAPI( + url = config.remoteAddress + baseURL + "notes" + "/$id", + auth = config.credentials ) } -suspend fun NextcloudAPI.testCredentials(config: NextcloudConfig) { - getNotesAPI( +suspend fun NextcloudAPI.getNotes(config: NextcloudConfig): List { + return getNotesAPI( url = config.remoteAddress + baseURL + "notes", auth = config.credentials, ) } + diff --git a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudAPIProvider.kt b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudAPIProvider.kt new file mode 100644 index 00000000..c3595403 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudAPIProvider.kt @@ -0,0 +1,84 @@ +package org.qosp.notes.data.sync.nextcloud + +import android.annotation.SuppressLint +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.flow.first +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.qosp.notes.preferences.PreferenceRepository +import org.qosp.notes.preferences.TrustSelfSignedCertificate +import retrofit2.Retrofit +import retrofit2.create +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +class NextcloudAPIProvider(private val preferenceRepository: PreferenceRepository) { + private val json = Json { ignoreUnknownKeys = true } + + + private val defaultClient: NextcloudAPI by lazy { + builder().client(createDefaultClient()).build().create() + } + + private val selfSignedTrustingClient: NextcloudAPI by lazy { + builder().client(createSelfSignedTrustingClient()).build().create() + } + + private fun builder(): Retrofit.Builder = Retrofit.Builder() + .baseUrl("http://localhost/") // Since the URL is configurable by the user we set it later during the request + .addConverterFactory( + json.asConverterFactory("application/json".toMediaType()) + ) + + @OptIn(ExperimentalSerializationApi::class) + suspend fun getAPI(): NextcloudAPI { + val preference = preferenceRepository.get().first() + return when (preference) { + TrustSelfSignedCertificate.YES -> selfSignedTrustingClient + TrustSelfSignedCertificate.NO -> defaultClient + } + } + + private fun createDefaultClient(): OkHttpClient { + val interceptor = HttpLoggingInterceptor() + interceptor.setLevel(HttpLoggingInterceptor.Level.BASIC) + interceptor.redactHeader("Authorization") + interceptor.redactHeader("Cookie") + interceptor.redactHeader("Set-Cookie") + + return OkHttpClient.Builder().addInterceptor(interceptor).build() + } + + @SuppressLint("TrustAllX509TrustManager") + private fun createSelfSignedTrustingClient(): OkHttpClient { + val interceptor = HttpLoggingInterceptor() + interceptor.setLevel(HttpLoggingInterceptor.Level.BASIC) + interceptor.redactHeader("Authorization") + interceptor.redactHeader("Cookie") + interceptor.redactHeader("Set-Cookie") + + // Create a trust manager that accepts all certificates + val trustAllCerts = arrayOf( + @SuppressLint("CustomX509TrustManager") + object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) {} + override fun checkServerTrusted(chain: Array, authType: String) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }) + + // Create SSL context that uses our custom trust manager + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, SecureRandom()) + + return OkHttpClient.Builder().addInterceptor(interceptor) + .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier { _, _ -> true } // Accept all hostnames + .build() + } +} diff --git a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudBackend.kt b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudBackend.kt new file mode 100644 index 00000000..3ce87fdc --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudBackend.kt @@ -0,0 +1,57 @@ +package org.qosp.notes.data.sync.nextcloud + +import android.util.Log +import org.qosp.notes.data.model.IdMapping +import org.qosp.notes.data.model.Note +import org.qosp.notes.data.sync.asSyncNote +import org.qosp.notes.data.sync.core.ISyncBackend +import org.qosp.notes.data.sync.core.SyncNote +import org.qosp.notes.data.sync.nextcloud.model.asNextcloudNote +import org.qosp.notes.preferences.CloudService + +class NextcloudBackend( + private val apiProvider: NextcloudAPIProvider, + private val config: NextcloudConfig +) : ISyncBackend { + + private val tag = javaClass.simpleName + override val type: CloudService = CloudService.NEXTCLOUD + + override suspend fun createNote(note: Note): SyncNote { + Log.d(tag, "createNote() called with: note = ${note.title}") + val api = apiProvider.getAPI() + return api.createNote(note.asNextcloudNote(0, ""), config).asSyncNote() + } + + override suspend fun updateNote(note: Note, mapping: IdMapping): IdMapping { + requireNotNull(mapping.remoteNoteId) { "Remote note id is null." } + Log.d(tag, "updateNote: ${note.title}") + val api = apiProvider.getAPI() + val nNote = note.asNextcloudNote(mapping.remoteNoteId, "") + val updatedNote = api.updateNote(nNote, mapping.extras ?: "", config) + return mapping.copy(remoteNoteId = updatedNote.id, extras = updatedNote.etag) + } + + override suspend fun deleteNote(mapping: IdMapping): Boolean = try { + // Delete the note on the server + Log.d(tag, "deleteNote() called with: mapping = $mapping") + requireNotNull(mapping.remoteNoteId) { "Remote note id is null." } + val api = apiProvider.getAPI() + api.deleteNote(mapping.remoteNoteId, config) + true + } catch (_: Exception) { + false + } + + override suspend fun getNote(mapping: IdMapping): SyncNote? { + requireNotNull(mapping.remoteNoteId) { "Remote note id is null." } + val api = apiProvider.getAPI() + return api.getNote(mapping.remoteNoteId, config).asSyncNote() + } + + override suspend fun getAll(): List { + Log.d(tag, "getAll() from Nextcloud") + val api = apiProvider.getAPI() + return api.getNotes(config).map { note -> note.asSyncNote() } + } +} diff --git a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudConfig.kt b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudConfig.kt index a957d2a2..47ea0bf8 100644 --- a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudConfig.kt +++ b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudConfig.kt @@ -5,20 +5,17 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import org.qosp.notes.data.sync.core.ProviderConfig -import org.qosp.notes.preferences.CloudService import org.qosp.notes.preferences.PreferenceRepository data class NextcloudConfig( - override val remoteAddress: String, - override val username: String, + val remoteAddress: String, + val username: String, private val password: String, -) : ProviderConfig { +) { val credentials = ("Basic " + Base64.encodeToString("$username:$password".toByteArray(), Base64.NO_WRAP)).trim() - override val provider: CloudService = CloudService.NEXTCLOUD - override val authenticationHeaders: Map + val authenticationHeaders: Map get() = mapOf("Authorization" to credentials) companion object { @@ -28,11 +25,11 @@ data class NextcloudConfig( val username = preferenceRepository.getEncryptedString(PreferenceRepository.NEXTCLOUD_USERNAME) val password = preferenceRepository.getEncryptedString(PreferenceRepository.NEXTCLOUD_PASSWORD) - return url.flatMapLatest { url -> + return url.flatMapLatest { u -> username.flatMapLatest { username -> password.map { password -> - NextcloudConfig(url, username, password) - .takeUnless { url.isBlank() or username.isBlank() or password.isBlank() } + NextcloudConfig(u, username, password) + .takeUnless { u.isBlank() or username.isBlank() or password.isBlank() } } } } diff --git a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudManager.kt b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudManager.kt deleted file mode 100644 index cb61cb79..00000000 --- a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudManager.kt +++ /dev/null @@ -1,289 +0,0 @@ -package org.qosp.notes.data.sync.nextcloud - -import kotlinx.coroutines.flow.first -import org.qosp.notes.data.model.IdMapping -import org.qosp.notes.data.model.Note -import org.qosp.notes.data.model.Notebook -import org.qosp.notes.data.repo.IdMappingRepository -import org.qosp.notes.data.repo.NoteRepository -import org.qosp.notes.data.repo.NotebookRepository -import org.qosp.notes.data.sync.core.ApiError -import org.qosp.notes.data.sync.core.BaseResult -import org.qosp.notes.data.sync.core.GenericError -import org.qosp.notes.data.sync.core.InvalidConfig -import org.qosp.notes.data.sync.core.ProviderConfig -import org.qosp.notes.data.sync.core.ServerNotSupported -import org.qosp.notes.data.sync.core.ServerNotSupportedException -import org.qosp.notes.data.sync.core.Success -import org.qosp.notes.data.sync.core.SyncProvider -import org.qosp.notes.data.sync.core.Unauthorized -import org.qosp.notes.data.sync.nextcloud.model.NextcloudNote -import org.qosp.notes.data.sync.nextcloud.model.asNewLocalNote -import org.qosp.notes.data.sync.nextcloud.model.asNextcloudNote -import org.qosp.notes.preferences.CloudService -import retrofit2.HttpException - -class NextcloudManager( - private val nextcloudAPI: NextcloudAPI, - private val noteRepository: NoteRepository, - private val notebookRepository: NotebookRepository, - private val idMappingRepository: IdMappingRepository, -) : SyncProvider { - - override suspend fun createNote( - note: Note, - config: ProviderConfig - ): BaseResult { - if (config !is NextcloudConfig) return InvalidConfig - - val nextcloudNote = note.asNextcloudNote() - - if (nextcloudNote.id != 0L) return GenericError("Cannot create note that already exists") - - return tryCalling { - val savedNote = nextcloudAPI.createNote(nextcloudNote, config) - idMappingRepository.assignProviderToNote( - IdMapping( - localNoteId = note.id, - remoteNoteId = savedNote.id, - provider = CloudService.NEXTCLOUD, - extras = savedNote.etag, - isDeletedLocally = false, - ), - ) - } - } - - override suspend fun deleteNote( - note: Note, - config: ProviderConfig - ): BaseResult { - if (config !is NextcloudConfig) return InvalidConfig - - val nextcloudNote = note.asNextcloudNote() - - if (nextcloudNote.id == 0L) return GenericError("Cannot delete note that does not exist.") - - return tryCalling { - nextcloudAPI.deleteNote(nextcloudNote, config) - idMappingRepository.deleteByRemoteId(CloudService.NEXTCLOUD, nextcloudNote.id) - } - } - - override suspend fun moveNoteToBin(note: Note, config: ProviderConfig): BaseResult { - if (config !is NextcloudConfig) return InvalidConfig - - val nextcloudNote = note.asNextcloudNote() - - if (nextcloudNote.id == 0L) return GenericError("Cannot delete note that does not exist.") - - return tryCalling { - nextcloudAPI.deleteNote(nextcloudNote, config) - idMappingRepository.unassignProviderFromNote(CloudService.NEXTCLOUD, note.id) - } - } - - override suspend fun restoreNote(note: Note, config: ProviderConfig) = createNote(note, config) - - override suspend fun updateNote( - note: Note, - config: ProviderConfig - ): BaseResult { - if (config !is NextcloudConfig) return InvalidConfig - - val nextcloudNote = note.asNextcloudNote() - - if (nextcloudNote.id == 0L) return GenericError("Cannot update note that does not exist.") - - return tryCalling { - updateNoteWithEtag(note, nextcloudNote, null, config) - } - } - - override suspend fun authenticate(config: ProviderConfig): BaseResult { - if (config !is NextcloudConfig) return InvalidConfig - - return tryCalling { - nextcloudAPI.testCredentials(config) - } - } - - override suspend fun isServerCompatible(config: ProviderConfig): BaseResult { - if (config !is NextcloudConfig) return InvalidConfig - - return tryCalling { - val capabilities = nextcloudAPI.getNotesCapabilities(config)!! - val maxServerVersion = capabilities.apiVersion.last().toFloat() - - if (MIN_SUPPORTED_VERSION.toFloat() > maxServerVersion) throw ServerNotSupportedException - } - } - - override suspend fun sync(config: ProviderConfig): BaseResult { - if (config !is NextcloudConfig) return InvalidConfig - - suspend fun handleConflict(local: Note, remote: NextcloudNote, mapping: IdMapping) { - if (mapping.isDeletedLocally) return - - if (remote.modified < local.modifiedDate) { - // Remote version is outdated - updateNoteWithEtag(local, remote, mapping.extras, config) - - // Nextcloud does not update change the modification date when a note is starred - } else if (remote.modified > local.modifiedDate || remote.favorite != local.isPinned) { - // Local version is outdated - noteRepository.updateNotes(remote.asUpdatedLocalNote(local)) - idMappingRepository.update( - mapping.copy( - extras = remote.etag, - ) - ) - } - } - - return tryCalling { - // Fetch notes from the cloud - val nextcloudNotes = nextcloudAPI.getNotes(config) - - val localNoteIds = noteRepository - .getAll() - .first() - .map { it.id } - - val localNotes = noteRepository - .getNonDeleted() - .first() - .filterNot { it.isLocalOnly } - - val idsInUse = mutableListOf() - - // Remove id mappings for notes that do not exist - idMappingRepository.deleteIfLocalIdNotIn(localNoteIds) - - // Handle conflicting notes - for (remoteNote in nextcloudNotes) { - idsInUse.add(remoteNote.id) - - when (val mapping = idMappingRepository.getByRemoteId(remoteNote.id, CloudService.NEXTCLOUD)) { - null -> { - // New note, we have to create it locally - val localNote = remoteNote.asNewLocalNote() - val localId = noteRepository.insertNote(localNote, shouldSync = false) - idMappingRepository.insert( - IdMapping( - localNoteId = localId, - remoteNoteId = remoteNote.id, - provider = CloudService.NEXTCLOUD, - isDeletedLocally = false, - extras = remoteNote.etag - ) - ) - } - else -> { - if (mapping.isDeletedLocally && mapping.remoteNoteId != null) { - nextcloudAPI.deleteNote(remoteNote, config) - continue - } - - if (mapping.isBeingUpdated) continue - - val localNote = localNotes.find { it.id == mapping.localNoteId } - if (localNote != null) handleConflict( - local = localNote, - remote = remoteNote, - mapping = mapping, - ) - } - } - } - - // Delete notes that have been deleted remotely - noteRepository.moveRemotelyDeletedNotesToBin(idsInUse, CloudService.NEXTCLOUD) - idMappingRepository.unassignProviderFromRemotelyDeletedNotes(idsInUse, CloudService.NEXTCLOUD) - - // Finally, upload any new local notes that are not mapped to any remote id - val newLocalNotes = noteRepository.getNonRemoteNotes(CloudService.NEXTCLOUD).first() - newLocalNotes.forEach { - val newRemoteNote = nextcloudAPI.createNote(it.asNextcloudNote(), config) - idMappingRepository.assignProviderToNote( - IdMapping( - localNoteId = it.id, - remoteNoteId = newRemoteNote.id, - provider = CloudService.NEXTCLOUD, - isDeletedLocally = false, - extras = newRemoteNote.etag, - ) - ) - } - } - } - - private suspend fun updateNoteWithEtag( - note: Note, - nextcloudNote: NextcloudNote, - etag: String? = null, - config: NextcloudConfig - ) { - val cloudId = idMappingRepository.getByRemoteId(nextcloudNote.id, CloudService.NEXTCLOUD) ?: return - val etag = etag ?: cloudId.extras - val newNote = nextcloudAPI.updateNote( - note.asNextcloudNote(nextcloudNote.id), - etag.toString(), - config, - ) - - idMappingRepository.update( - cloudId.copy(extras = newNote.etag, isBeingUpdated = false) - ) - } - - private suspend fun Note.asNextcloudNote(newId: Long? = null): NextcloudNote { - val id = newId ?: idMappingRepository.getByLocalIdAndProvider(id, CloudService.NEXTCLOUD)?.remoteNoteId - val notebookName = notebookId?.let { notebookRepository.getById(it).first()?.name } - return asNextcloudNote(id = id ?: 0L, category = notebookName ?: "") - } - - private suspend fun NextcloudNote.asUpdatedLocalNote(note: Note) = note.copy( - title = title, - content = content, - isPinned = favorite, - modifiedDate = modified, - notebookId = getNotebookIdForCategory(category) - ) - - private suspend fun NextcloudNote.asNewLocalNote(newId: Long? = null): Note { - val id = newId ?: idMappingRepository.getByRemoteId(id, CloudService.NEXTCLOUD)?.localNoteId - val notebookId = getNotebookIdForCategory(category) - return asNewLocalNote(id = id ?: 0L, notebookId = notebookId) - } - - private suspend fun getNotebookIdForCategory(category: String): Long? { - return category - .takeUnless { it.isBlank() } - ?.let { - notebookRepository.getByName(it).first()?.id ?: notebookRepository.insert(Notebook(name = category)) - } - } - - private inline fun tryCalling(block: () -> Unit): BaseResult { - return try { - block() - Success - } catch (e: Exception) { - when (e) { - ServerNotSupportedException -> ServerNotSupported - is HttpException -> { - when (e.code()) { - 401 -> Unauthorized - else -> ApiError(e.message(), e.code()) - } - } - else -> GenericError(e.message.toString()) - } - } - } - - companion object { - const val MIN_SUPPORTED_VERSION = 1 - } -} diff --git a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudNote.kt b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudNote.kt similarity index 62% rename from app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudNote.kt rename to app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudNote.kt index 6b87f68b..94968e66 100644 --- a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudNote.kt +++ b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/NextcloudNote.kt @@ -1,4 +1,4 @@ -package org.qosp.notes.data.sync.nextcloud.model +package org.qosp.notes.data.sync.nextcloud import kotlinx.serialization.Serializable @@ -6,10 +6,11 @@ import kotlinx.serialization.Serializable data class NextcloudNote( val id: Long, val etag: String? = null, - val content: String, + val content: String?, val title: String, val category: String, val favorite: Boolean, - val modified: Long, + val modified: Long, // seconds val readOnly: Boolean? = null, + val remoteId: String = id.toString(), ) diff --git a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/ValidateNextcloud.kt b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/ValidateNextcloud.kt new file mode 100644 index 00000000..f2c6076f --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/ValidateNextcloud.kt @@ -0,0 +1,48 @@ +package org.qosp.notes.data.sync.nextcloud + +import android.util.Log +import org.acra.ktx.sendWithAcra +import retrofit2.HttpException +import javax.net.ssl.SSLException + +class ValidateNextcloud(private val apiProvider: NextcloudAPIProvider) { + suspend operator fun invoke(config: NextcloudConfig): BackendValidationResult { + + val api = apiProvider.getAPI() + return try { + val capabilities = api.getNotesCapabilities(config) ?: return BackendValidationResult.NotesNotInstalled + val maxServerVersion = capabilities.apiVersion.mapNotNull { it.toFloatOrNull() }.maxOrNull() ?: 0f + if (MIN_SUPPORTED_VERSION > maxServerVersion) + BackendValidationResult.Incompatible + else BackendValidationResult.Success + } catch (exception: Exception) { + return when (exception) { + is SSLException -> BackendValidationResult.CertificateError.also { + // Don't send a crash report for SSL certificate issues + Log.w( + "ValidateNextcloud", + "SSL certificate error - user may need to enable trust self-signed certificates", + exception + ) + } + + else -> BackendValidationResult.InvalidConfig.also { + Log.e("ValidateNextcloud", "invoke: Error validating config", exception) + if (exception !is HttpException || exception.code() != 401) exception.sendWithAcra() + } + } + } + } + + companion object { + const val MIN_SUPPORTED_VERSION = 1.0f + } +} + +sealed class BackendValidationResult { + object Success : BackendValidationResult() + object InvalidConfig : BackendValidationResult() + object Incompatible : BackendValidationResult() + object CertificateError : BackendValidationResult() + object NotesNotInstalled : BackendValidationResult() +} diff --git a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudCapabilities.kt b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudCapabilities.kt deleted file mode 100644 index c02e503b..00000000 --- a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudCapabilities.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.qosp.notes.data.sync.nextcloud.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class NextcloudCapabilities( - @SerialName("api_version") - val apiVersion: List, - val version: String, -) diff --git a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudConverters.kt b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudConverters.kt index 3d690a16..1a6ecac4 100644 --- a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudConverters.kt +++ b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudConverters.kt @@ -1,21 +1,13 @@ package org.qosp.notes.data.sync.nextcloud.model import org.qosp.notes.data.model.Note +import org.qosp.notes.data.sync.nextcloud.NextcloudNote fun Note.asNextcloudNote(id: Long, category: String): NextcloudNote = NextcloudNote( id = id, title = title, - content = if (isList) taskListToString() else content, + content = toStorableContent(), category = category, favorite = isPinned, modified = modifiedDate ) - -fun NextcloudNote.asNewLocalNote(id: Long, notebookId: Long?) = Note( - id = id, - title = title, - content = content, - isPinned = favorite, - modifiedDate = modified, - notebookId = notebookId -) diff --git a/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudNotesCapabilities.kt b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudNotesCapabilities.kt new file mode 100644 index 00000000..fbdfcceb --- /dev/null +++ b/app/src/main/java/org/qosp/notes/data/sync/nextcloud/model/NextcloudNotesCapabilities.kt @@ -0,0 +1,33 @@ +package org.qosp.notes.data.sync.nextcloud.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NextcloudNotesCapabilities( + @SerialName("api_version") + val apiVersion: List, + val version: String, + @SerialName("notes_path") + val notesPath: String? = null, +) + +@Serializable +data class NextcloudCapabilitiesResult( + val ocs: NextcloudCapabilitiesResultOcs +) + +@Serializable +data class NextcloudCapabilitiesResultOcs( + val data: NextcloudCapabilitiesResultData +) + +@Serializable +data class NextcloudCapabilitiesResultData( + val capabilities: NextcloudCapabilitiesResultCapabilities +) + +@Serializable +data class NextcloudCapabilitiesResultCapabilities( + val notes: NextcloudNotesCapabilities? = null +) diff --git a/app/src/main/java/org/qosp/notes/di/DatabaseModule.kt b/app/src/main/java/org/qosp/notes/di/DatabaseModule.kt index 9132bac5..f6bb30fe 100644 --- a/app/src/main/java/org/qosp/notes/di/DatabaseModule.kt +++ b/app/src/main/java/org/qosp/notes/di/DatabaseModule.kt @@ -1,26 +1,46 @@ package org.qosp.notes.di -import android.content.Context import androidx.room.Room -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module import org.qosp.notes.data.AppDatabase -import javax.inject.Singleton -@Module -@InstallIn(SingletonComponent::class) object DatabaseModule { - @Provides - @Singleton - fun provideRoomDatabase( - @ApplicationContext context: Context, - ): AppDatabase { - return Room.databaseBuilder(context, AppDatabase::class.java, AppDatabase.DB_NAME) - .fallbackToDestructiveMigration() - .build() + val dbModule = module { + single { + Room.databaseBuilder( + context = androidContext(), + klass = AppDatabase::class.java, + name = AppDatabase.DB_NAME + ) + // we don't want to silently wipe user data in case DB migration fails, + // rather let the app crash + .addMigrations(AppDatabase.MIGRATION_1_2) + .addMigrations(AppDatabase.MIGRATION_2_3) + .addMigrations(AppDatabase.MIGRATION_3_4) + .addMigrations(AppDatabase.MIGRATION_4_5) + .build() + } + + single { + get().noteDao + } + single { + get().notebookDao + } + single { + get().tagDao + } + single { + get().noteTagDao + } + single { + get().reminderDao + } + single { + get().idMappingDao + } } + } diff --git a/app/src/main/java/org/qosp/notes/di/MarkwonModule.kt b/app/src/main/java/org/qosp/notes/di/MarkwonModule.kt index 6eb619c7..f3b99795 100644 --- a/app/src/main/java/org/qosp/notes/di/MarkwonModule.kt +++ b/app/src/main/java/org/qosp/notes/di/MarkwonModule.kt @@ -1,18 +1,17 @@ package org.qosp.notes.di import android.content.Context -import android.text.util.Linkify -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.FragmentComponent -import dagger.hilt.android.qualifiers.ActivityContext -import dagger.hilt.android.scopes.FragmentScoped +import android.text.style.BackgroundColorSpan +import android.text.util.Linkify.EMAIL_ADDRESSES +import android.text.util.Linkify.WEB_URLS +import android.util.TypedValue import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.LinkResolverDef import io.noties.markwon.Markwon +import io.noties.markwon.Markwon.builder import io.noties.markwon.MarkwonConfiguration import io.noties.markwon.SoftBreakAddsNewLinePlugin +import io.noties.markwon.SpanFactory import io.noties.markwon.editor.MarkwonEditor import io.noties.markwon.editor.handler.EmphasisEditHandler import io.noties.markwon.editor.handler.StrongEmphasisEditHandler @@ -21,9 +20,14 @@ import io.noties.markwon.ext.tables.TablePlugin import io.noties.markwon.ext.tasklist.TaskListPlugin import io.noties.markwon.linkify.LinkifyPlugin import io.noties.markwon.movement.MovementMethodPlugin -import me.saket.bettermovementmethod.BetterLinkMovementMethod -import org.qosp.notes.R -import org.qosp.notes.data.sync.core.SyncManager +import io.noties.markwon.simple.ext.SimpleExtPlugin +import me.saket.bettermovementmethod.BetterLinkMovementMethod.getInstance +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module +import org.qosp.notes.R.attr.colorBackground +import org.qosp.notes.R.attr.colorMarkdownTask +import org.qosp.notes.R.attr.colorNoteTextHighlight +import org.qosp.notes.preferences.PreferenceRepository import org.qosp.notes.ui.editor.markdown.BlockQuoteHandler import org.qosp.notes.ui.editor.markdown.CodeBlockHandler import org.qosp.notes.ui.editor.markdown.CodeHandler @@ -32,36 +36,47 @@ import org.qosp.notes.ui.editor.markdown.StrikethroughHandler import org.qosp.notes.ui.utils.coil.CoilImagesPlugin import org.qosp.notes.ui.utils.resolveAttribute -@Module -@InstallIn(FragmentComponent::class) object MarkwonModule { - @Provides - @FragmentScoped - fun provideMarkwon(@ActivityContext context: Context, syncManager: SyncManager): Markwon { - return Markwon.builder(context) - .usePlugin(LinkifyPlugin.create(Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS)) - .usePlugin(SoftBreakAddsNewLinePlugin.create()) - .usePlugin(MovementMethodPlugin.create(BetterLinkMovementMethod.getInstance())) - .usePlugin(StrikethroughPlugin.create()) - .usePlugin(TablePlugin.create(context)) - .usePlugin(object : AbstractMarkwonPlugin() { - override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { - builder.linkResolver(LinkResolverDef()) - } - }) - .usePlugin(CoilImagesPlugin.create(context, syncManager)) - .apply { - val mainColor = context.resolveAttribute(R.attr.colorMarkdownTask) ?: return@apply - val backgroundColor = context.resolveAttribute(R.attr.colorBackground) ?: return@apply - usePlugin(TaskListPlugin.create(mainColor, mainColor, backgroundColor)) - } - .build() + val markwonModule = module { + factory { getMarkwon(context = androidContext(), preferenceRepository = get()) } + factory { getMarkWonEditor(markwon = get()) } } - @Provides - @FragmentScoped - fun provideMarkwonEditor(markwon: Markwon): MarkwonEditor { + private fun getMarkwon(context: Context, preferenceRepository: PreferenceRepository): Markwon = builder(context) + .usePlugin(LinkifyPlugin.create(EMAIL_ADDRESSES or WEB_URLS)) + .usePlugin(SoftBreakAddsNewLinePlugin.create()) + .usePlugin(MovementMethodPlugin.create(getInstance())) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(TablePlugin.create(context)) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { + builder.linkResolver(LinkResolverDef()) + } + }) + .usePlugin(SimpleExtPlugin.create { plugin: SimpleExtPlugin -> + plugin + .addExtension( + /* length = */ 2, + /* character = */ '=', + /* spanFactory = */ SpanFactory { _, _ -> + val typedValue = TypedValue() + context.theme.resolveAttribute(colorNoteTextHighlight, typedValue, true) + return@SpanFactory BackgroundColorSpan(typedValue.data) + }) + }) + .usePlugin(CoilImagesPlugin.create(context, preferenceRepository)) + .apply { + val mainColor = context.resolveAttribute(colorMarkdownTask) + val backgroundColor = context.resolveAttribute(colorBackground) + val taskPlugin = if (mainColor != null && backgroundColor != null) + TaskListPlugin.create(mainColor, mainColor, backgroundColor) + else TaskListPlugin.create(context) + usePlugin(taskPlugin) + } + .build() + + private fun getMarkWonEditor(markwon: Markwon): MarkwonEditor { return MarkwonEditor.builder(markwon) .useEditHandler(EmphasisEditHandler()) .useEditHandler(StrongEmphasisEditHandler()) @@ -72,4 +87,5 @@ object MarkwonModule { .useEditHandler(HeadingHandler()) .build() } + } diff --git a/app/src/main/java/org/qosp/notes/di/NextcloudModule.kt b/app/src/main/java/org/qosp/notes/di/NextcloudModule.kt index 003aa736..aa7e80dd 100644 --- a/app/src/main/java/org/qosp/notes/di/NextcloudModule.kt +++ b/app/src/main/java/org/qosp/notes/di/NextcloudModule.kt @@ -1,47 +1,13 @@ package org.qosp.notes.di -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.serialization.json.Json -import okhttp3.MediaType -import org.qosp.notes.data.repo.IdMappingRepository -import org.qosp.notes.data.repo.NoteRepository -import org.qosp.notes.data.repo.NotebookRepository -import org.qosp.notes.data.sync.nextcloud.NextcloudAPI -import org.qosp.notes.data.sync.nextcloud.NextcloudManager -import retrofit2.Retrofit -import retrofit2.create -import javax.inject.Named -import javax.inject.Singleton +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module +import org.qosp.notes.data.sync.nextcloud.NextcloudAPIProvider +import org.qosp.notes.data.sync.nextcloud.ValidateNextcloud -@Module -@InstallIn(SingletonComponent::class) object NextcloudModule { - - @Provides - @Singleton - fun provideNextcloud(): NextcloudAPI { - return Retrofit.Builder() - .baseUrl("http://localhost/") // Since the URL is configurable by the user we set it later during the request - .addConverterFactory( - Json { - ignoreUnknownKeys = true - } - .asConverterFactory(MediaType.get("application/json")) - ) - .build() - .create() + val nextcloudModule = module { + singleOf(::NextcloudAPIProvider) + singleOf(::ValidateNextcloud) } - - @Provides - @Singleton - fun provideNextcloudManager( - nextcloudAPI: NextcloudAPI, - @Named(NO_SYNC) noteRepository: NoteRepository, - @Named(NO_SYNC) notebookRepository: NotebookRepository, - idMappingRepository: IdMappingRepository, - ) = NextcloudManager(nextcloudAPI, noteRepository, notebookRepository, idMappingRepository) } diff --git a/app/src/main/java/org/qosp/notes/di/PreferencesModule.kt b/app/src/main/java/org/qosp/notes/di/PreferencesModule.kt index c3b4ede5..f9013623 100644 --- a/app/src/main/java/org/qosp/notes/di/PreferencesModule.kt +++ b/app/src/main/java/org/qosp/notes/di/PreferencesModule.kt @@ -3,46 +3,31 @@ package org.qosp.notes.di import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences +import androidx.core.content.edit import androidx.datastore.preferences.preferencesDataStore import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.tfcporciuncula.flow.FlowSharedPreferences -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module import org.qosp.notes.preferences.PreferenceRepository import java.io.File import java.security.KeyStore -import javax.inject.Singleton val Context.dataStore by preferencesDataStore("preferences") -@Module -@InstallIn(SingletonComponent::class) @OptIn(ExperimentalCoroutinesApi::class) object PreferencesModule { - @Provides - @Singleton - fun providePreferenceRepository( - dataStore: DataStore, - sharedPreferences: FlowSharedPreferences, - ): PreferenceRepository { - return PreferenceRepository(dataStore, sharedPreferences) + val prefModule = module { + single { androidContext().dataStore } + single { encryptedSharedPreferences(androidContext()) } + singleOf(::PreferenceRepository) } - @Provides - @Singleton - fun provideDataStore(@ApplicationContext context: Context) = context.dataStore - - @Provides - @Singleton - fun provideEncryptedSharedPreferences(@ApplicationContext context: Context): FlowSharedPreferences { + fun encryptedSharedPreferences(context: Context): FlowSharedPreferences { val filename = "encrypted_prefs" fun createEncryptedSharedPreferences(context: Context): SharedPreferences { @@ -66,15 +51,13 @@ object PreferencesModule { val appStorageDir = context.filesDir?.parent ?: return val prefsFile = File("$appStorageDir/shared_prefs/$filename.xml") - context.getSharedPreferences(filename, Context.MODE_PRIVATE) - .edit() - .clear() - .commit() + context.getSharedPreferences(filename, Context.MODE_PRIVATE).edit(commit = true) { clear() } keyStore.load(null) keyStore.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS) prefsFile.delete() - } catch (e: Throwable) {} + } catch (_: Throwable) { + } } return FlowSharedPreferences( diff --git a/app/src/main/java/org/qosp/notes/di/RepositoryModule.kt b/app/src/main/java/org/qosp/notes/di/RepositoryModule.kt index 320bdd7c..ab3308c2 100644 --- a/app/src/main/java/org/qosp/notes/di/RepositoryModule.kt +++ b/app/src/main/java/org/qosp/notes/di/RepositoryModule.kt @@ -1,66 +1,24 @@ package org.qosp.notes.di -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import org.qosp.notes.data.AppDatabase +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module import org.qosp.notes.data.repo.IdMappingRepository import org.qosp.notes.data.repo.NoteRepository +import org.qosp.notes.data.repo.NoteRepositoryImpl import org.qosp.notes.data.repo.NotebookRepository import org.qosp.notes.data.repo.ReminderRepository import org.qosp.notes.data.repo.TagRepository -import org.qosp.notes.data.sync.core.SyncManager -import javax.inject.Named -import javax.inject.Singleton -const val NO_SYNC = "NO_SYNC" -@Module -@InstallIn(SingletonComponent::class) object RepositoryModule { - @Provides - @Singleton - fun provideNotebookRepository( - appDatabase: AppDatabase, - noteRepository: NoteRepository, - syncManager: SyncManager, - ) = NotebookRepository(appDatabase.notebookDao, noteRepository, syncManager) - @Provides - @Named(NO_SYNC) - @Singleton - fun provideNotebookRepositoryWithNullSyncManager( - appDatabase: AppDatabase, - @Named(NO_SYNC) noteRepository: NoteRepository, - ) = NotebookRepository(appDatabase.notebookDao, noteRepository, null) + val repoModule = module { + includes(DatabaseModule.dbModule) - @Provides - @Singleton - fun provideNoteRepository( - appDatabase: AppDatabase, - syncManager: SyncManager, - ) = NoteRepository(appDatabase.noteDao, appDatabase.idMappingDao, appDatabase.reminderDao, syncManager) - - @Provides - @Named(NO_SYNC) - @Singleton - fun provideNoteRepositoryWithNullSyncManager( - appDatabase: AppDatabase, - ) = NoteRepository(appDatabase.noteDao, appDatabase.idMappingDao, appDatabase.reminderDao, null) - - @Provides - @Singleton - fun provideReminderRepository(appDatabase: AppDatabase) = ReminderRepository(appDatabase.reminderDao) - - @Provides - @Singleton - fun provideTagRepository( - appDatabase: AppDatabase, - syncManager: SyncManager, - noteRepository: NoteRepository, - ) = TagRepository(appDatabase.tagDao, appDatabase.noteTagDao, noteRepository, syncManager) - - @Provides - @Singleton - fun provideCloudIdRepository(appDatabase: AppDatabase) = IdMappingRepository(appDatabase.idMappingDao) + singleOf(::NoteRepositoryImpl) bind NoteRepository::class + singleOf(::ReminderRepository) + singleOf(::NotebookRepository) + singleOf(::TagRepository) + singleOf(::IdMappingRepository) + } } diff --git a/app/src/main/java/org/qosp/notes/di/SyncModule.kt b/app/src/main/java/org/qosp/notes/di/SyncModule.kt new file mode 100644 index 00000000..b42f3165 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/di/SyncModule.kt @@ -0,0 +1,23 @@ +package org.qosp.notes.di + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module +import org.qosp.notes.data.sync.core.BackendProvider +import org.qosp.notes.data.sync.core.ProcessRemoteActions +import org.qosp.notes.data.sync.core.SynchronizeNotes + +object SyncModule { + + val syncModule = module { + single { SyncScope(scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) } + + singleOf(::BackendProvider) + singleOf(::ProcessRemoteActions) + singleOf(::SynchronizeNotes) + } +} + +class SyncScope(scope: CoroutineScope) : CoroutineScope by scope diff --git a/app/src/main/java/org/qosp/notes/di/UIModule.kt b/app/src/main/java/org/qosp/notes/di/UIModule.kt new file mode 100644 index 00000000..b5d55269 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/di/UIModule.kt @@ -0,0 +1,41 @@ +package org.qosp.notes.di + +import org.koin.android.ext.koin.androidApplication +import org.koin.core.module.dsl.viewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module +import org.qosp.notes.ui.ActivityViewModel +import org.qosp.notes.ui.archive.ArchiveViewModel +import org.qosp.notes.ui.attachments.dialog.AttachmentDialogViewModel +import org.qosp.notes.ui.deleted.DeletedViewModel +import org.qosp.notes.ui.editor.EditorViewModel +import org.qosp.notes.ui.launcher.LauncherViewModel +import org.qosp.notes.ui.main.MainViewModel +import org.qosp.notes.ui.notebooks.ManageNotebooksViewModel +import org.qosp.notes.ui.notebooks.dialog.NotebookDialogViewModel +import org.qosp.notes.ui.reminders.EditReminderViewModel +import org.qosp.notes.ui.search.SearchViewModel +import org.qosp.notes.ui.settings.SettingsViewModel +import org.qosp.notes.ui.sync.nextcloud.NextcloudViewModel +import org.qosp.notes.ui.tags.TagsViewModel +import org.qosp.notes.ui.tags.dialog.TagDialogViewModel + +object UIModule { + val uiModule = module { + viewModelOf(::EditorViewModel) + viewModelOf(::ActivityViewModel) + viewModelOf(::ArchiveViewModel) + viewModelOf(::TagsViewModel) + viewModelOf(::TagDialogViewModel) + viewModelOf(::NextcloudViewModel) + viewModelOf(::SettingsViewModel) + viewModelOf(::SearchViewModel) + viewModelOf(::EditReminderViewModel) + viewModelOf(::ManageNotebooksViewModel) + viewModelOf(::NotebookDialogViewModel) + viewModelOf(::MainViewModel) + viewModel { LauncherViewModel(androidApplication(), get()) } + viewModelOf(::DeletedViewModel) + viewModelOf(::AttachmentDialogViewModel) + } +} diff --git a/app/src/main/java/org/qosp/notes/di/UtilModule.kt b/app/src/main/java/org/qosp/notes/di/UtilModule.kt index 90fd64b6..e5ddd069 100644 --- a/app/src/main/java/org/qosp/notes/di/UtilModule.kt +++ b/app/src/main/java/org/qosp/notes/di/UtilModule.kt @@ -1,80 +1,49 @@ package org.qosp.notes.di -import android.app.Application -import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.workmanager.dsl.workerOf +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module import org.qosp.notes.App import org.qosp.notes.BuildConfig import org.qosp.notes.components.MediaStorageManager import org.qosp.notes.components.backup.BackupManager -import org.qosp.notes.data.repo.IdMappingRepository -import org.qosp.notes.data.repo.NoteRepository -import org.qosp.notes.data.repo.NotebookRepository -import org.qosp.notes.data.repo.ReminderRepository -import org.qosp.notes.data.repo.TagRepository -import org.qosp.notes.data.sync.core.SyncManager -import org.qosp.notes.data.sync.nextcloud.NextcloudManager -import org.qosp.notes.preferences.PreferenceRepository +import org.qosp.notes.components.workers.BinCleaningWorker +import org.qosp.notes.components.workers.SyncWorker import org.qosp.notes.ui.reminders.ReminderManager import org.qosp.notes.ui.utils.ConnectionManager -import javax.inject.Singleton +import org.qosp.notes.ui.utils.Toaster -@Module -@InstallIn(SingletonComponent::class) object UtilModule { - @Provides - @Singleton - fun provideMediaStorageManager( - @ApplicationContext context: Context, - noteRepository: NoteRepository, - ) = MediaStorageManager(context, noteRepository, App.MEDIA_FOLDER) + val utilModule = module { + includes(RepositoryModule.repoModule, SyncModule.syncModule) - @Provides - @Singleton - fun provideReminderManager( - @ApplicationContext context: Context, - reminderRepository: ReminderRepository, - ) = ReminderManager(context, reminderRepository) + workerOf(::BinCleaningWorker) + workerOf(::SyncWorker) - @Provides - @Singleton - fun provideSyncManager( - @ApplicationContext context: Context, - preferenceRepository: PreferenceRepository, - idMappingRepository: IdMappingRepository, - nextcloudManager: NextcloudManager, - app: Application, - ) = SyncManager( - preferenceRepository, - idMappingRepository, - ConnectionManager(context), - nextcloudManager, - (app as App).syncingScope - ) + single { + MediaStorageManager( + context = androidContext(), + noteRepository = get(), + mediaFolder = App.MEDIA_FOLDER + ) + } - @Provides - @Singleton - fun provideBackupManager( - noteRepository: NoteRepository, - notebookRepository: NotebookRepository, - tagRepository: TagRepository, - reminderRepository: ReminderRepository, - idMappingRepository: IdMappingRepository, - reminderManager: ReminderManager, - @ApplicationContext context: Context, - ) = BackupManager( - BuildConfig.VERSION_CODE, - noteRepository, - notebookRepository, - tagRepository, - reminderRepository, - idMappingRepository, - reminderManager, - context - ) + single { + BackupManager( + BuildConfig.VERSION_CODE, + noteRepository = get(), + notebookRepository = get(), + tagRepository = get(), + reminderRepository = get(), + idMappingRepository = get(), + reminderManager = get(), + context = androidContext(), + ) + } + singleOf(::ReminderManager) + singleOf(::ConnectionManager) + singleOf(::Toaster) + } } diff --git a/app/src/main/java/org/qosp/notes/preferences/AppPreferences.kt b/app/src/main/java/org/qosp/notes/preferences/AppPreferences.kt index aa167542..82b7e3bc 100644 --- a/app/src/main/java/org/qosp/notes/preferences/AppPreferences.kt +++ b/app/src/main/java/org/qosp/notes/preferences/AppPreferences.kt @@ -8,15 +8,21 @@ data class AppPreferences( val darkThemeMode: DarkThemeMode = defaultOf(), val colorScheme: ColorScheme = defaultOf(), val sortMethod: SortMethod = defaultOf(), + val sortTagsMethod: SortTagsMethod = defaultOf(), + val sortNavdrawerNotebooksMethod: SortNavdrawerNotebooksMethod = defaultOf(), val backupStrategy: BackupStrategy = defaultOf(), val noteDeletionTime: NoteDeletionTime = defaultOf(), val dateFormat: DateFormat = defaultOf(), val timeFormat: TimeFormat = defaultOf(), val openMediaIn: OpenMediaIn = defaultOf(), val showDate: ShowDate = defaultOf(), + val editorFontSize: FontSize = defaultOf(), + val showFabChangeMode: ShowFabChangeMode = defaultOf(), val groupNotesWithoutNotebook: GroupNotesWithoutNotebook = defaultOf(), + val moveCheckedItems: MoveCheckedItems = defaultOf(), val cloudService: CloudService = defaultOf(), val syncMode: SyncMode = defaultOf(), val backgroundSync: BackgroundSync = defaultOf(), val newNotesSyncable: NewNotesSyncable = defaultOf(), + val trustSelfSignedCertificate: TrustSelfSignedCertificate = defaultOf(), ) diff --git a/app/src/main/java/org/qosp/notes/preferences/PreferenceEnums.kt b/app/src/main/java/org/qosp/notes/preferences/PreferenceEnums.kt index 02832c56..e0765adc 100755 --- a/app/src/main/java/org/qosp/notes/preferences/PreferenceEnums.kt +++ b/app/src/main/java/org/qosp/notes/preferences/PreferenceEnums.kt @@ -1,10 +1,12 @@ package org.qosp.notes.preferences +import android.os.Build import androidx.appcompat.app.AppCompatDelegate import me.msoul.datastore.EnumPreference import me.msoul.datastore.key import org.qosp.notes.R import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.days enum class LayoutMode(override val nameResource: Int) : HasNameResource, EnumPreference by key("layout_mode") { GRID(R.string.preferences_layout_mode_grid) { override val isDefault = true }, @@ -21,16 +23,20 @@ enum class DarkThemeMode(override val nameResource: Int, val styleResource: Int? STANDARD(R.string.preferences_theme_dark_mode_standard, null) { override val isDefault = true }, BLACK(R.string.preferences_theme_dark_mode_black, R.style.DarkBlack), } - enum class ColorScheme( override val nameResource: Int, val styleResource: Int, -) : HasNameResource, EnumPreference by key("color_scheme") { +) : HasNameResource, HasSupportRequirement, EnumPreference by key("color_scheme") { BLUE(R.string.preferences_color_scheme_blue, R.style.Blue) { override val isDefault = true }, GREEN(R.string.preferences_color_scheme_green, R.style.Green), PINK(R.string.preferences_color_scheme_pink, R.style.Pink), YELLOW(R.string.preferences_color_scheme_orange, R.style.Orange), RED(R.string.preferences_color_scheme_purple, R.style.Purple), + SYSTEM(R.string.preferences_color_scheme_system, R.style.System) { + override fun isSupported(): Boolean { + return Build.VERSION.SDK_INT >= 31 + } + }, } enum class SortMethod(override val nameResource: Int) : HasNameResource, EnumPreference by key("sort_method") { @@ -42,6 +48,20 @@ enum class SortMethod(override val nameResource: Int) : HasNameResource, EnumPre MODIFIED_DESC(R.string.preferences_sort_method_modified_desc) { override val isDefault = true }, } +enum class SortTagsMethod(override val nameResource: Int) : HasNameResource, EnumPreference by key("sort_tags_method") { + TITLE_ASC(R.string.preferences_sort_method_title_asc), + TITLE_DESC(R.string.preferences_sort_method_title_desc), + CREATION_ASC(R.string.preferences_sort_method_created_asc), + CREATION_DESC(R.string.preferences_sort_method_created_desc), +} + +enum class SortNavdrawerNotebooksMethod(override val nameResource: Int) : HasNameResource, EnumPreference by key("sort_navdrawer_notebooks_method") { + TITLE_ASC(R.string.preferences_sort_method_title_asc), + TITLE_DESC(R.string.preferences_sort_method_title_desc), + CREATION_ASC(R.string.preferences_sort_method_created_asc), + CREATION_DESC(R.string.preferences_sort_method_created_desc), +} + enum class BackupStrategy(override val nameResource: Int) : HasNameResource, EnumPreference by key("backup_strategy") { INCLUDE_FILES(R.string.preferences_backup_strategy_include_files) { override val isDefault = true }, KEEP_INFO(R.string.preferences_backup_strategy_keep_info), @@ -52,12 +72,13 @@ enum class NoteDeletionTime( override val nameResource: Int, val interval: Long, ) : HasNameResource, EnumPreference by key("note_deletion_time") { - WEEK(R.string.preferences_note_deletion_time_week, TimeUnit.DAYS.toSeconds(7)) { override val isDefault = true }, - TWO_WEEKS(R.string.preferences_note_deletion_time_two_weeks, TimeUnit.DAYS.toSeconds(14)), - MONTH(R.string.preferences_note_deletion_time_month, TimeUnit.DAYS.toSeconds(30)), + WEEK(R.string.preferences_note_deletion_time_week, 7.days.inWholeSeconds) { override val isDefault = true }, + TWO_WEEKS(R.string.preferences_note_deletion_time_two_weeks, 14.days.inWholeSeconds), + MONTH(R.string.preferences_note_deletion_time_month, 30.days.inWholeSeconds), + NEVER(R.string.never, -1), INSTANTLY(R.string.preferences_note_deletion_time_instantly, 0L); - fun toDays() = TimeUnit.SECONDS.toDays(this.interval) + fun toDays() = if (this.interval == -1L) -1L else TimeUnit.SECONDS.toDays(this.interval) } enum class DateFormat(val patternResource: Int) : EnumPreference by key("date_format") { @@ -65,6 +86,7 @@ enum class DateFormat(val patternResource: Int) : EnumPreference by key("date_fo d_MMMM_yyyy(R.string.d_MMMM_yyyy), MM_d_yyyy(R.string.MM_d_yyyy), d_MM_yyyy(R.string.d_MM_yyyy), + yyyy_MM_dd(R.string.yyyy_MM_dd), } enum class TimeFormat(val patternResource: Int) : EnumPreference by key("time_format") { @@ -82,6 +104,27 @@ enum class ShowDate(override val nameResource: Int) : HasNameResource, EnumPrefe NO(R.string.no), } +// TODO (maybe): make this a number input dialog rather than radio buttons choice +enum class FontSize( + override val nameResource: Int, val fontSize: Int +) : HasNameResource, EnumPreference by key("editor_font_size") { + DEFAULT(R.string.preferences_font_size_default, -1) { override val isDefault = true }, // uses predefined/default MaterialComponents.Body1 font size + TEN(R.string.preferences_font_size_ten, 10), + FIFTEEN(R.string.preferences_font_size_fifteen, 15), + TWENTY(R.string.preferences_font_size_twenty, 20), + TWENTYFIVE(R.string.preferences_font_size_twentyfive, 25), + THIRTY(R.string.preferences_font_size_thirty, 30), + THIRTYFIVE(R.string.preferences_font_size_thirtyfive, 35), + FORTY(R.string.preferences_font_size_forty, 40), + FORTYFIVE(R.string.preferences_font_size_fortyfive, 45), + FIFTY(R.string.preferences_font_size_fifty, 50), +} + +enum class ShowFabChangeMode(override val nameResource: Int) : HasNameResource, EnumPreference by key("show_fab_change_mode") { + FAB(R.string.preferences_fab) { override val isDefault = true }, + TOPBAR(R.string.preferences_top_bar), +} + enum class GroupNotesWithoutNotebook( override val nameResource: Int, ) : HasNameResource, EnumPreference by key("group_notes_without_notebook") { @@ -89,9 +132,17 @@ enum class GroupNotesWithoutNotebook( NO(R.string.no) { override val isDefault = true }, } +enum class MoveCheckedItems( + override val nameResource: Int, +) : HasNameResource, EnumPreference by key("move_checked_items") { + YES(R.string.yes) { override val isDefault = true }, + NO(R.string.no), +} + enum class CloudService(override val nameResource: Int) : HasNameResource, EnumPreference by key("cloud_service") { DISABLED(R.string.preferences_cloud_service_disabled) { override val isDefault = true }, NEXTCLOUD(R.string.preferences_cloud_service_nextcloud), + FILE_STORAGE(R.string.preferences_cloud_service_files), } enum class SyncMode(override val nameResource: Int) : HasNameResource, EnumPreference by key("sync_mode") { @@ -108,3 +159,11 @@ enum class NewNotesSyncable(override val nameResource: Int) : HasNameResource, E YES(R.string.yes) { override val isDefault = true }, NO(R.string.no), } + +enum class TrustSelfSignedCertificate(override val nameResource: Int) : HasNameResource, + EnumPreference by key("trust_self_signed_certificate") { + NO(R.string.no) { + override val isDefault = true + }, + YES(R.string.yes), +} diff --git a/app/src/main/java/org/qosp/notes/preferences/PreferenceHelpers.kt b/app/src/main/java/org/qosp/notes/preferences/PreferenceHelpers.kt index 6bc72cec..1abd21b0 100644 --- a/app/src/main/java/org/qosp/notes/preferences/PreferenceHelpers.kt +++ b/app/src/main/java/org/qosp/notes/preferences/PreferenceHelpers.kt @@ -3,3 +3,7 @@ package org.qosp.notes.preferences interface HasNameResource { val nameResource: Int } + +interface HasSupportRequirement { + fun isSupported() = true +} \ No newline at end of file diff --git a/app/src/main/java/org/qosp/notes/preferences/PreferenceRepository.kt b/app/src/main/java/org/qosp/notes/preferences/PreferenceRepository.kt index d7a42d40..2686acf8 100755 --- a/app/src/main/java/org/qosp/notes/preferences/PreferenceRepository.kt +++ b/app/src/main/java/org/qosp/notes/preferences/PreferenceRepository.kt @@ -16,7 +16,7 @@ import java.io.IOException @OptIn(ExperimentalCoroutinesApi::class) class PreferenceRepository( val dataStore: DataStore, - private val sharedPreferences: FlowSharedPreferences, + private val sharedPreferences: FlowSharedPreferences ) { fun getEncryptedString(key: String): Flow { return sharedPreferences.getString(key, "").asFlow() @@ -38,24 +38,28 @@ class PreferenceRepository( darkThemeMode = prefs.getEnum(), colorScheme = prefs.getEnum(), sortMethod = prefs.getEnum(), + sortTagsMethod = prefs.getEnum(), + sortNavdrawerNotebooksMethod = prefs.getEnum(), backupStrategy = prefs.getEnum(), noteDeletionTime = prefs.getEnum(), dateFormat = prefs.getEnum(), timeFormat = prefs.getEnum(), openMediaIn = prefs.getEnum(), showDate = prefs.getEnum(), + editorFontSize = prefs.getEnum(), + showFabChangeMode = prefs.getEnum(), groupNotesWithoutNotebook = prefs.getEnum(), + moveCheckedItems = prefs.getEnum(), cloudService = prefs.getEnum(), syncMode = prefs.getEnum(), backgroundSync = prefs.getEnum(), newNotesSyncable = prefs.getEnum(), + trustSelfSignedCertificate = prefs.getEnum(), ) } } - inline fun get(): Flow where T : Enum, T : EnumPreference { - return dataStore.getEnum() - } + inline fun get(): Flow where T : Enum, T : EnumPreference = dataStore.getEnum() suspend fun putEncryptedStrings(vararg pairs: Pair) { pairs.forEach { (key, value) -> @@ -63,13 +67,12 @@ class PreferenceRepository( } } - suspend fun set(preference: T) where T : Enum, T : EnumPreference { - dataStore.setEnum(preference) - } + suspend fun set(preference: T) where T : Enum, T : EnumPreference = dataStore.setEnum(preference) companion object { const val NEXTCLOUD_INSTANCE_URL = "NEXTCLOUD_INSTANCE_URL" const val NEXTCLOUD_USERNAME = "NEXTCLOUD_USERNAME" const val NEXTCLOUD_PASSWORD = "NEXTCLOUD_PASSWORD" + const val STORAGE_LOCATION = "STORAGE_LOCATION" } } diff --git a/app/src/main/java/org/qosp/notes/ui/ActivityViewModel.kt b/app/src/main/java/org/qosp/notes/ui/ActivityViewModel.kt index 7d10ca45..cd37bcc6 100755 --- a/app/src/main/java/org/qosp/notes/ui/ActivityViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/ActivityViewModel.kt @@ -1,9 +1,9 @@ package org.qosp.notes.ui import android.net.Uri +import android.webkit.MimeTypeMap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import me.msoul.datastore.defaultOf import org.qosp.notes.components.MediaStorageManager import org.qosp.notes.data.model.Note @@ -25,18 +26,19 @@ import org.qosp.notes.data.repo.NotebookRepository import org.qosp.notes.data.repo.ReminderRepository import org.qosp.notes.data.repo.TagRepository import org.qosp.notes.data.sync.core.BaseResult -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.di.SyncScope import org.qosp.notes.preferences.GroupNotesWithoutNotebook import org.qosp.notes.preferences.LayoutMode import org.qosp.notes.preferences.NoteDeletionTime import org.qosp.notes.preferences.PreferenceRepository import org.qosp.notes.preferences.SortMethod +import org.qosp.notes.preferences.SortNavdrawerNotebooksMethod +import org.qosp.notes.preferences.SortTagsMethod import org.qosp.notes.ui.reminders.ReminderManager +import org.qosp.notes.ui.utils.Toaster import java.time.Instant -import javax.inject.Inject -@HiltViewModel -class ActivityViewModel @Inject constructor( +class ActivityViewModel( private val noteRepository: NoteRepository, private val notebookRepository: NotebookRepository, private val preferenceRepository: PreferenceRepository, @@ -44,7 +46,8 @@ class ActivityViewModel @Inject constructor( private val reminderManager: ReminderManager, private val tagRepository: TagRepository, private val mediaStorageManager: MediaStorageManager, - private val syncManager: SyncManager, + private val syncScope: SyncScope, + private val toaster: Toaster, ) : ViewModel() { @OptIn(ExperimentalCoroutinesApi::class) @@ -64,21 +67,9 @@ class ActivityViewModel @Inject constructor( var notesToBackup: Set? = null var tempPhotoUri: Uri? = null - fun syncAsync(): Deferred { - return syncManager.syncingScope.async { - syncManager.sync() - } - } + fun syncAsync(): Deferred = syncScope.async { noteRepository.syncNotes() } - fun sync() { - syncManager.syncingScope.launch { - syncManager.sync() - } - } - - fun discardEmptyNotesAsync() = viewModelScope.async(Dispatchers.IO) { - noteRepository.discardEmptyNotes() - } + fun discardEmptyNotesAsync() = viewModelScope.async(Dispatchers.IO) { noteRepository.discardEmptyNotes() } fun deleteNotesPermanently(vararg notes: Note) = viewModelScope.launch(Dispatchers.IO) { notes.forEach { reminderManager.cancelAllRemindersForNote(it.id) } @@ -94,6 +85,7 @@ class ActivityViewModel @Inject constructor( noteRepository.deleteNotes(*notes) mediaStorageManager.cleanUpStorage() } + else -> { noteRepository.moveNotesToBin(*notes) } @@ -103,72 +95,47 @@ class ActivityViewModel @Inject constructor( fun restoreNotes(vararg notes: Note) { viewModelScope.launch(Dispatchers.IO) { - noteRepository.restoreNotes(*notes) + val result = runCatching { noteRepository.restoreNotes(*notes) } + if (result.isFailure) { + toaster.showLong(result.exceptionOrNull()?.message ?: "Error restoring notes") + } } } - fun archiveNotes(vararg notes: Note) = update(*notes) { note -> - note.copy( - isArchived = true, - ) - } + fun archiveNotes(vararg notes: Note) = update(*notes) { it.copy(isArchived = true) } - fun unarchiveNotes(vararg notes: Note) = update(*notes) { note -> - note.copy( - isArchived = false, - ) - } + fun unarchiveNotes(vararg notes: Note) = update(*notes) { it.copy(isArchived = false) } - fun showNotes(vararg notes: Note) = update(*notes) { note -> - note.copy( - isHidden = false, - ) - } + fun showNotes(vararg notes: Note) = update(*notes) { it.copy(isHidden = false) } - fun hideNotes(vararg notes: Note) = update(*notes) { note -> - note.copy( - isHidden = true, - ) - } + fun hideNotes(vararg notes: Note) = update(*notes) { it.copy(isHidden = true) } - fun pinNotes(vararg notes: Note) = update(*notes) { note -> - note.copy( - isPinned = !note.isPinned, - ) - } + fun pinNotes(vararg notes: Note) = update(*notes) { it.copy(isPinned = !it.isPinned) } - fun moveNotes(notebookId: Long?, vararg notes: Note) = update(*notes) { note -> - note.copy( - notebookId = notebookId, - modifiedDate = Instant.now().epochSecond, - ) - } + fun compactPreviewNotes(vararg notes: Note) = update(*notes) { it.copy(isCompactPreview = true) } - fun makeNotesSyncable(vararg notes: Note) = update(*notes) { note -> - note.copy( - isLocalOnly = false, - ) - } + fun fullPreviewNotes(vararg notes: Note) = update(*notes) { it.copy(isCompactPreview = false) } - fun makeNotesLocal(vararg notes: Note) = update(*notes) { note -> - note.copy( - isLocalOnly = true, - ) - } + fun moveNotes(notebookId: Long?, vararg notes: Note) = + update(*notes) { it.copy(notebookId = notebookId, modifiedDate = Instant.now().epochSecond) } - fun disableMarkdown(vararg notes: Note) = update(*notes) { note -> - note.copy( - isMarkdownEnabled = false, - modifiedDate = Instant.now().epochSecond, - ) - } + fun makeNotesSyncable(vararg notes: Note) = update(*notes) { it.copy(isLocalOnly = false) } - fun enableMarkdown(vararg notes: Note) = update(*notes) { note -> - note.copy( - isMarkdownEnabled = true, - modifiedDate = Instant.now().epochSecond, - ) - } + fun makeNotesLocal(vararg notes: Note) = update(*notes) { it.copy(isLocalOnly = true) } + + fun makeNotesFullPreview(vararg notes: Note) = update(*notes) { it.copy(isCompactPreview = false) } + + fun makeNotesCompactPreview(vararg notes: Note) = update(*notes) { it.copy(isCompactPreview = true) } + + fun disableScreenAlwaysOn(vararg notes: Note) = update(*notes) { it.copy(screenAlwaysOn = false) } + + fun enableScreenAlwaysOn(vararg notes: Note) = update(*notes) { it.copy(screenAlwaysOn = true) } + + fun disableMarkdown(vararg notes: Note) = + update(*notes) { it.copy(isMarkdownEnabled = false, modifiedDate = Instant.now().epochSecond) } + + fun enableMarkdown(vararg notes: Note) = + update(*notes) { it.copy(isMarkdownEnabled = true, modifiedDate = Instant.now().epochSecond) } fun duplicateNotes(vararg notes: Note) = notes.forEachAsync { note -> val oldId = note.id @@ -192,15 +159,19 @@ class ActivityViewModel @Inject constructor( } fun setLayoutMode(layoutMode: LayoutMode) { - viewModelScope.launch(Dispatchers.IO) { - preferenceRepository.set(layoutMode) - } + viewModelScope.launch(Dispatchers.IO) { preferenceRepository.set(layoutMode) } } fun setSortMethod(method: SortMethod) { - viewModelScope.launch(Dispatchers.IO) { - preferenceRepository.set(method) - } + viewModelScope.launch(Dispatchers.IO) { preferenceRepository.set(method) } + } + + fun setSortTagsMethod(method: SortTagsMethod) { + viewModelScope.launch(Dispatchers.IO) { preferenceRepository.set(method) } + } + + fun setSortNavdrawerNotebooksMethod(method: SortNavdrawerNotebooksMethod) { + viewModelScope.launch(Dispatchers.IO) { preferenceRepository.set(method) } } suspend fun createImageFile(): Uri? { @@ -209,6 +180,26 @@ class ActivityViewModel @Inject constructor( return uri } + /** + * Creates a file for the shared media in app's private storage + * @param uri The source URI of the media file + * @param mimeType The MIME type of the media + * @return The new URI in app's private storage, or null if creation failed + */ + suspend fun copyMediaToPrivateStorage(uri: Uri, mimeType: String): Uri? = withContext(Dispatchers.IO) { + val mediaType = when { + mimeType.startsWith("image/") -> MediaStorageManager.MediaType.IMAGE + mimeType.startsWith("video/") -> MediaStorageManager.MediaType.VIDEO + mimeType.startsWith("audio/") -> MediaStorageManager.MediaType.AUDIO + else -> return@withContext null + } + + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" } + ?: mediaType.defaultExtension + + mediaStorageManager.createMediaFile(mediaType, extension)?.first + } + private inline fun update( vararg notes: Note, crossinline transform: suspend (Note) -> Note, @@ -221,8 +212,6 @@ class ActivityViewModel @Inject constructor( } private inline fun Array.forEachAsync(crossinline block: suspend CoroutineScope.(Note) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - forEach { block(it) } - } + viewModelScope.launch(Dispatchers.IO) { forEach { block(it) } } } } diff --git a/app/src/main/java/org/qosp/notes/ui/BaseActivity.kt b/app/src/main/java/org/qosp/notes/ui/BaseActivity.kt index 8a2142f3..69f2eea5 100644 --- a/app/src/main/java/org/qosp/notes/ui/BaseActivity.kt +++ b/app/src/main/java/org/qosp/notes/ui/BaseActivity.kt @@ -2,25 +2,31 @@ package org.qosp.notes.ui import android.content.res.Configuration import android.os.Bundle +import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import dagger.hilt.android.AndroidEntryPoint +import androidx.core.view.WindowCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject import org.qosp.notes.preferences.PreferenceRepository import org.qosp.notes.preferences.ThemeMode -import javax.inject.Inject +import org.qosp.notes.ui.utils.Toaster +import org.qosp.notes.ui.utils.collect -@AndroidEntryPoint open class BaseActivity : AppCompatActivity() { - @Inject - lateinit var preferenceRepository: PreferenceRepository + val preferenceRepository: PreferenceRepository by inject() + private val toaster: Toaster by inject() override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + runBlocking { val (colorScheme, themeMode, darkThemeModeStyle) = withContext(Dispatchers.IO) { preferenceRepository @@ -44,11 +50,19 @@ open class BaseActivity : AppCompatActivity() { val isAutoDark = themeMode == ThemeMode.SYSTEM.mode && (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + // Check which theme should be used (light, dark, black) and set navbar color accordingly + // if dark if (themeMode == ThemeMode.DARK.mode || isAutoDark) { + // if black darkThemeModeStyle?.let { theme.applyStyle(darkThemeModeStyle, true) } } } + + // Observe toast messages with lifecycle awareness + toaster.messages.collect(this) { (message, duration) -> + Toast.makeText(this@BaseActivity, message, duration).show() + } } } diff --git a/app/src/main/java/org/qosp/notes/ui/MainActivity.kt b/app/src/main/java/org/qosp/notes/ui/MainActivity.kt index b6e1262d..c6d3ec04 100755 --- a/app/src/main/java/org/qosp/notes/ui/MainActivity.kt +++ b/app/src/main/java/org/qosp/notes/ui/MainActivity.kt @@ -1,9 +1,10 @@ package org.qosp.notes.ui +import android.annotation.SuppressLint import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle -import androidx.activity.viewModels import androidx.appcompat.widget.AppCompatImageButton import androidx.appcompat.widget.AppCompatTextView import androidx.core.os.bundleOf @@ -11,32 +12,52 @@ import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.children +import androidx.core.view.get +import androidx.core.view.size import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController import androidx.navigation.NavDeepLinkBuilder import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.navigateUp -import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel import org.qosp.notes.R import org.qosp.notes.components.backup.BackupService +import org.qosp.notes.data.model.Attachment import org.qosp.notes.data.model.Notebook -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.data.sync.core.BackendProvider +import org.qosp.notes.data.sync.fs.StorageConfig +import org.qosp.notes.data.sync.fs.toFriendlyString +import org.qosp.notes.data.sync.nextcloud.NextcloudConfig import org.qosp.notes.databinding.ActivityMainBinding +import org.qosp.notes.preferences.CloudService +import org.qosp.notes.preferences.SortNavdrawerNotebooksMethod +import org.qosp.notes.ui.attachments.fromUri import org.qosp.notes.ui.utils.closeAndThen import org.qosp.notes.ui.utils.collect import org.qosp.notes.ui.utils.hideKeyboard import org.qosp.notes.ui.utils.navigateSafely -import javax.inject.Inject -@AndroidEntryPoint class MainActivity : BaseActivity() { lateinit var appBarConfiguration: AppBarConfiguration lateinit var navController: NavController private lateinit var binding: ActivityMainBinding - private val activityModel: ActivityViewModel by viewModels() + private val activityModel: ActivityViewModel by viewModel() + private val backendProvider by inject() private val topLevelMenu get() = binding.navigationView.menu private val notebooksMenu get() = topLevelMenu.findItem(R.id.menu_notebooks).subMenu @@ -57,9 +78,6 @@ class MainActivity : BaseActivity() { R.id.fragment_tags, ) - @Inject - lateinit var syncManager: SyncManager - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -73,24 +91,58 @@ class MainActivity : BaseActivity() { insets } + // Apply insets to the NavigationView to prevent it from overlapping with the status bar + // This is to fix the insets after Android 15 enforcing edge-to-edge display + ViewCompat.setOnApplyWindowInsetsListener(binding.navigationView) { view, insets -> + // Reduce padding but keep it below the status bar + view.setPadding(0, 0, 0, 0) + insets + } + setupDrawerHeader() if (intent != null) handleIntent(intent) } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - if (intent != null) handleIntent(intent) + handleIntent(intent) } + override fun onSupportNavigateUp(): Boolean { return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp() } + @SuppressLint("MissingSuperCall") + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (binding.drawer.isDrawerOpen(GravityCompat.START)) { binding.drawer.closeDrawer(GravityCompat.START) + } else if (navController.previousBackStackEntry != null) { + navController.popBackStack() } else { - super.onBackPressed() + moveTaskToBack(true) + } + } + + /** + * Copies shared media from a URI to the app's private storage. + * @param uri The source URI of the media file to copy + * @return The new URI in app's private storage, or null if copy failed + */ + private suspend fun copySharedMedia(uri: Uri): Uri? = withContext(Dispatchers.IO) { + try { + val mimeType = contentResolver.getType(uri) ?: return@withContext null + val newUri = activityModel.copyMediaToPrivateStorage(uri, mimeType) ?: return@withContext null + + contentResolver.openInputStream(uri)?.use { input -> + contentResolver.openOutputStream(newUri)?.use { output -> + input.copyTo(output) + } + } + newUri + } catch (e: Exception) { + null } } @@ -99,29 +151,101 @@ class MainActivity : BaseActivity() { Intent.ACTION_SEND -> { val title = intent.getStringExtra(Intent.EXTRA_TITLE) ?: "" val content = intent.getStringExtra(Intent.EXTRA_TEXT) ?: "" + var uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + + // Try getting URI from ClipData if not in EXTRA_STREAM + if (uri == null && intent.clipData != null && intent.clipData!!.itemCount > 0) { + uri = intent.clipData!!.getItemAt(0).uri + } + + lifecycleScope.launch { + val args = bundleOf( + "transitionName" to "", + "newNoteTitle" to title, + "newNoteContent" to content, + ) + + if (uri != null) { + val newUri = copySharedMedia(uri) + if (newUri != null) { + withContext(Dispatchers.IO) { + val attachment = Attachment.fromUri(this@MainActivity, newUri) + args.putParcelableArray("newNoteAttachments", arrayOf(attachment)) + } + } + } + + val link = NavDeepLinkBuilder(this@MainActivity) + .setGraph(R.navigation.nav_graph) + .setDestination(R.id.fragment_editor) + .setArguments(args) + .createTaskStackBuilder() + .first() + + navController.handleDeepLink(link) + } + } + Intent.ACTION_SEND_MULTIPLE -> { + var uris = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + + // Try getting URIs from ClipData if not in EXTRA_STREAM + if (uris == null && intent.clipData != null) { + uris = ArrayList() + for (i in 0 until intent.clipData!!.itemCount) { + intent.clipData!!.getItemAt(i).uri?.let { uris.add(it) } + } + } - val link = NavDeepLinkBuilder(this) - .setGraph(R.navigation.nav_graph) - .setDestination(R.id.fragment_editor) - .setArguments( - bundleOf( + if (!uris.isNullOrEmpty()) { + lifecycleScope.launch { + val args = bundleOf( "transitionName" to "", - "newNoteTitle" to title, - "newNoteContent" to content, + "newNoteTitle" to (intent.getStringExtra(Intent.EXTRA_TITLE) ?: ""), + "newNoteContent" to (intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""), ) - ) - .createTaskStackBuilder() - .first() - navController.handleDeepLink(link) + withContext(Dispatchers.IO) { + val attachments = uris.mapNotNull { uri -> + copySharedMedia(uri)?.let { newUri -> + Attachment.fromUri(this@MainActivity, newUri) + } + }.toTypedArray() + + if (attachments.isNotEmpty()) { + args.putParcelableArray("newNoteAttachments", attachments) + } + } + + val link = NavDeepLinkBuilder(this@MainActivity) + .setGraph(R.navigation.nav_graph) + .setDestination(R.id.fragment_editor) + .setArguments(args) + .createTaskStackBuilder() + .first() + + navController.handleDeepLink(link) + } + } } + else -> navController.handleDeepLink(intent) } } private fun setupDrawerHeader() { val header = binding.navigationView.getHeaderView(0) - val syncSettingsButton = header.findViewById(R.id.button_sync_settings) + val syncSettingsButton = + header.findViewById(R.id.button_sync_settings) val textViewUsername = header.findViewById(R.id.text_view_username) val textViewProvider = header.findViewById(R.id.text_view_provider) @@ -136,27 +260,58 @@ class MainActivity : BaseActivity() { navController.navigateSafely(R.id.fragment_sync_settings) } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + backendProvider.syncProvider.collect { backend -> + when (backend?.type) { + CloudService.NEXTCLOUD -> { + NextcloudConfig.fromPreferences(preferenceRepository) + .filterNotNull().firstOrNull()?.let { config -> + textViewUsername.text = config.username + textViewProvider.text = getString(R.string.preferences_cloud_service_nextcloud) + } + } + + CloudService.FILE_STORAGE -> { + val uri = StorageConfig.storageLocation(preferenceRepository) + .filterNotNull().firstOrNull()?.location + if (uri != null && uri.toString().isNotEmpty()) { + textViewUsername.text = + uri.toFriendlyString(applicationContext) + textViewProvider.text = getString(R.string.preferences_cloud_service_files) + } else { + textViewUsername.text = getString(R.string.preferences_cloud_service) + textViewProvider.text = getString(R.string.preferences_cloud_service_disabled) + } + } - syncManager.config - .collect(this@MainActivity) { config -> - textViewUsername.text = config?.username ?: getString(R.string.indicator_offline_account) - textViewProvider.text = getString(config?.provider?.nameResource ?: R.string.preferences_currently_not_syncing) + CloudService.DISABLED, null -> { + textViewUsername.text = getString(R.string.preferences_cloud_service) + textViewProvider.text = getString(R.string.preferences_cloud_service_disabled) + } + } + } } + } } - private fun selectCurrentDestinationMenuItem(destinationId: Int? = null, arguments: Bundle? = null) { - val destinationId = when (val id = destinationId ?: navController.currentDestination?.id ?: return) { - // Assign destinations that do not have a drawer entry to an existing entry - R.id.fragment_sync_settings -> R.id.fragment_settings - R.id.fragment_search -> R.id.fragment_main - else -> id - } + private fun selectCurrentDestinationMenuItem( + destinationId: Int? = null, + arguments: Bundle? = null + ) { + val destinationId = + when (val id = destinationId ?: navController.currentDestination?.id ?: return) { + // Assign destinations that do not have a drawer entry to an existing entry + R.id.fragment_sync_settings -> R.id.fragment_settings + R.id.fragment_search -> R.id.fragment_main + else -> id + } val arguments = arguments ?: navController.currentBackStackEntry?.arguments val notebookId = arguments?.getLong("notebookId", -1L)?.takeIf { it >= 0L } binding.navigationView.post { - (notebooksMenu.children + topLevelMenu.children) + ((notebooksMenu?.children ?: emptySequence()) + topLevelMenu.children) .forEach { item -> item.isChecked = when (notebookId) { null -> item.itemId == destinationId @@ -170,7 +325,7 @@ class MainActivity : BaseActivity() { // Alternative of setupWithNavController(), NavigationUI.java // Sets up click listeners for all drawer menu items except from notebooks. // Those are handled in createNotebookMenuItems() - (topLevelMenu.children + listOfNotNull(notebooksMenu.findItem(R.id.fragment_manage_notebooks))) + (topLevelMenu.children + listOfNotNull(notebooksMenu?.findItem(R.id.fragment_manage_notebooks))) .forEach { item -> if (item.itemId !in primaryDestinations + secondaryDestinations) return@forEach @@ -183,7 +338,7 @@ class MainActivity : BaseActivity() { } } - notebooksMenu.findItem(R.id.nav_default_notebook)?.setOnMenuItemClickListener { + notebooksMenu?.findItem(R.id.nav_default_notebook)?.setOnMenuItemClickListener { binding.drawer.closeAndThen { navController.navigateSafely( R.id.fragment_notebook, @@ -200,17 +355,41 @@ class MainActivity : BaseActivity() { private fun setupNavigation() { fun createNotebookMenuItems(notebooks: List) { - notebooks.forEach { notebook -> - val menuItem = notebooksMenu.findItem(notebook.id.toInt()) + + // Obtaining the current setting of notebook sorting order. + val sort = runBlocking { + return@runBlocking preferenceRepository + .getAll() + .map { it.sortNavdrawerNotebooksMethod } + .first() + .name + } + + // Sorting the notebooks. + val sortedNotebooks: List = when (sort) { + SortNavdrawerNotebooksMethod.CREATION_ASC.name -> notebooks.sortedBy { it.id } + SortNavdrawerNotebooksMethod.CREATION_DESC.name -> notebooks.sortedByDescending { it.id } + SortNavdrawerNotebooksMethod.TITLE_ASC.name -> notebooks.sortedBy { it.name } + SortNavdrawerNotebooksMethod.TITLE_DESC.name -> notebooks.sortedByDescending { it.name } + else -> notebooks.sortedBy { it.name } + } + + // Displaying the notebooks. + sortedNotebooks.forEach { notebook -> + val menuItem = notebooksMenu?.findItem(notebook.id.toInt()) if (menuItem != null && notebook.name != menuItem.title) { menuItem.title = notebook.name } if (menuItem == null) { - notebooksMenu - .add(R.id.section_notebooks, notebook.id.toInt(), 0, notebook.name) - .setIcon(R.drawable.ic_notebook) - .setCheckable(true) - .setOnMenuItemClickListener { + notebooksMenu?.add( + R.id.section_notebooks, + notebook.id.toInt(), + 0, + notebook.name + ) + ?.setIcon(R.drawable.ic_notebook) + ?.setCheckable(true) + ?.setOnMenuItemClickListener { binding.drawer.closeAndThen { navController.navigateSafely( R.id.fragment_notebook, @@ -251,19 +430,23 @@ class MainActivity : BaseActivity() { // Remove deleted notebooks from the menu (primaryDestinations + secondaryDestinations + notebookIds).let { dests -> - var index = 0 - while (index < notebooksMenu.size()) { - val item = notebooksMenu.getItem(index) - if (item.itemId !in dests) notebooksMenu.removeItem(item.itemId) else index++ + notebooksMenu?.let { nbMenu -> + var index = 0 + while (index < nbMenu.size) { + val item = nbMenu[index] + if (item.itemId !in dests) nbMenu.removeItem(item.itemId) else index++ + } } } createNotebookMenuItems(notebooks) val defaultTitle = getString(R.string.default_notebook) - notebooksMenu.findItem(R.id.nav_default_notebook)?.apply { + notebooksMenu?.findItem(R.id.nav_default_notebook)?.apply { isVisible = showDefaultNotebook - title = defaultTitle + " (${getString(R.string.default_string)})".takeIf { notebooks.any { it.name == defaultTitle } }.orEmpty() + title = + defaultTitle + " (${getString(R.string.default_string)})".takeIf { notebooks.any { it.name == defaultTitle } } + .orEmpty() } } } diff --git a/app/src/main/java/org/qosp/notes/ui/about/AboutFragment.kt b/app/src/main/java/org/qosp/notes/ui/about/AboutFragment.kt index f0681c6d..a572c9d8 100644 --- a/app/src/main/java/org/qosp/notes/ui/about/AboutFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/about/AboutFragment.kt @@ -6,8 +6,9 @@ import android.os.Bundle import android.view.View import androidx.appcompat.widget.Toolbar import androidx.core.view.isVisible -import dagger.hilt.android.AndroidEntryPoint import io.noties.markwon.Markwon +import org.acra.ACRA +import org.koin.android.ext.android.inject import org.qosp.notes.BuildConfig import org.qosp.notes.R import org.qosp.notes.databinding.FragmentAboutBinding @@ -15,9 +16,7 @@ import org.qosp.notes.ui.common.BaseDialog import org.qosp.notes.ui.common.BaseFragment import org.qosp.notes.ui.utils.liftAppBarOnScroll import org.qosp.notes.ui.utils.viewBinding -import javax.inject.Inject -@AndroidEntryPoint class AboutFragment : BaseFragment(resId = R.layout.fragment_about) { private val binding by viewBinding(FragmentAboutBinding::bind) @@ -27,8 +26,7 @@ class AboutFragment : BaseFragment(resId = R.layout.fragment_about) { override val toolbarTitle: String get() = getString(R.string.nav_about) - @Inject - lateinit var markwon: Markwon + val markwon: Markwon by inject() private fun launchUrl(url: String) { val intent = Intent(Intent.ACTION_VIEW) @@ -43,10 +41,8 @@ class AboutFragment : BaseFragment(resId = R.layout.fragment_about) { binding.layoutAppBar.appBar, requireContext().resources.getDimension(R.dimen.app_bar_elevation) ) - - if (!BuildConfig.IS_GOOGLE) { - binding.actionSupport.isVisible = true - } + binding.appVersion.subText = BuildConfig.VERSION_NAME + binding.actionSupport.isVisible = true } private fun setupListeners() = with(binding) { @@ -54,9 +50,8 @@ class AboutFragment : BaseFragment(resId = R.layout.fragment_about) { actionContribute.setOnClickListener { launchUrl(requireContext().getString(R.string.app_repo)) } actionVisitDeveloper.setOnClickListener { launchUrl(requireContext().getString(R.string.app_developer_repo)) } actionViewLibraries.setOnClickListener { showLibrariesDialog() } - if (!BuildConfig.IS_GOOGLE) { - actionSupport.setOnClickListener { launchUrl(requireContext().getString(R.string.app_support_page)) } - } + actionSupport.setOnClickListener { launchUrl(requireContext().getString(R.string.app_support_page)) } + actionSendLogs.setOnClickListener { ACRA.errorReporter.handleException(null) } } private fun showLibrariesDialog() { diff --git a/app/src/main/java/org/qosp/notes/ui/archive/ArchiveFragment.kt b/app/src/main/java/org/qosp/notes/ui/archive/ArchiveFragment.kt index e9a0b562..325426bd 100755 --- a/app/src/main/java/org/qosp/notes/ui/archive/ArchiveFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/archive/ArchiveFragment.kt @@ -5,13 +5,12 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.appcompat.widget.Toolbar -import androidx.fragment.app.viewModels import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint +import org.koin.androidx.viewmodel.ext.android.viewModel import org.qosp.notes.R import org.qosp.notes.databinding.FragmentArchiveBinding import org.qosp.notes.databinding.LayoutNoteBinding @@ -19,12 +18,11 @@ import org.qosp.notes.ui.common.AbstractNotesFragment import org.qosp.notes.ui.utils.navigateSafely import org.qosp.notes.ui.utils.viewBinding -@AndroidEntryPoint class ArchiveFragment : AbstractNotesFragment(R.layout.fragment_archive) { private val binding by viewBinding(FragmentArchiveBinding::bind) override val currentDestinationId: Int = R.id.fragment_archive - override val model: ArchiveViewModel by viewModels() + override val model: ArchiveViewModel by viewModel() override val recyclerView: RecyclerView get() = binding.recyclerArchive @@ -44,6 +42,7 @@ class ArchiveFragment : AbstractNotesFragment(R.layout.fragment_archive) { get() = binding.layoutAppBar.toolbarSelection override val secondaryToolbarMenuRes: Int = R.menu.archive_selected_notes + @Deprecated("") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.archive, menu) @@ -51,6 +50,7 @@ class ArchiveFragment : AbstractNotesFragment(R.layout.fragment_archive) { setHiddenNotesItemActionText() } + @Deprecated("") override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_search -> findNavController().navigateSafely(ArchiveFragmentDirections.actionArchiveToSearch()) diff --git a/app/src/main/java/org/qosp/notes/ui/archive/ArchiveViewModel.kt b/app/src/main/java/org/qosp/notes/ui/archive/ArchiveViewModel.kt index dc992e36..f7bbaa06 100755 --- a/app/src/main/java/org/qosp/notes/ui/archive/ArchiveViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/archive/ArchiveViewModel.kt @@ -1,17 +1,14 @@ package org.qosp.notes.ui.archive -import dagger.hilt.android.lifecycle.HiltViewModel import org.qosp.notes.data.repo.NoteRepository -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.data.sync.core.BackendProvider import org.qosp.notes.preferences.PreferenceRepository import org.qosp.notes.ui.common.AbstractNotesViewModel -import javax.inject.Inject -@HiltViewModel -class ArchiveViewModel @Inject constructor( +class ArchiveViewModel( noteRepository: NoteRepository, preferenceRepository: PreferenceRepository, - syncManager: SyncManager -) : AbstractNotesViewModel(preferenceRepository, syncManager) { + backendProvider: BackendProvider +) : AbstractNotesViewModel(preferenceRepository, backendProvider) { override val provideNotes = noteRepository::getArchived } diff --git a/app/src/main/java/org/qosp/notes/ui/attachments/dialog/AttachmentDialogViewModel.kt b/app/src/main/java/org/qosp/notes/ui/attachments/dialog/AttachmentDialogViewModel.kt index 0ed38ef8..adad4cd6 100644 --- a/app/src/main/java/org/qosp/notes/ui/attachments/dialog/AttachmentDialogViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/attachments/dialog/AttachmentDialogViewModel.kt @@ -2,19 +2,14 @@ package org.qosp.notes.ui.attachments.dialog import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.qosp.notes.data.repo.NoteRepository import java.time.Instant -import javax.inject.Inject -@HiltViewModel -class AttachmentDialogViewModel @Inject constructor( - private val noteRepository: NoteRepository, -) : ViewModel() { +class AttachmentDialogViewModel(private val noteRepository: NoteRepository) : ViewModel() { fun getAttachment(noteId: Long, path: String) = noteRepository.getById(noteId).map { note -> note?.attachments?.find { it.path == path } diff --git a/app/src/main/java/org/qosp/notes/ui/attachments/dialog/EditAttachmentDialog.kt b/app/src/main/java/org/qosp/notes/ui/attachments/dialog/EditAttachmentDialog.kt index 6e0ed9d6..fcb6daa0 100644 --- a/app/src/main/java/org/qosp/notes/ui/attachments/dialog/EditAttachmentDialog.kt +++ b/app/src/main/java/org/qosp/notes/ui/attachments/dialog/EditAttachmentDialog.kt @@ -5,17 +5,15 @@ import android.view.LayoutInflater import android.view.View import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf -import androidx.fragment.app.activityViewModels -import dagger.hilt.android.AndroidEntryPoint +import org.koin.androidx.viewmodel.ext.android.viewModel import org.qosp.notes.R import org.qosp.notes.databinding.DialogEditAttachmentBinding import org.qosp.notes.ui.common.BaseDialog import org.qosp.notes.ui.utils.collect import org.qosp.notes.ui.utils.requestFocusAndKeyboard -@AndroidEntryPoint class EditAttachmentDialog : BaseDialog() { - private val model: AttachmentDialogViewModel by activityViewModels() + private val model: AttachmentDialogViewModel by viewModel() private var path: String? = null private var noteId: Long? = null diff --git a/app/src/main/java/org/qosp/notes/ui/attachments/recycler/AttachmentViewHolder.kt b/app/src/main/java/org/qosp/notes/ui/attachments/recycler/AttachmentViewHolder.kt index a2e1a589..3b016ff1 100755 --- a/app/src/main/java/org/qosp/notes/ui/attachments/recycler/AttachmentViewHolder.kt +++ b/app/src/main/java/org/qosp/notes/ui/attachments/recycler/AttachmentViewHolder.kt @@ -9,7 +9,8 @@ import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView -import coil.fetch.VideoFrameUriFetcher +import coil.decode.VideoFrameDecoder +import coil.fetch.Fetcher import coil.load import coil.request.ImageRequest import org.qosp.notes.R @@ -30,7 +31,12 @@ class AttachmentViewHolder( if (listener != null) { itemView.setOnClickListener { listener.onItemClick(bindingAdapterPosition, binding) } - itemView.setOnLongClickListener { listener.onLongClick(bindingAdapterPosition, binding) } + itemView.setOnLongClickListener { + listener.onLongClick( + bindingAdapterPosition, + binding + ) + } } } @@ -68,14 +74,18 @@ class AttachmentViewHolder( imageView.apply { scaleType = ImageView.ScaleType.FIT_CENTER loadThumbnail(attachment.uri(context)) { - fetcher(VideoFrameUriFetcher(context)) + decoderFactory(VideoFrameDecoder.Factory()) } } setIndicator(R.drawable.ic_movie) } Attachment.Type.AUDIO -> { imageView.loadThumbnail(attachment.uri(context)) { - fetcher(AlbumArtFetcher(context)) + fetcherFactory(Fetcher.Factory { data, options, _ -> + (data as? Uri)?.let { + AlbumArtFetcher(context, data, options) + } + }) } setIndicator(R.drawable.ic_music) diff --git a/app/src/main/java/org/qosp/notes/ui/attachments/recycler/AttachmentsPreviewGridManager.kt b/app/src/main/java/org/qosp/notes/ui/attachments/recycler/AttachmentsPreviewGridManager.kt index cf733035..e5332dd7 100644 --- a/app/src/main/java/org/qosp/notes/ui/attachments/recycler/AttachmentsPreviewGridManager.kt +++ b/app/src/main/java/org/qosp/notes/ui/attachments/recycler/AttachmentsPreviewGridManager.kt @@ -10,7 +10,7 @@ class AttachmentsPreviewGridManager(private val context: Context, private val sp private var itemSpans = listOf() init { - spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + spanSizeLookup = object : SpanSizeLookup() { override fun getSpanSize(position: Int) = itemSpans.getOrNull(position) ?: 1 } } diff --git a/app/src/main/java/org/qosp/notes/ui/common/AbstractNotesFragment.kt b/app/src/main/java/org/qosp/notes/ui/common/AbstractNotesFragment.kt index 89771a6f..470cc571 100755 --- a/app/src/main/java/org/qosp/notes/ui/common/AbstractNotesFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/common/AbstractNotesFragment.kt @@ -27,9 +27,9 @@ import com.google.android.material.appbar.AppBarLayout import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import com.google.android.material.transition.MaterialElevationScale -import dagger.hilt.android.AndroidEntryPoint import io.noties.markwon.Markwon import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.qosp.notes.R import org.qosp.notes.data.model.Note import org.qosp.notes.data.sync.core.BaseResult @@ -41,16 +41,13 @@ import org.qosp.notes.ui.common.recycler.NoteRecyclerAdapter import org.qosp.notes.ui.common.recycler.NoteRecyclerListener import org.qosp.notes.ui.common.recycler.onBackPressedHandler import org.qosp.notes.ui.utils.collect -import org.qosp.notes.ui.utils.launch import org.qosp.notes.ui.utils.liftAppBarOnScroll import org.qosp.notes.ui.utils.shareNote import org.qosp.notes.ui.utils.views.BottomSheet import java.util.concurrent.TimeUnit -import javax.inject.Inject private typealias Data = AbstractNotesViewModel.Data -@AndroidEntryPoint abstract class AbstractNotesFragment(@LayoutRes resId: Int) : BaseFragment(resId) { abstract val currentDestinationId: Int abstract val recyclerView: RecyclerView @@ -80,8 +77,7 @@ abstract class AbstractNotesFragment(@LayoutRes resId: Int) : BaseFragment(resId recyclerAdapter.showHiddenNotes = value } - @Inject - lateinit var markwon: Markwon + val markwon: Markwon by inject() // Bug: // @@ -248,22 +244,19 @@ abstract class AbstractNotesFragment(@LayoutRes resId: Int) : BaseFragment(resId } if (model.isSyncingEnabled() && !recyclerAdapter.searchMode) { - swipeRefreshLayout.isRefreshing = true activityModel .syncAsync() .await() .showToastOnCriticalError() } - swipeRefreshLayout.isRefreshing = false } } - postponeEnterTransition(1500L, TimeUnit.MILLISECONDS) } override fun onDestroyView() { - // Bug fix. See the the comments at the declaration of destinationChangedListener for more info. + // Bug fix. See the comments at the declaration of destinationChangedListener for more info. isListenerSet = false findNavController().removeOnDestinationChangedListener(destinationChangedListener) @@ -339,6 +332,8 @@ abstract class AbstractNotesFragment(@LayoutRes resId: Int) : BaseFragment(resId when (item.itemId) { R.id.action_pin_selected -> activityModel.pinNotes(*selectedNotes) + R.id.action_compact_preview_selected -> activityModel.compactPreviewNotes(*selectedNotes) + R.id.action_full_preview_selected -> activityModel.fullPreviewNotes(*selectedNotes) R.id.action_archive_selected -> { activityModel.archiveNotes(*selectedNotes) sendMessage( @@ -372,7 +367,7 @@ abstract class AbstractNotesFragment(@LayoutRes resId: Int) : BaseFragment(resId R.id.action_move_selected -> showMoveToNotebookDialog(*selectedNotes) R.id.action_export_selected -> { activityModel.notesToBackup = selectedNotes.toSet() - exportNotesLauncher.launch() + exportNotesLauncher.launch(null) } R.id.action_select_all -> { selectAllNotes() @@ -487,6 +482,18 @@ abstract class AbstractNotesFragment(@LayoutRes resId: Int) : BaseFragment(resId action(R.string.action_hide, R.drawable.ic_hidden, condition = !note.isHidden) { activityModel.hideNotes(note) } + action(R.string.action_compact_preview, R.drawable.ic_preview, condition = !note.isCompactPreview) { + activityModel.makeNotesCompactPreview(note) + } + action(R.string.action_full_preview, R.drawable.ic_preview, condition = note.isCompactPreview) { + activityModel.makeNotesFullPreview(note) + } + action(R.string.action_disable_screen_always_on, R.drawable.ic_pin, condition = !note.isDeleted && note.screenAlwaysOn) { + activityModel.disableScreenAlwaysOn(note) + } + action(R.string.action_enable_screen_always_on, R.drawable.ic_pin, condition = !note.isDeleted && !note.screenAlwaysOn) { + activityModel.enableScreenAlwaysOn(note) + } action(R.string.action_disable_markdown, R.drawable.ic_markdown, condition = !note.isDeleted && note.isMarkdownEnabled) { activityModel.disableMarkdown(note) } @@ -498,7 +505,7 @@ abstract class AbstractNotesFragment(@LayoutRes resId: Int) : BaseFragment(resId } action(R.string.action_export, R.drawable.ic_export_note) { activityModel.notesToBackup = setOf(note) - exportNotesLauncher.launch() + exportNotesLauncher.launch(null) } action(R.string.action_share, R.drawable.ic_share) { shareNote(requireContext(), note) diff --git a/app/src/main/java/org/qosp/notes/ui/common/AbstractNotesViewModel.kt b/app/src/main/java/org/qosp/notes/ui/common/AbstractNotesViewModel.kt index c3cdb23f..b20cd4be 100644 --- a/app/src/main/java/org/qosp/notes/ui/common/AbstractNotesViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/common/AbstractNotesViewModel.kt @@ -10,8 +10,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import me.msoul.datastore.defaultOf import org.qosp.notes.data.model.Note -import org.qosp.notes.data.sync.core.Success -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.data.sync.core.BackendProvider import org.qosp.notes.preferences.LayoutMode import org.qosp.notes.preferences.NoteDeletionTime import org.qosp.notes.preferences.PreferenceRepository @@ -19,7 +18,7 @@ import org.qosp.notes.preferences.SortMethod abstract class AbstractNotesViewModel( protected val preferenceRepository: PreferenceRepository, - protected val syncManager: SyncManager, + protected val backendProvider: BackendProvider, ) : ViewModel() { protected abstract val provideNotes: (SortMethod) -> Flow> @@ -33,7 +32,7 @@ abstract class AbstractNotesViewModel( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Data()) - suspend fun isSyncingEnabled(): Boolean = syncManager.ifSyncing { _, _ -> Success } == Success + fun isSyncingEnabled(): Boolean = backendProvider.isSyncing data class Data( val notes: List = emptyList(), diff --git a/app/src/main/java/org/qosp/notes/ui/common/BaseFragment.kt b/app/src/main/java/org/qosp/notes/ui/common/BaseFragment.kt index e7e6bcf7..ad85e550 100755 --- a/app/src/main/java/org/qosp/notes/ui/common/BaseFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/common/BaseFragment.kt @@ -6,10 +6,10 @@ import androidx.annotation.LayoutRes import androidx.appcompat.widget.Toolbar import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResult import androidx.navigation.ui.setupActionBarWithNavController import com.google.android.material.transition.MaterialSharedAxis +import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.qosp.notes.ui.ActivityViewModel import org.qosp.notes.ui.MainActivity import org.qosp.notes.ui.utils.ExportNotesContract @@ -21,7 +21,9 @@ open class BaseFragment(@LayoutRes resId: Int) : Fragment(resId) { protected open val hasMenu: Boolean = true protected open val hasDefaultAnimation: Boolean = true - val activityModel: ActivityViewModel by activityViewModels() + protected val TAG = this::class.simpleName ?: "Quillpad" + + val activityModel: ActivityViewModel by activityViewModel() protected open val toolbar: Toolbar? = null protected open val toolbarTitle: String = "" diff --git a/app/src/main/java/org/qosp/notes/ui/common/recycler/ExtendedListAdapter.kt b/app/src/main/java/org/qosp/notes/ui/common/recycler/ExtendedListAdapter.kt index a5a94b11..cee2772c 100644 --- a/app/src/main/java/org/qosp/notes/ui/common/recycler/ExtendedListAdapter.kt +++ b/app/src/main/java/org/qosp/notes/ui/common/recycler/ExtendedListAdapter.kt @@ -155,8 +155,9 @@ abstract class ExtendedListAdapter( } private fun updateSelectedIds(newList: List) { - val newListIds = newList.mapIndexed { index, _ -> getItemId(index) } - _selectedItemIds.removeIf { it !in newListIds } + val newListIds = List(newList.size) { index -> getItemId(index) } + val toRemove = _selectedItemIds.filter { it !in newListIds } + _selectedItemIds.removeAll(toRemove) onSelectionChanged() } @@ -172,7 +173,7 @@ abstract class ExtendedListAdapter( } @CallSuper - override fun onBindViewHolder(holder: VH, position: Int) { + override fun onBindViewHolder(holder: VH & Any, position: Int) { if (isSelectionEnabled) onItemSelectedStatusChanged(getItemId(position), holder) } diff --git a/app/src/main/java/org/qosp/notes/ui/common/recycler/NoteViewHolder.kt b/app/src/main/java/org/qosp/notes/ui/common/recycler/NoteViewHolder.kt index ce9d8339..c4527724 100755 --- a/app/src/main/java/org/qosp/notes/ui/common/recycler/NoteViewHolder.kt +++ b/app/src/main/java/org/qosp/notes/ui/common/recycler/NoteViewHolder.kt @@ -94,14 +94,14 @@ class NoteViewHolder( } private fun setContent(note: Note) = with(binding) { - recyclerTasks.isVisible = note.isList && note.taskList.isNotEmpty() + recyclerTasks.isVisible = note.isList && note.taskList.isNotEmpty() && !note.isCompactPreview indicatorMoreTasks.isVisible = false - textViewContent.isVisible = !note.isList && note.content.isNotEmpty() + textViewContent.isVisible = !note.isList && note.content.isNotEmpty() && !note.isCompactPreview val taskList = note.taskList.takeIf { it.size <= 8 } ?: note.taskList.subList(0, 8).also { val moreItems = note.taskList.size - 8 - indicatorMoreTasks.isVisible = true + indicatorMoreTasks.isVisible = !note.isCompactPreview indicatorMoreTasks.text = context.resources.getQuantityString(R.plurals.more_items, moreItems, moreItems) } diff --git a/app/src/main/java/org/qosp/notes/ui/deleted/DeletedFragment.kt b/app/src/main/java/org/qosp/notes/ui/deleted/DeletedFragment.kt index 39155dcf..e02a3932 100755 --- a/app/src/main/java/org/qosp/notes/ui/deleted/DeletedFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/deleted/DeletedFragment.kt @@ -5,12 +5,12 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.appcompat.widget.Toolbar -import androidx.fragment.app.viewModels import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.appbar.AppBarLayout +import org.koin.androidx.viewmodel.ext.android.viewModel import org.qosp.notes.R import org.qosp.notes.databinding.FragmentDeletedBinding import org.qosp.notes.databinding.LayoutNoteBinding @@ -24,7 +24,7 @@ class DeletedFragment : AbstractNotesFragment(R.layout.fragment_deleted) { private val binding by viewBinding(FragmentDeletedBinding::bind) override val currentDestinationId: Int = R.id.fragment_deleted - override val model: DeletedViewModel by viewModels() + override val model: DeletedViewModel by viewModel() override val recyclerView: RecyclerView get() = binding.recyclerDeleted @@ -44,6 +44,7 @@ class DeletedFragment : AbstractNotesFragment(R.layout.fragment_deleted) { get() = binding.layoutAppBar.toolbarSelection override val secondaryToolbarMenuRes = R.menu.deleted_selected_notes + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.deleted, menu) @@ -51,6 +52,7 @@ class DeletedFragment : AbstractNotesFragment(R.layout.fragment_deleted) { setHiddenNotesItemActionText() } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_empty_bin -> showEmptyBinDialog() @@ -66,8 +68,11 @@ class DeletedFragment : AbstractNotesFragment(R.layout.fragment_deleted) { val days = data.noteDeletionTimeInDays - binding.indicatorDeletedEmptyText.text = - if (days != 0L) getString(R.string.indicator_deleted_empty, days) else getString(R.string.indicator_bin_disabled) + binding.indicatorDeletedEmptyText.text = when (days) { + -1L -> getString(R.string.indicator_bin_forever) + 0L -> getString(R.string.indicator_bin_disabled) + else -> getString(R.string.indicator_deleted_empty, days) + } } override fun onNoteClick(noteId: Long, position: Int, viewBinding: LayoutNoteBinding) { diff --git a/app/src/main/java/org/qosp/notes/ui/deleted/DeletedViewModel.kt b/app/src/main/java/org/qosp/notes/ui/deleted/DeletedViewModel.kt index 51599695..e44640e2 100755 --- a/app/src/main/java/org/qosp/notes/ui/deleted/DeletedViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/deleted/DeletedViewModel.kt @@ -1,23 +1,20 @@ package org.qosp.notes.ui.deleted import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.qosp.notes.components.MediaStorageManager import org.qosp.notes.data.repo.NoteRepository -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.data.sync.core.BackendProvider import org.qosp.notes.preferences.PreferenceRepository import org.qosp.notes.ui.common.AbstractNotesViewModel -import javax.inject.Inject -@HiltViewModel -class DeletedViewModel @Inject constructor( +class DeletedViewModel( private val notesRepository: NoteRepository, private val mediaStorageManager: MediaStorageManager, preferenceRepository: PreferenceRepository, - syncManager: SyncManager, -) : AbstractNotesViewModel(preferenceRepository, syncManager) { + backendProvider: BackendProvider, +) : AbstractNotesViewModel(preferenceRepository, backendProvider) { override val provideNotes = notesRepository::getDeleted diff --git a/app/src/main/java/org/qosp/notes/ui/editor/EditorFragment.kt b/app/src/main/java/org/qosp/notes/ui/editor/EditorFragment.kt index ac26dd6d..598ba9ec 100755 --- a/app/src/main/java/org/qosp/notes/ui/editor/EditorFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/editor/EditorFragment.kt @@ -1,18 +1,25 @@ package org.qosp.notes.ui.editor +import android.app.AlarmManager import android.content.Intent import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.RectF +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.Settings +import android.text.Editable import android.text.InputType import android.text.TextWatcher +import android.util.Log import android.view.ContextThemeWrapper import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View +import android.view.WindowManager import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.activity.addCallback @@ -29,7 +36,6 @@ import androidx.core.view.updateLayoutParams import androidx.core.widget.doOnTextChanged import androidx.fragment.app.clearFragmentResult import androidx.fragment.app.setFragmentResultListener -import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -46,13 +52,14 @@ import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import com.google.android.material.transition.MaterialContainerTransform import com.google.android.material.transition.MaterialSharedAxis -import dagger.hilt.android.AndroidEntryPoint import io.noties.markwon.Markwon import io.noties.markwon.editor.MarkwonEditor import io.noties.markwon.editor.MarkwonEditorTextWatcher import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.commonmark.node.Code +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel import org.qosp.notes.R import org.qosp.notes.data.model.Attachment import org.qosp.notes.data.model.Note @@ -73,7 +80,6 @@ import org.qosp.notes.ui.editor.dialog.InsertHyperlinkDialog import org.qosp.notes.ui.editor.dialog.InsertImageDialog import org.qosp.notes.ui.editor.dialog.InsertTableDialog import org.qosp.notes.ui.editor.markdown.MarkdownSpan -import org.qosp.notes.ui.editor.markdown.addListItemListener import org.qosp.notes.ui.editor.markdown.applyTo import org.qosp.notes.ui.editor.markdown.insertMarkdown import org.qosp.notes.ui.editor.markdown.toggleCheckmarkCurrentLine @@ -92,7 +98,6 @@ import org.qosp.notes.ui.utils.dp import org.qosp.notes.ui.utils.getDimensionAttribute import org.qosp.notes.ui.utils.getDrawableCompat import org.qosp.notes.ui.utils.hideKeyboard -import org.qosp.notes.ui.utils.launch import org.qosp.notes.ui.utils.liftAppBarOnScroll import org.qosp.notes.ui.utils.navigateSafely import org.qosp.notes.ui.utils.requestFocusAndKeyboard @@ -107,14 +112,12 @@ import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.concurrent.Executors -import javax.inject.Inject private typealias Data = EditorViewModel.Data -@AndroidEntryPoint class EditorFragment : BaseFragment(R.layout.fragment_editor) { private val binding by viewBinding(FragmentEditorBinding::bind) - private val model: EditorViewModel by viewModels() + private val model: EditorViewModel by viewModel() private val args: EditorFragmentArgs by navArgs() private var snackbar: Snackbar? = null @@ -136,11 +139,9 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { private lateinit var attachmentsAdapter: AttachmentsAdapter private lateinit var tasksAdapter: TasksAdapter - @Inject - lateinit var markwon: Markwon + val markwon: Markwon by inject() - @Inject - lateinit var markwonEditor: MarkwonEditor + val markwonEditor: MarkwonEditor by inject() override val hasDefaultAnimation = false override val toolbar: Toolbar @@ -209,6 +210,7 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) } } + ACTION_STATE_SWIPE -> { val newDx = dX / 3 val p = Paint().apply { color = context?.resolveAttribute(R.attr.colorTaskSwipe) ?: Color.RED } @@ -317,12 +319,12 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { requireContext().resources.getDimension(R.dimen.app_bar_elevation) ) - setFragmentResultListener(RECORD_CODE) { s, bundle -> + setFragmentResultListener(RECORD_CODE) { _, bundle -> val attachment = bundle.getParcelable(RECORDED_ATTACHMENT) ?: return@setFragmentResultListener model.insertAttachments(attachment) } - setFragmentResultListener(MARKDOWN_DIALOG_RESULT) { s, bundle -> + setFragmentResultListener(MARKDOWN_DIALOG_RESULT) { _, bundle -> val markdown = bundle.getString(MARKDOWN_DIALOG_RESULT) ?: return@setFragmentResultListener binding.editTextContent.apply { if (selectedText?.isNotEmpty() == true) { @@ -338,6 +340,7 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { } } + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.editor_top, menu) this.mainMenu = menu @@ -347,70 +350,95 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { } } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { data.note?.let { note -> when (item.itemId) { R.id.action_convert_note -> { if (note.isList) model.toTextNote() else model.toList() } + R.id.action_archive_note -> { if (note.isArchived) activityModel.unarchiveNotes(note) else activityModel.archiveNotes(note) sendMessage(getString(R.string.indicator_archive_note)) activity?.onBackPressed() } + R.id.action_delete_note -> { activityModel.deleteNotes(note) sendMessage(getString(R.string.indicator_moved_note_to_bin)) activity?.onBackPressed() } + R.id.action_restore_note -> { activityModel.restoreNotes(note) activity?.onBackPressed() } + R.id.action_delete_permanently_note -> { activityModel.deleteNotesPermanently(note) sendMessage(getString(R.string.indicator_deleted_note_permanently)) activity?.onBackPressed() } + R.id.action_view_tags -> { findNavController().navigateSafely( EditorFragmentDirections.actionEditorToTags().setNoteId(note.id) ) } + R.id.action_view_reminders -> { showRemindersDialog(note) } + R.id.action_pin_note -> { activityModel.pinNotes(note) } + + R.id.action_change_mode -> { + updateEditMode(!model.inEditMode) + if (model.inEditMode) requestFocusForFields(true) else view?.hideKeyboard() + setupMenuItems(note, note.reminders.isNotEmpty()) + } + R.id.action_hide_note -> { if (note.isHidden) activityModel.showNotes(note) else activityModel.hideNotes(note) } + R.id.action_do_not_sync -> { if (note.isLocalOnly) activityModel.makeNotesSyncable(note) else activityModel.makeNotesLocal(note) } + R.id.action_change_color -> { showColorChangeDialog() } + R.id.action_export_note -> { activityModel.notesToBackup = setOf(note) - exportNotesLauncher.launch() + exportNotesLauncher.launch(null) } + R.id.action_share -> { shareNote(requireContext(), note) } + R.id.action_attach_file -> { - requestMediaLauncher.launch() + requestMediaLauncher.launch(null) } + R.id.action_take_photo -> { lifecycleScope.launch { - takePhotoLauncher.launch(activityModel.createImageFile()) + runCatching { + takePhotoLauncher.launch(activityModel.createImageFile()) + }.getOrElse { Log.e(TAG, "Cannot launch camera app", it) } } } + R.id.action_record_audio -> { clearFragmentResult(RECORD_CODE) RecordAudioDialog().show(parentFragmentManager, null) } + R.id.action_enable_disable_markdown -> { if (note.isMarkdownEnabled) { activityModel.disableMarkdown(note) @@ -418,6 +446,26 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { activityModel.enableMarkdown(note) } } + + R.id.action_screen_always_on -> { + if (note.screenAlwaysOn) { + activityModel.disableScreenAlwaysOn(note) + } else { + activityModel.enableScreenAlwaysOn(note) + } + setupScreenAlwaysOn(!note.screenAlwaysOn) + } + + R.id.action_uncheck_all_tasks -> { + uncheckAllTasks() + true + } + + R.id.action_remove_all_checked_tasks -> { + removeAllCheckedTasks() + true + } + else -> false } } @@ -435,6 +483,7 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { itemTouchHelper.attachToRecyclerView(null) attachmentsAdapter.listener = null tasksAdapter.listener = null + setupScreenAlwaysOn(false) super.onDestroyView() } @@ -548,17 +597,18 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { imeOptions = EditorInfo.IME_ACTION_NEXT setRawInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) - setOnEditorActionListener { v, actionId, event -> + setOnEditorActionListener { _, actionId, _ -> when { actionId == EditorInfo.IME_ACTION_NEXT && data.note?.isList == true -> { jumpToNextTaskOrAdd(-1) true } + else -> false } } - doOnTextChanged { text, start, before, count -> + doOnTextChanged { text, _, _, _ -> // Only listen for meaningful changes if (data.note == null) { return@doOnTextChanged @@ -571,7 +621,7 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { editTextContent.apply { enableUndoRedo(this@EditorFragment) setRawInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) - doOnTextChanged { text, start, before, count -> + doOnTextChanged { text, _, _, _ -> // Only listen for meaningful changes, we do not care about empty text if (data.note == null) { return@doOnTextChanged @@ -579,12 +629,53 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { model.setNoteContent(text.toString().trim()) } - setOnFocusChangeListener { v, hasFocus -> + setOnFocusChangeListener { _, hasFocus -> contentHasFocus = hasFocus setMarkdownToolbarVisibility() } - setOnEditorActionListener(addListItemListener) + + addTextChangedListener(object : TextWatcher { + var changedText = "" + private val listRegex = Regex("^((\\s*)([\\-+*] +)).*") + private val checkRegex = Regex("^((\\s*)[-+*] *\\[([ xX])] +).*") + private val numListRegex = Regex("((\\s*)([1-9][0-9]*)[.] +).*") + private val indentedLine = Regex("((\\s+)).*") + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + changedText = s?.substring(start, start + count).toString() + } + + override fun afterTextChanged(s: Editable?) { + if (changedText.endsWith('\n')) { + val txt = text ?: return + val prevLine = txt.lines().getOrNull(currentLineIndex - 1) ?: return + when { + prevLine.matches(checkRegex) -> nextListLine(checkRegex, prevLine, txt, "- [ ] ") + prevLine.matches(listRegex) -> nextListLine(listRegex, prevLine, txt) + prevLine.matches(numListRegex) -> { + val nextNum = numListRegex.find(prevLine)?.groupValues?.get(3)?.toInt()?.inc() ?: 1 + nextListLine(numListRegex, prevLine, txt, "$nextNum. ") + } + + prevLine.matches(indentedLine) -> nextListLine(indentedLine, prevLine, txt) + } + } + } + + private fun nextListLine(regex: Regex, line: String, text: Editable, suffix: String? = null) { + val groups = regex.find(line)?.groupValues + val matchedLine = groups?.getOrNull(1) ?: "" + if (matchedLine == line) { + text.delete(currentLineStartPos - line.length - 1, currentLineStartPos - 1) + } else { + val indent = groups?.getOrNull(2) ?: "" + text.insert(currentLineStartPos, "$indent${suffix ?: groups?.getOrNull(3) ?: ""}") + } + } + }) setOnCanUndoRedoListener { canUndo, canRedo -> binding.bottomToolbar.menu?.run { @@ -595,7 +686,7 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { } // Used to clear focus and hide the keyboard when touching outside of the edit texts - linearLayout.setOnFocusChangeListener { v, hasFocus -> + linearLayout.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) root.hideKeyboard() } } @@ -615,8 +706,18 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { isVisible = !note.isDeleted } + findItem(R.id.action_change_mode)?.apply { + // if view/edit mode FAB isn't displayed (user pref) show it in the top menu + if (!data.showFabChangeMode) { + setIcon(if (model.inEditMode) R.drawable.ic_show else R.drawable.ic_pencil) + + isVisible = !note.isDeleted && !hasNoteEmptyContent(note) + } + } + findItem(R.id.action_pin_note)?.apply { setIcon(if (note.isPinned) R.drawable.ic_pin_filled else R.drawable.ic_pin) + setTitle(if (note.isPinned) R.string.action_unpin else R.string.action_pin) isVisible = !note.isDeleted } @@ -645,6 +746,19 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { isChecked = note.isLocalOnly isVisible = !note.isDeleted } + + findItem(R.id.action_screen_always_on)?.apply { + isChecked = note.screenAlwaysOn + isVisible = !note.isDeleted + } + + findItem(R.id.action_uncheck_all_tasks)?.apply { + isVisible = note.isList && !note.isDeleted + } + + findItem(R.id.action_remove_all_checked_tasks)?.apply { + isVisible = note.isList && !note.isDeleted + } } private fun observeData() = with(binding) { @@ -660,6 +774,7 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { val isConverted = data.note.isList != isList val isMarkdownEnabled = data.note.isMarkdownEnabled val (dateFormat, timeFormat) = data.dateTimeFormats + val screenAlwaysOn = data.note.screenAlwaysOn isList = data.note.isList isNoteDeleted = data.note.isDeleted @@ -670,9 +785,26 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { disableMarkdownTextWatcher() } + setupScreenAlwaysOn(screenAlwaysOn) + // Update Title and Content only the first the since they are EditTexts if (isFirstLoad) { + // apply font size preference + if (data.editorFontSize != -1) { // is customised + val fontSizeFloat = data.editorFontSize.toFloat() + + textViewTitlePreview.textSize = fontSizeFloat + textViewContentPreview.textSize = fontSizeFloat + + editTextTitle.textSize = fontSizeFloat + editTextContent.textSize = fontSizeFloat + + if (isList) { + tasksAdapter.setFontSize(fontSizeFloat) + } + } + editTextTitle.withoutTextWatchers { setText(data.note.title) } @@ -692,11 +824,16 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { } } - nextTaskId = data.note.taskList.map { it.id }.maxOrNull()?.plus(1) ?: 0L + nextTaskId = data.note.taskList.maxOfOrNull { it.id }?.plus(1) ?: 0L } // We only want to update the task list when the user converts the note from text to list if (isConverted) { + + if (data.editorFontSize != -1) { + tasksAdapter.setFontSize(data.editorFontSize.toFloat()) + } + tasksAdapter.tasks.clear() tasksAdapter.notifyDataSetChanged() tasksAdapter.submitList(data.note.taskList) @@ -756,7 +893,11 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { textViewDate.isVisible = data.showDates if (formatter != null && data.showDates) { textViewDate.text = - getString(R.string.indicator_note_date, creationDate.format(formatter), modifiedDate.format(formatter)) + getString( + R.string.indicator_note_date, + creationDate.format(formatter), + modifiedDate.format(formatter) + ) } // We want to start the transition only when everything is loaded @@ -767,7 +908,7 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { if (isNoteDeleted) { snackbar = Snackbar.make(binding.root, "", Snackbar.LENGTH_INDEFINITE) .setText(getString(R.string.indicator_deleted_note_cannot_be_edited)) - .setAction(getString(R.string.action_restore)) { view -> + .setAction(getString(R.string.action_restore)) { _ -> activityModel.restoreNotes(data.note) activity?.onBackPressed() } @@ -809,6 +950,7 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { R.id.action_insert_code -> MarkdownSpan.CODE R.id.action_insert_quote -> MarkdownSpan.QUOTE R.id.action_insert_heading -> MarkdownSpan.HEADING + R.id.action_insert_highlight -> MarkdownSpan.HIGHLIGHT R.id.action_insert_link -> { clearFragmentResult(MARKDOWN_DIALOG_RESULT) InsertHyperlinkDialog @@ -816,6 +958,7 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { .show(parentFragmentManager, null) null } + R.id.action_insert_image -> { clearFragmentResult(MARKDOWN_DIALOG_RESULT) InsertImageDialog @@ -823,33 +966,43 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { .show(parentFragmentManager, null) null } + R.id.action_insert_table -> { clearFragmentResult(MARKDOWN_DIALOG_RESULT) InsertTableDialog().show(parentFragmentManager, null) null } + R.id.action_toggle_check_line -> { editTextContent.toggleCheckmarkCurrentLine() null } + R.id.action_scroll_to_top -> { scrollView.smoothScrollTo(0, 0) editTextContent.setSelection(0) null } + R.id.action_scroll_to_bottom -> { - scrollView.smoothScrollTo(0, editTextContent.bottom + editTextContent.paddingBottom + editTextContent.marginBottom) + scrollView.smoothScrollTo( + 0, + editTextContent.bottom + editTextContent.paddingBottom + editTextContent.marginBottom + ) editTextContent.setSelection(editTextContent.length()) null } + R.id.action_undo -> { editTextContent.undo() null } + R.id.action_redo -> { editTextContent.redo() null } + else -> return@setOnMenuItemClickListener false } editTextContent.insertMarkdown(span ?: return@setOnMenuItemClickListener false) @@ -861,7 +1014,11 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { } actionAddTask.setOnClickListener { - addTask() + var addTaskIndex = tasksAdapter.tasks.size + if (model.moveCheckedItems) + addTaskIndex = tasksAdapter.tasks.indexOfLast { !it.isDone } + 1 + + addTask(addTaskIndex) } } @@ -903,6 +1060,14 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { } } + private fun setupScreenAlwaysOn(enable: Boolean) { + if (enable) { + activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + override fun setupToolbar(): Unit = with(binding) { super.setupToolbar() val onBackPressedHandler = { @@ -945,28 +1110,51 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { } private fun updateTask(position: Int, content: String? = null, isDone: Boolean? = null) { - tasksAdapter.tasks = tasksAdapter.tasks - .mapIndexed { index, task -> - when (index) { - position -> task.copy( - content = content ?: task.content, - isDone = isDone ?: task.isDone - ) - else -> task + val tasks = tasksAdapter.tasks + val oldTask = tasks[position] + val newTask = tasks[position].copy( + content = content ?: oldTask.content, + isDone = isDone ?: oldTask.isDone + ) + tasks[position] = newTask + + if (oldTask.isDone != newTask.isDone && model.moveCheckedItems) { + if (newTask.isDone) { + // Move to very end + tasks.removeAt(position) + tasks.add(newTask) + + tasksAdapter.notifyItemMoved(position, tasks.indexOf(newTask)) + tasksAdapter.notifyItemRangeChanged(position, tasks.size - position) + } else { + // Move to after last open task or to very beginning if all tasks are done + val newPosition = tasks.indexOfLast { it.id != newTask.id && !it.isDone } + 1 + + // Only move upwards; don't move further down + if (newPosition < position) { + tasks.removeAt(position) + tasks.add(newPosition, newTask) + + tasksAdapter.notifyItemMoved(position, newPosition) + tasksAdapter.notifyItemRangeChanged(newPosition, position - newPosition + 1) } } - .toMutableList() + } + model.updateTaskList(tasksAdapter.tasks) } private fun showColorChangeDialog() { - val selected = NoteColor.values().indexOf(data.note?.color).coerceAtLeast(0) + val selected = NoteColor.entries.indexOf(data.note?.color).coerceAtLeast(0) val dialog = BaseDialog.build(requireContext()) { setTitle(getString(R.string.action_change_color)) - setSingleChoiceItems(NoteColor.values().map { it.localizedName }.toTypedArray(), selected) { dialog, which -> - model.setColor(NoteColor.values()[which]) + setSingleChoiceItems( + NoteColor.entries.map { it.localizedName }.toTypedArray(), + selected + ) { _, which -> + model.setColor(NoteColor.entries[which]) } - setPositiveButton(getString(R.string.action_done)) { dialog, which -> } + setPositiveButton(getString(R.string.action_done)) { _, _ -> } } dialog.show() @@ -979,32 +1167,40 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { val reminderDate = LocalDateTime.ofEpochSecond(reminder.date, 0, offset) action(reminder.name + " (${reminderDate.format(formatter)})", R.drawable.ic_bell) { - EditReminderDialog.build(note.id, reminder).show(parentFragmentManager, null) + if (checkSchedulePermission()) EditReminderDialog.build(note.id, reminder) + .show(parentFragmentManager, null) } } action(R.string.action_new_reminder, R.drawable.ic_add) { - EditReminderDialog.build(note.id, null).show(parentFragmentManager, null) + if (checkSchedulePermission()) EditReminderDialog.build(note.id, null).show(parentFragmentManager, null) } } } - /** Gives the focus to the editor fields if they are empty */ - private fun requestFocusForFields(forceFocus: Boolean = false) = with(binding) { - if (editTextTitle.text.isNullOrEmpty()) { - editTextTitle.requestFocusAndKeyboard() - } else { - if (editTextContent.text.isNullOrEmpty() || forceFocus) { - editTextContent.requestFocusAndKeyboard() + private fun checkSchedulePermission(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val alarmManager = context?.getSystemService(AlarmManager::class.java) + if (alarmManager?.canScheduleExactAlarms() != true) { + val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { + data = Uri.fromParts("package", context?.packageName, null) + } + context?.startActivity(intent) + return false } } + return true + } + + /** Gives the focus to the note body if it is empty */ + private fun requestFocusForFields(forceFocus: Boolean = false) = with(binding) { + if (editTextContent.text.isNullOrEmpty() || forceFocus) { + editTextContent.requestFocusAndKeyboard() + } } private fun updateEditMode(inEditMode: Boolean = model.inEditMode, note: Note? = data.note) = with(binding) { // If the note is empty the fragment should open in edit mode by default - val noteHasEmptyContent = note?.title?.isBlank() == true || when (note?.isList) { - true -> note.taskList.isEmpty() - else -> note?.content?.isBlank() == true - } + val noteHasEmptyContent = hasNoteEmptyContent(note) model.inEditMode = (inEditMode || noteHasEmptyContent) && !isNoteDeleted @@ -1021,10 +1217,11 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { textViewContentPreview.isVisible = !model.inEditMode && !isList editTextContent.isVisible = model.inEditMode && !isList - val shouldDisplayFAB = !isNoteDeleted && !noteHasEmptyContent + val shouldDisplayFAB = data.showFabChangeMode && !isNoteDeleted && !noteHasEmptyContent when { fabChangeMode.isVisible == shouldDisplayFAB -> { /* FAB is already like it should be, no reason to animate */ } + fabChangeMode.isVisible && !shouldDisplayFAB -> fabChangeMode.hide() else -> fabChangeMode.show() } @@ -1033,17 +1230,40 @@ class EditorFragment : BaseFragment(R.layout.fragment_editor) { setMarkdownToolbarVisibility(note) } - private val NoteColor.localizedName get() = getString( - when (this) { - NoteColor.Default -> R.string.default_string - NoteColor.Green -> R.string.preferences_color_scheme_green - NoteColor.Pink -> R.string.preferences_color_scheme_pink - NoteColor.Blue -> R.string.preferences_color_scheme_blue - NoteColor.Red -> R.string.preferences_color_scheme_red - NoteColor.Orange -> R.string.preferences_color_scheme_orange - NoteColor.Yellow -> R.string.preferences_color_scheme_yellow + private fun hasNoteEmptyContent(note: Note? = data.note): Boolean { + return note?.content?.isBlank() == true || (note?.isList == true && note.taskList.isEmpty()) + } + + private fun uncheckAllTasks() { + val updatedTasks = tasksAdapter.tasks.map { task -> + task.copy(isDone = false) } - ) + tasksAdapter.submitList(updatedTasks) + + model.updateTaskList(updatedTasks) + } + + private fun removeAllCheckedTasks() { + val updatedTasks = tasksAdapter.tasks.filter { task -> + !task.isDone + } + tasksAdapter.submitList(updatedTasks) + + model.updateTaskList(updatedTasks) + } + + private val NoteColor.localizedName + get() = getString( + when (this) { + NoteColor.Default -> R.string.default_string + NoteColor.Green -> R.string.preferences_color_scheme_green + NoteColor.Pink -> R.string.preferences_color_scheme_pink + NoteColor.Blue -> R.string.preferences_color_scheme_blue + NoteColor.Red -> R.string.preferences_color_scheme_red + NoteColor.Orange -> R.string.preferences_color_scheme_orange + NoteColor.Yellow -> R.string.preferences_color_scheme_yellow + } + ) companion object { const val MARKDOWN_DIALOG_RESULT = "MARKDOWN_DIALOG_RESULT" diff --git a/app/src/main/java/org/qosp/notes/ui/editor/EditorViewModel.kt b/app/src/main/java/org/qosp/notes/ui/editor/EditorViewModel.kt index 9eca5a3f..6acd619c 100755 --- a/app/src/main/java/org/qosp/notes/ui/editor/EditorViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/editor/EditorViewModel.kt @@ -2,11 +2,8 @@ package org.qosp.notes.ui.editor import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -26,30 +23,26 @@ import org.qosp.notes.data.model.NoteTask import org.qosp.notes.data.model.Notebook import org.qosp.notes.data.repo.NoteRepository import org.qosp.notes.data.repo.NotebookRepository -import org.qosp.notes.data.sync.core.SyncManager import org.qosp.notes.preferences.DateFormat +import org.qosp.notes.preferences.MoveCheckedItems import org.qosp.notes.preferences.NewNotesSyncable import org.qosp.notes.preferences.OpenMediaIn import org.qosp.notes.preferences.PreferenceRepository import org.qosp.notes.preferences.ShowDate +import org.qosp.notes.preferences.ShowFabChangeMode import org.qosp.notes.preferences.TimeFormat import java.time.Instant -import javax.inject.Inject -@HiltViewModel -class EditorViewModel @Inject constructor( +class EditorViewModel( private val noteRepository: NoteRepository, private val notebookRepository: NotebookRepository, - private val syncManager: SyncManager, private val preferenceRepository: PreferenceRepository, ) : ViewModel() { var inEditMode: Boolean = false var isNotInitialized = true - - private var syncJob: Job? = null + var moveCheckedItems: Boolean = true private val noteIdFlow: MutableStateFlow = MutableStateFlow(null) - var selectedRange = 0 to 0 @OptIn(ExperimentalCoroutinesApi::class) @@ -66,6 +59,8 @@ class EditorViewModel @Inject constructor( dateTimeFormats = prefs.dateFormat to prefs.timeFormat, openMediaInternally = prefs.openMediaIn == OpenMediaIn.INTERNAL, showDates = prefs.showDate == ShowDate.YES, + editorFontSize = prefs.editorFontSize.fontSize, + showFabChangeMode = prefs.showFabChangeMode == ShowFabChangeMode.FAB, isInitialized = true, ) } @@ -108,6 +103,7 @@ class EditorViewModel @Inject constructor( noteIdFlow.emit(id) isNotInitialized = false + moveCheckedItems = preferenceRepository.get().first() == MoveCheckedItems.YES } } @@ -177,15 +173,7 @@ class EditorViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { val note = data.value.note ?: return@launch val new = transform(note) - noteRepository.updateNotes(new, shouldSync = false) - - if (new.isLocalOnly) return@launch - - syncJob?.cancel() - syncJob = launch { - delay(300L) // To prevent multiple requests - syncManager.updateOrCreate(new) - } + noteRepository.updateNotes(new) } } @@ -195,6 +183,11 @@ class EditorViewModel @Inject constructor( val dateTimeFormats: Pair = defaultOf() to defaultOf(), val openMediaInternally: Boolean = true, val showDates: Boolean = true, + val editorFontSize: Int = -1, // -1: not customised, default font size + val showFabChangeMode: Boolean = true, val isInitialized: Boolean = false, + val moveCheckedItems: Boolean = true, ) } + +private const val TAG = "EditorViewModel" diff --git a/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertHyperlinkDialog.kt b/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertHyperlinkDialog.kt index f3fe05b7..bf7f7651 100644 --- a/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertHyperlinkDialog.kt +++ b/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertHyperlinkDialog.kt @@ -6,7 +6,6 @@ import android.view.View import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult -import dagger.hilt.android.AndroidEntryPoint import org.qosp.notes.R import org.qosp.notes.databinding.DialogInsertLinkBinding import org.qosp.notes.ui.common.BaseDialog @@ -14,7 +13,7 @@ import org.qosp.notes.ui.editor.EditorFragment import org.qosp.notes.ui.editor.markdown.hyperlinkMarkdown import org.qosp.notes.ui.utils.requestFocusAndKeyboard -@AndroidEntryPoint + class InsertHyperlinkDialog : BaseDialog() { private var text: String? = null diff --git a/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertImageDialog.kt b/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertImageDialog.kt index def82750..94087542 100644 --- a/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertImageDialog.kt +++ b/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertImageDialog.kt @@ -6,7 +6,6 @@ import android.view.View import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult -import dagger.hilt.android.AndroidEntryPoint import org.qosp.notes.R import org.qosp.notes.databinding.DialogInsertImageBinding import org.qosp.notes.ui.common.BaseDialog @@ -14,7 +13,7 @@ import org.qosp.notes.ui.editor.EditorFragment import org.qosp.notes.ui.editor.markdown.imageMarkdown import org.qosp.notes.ui.utils.requestFocusAndKeyboard -@AndroidEntryPoint + class InsertImageDialog : BaseDialog() { private var text: String? = null diff --git a/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertTableDialog.kt b/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertTableDialog.kt index 23cb18dc..6259f18c 100644 --- a/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertTableDialog.kt +++ b/app/src/main/java/org/qosp/notes/ui/editor/dialog/InsertTableDialog.kt @@ -8,7 +8,6 @@ import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.core.text.isDigitsOnly import androidx.fragment.app.setFragmentResult -import dagger.hilt.android.AndroidEntryPoint import org.qosp.notes.R import org.qosp.notes.databinding.DialogInsertTableBinding import org.qosp.notes.ui.common.BaseDialog @@ -17,7 +16,6 @@ import org.qosp.notes.ui.editor.EditorFragment import org.qosp.notes.ui.editor.markdown.tableMarkdown import org.qosp.notes.ui.utils.requestFocusAndKeyboard -@AndroidEntryPoint class InsertTableDialog : BaseDialog() { override fun createBinding(inflater: LayoutInflater) = DialogInsertTableBinding.inflate(layoutInflater) diff --git a/app/src/main/java/org/qosp/notes/ui/editor/markdown/MarkdownUtils.kt b/app/src/main/java/org/qosp/notes/ui/editor/markdown/MarkdownUtils.kt index 1f8c9847..613be85b 100644 --- a/app/src/main/java/org/qosp/notes/ui/editor/markdown/MarkdownUtils.kt +++ b/app/src/main/java/org/qosp/notes/ui/editor/markdown/MarkdownUtils.kt @@ -1,8 +1,5 @@ package org.qosp.notes.ui.editor.markdown -import android.view.KeyEvent -import android.view.inputmethod.EditorInfo -import android.widget.TextView import org.qosp.notes.ui.utils.views.ExtendedEditText enum class MarkdownSpan(val value: String) { @@ -12,6 +9,7 @@ enum class MarkdownSpan(val value: String) { CODE("`"), QUOTE(">"), HEADING("#"), + HIGHLIGHT("=="), } fun ExtendedEditText.insertMarkdown(markdownSpan: MarkdownSpan) { @@ -61,14 +59,16 @@ fun ExtendedEditText.toggleCheckmarkCurrentLine() { val oldLength = line.length line = when { - line.matches(Regex("-[ ]*\\[ \\][ ]+.*")) -> { + line.matches(Regex("[-+*] *\\[ ] +.*")) -> { line.replaceFirst("[ ]", "[x]").trimEnd() + " " // There's a strange bug which causes // text to be duplicated after pressing Enter // .trimEnd() + " " seems to be fixing it } - line.matches(Regex("-[ ]*\\[x\\][ ]+.*")) -> { + + line.matches(Regex("[-+*] *\\[[xX]] +.*")) -> { line.replaceFirst("[x]", "[ ]").trimEnd() + " " } + else -> "- [ ] $line" } @@ -96,24 +96,3 @@ fun tableMarkdown(rows: Int, columns: Int): String { } return markdown } - -val ExtendedEditText.addListItemListener: TextView.OnEditorActionListener - get() = TextView.OnEditorActionListener { v: TextView, actionId: Int, event: KeyEvent -> - if (actionId == EditorInfo.TYPE_NULL && event.action == KeyEvent.ACTION_DOWN) { - val text = text ?: return@OnEditorActionListener true - text.insert(selectionStart, "\n") - - val previousLine = text.lines().getOrNull(currentLineIndex - 1) ?: return@OnEditorActionListener true - - when { - previousLine.matches(Regex("-[ ]*\\[( |x)\\][ ]+.*")) -> text.insert(currentLineStartPos, "- [ ] ") - previousLine.matches(Regex("-[ ]+.*")) -> text.insert(currentLineStartPos, "- ") - previousLine.matches(Regex("[1-9]+[0-9]*[.][ ]+.*")) -> { - val inc = Regex("[1-9]+[0-9]*").findAll(previousLine).first().value.toInt().inc() - text.insert(currentLineStartPos, "$inc. ") - } - } - } - - true - } diff --git a/app/src/main/java/org/qosp/notes/ui/launcher/LauncherActivity.kt b/app/src/main/java/org/qosp/notes/ui/launcher/LauncherActivity.kt new file mode 100644 index 00000000..20ac52f8 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/ui/launcher/LauncherActivity.kt @@ -0,0 +1,59 @@ +package org.qosp.notes.ui.launcher + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +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 kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.qosp.notes.BuildConfig +import org.qosp.notes.ui.MainActivity +import org.qosp.notes.ui.theme.QuillpadTheme + +class LauncherActivity : ComponentActivity() { + + private val viewModel: LauncherViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (BuildConfig.TESTLAB_BUILD) { + // Skip the welcome screen in Firebase TestLab builds + proceedToMainActivity() + return + } + + lifecycleScope.launch { + val whatsNewToShow = viewModel.whatsNewToShow().first() + if (whatsNewToShow != null) { + setContent { + QuillpadTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + WelcomeScreen(whatsNewItem = whatsNewToShow) { + runBlocking { viewModel.setLatestWhatsNewId() } + proceedToMainActivity() + } + } + } + } + } else { + proceedToMainActivity() + } + } + } + + private fun proceedToMainActivity() { + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + finish() // Finish LauncherActivity after starting MainActivity + } +} diff --git a/app/src/main/java/org/qosp/notes/ui/launcher/LauncherViewModel.kt b/app/src/main/java/org/qosp/notes/ui/launcher/LauncherViewModel.kt new file mode 100644 index 00000000..0c7f5b6a --- /dev/null +++ b/app/src/main/java/org/qosp/notes/ui/launcher/LauncherViewModel.kt @@ -0,0 +1,37 @@ +package org.qosp.notes.ui.launcher + +import android.app.Application +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.mamoe.yamlkt.Yaml +import org.qosp.notes.data.WhatsNew +import org.qosp.notes.data.WhatsNewItem +import org.qosp.notes.preferences.PreferenceRepository + +class LauncherViewModel( + private val application: Application, + private val preferenceRepository: PreferenceRepository +) : ViewModel() { + + private val whatsNew: WhatsNew by lazy { + val yamlText = application.assets.open("whatsnew.yaml").bufferedReader().use { it.readText() } + Yaml.decodeFromString(WhatsNew.serializer(), yamlText) + } + + fun whatsNewToShow(): Flow = preferenceRepository.getEncryptedString(LAST_SHOWN_WHATS_NEW_ID) + .map { it.toIntOrNull() ?: 1 } + .map { lastShownId -> + val latest = whatsNew.updates.maxByOrNull { it.id } + latest?.takeIf { latest.id > lastShownId } + } + + suspend fun setLatestWhatsNewId() { + val latestId = whatsNew.updates.maxOfOrNull { it.id } ?: 0 + preferenceRepository.putEncryptedStrings(LAST_SHOWN_WHATS_NEW_ID to latestId.toString()) + } + + companion object { + private const val LAST_SHOWN_WHATS_NEW_ID = "LAST_SHOWN_WHATS_NEW_ID" + } +} diff --git a/app/src/main/java/org/qosp/notes/ui/launcher/WelcomeScreen.kt b/app/src/main/java/org/qosp/notes/ui/launcher/WelcomeScreen.kt new file mode 100644 index 00000000..ee891ee0 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/ui/launcher/WelcomeScreen.kt @@ -0,0 +1,71 @@ +package org.qosp.notes.ui.launcher + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +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.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.qosp.notes.R +import org.qosp.notes.data.WhatsNewItem + +@Composable +fun WelcomeScreen(whatsNewItem: WhatsNewItem, onNextClicked: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = whatsNewItem.title, + modifier = Modifier.padding(bottom = 16.dp), + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 30.sp + ) + ) + LazyColumn { + items(whatsNewItem.items) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Absolute.Left + ) { + Text( + text = "🟢", + modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), + fontSize = 15.sp + ) + Text(text = it, modifier = Modifier.padding(8.dp)) + } + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 64.dp), + horizontalArrangement = Arrangement.Center + ) { + Button(onClick = onNextClicked) { Text(stringResource(id = R.string.continue_next)) } + } + } +} diff --git a/app/src/main/java/org/qosp/notes/ui/main/MainFragment.kt b/app/src/main/java/org/qosp/notes/ui/main/MainFragment.kt index 05955c22..9484f1aa 100755 --- a/app/src/main/java/org/qosp/notes/ui/main/MainFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/main/MainFragment.kt @@ -2,6 +2,7 @@ package org.qosp.notes.ui.main import android.content.Intent import android.os.Bundle +import android.util.Log import android.view.Menu import android.view.MenuInflater import android.view.MenuItem @@ -11,7 +12,6 @@ import androidx.core.view.ViewCompat import androidx.core.view.isVisible import androidx.fragment.app.clearFragmentResult import androidx.fragment.app.setFragmentResultListener -import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDirections import androidx.navigation.fragment.FragmentNavigatorExtras @@ -19,11 +19,10 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.viewModel import org.qosp.notes.R import org.qosp.notes.data.model.Attachment -import org.qosp.notes.data.sync.core.SyncManager import org.qosp.notes.databinding.FragmentMainBinding import org.qosp.notes.databinding.LayoutNoteBinding import org.qosp.notes.preferences.LayoutMode @@ -35,17 +34,14 @@ import org.qosp.notes.ui.recorder.RECORD_CODE import org.qosp.notes.ui.recorder.RecordAudioDialog import org.qosp.notes.ui.utils.ChooseFilesContract import org.qosp.notes.ui.utils.TakePictureContract -import org.qosp.notes.ui.utils.launch import org.qosp.notes.ui.utils.navigateSafely import org.qosp.notes.ui.utils.viewBinding -import javax.inject.Inject -@AndroidEntryPoint open class MainFragment : AbstractNotesFragment(R.layout.fragment_main) { private val binding by viewBinding(FragmentMainBinding::bind) override val currentDestinationId: Int = R.id.fragment_main - override val model: MainViewModel by viewModels() + override val model: MainViewModel by viewModel() open val notebookId: Long? = null @@ -95,9 +91,6 @@ open class MainFragment : AbstractNotesFragment(R.layout.fragment_main) { get() = binding.layoutAppBar.toolbarSelection override val secondaryToolbarMenuRes: Int = R.menu.main_selected_notes - @Inject - lateinit var syncManager: SyncManager - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) model.initialize(notebookId) @@ -113,6 +106,7 @@ open class MainFragment : AbstractNotesFragment(R.layout.fragment_main) { } } + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.main_top, menu) @@ -122,6 +116,7 @@ open class MainFragment : AbstractNotesFragment(R.layout.fragment_main) { selectSortMethodItem() } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_search -> findNavController().navigateSafely(actionToSearch()) @@ -250,12 +245,14 @@ open class MainFragment : AbstractNotesFragment(R.layout.fragment_main) { true } R.id.action_attach_file -> { - chooseFileLauncher.launch() + chooseFileLauncher.launch(null) true } R.id.action_take_photo -> { lifecycleScope.launch { - takePhotoLauncher.launch(activityModel.createImageFile()) + runCatching { + takePhotoLauncher.launch(activityModel.createImageFile()) + }.getOrElse { Log.e(TAG, "Cannot launch camera app", it) } } true } diff --git a/app/src/main/java/org/qosp/notes/ui/main/MainViewModel.kt b/app/src/main/java/org/qosp/notes/ui/main/MainViewModel.kt index 351a9dfb..6d5b49f1 100755 --- a/app/src/main/java/org/qosp/notes/ui/main/MainViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/main/MainViewModel.kt @@ -1,7 +1,6 @@ package org.qosp.notes.ui.main import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull @@ -10,19 +9,17 @@ import kotlinx.coroutines.launch import org.qosp.notes.R import org.qosp.notes.data.repo.NoteRepository import org.qosp.notes.data.repo.NotebookRepository -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.data.sync.core.BackendProvider import org.qosp.notes.preferences.PreferenceRepository import org.qosp.notes.preferences.SortMethod import org.qosp.notes.ui.common.AbstractNotesViewModel -import javax.inject.Inject -@HiltViewModel -class MainViewModel @Inject constructor( +class MainViewModel( private val noteRepository: NoteRepository, private val notebookRepository: NotebookRepository, preferenceRepository: PreferenceRepository, - syncManager: SyncManager, -) : AbstractNotesViewModel(preferenceRepository, syncManager) { + backendProvider: BackendProvider, +) : AbstractNotesViewModel(preferenceRepository, backendProvider) { private val notebookIdFlow: MutableStateFlow = MutableStateFlow(null) diff --git a/app/src/main/java/org/qosp/notes/ui/media/MediaActivity.kt b/app/src/main/java/org/qosp/notes/ui/media/MediaActivity.kt index 87f3bb54..6a20e11c 100644 --- a/app/src/main/java/org/qosp/notes/ui/media/MediaActivity.kt +++ b/app/src/main/java/org/qosp/notes/ui/media/MediaActivity.kt @@ -1,12 +1,10 @@ package org.qosp.notes.ui.media import android.content.ComponentName -import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.content.res.ColorStateList import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.os.IBinder import android.view.WindowManager @@ -14,6 +12,7 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat import androidx.core.graphics.ColorUtils +import androidx.core.graphics.drawable.toDrawable import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -24,10 +23,8 @@ import androidx.palette.graphics.Palette import coil.load import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.ui.PlayerControlView import com.google.android.exoplayer2.util.Util -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -40,7 +37,6 @@ import org.qosp.notes.ui.attachments.uri import org.qosp.notes.ui.utils.collect import org.qosp.notes.ui.utils.getDrawableCompat -@AndroidEntryPoint class MediaActivity : BaseActivity() { private lateinit var binding: ActivityMediaBinding @@ -82,7 +78,7 @@ class MediaActivity : BaseActivity() { attachment = intent.extras?.getParcelable(ATTACHMENT) ?: return - window.setBackgroundDrawable(ColorDrawable(backgroundColor)) + window.setBackgroundDrawable(backgroundColor.toDrawable()) WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = false ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> @@ -171,7 +167,7 @@ class MediaActivity : BaseActivity() { } else { startService(intent) } - bindService(intent, connection, Context.BIND_AUTO_CREATE) + bindService(intent, connection, BIND_AUTO_CREATE) } } @@ -221,7 +217,7 @@ class MediaActivity : BaseActivity() { val dominant = palette.getDominantColor(backgroundColor) imageView.load(bitmap) - root.background = ColorDrawable(dominant) + root.background = dominant.toDrawable() } else { imageView.setColorFilter(Color.WHITE) imageView.load(R.drawable.ic_music) diff --git a/app/src/main/java/org/qosp/notes/ui/media/MusicService.kt b/app/src/main/java/org/qosp/notes/ui/media/MusicService.kt index 04e9dc15..695be2c0 100644 --- a/app/src/main/java/org/qosp/notes/ui/media/MusicService.kt +++ b/app/src/main/java/org/qosp/notes/ui/media/MusicService.kt @@ -10,7 +10,6 @@ import android.media.MediaPlayer import android.media.session.PlaybackState import android.net.Uri import android.os.Binder -import android.os.Build import android.os.IBinder import android.os.Parcelable import android.os.PowerManager @@ -53,7 +52,7 @@ class MusicService : LifecycleService() { IntentAction.PAUSE -> binder?.pausePlaying() IntentAction.STOP -> binder?.stopPlaying(shouldStopService = true, shouldReleasePlayer = true) IntentAction.STOP_SERVICE -> { - stopForeground(true) + stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } else -> { @@ -201,7 +200,7 @@ class MusicServiceBinder( action: MusicService.IntentAction, extras: List> = listOf() ): PendingIntent { - val defaultFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + val defaultFlag = PendingIntent.FLAG_IMMUTABLE return PendingIntent.getService( applicationContext, 0, diff --git a/app/src/main/java/org/qosp/notes/ui/notebooks/ManageNotebooksFragment.kt b/app/src/main/java/org/qosp/notes/ui/notebooks/ManageNotebooksFragment.kt index e4d4292b..022cd60a 100755 --- a/app/src/main/java/org/qosp/notes/ui/notebooks/ManageNotebooksFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/notebooks/ManageNotebooksFragment.kt @@ -9,12 +9,16 @@ import androidx.appcompat.widget.Toolbar import androidx.core.os.bundleOf import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible -import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager -import dagger.hilt.android.AndroidEntryPoint +import org.koin.androidx.viewmodel.ext.android.viewModel import org.qosp.notes.R +import org.qosp.notes.data.model.Notebook import org.qosp.notes.databinding.FragmentManageNotebooksBinding +import org.qosp.notes.preferences.SortNavdrawerNotebooksMethod.CREATION_ASC +import org.qosp.notes.preferences.SortNavdrawerNotebooksMethod.CREATION_DESC +import org.qosp.notes.preferences.SortNavdrawerNotebooksMethod.TITLE_ASC +import org.qosp.notes.preferences.SortNavdrawerNotebooksMethod.TITLE_DESC import org.qosp.notes.ui.common.BaseFragment import org.qosp.notes.ui.common.recycler.onBackPressedHandler import org.qosp.notes.ui.notebooks.dialog.EditNotebookDialog @@ -26,11 +30,12 @@ import org.qosp.notes.ui.utils.navigateSafely import org.qosp.notes.ui.utils.viewBinding import org.qosp.notes.ui.utils.views.BottomSheet -@AndroidEntryPoint class ManageNotebooksFragment : BaseFragment(R.layout.fragment_manage_notebooks) { private val binding by viewBinding(FragmentManageNotebooksBinding::bind) - private val model: ManageNotebooksViewModel by viewModels() + protected var mainMenu: Menu? = null + + private val model: ManageNotebooksViewModel by viewModel() private lateinit var adapter: NotebooksRecyclerAdapter override val toolbar: Toolbar @@ -43,9 +48,7 @@ class ManageNotebooksFragment : BaseFragment(R.layout.fragment_manage_notebooks) setupRecyclerView() - activityModel.notebooks.collect(viewLifecycleOwner) { (_, notebooks) -> - adapter.submitList(notebooks) - } + enlistNotebooks() binding.layoutAppBar.toolbarSelection.apply { inflateMenu(R.menu.manage_notebooks_selected) @@ -60,14 +63,30 @@ class ManageNotebooksFragment : BaseFragment(R.layout.fragment_manage_notebooks) } } + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.manage_notebooks, menu) + mainMenu = menu + selectSortMethodItem() } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_create_notebook -> EditNotebookDialog.build(null).show(childFragmentManager, null) + R.id.action_sort_navdrawer_notebook_created_asc -> activityModel.setSortNavdrawerNotebooksMethod( + CREATION_ASC + ) + + R.id.action_sort_navdrawer_notebook_created_desc -> activityModel.setSortNavdrawerNotebooksMethod( + CREATION_DESC + ) + + R.id.action_sort_navdrawer_notebook_name_asc -> activityModel.setSortNavdrawerNotebooksMethod(TITLE_ASC) + R.id.action_sort_navdrawer_notebook_name_desc -> activityModel.setSortNavdrawerNotebooksMethod(TITLE_DESC) } + enlistNotebooks() + selectSortMethodItem() return super.onOptionsItemSelected(item) } @@ -143,4 +162,35 @@ class ManageNotebooksFragment : BaseFragment(R.layout.fragment_manage_notebooks) requireContext().resources.getDimension(R.dimen.app_bar_elevation) ) } + + private fun enlistNotebooks() { + // Get the current sort method preferences. + val sort = model.getSortNavdrawerNotebooksMethod() + + activityModel.notebooks.collect(viewLifecycleOwner) { (_, notebooks) -> + // Apply sorting to the list of notebooks. + val sortedNotebooks: List = when (sort) { + TITLE_ASC.name -> notebooks.sortedBy { it.name } + TITLE_DESC.name -> notebooks.sortedByDescending { it.name } + CREATION_ASC.name -> notebooks.sortedBy { it.id } + CREATION_DESC.name -> notebooks.sortedByDescending { it.id } + else -> notebooks.sortedBy { it.name } + } + + // Displaying the sorted list of notebooks in the view. + adapter.submitList(sortedNotebooks) + } + } + + private fun selectSortMethodItem() { + mainMenu?.findItem( + when (model.getSortNavdrawerNotebooksMethod()) { + TITLE_ASC.name -> R.id.action_sort_navdrawer_notebook_name_asc + TITLE_DESC.name -> R.id.action_sort_navdrawer_notebook_name_desc + CREATION_ASC.name -> R.id.action_sort_navdrawer_notebook_created_asc + CREATION_DESC.name -> R.id.action_sort_navdrawer_notebook_created_desc + else -> R.id.action_sort_navdrawer_notebook_name_asc + } + )?.isChecked = true + } } diff --git a/app/src/main/java/org/qosp/notes/ui/notebooks/ManageNotebooksViewModel.kt b/app/src/main/java/org/qosp/notes/ui/notebooks/ManageNotebooksViewModel.kt index d8dd8bfd..7510bfb7 100755 --- a/app/src/main/java/org/qosp/notes/ui/notebooks/ManageNotebooksViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/notebooks/ManageNotebooksViewModel.kt @@ -2,18 +2,27 @@ package org.qosp.notes.ui.notebooks import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.qosp.notes.data.model.Notebook import org.qosp.notes.data.repo.NotebookRepository -import javax.inject.Inject +import org.qosp.notes.preferences.PreferenceRepository -@HiltViewModel -class ManageNotebooksViewModel @Inject constructor(private val notebookRepository: NotebookRepository) : ViewModel() { +class ManageNotebooksViewModel( + private val notebookRepository: NotebookRepository, + private val preferenceRepository: PreferenceRepository +) : ViewModel() { fun deleteNotebooks(vararg notebooks: Notebook) { viewModelScope.launch(Dispatchers.IO) { notebookRepository.delete(*notebooks) } } + + fun getSortNavdrawerNotebooksMethod(): String { + return runBlocking { + preferenceRepository.getAll().first().sortNavdrawerNotebooksMethod.name + } + } } diff --git a/app/src/main/java/org/qosp/notes/ui/notebooks/NotebookFragment.kt b/app/src/main/java/org/qosp/notes/ui/notebooks/NotebookFragment.kt index ce1103c0..17f374f6 100755 --- a/app/src/main/java/org/qosp/notes/ui/notebooks/NotebookFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/notebooks/NotebookFragment.kt @@ -14,7 +14,7 @@ class NotebookFragment : MainFragment() { private val args: NotebookFragmentArgs by navArgs() override val notebookId: Long? - get() = args.notebookId.takeIf { it >= 0L || it == R.id.nav_default_notebook.toLong() } + get() = args.notebookId.takeIf { it >= 0L } override val toolbarTitle: String get() = args.notebookName diff --git a/app/src/main/java/org/qosp/notes/ui/notebooks/dialog/EditNotebookDialog.kt b/app/src/main/java/org/qosp/notes/ui/notebooks/dialog/EditNotebookDialog.kt index 45e52c86..79faee4c 100644 --- a/app/src/main/java/org/qosp/notes/ui/notebooks/dialog/EditNotebookDialog.kt +++ b/app/src/main/java/org/qosp/notes/ui/notebooks/dialog/EditNotebookDialog.kt @@ -6,10 +6,9 @@ import android.view.LayoutInflater import android.view.View import android.widget.Toast import androidx.core.os.bundleOf -import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.qosp.notes.R import org.qosp.notes.data.model.Notebook import org.qosp.notes.databinding.DialogEditNotebookBinding @@ -17,9 +16,8 @@ import org.qosp.notes.ui.common.BaseDialog import org.qosp.notes.ui.common.setButton import org.qosp.notes.ui.utils.requestFocusAndKeyboard -@AndroidEntryPoint class EditNotebookDialog : BaseDialog() { - private val model: NotebookDialogViewModel by activityViewModels() + private val model: NotebookDialogViewModel by activityViewModel() private lateinit var notebook: Notebook override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/qosp/notes/ui/notebooks/dialog/NotebookDialogViewModel.kt b/app/src/main/java/org/qosp/notes/ui/notebooks/dialog/NotebookDialogViewModel.kt index f886fb11..d1345203 100644 --- a/app/src/main/java/org/qosp/notes/ui/notebooks/dialog/NotebookDialogViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/notebooks/dialog/NotebookDialogViewModel.kt @@ -2,16 +2,13 @@ package org.qosp.notes.ui.notebooks.dialog import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.qosp.notes.data.model.Notebook import org.qosp.notes.data.repo.NotebookRepository -import javax.inject.Inject -@HiltViewModel -class NotebookDialogViewModel @Inject constructor(private val notebookRepository: NotebookRepository) : ViewModel() { +class NotebookDialogViewModel(private val notebookRepository: NotebookRepository) : ViewModel() { fun insertNotebook(notebook: Notebook) { viewModelScope.launch(Dispatchers.IO) { notebookRepository.insert(notebook) diff --git a/app/src/main/java/org/qosp/notes/ui/recorder/RecordAudioDialog.kt b/app/src/main/java/org/qosp/notes/ui/recorder/RecordAudioDialog.kt index 94049a66..68e4849d 100644 --- a/app/src/main/java/org/qosp/notes/ui/recorder/RecordAudioDialog.kt +++ b/app/src/main/java/org/qosp/notes/ui/recorder/RecordAudioDialog.kt @@ -17,8 +17,8 @@ import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.qosp.notes.R import org.qosp.notes.components.MediaStorageManager import org.qosp.notes.data.model.Attachment @@ -26,18 +26,15 @@ import org.qosp.notes.databinding.DialogRecordAudioBinding import org.qosp.notes.ui.attachments.fromUri import org.qosp.notes.ui.common.BaseDialog import org.qosp.notes.ui.utils.collect -import javax.inject.Inject const val RECORD_CODE = "RECORD" const val RECORDED_ATTACHMENT = "RECORDED_ATTACHMENT" -@AndroidEntryPoint -class RecordAudioDialog() : BaseDialog() { +class RecordAudioDialog : BaseDialog() { private var recorderService: RecorderServiceBinder? = null private var isPermissionGranted = false - @Inject - lateinit var mediaStorageManager: MediaStorageManager + val mediaStorageManager: MediaStorageManager by inject() private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { diff --git a/app/src/main/java/org/qosp/notes/ui/reminders/EditReminderDialog.kt b/app/src/main/java/org/qosp/notes/ui/reminders/EditReminderDialog.kt index 5747ae29..429c423f 100644 --- a/app/src/main/java/org/qosp/notes/ui/reminders/EditReminderDialog.kt +++ b/app/src/main/java/org/qosp/notes/ui/reminders/EditReminderDialog.kt @@ -1,17 +1,19 @@ package org.qosp.notes.ui.reminders import android.app.DatePickerDialog +import android.app.NotificationManager import android.app.TimePickerDialog +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.widget.DatePicker import android.widget.TimePicker import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf -import androidx.fragment.app.activityViewModels -import dagger.hilt.android.AndroidEntryPoint +import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.qosp.notes.R import org.qosp.notes.data.model.Reminder import org.qosp.notes.databinding.DialogEditReminderBinding @@ -24,9 +26,8 @@ import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -@AndroidEntryPoint class EditReminderDialog : BaseDialog() { - private val model: EditReminderViewModel by activityViewModels() + private val model: EditReminderViewModel by activityViewModel() private lateinit var reminder: Reminder private var noteId: Long? = null @@ -34,6 +35,13 @@ class EditReminderDialog : BaseDialog() { private var dateFormatter: DateTimeFormatter? = null private var timeFormatter: DateTimeFormatter? = null + private val pushNotificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (!granted) { + dismiss() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -58,6 +66,7 @@ class EditReminderDialog : BaseDialog() { } setupListeners(dialog, binding, reminder) } + else -> { val noteId = noteId ?: return // Create a new reminder @@ -74,6 +83,13 @@ class EditReminderDialog : BaseDialog() { timeFormatter = DateTimeFormatter.ofPattern(getString(tf.patternResource)) binding.buttonSetTime.text = model.date.format(timeFormatter) } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationManager = context?.getSystemService(NotificationManager::class.java) + if (notificationManager?.areNotificationsEnabled() != true) { + pushNotificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + } } private fun setupListeners( diff --git a/app/src/main/java/org/qosp/notes/ui/reminders/EditReminderViewModel.kt b/app/src/main/java/org/qosp/notes/ui/reminders/EditReminderViewModel.kt index 3d7f3c88..e56d1097 100644 --- a/app/src/main/java/org/qosp/notes/ui/reminders/EditReminderViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/reminders/EditReminderViewModel.kt @@ -2,7 +2,6 @@ package org.qosp.notes.ui.reminders import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -10,10 +9,8 @@ import org.qosp.notes.data.model.Reminder import org.qosp.notes.data.repo.ReminderRepository import org.qosp.notes.preferences.PreferenceRepository import java.time.ZonedDateTime -import javax.inject.Inject -@HiltViewModel -class EditReminderViewModel @Inject constructor( +class EditReminderViewModel( private val reminderRepository: ReminderRepository, private val reminderManager: ReminderManager, preferenceRepository: PreferenceRepository diff --git a/app/src/main/java/org/qosp/notes/ui/reminders/ReminderManager.kt b/app/src/main/java/org/qosp/notes/ui/reminders/ReminderManager.kt index b997ea85..6bacd2bf 100644 --- a/app/src/main/java/org/qosp/notes/ui/reminders/ReminderManager.kt +++ b/app/src/main/java/org/qosp/notes/ui/reminders/ReminderManager.kt @@ -5,7 +5,6 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.Build import androidx.core.app.AlarmManagerCompat import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat @@ -15,13 +14,17 @@ import kotlinx.coroutines.flow.first import org.qosp.notes.App import org.qosp.notes.R import org.qosp.notes.data.repo.ReminderRepository +import org.qosp.notes.data.repo.NoteRepository +import org.qosp.notes.ui.MainActivity + class ReminderManager( private val context: Context, private val reminderRepository: ReminderRepository, + private val noteRepository: NoteRepository, ) { private fun requestBroadcast(reminderId: Long, noteId: Long, flag: Int = PendingIntent.FLAG_UPDATE_CURRENT): PendingIntent? { - val defaultFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + val defaultFlag = PendingIntent.FLAG_IMMUTABLE val notificationIntent = Intent(context, ReminderReceiver::class.java).apply { putExtras( @@ -41,7 +44,7 @@ class ReminderManager( } fun isReminderSet(reminderId: Long, noteId: Long): Boolean { - val defaultFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + val defaultFlag = PendingIntent.FLAG_IMMUTABLE return requestBroadcast(reminderId, noteId, PendingIntent.FLAG_NO_CREATE or defaultFlag) != null } @@ -60,7 +63,7 @@ class ReminderManager( fun cancel(reminderId: Long, noteId: Long, keepIntent: Boolean = false) { val alarmManager = ContextCompat.getSystemService(context, AlarmManager::class.java) ?: return - val defaultFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + val defaultFlag = PendingIntent.FLAG_IMMUTABLE val broadcast = requestBroadcast(reminderId, noteId, PendingIntent.FLAG_NO_CREATE or defaultFlag) ?: return alarmManager.cancel(broadcast) if (!keepIntent) broadcast.cancel() @@ -92,6 +95,10 @@ class ReminderManager( reminderRepository.getById(reminderId).first()?.let { notificationTitle = it.name } reminderRepository.deleteById(reminderId) + if (notificationTitle.isEmpty()) { + noteRepository.getById(noteId).first()?.let { notificationTitle = it.title } + } + val pendingIntent = NavDeepLinkBuilder(context) .setGraph(R.navigation.nav_graph) .setDestination(R.id.fragment_editor) @@ -101,6 +108,7 @@ class ReminderManager( "transitionName" to "" ) ) + .setComponentName(MainActivity::class.java) .createPendingIntent() val notification = NotificationCompat.Builder(context, App.REMINDERS_CHANNEL_ID) diff --git a/app/src/main/java/org/qosp/notes/ui/reminders/ReminderReceiver.kt b/app/src/main/java/org/qosp/notes/ui/reminders/ReminderReceiver.kt index 3fcca45f..aeb46a35 100644 --- a/app/src/main/java/org/qosp/notes/ui/reminders/ReminderReceiver.kt +++ b/app/src/main/java/org/qosp/notes/ui/reminders/ReminderReceiver.kt @@ -3,15 +3,13 @@ package org.qosp.notes.ui.reminders import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.runBlocking +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.qosp.notes.BuildConfig -import javax.inject.Inject -@AndroidEntryPoint -class ReminderReceiver : BroadcastReceiver() { - @Inject - lateinit var reminderManager: ReminderManager +class ReminderReceiver : BroadcastReceiver(), KoinComponent { + val reminderManager: ReminderManager by inject() override fun onReceive(context: Context, intent: Intent?) = runBlocking { when (intent?.action) { diff --git a/app/src/main/java/org/qosp/notes/ui/search/SearchFragment.kt b/app/src/main/java/org/qosp/notes/ui/search/SearchFragment.kt index eaedadf2..70cfc267 100644 --- a/app/src/main/java/org/qosp/notes/ui/search/SearchFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/search/SearchFragment.kt @@ -4,12 +4,12 @@ import android.os.Bundle import android.view.View import androidx.appcompat.widget.Toolbar import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.viewModels import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import org.koin.androidx.viewmodel.ext.android.viewModel import org.qosp.notes.R import org.qosp.notes.data.model.Note import org.qosp.notes.databinding.FragmentSearchBinding @@ -24,7 +24,7 @@ class SearchFragment : AbstractNotesFragment(resId = R.layout.fragment_search) { private val args: SearchFragmentArgs by navArgs() override val currentDestinationId: Int = R.id.fragment_search - override val model: SearchViewModel by viewModels() + override val model: SearchViewModel by viewModel() override val isSelectionEnabled = false override val hasMenu: Boolean = false diff --git a/app/src/main/java/org/qosp/notes/ui/search/SearchViewModel.kt b/app/src/main/java/org/qosp/notes/ui/search/SearchViewModel.kt index ed9323d9..d9f292fd 100755 --- a/app/src/main/java/org/qosp/notes/ui/search/SearchViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/search/SearchViewModel.kt @@ -1,7 +1,6 @@ package org.qosp.notes.ui.search import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce @@ -13,19 +12,17 @@ import org.qosp.notes.data.model.Note import org.qosp.notes.data.model.Notebook import org.qosp.notes.data.repo.NoteRepository import org.qosp.notes.data.repo.NotebookRepository -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.data.sync.core.BackendProvider import org.qosp.notes.preferences.PreferenceRepository import org.qosp.notes.preferences.SortMethod import org.qosp.notes.ui.common.AbstractNotesViewModel -import javax.inject.Inject -@HiltViewModel -class SearchViewModel @Inject constructor( +class SearchViewModel( noteRepository: NoteRepository, notebookRepository: NotebookRepository, preferenceRepository: PreferenceRepository, - syncManager: SyncManager, -) : AbstractNotesViewModel(preferenceRepository, syncManager) { + backendProvider: BackendProvider, +) : AbstractNotesViewModel(preferenceRepository, backendProvider) { private val searchKeyData: MutableStateFlow = MutableStateFlow("") var isFirstLoad = true diff --git a/app/src/main/java/org/qosp/notes/ui/settings/SettingsFragment.kt b/app/src/main/java/org/qosp/notes/ui/settings/SettingsFragment.kt index 6f2e4180..99fdc60a 100644 --- a/app/src/main/java/org/qosp/notes/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/settings/SettingsFragment.kt @@ -4,11 +4,10 @@ import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.widget.Toolbar -import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.viewModel import org.qosp.notes.R import org.qosp.notes.databinding.FragmentSettingsBinding import org.qosp.notes.preferences.AppPreferences @@ -22,7 +21,6 @@ import org.qosp.notes.ui.MainActivity import org.qosp.notes.ui.common.BaseFragment import org.qosp.notes.ui.utils.RestoreNotesContract import org.qosp.notes.ui.utils.collect -import org.qosp.notes.ui.utils.launch import org.qosp.notes.ui.utils.liftAppBarOnScroll import org.qosp.notes.ui.utils.navigateSafely import org.qosp.notes.ui.utils.viewBinding @@ -30,10 +28,9 @@ import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter -@AndroidEntryPoint class SettingsFragment : BaseFragment(resId = R.layout.fragment_settings) { private val binding by viewBinding(FragmentSettingsBinding::bind) - private val model: SettingsViewModel by viewModels() + private val model: SettingsViewModel by viewModel() private var appPreferences = AppPreferences() @@ -56,11 +53,16 @@ class SettingsFragment : BaseFragment(resId = R.layout.fragment_settings) { setupDarkThemeModeListener() setupLayoutModeListener() setupSortMethodListener() + setupSortTagsMethodListener() + setupSortNavdrawerMethodListener() setupGroupNotesWithoutNotebookListener() + setupMoveCheckedItemsListener() setupOpenMediaInListener() setupNoteDeletionTimeListener() setupBackupStrategyListener() setupShowDateListener() + setupShowFontSizeListener() + setupShowFabChangeModeListener() setupDateFormatListener() setupTimeFormatListener() setupSyncSettingsListener() @@ -70,11 +72,11 @@ class SettingsFragment : BaseFragment(resId = R.layout.fragment_settings) { requireContext().resources.getDimension(R.dimen.app_bar_elevation) ) - binding.settingRestoreNotes.setOnClickListener { loadBackupLauncher.launch() } + binding.settingRestoreNotes.setOnClickListener { loadBackupLauncher.launch(null) } binding.settingBackupNotes.setOnClickListener { activityModel.notesToBackup = null - exportNotesLauncher.launch() + exportNotesLauncher.launch(null) } } @@ -83,14 +85,11 @@ class SettingsFragment : BaseFragment(resId = R.layout.fragment_settings) { appPreferences = it with(appPreferences) { - binding.settingGoToSyncSettings.subText = when (cloudService) { + val goToSync = when (cloudService) { CloudService.DISABLED -> getString(R.string.preferences_currently_not_syncing) else -> getString(R.string.preferences_currently_syncing_with, getString(cloudService.nameResource)) } - binding.settingColorScheme.subText = getString(colorScheme.nameResource) - binding.settingThemeMode.subText = getString(themeMode.nameResource) - binding.settingDarkThemeMode.subText = getString(darkThemeMode.nameResource) - binding.settingLayoutMode.subText = getString(layoutMode.nameResource) + binding.settingGoToSyncSettings.subText = goToSync binding.settingLayoutMode.setIcon( when (layoutMode) { LayoutMode.GRID -> R.drawable.ic_grid @@ -98,20 +97,29 @@ class SettingsFragment : BaseFragment(resId = R.layout.fragment_settings) { } ) binding.settingSortMethod.subText = getString(sortMethod.nameResource) - binding.settingSortMethod.subText = getString(sortMethod.nameResource) + binding.settingSortTagsMethod.subText = getString(sortTagsMethod.nameResource) + binding.settingSortNavdrawerMethod.subText = getString(sortNavdrawerNotebooksMethod.nameResource) binding.settingBackupStrategy.subText = getString(backupStrategy.nameResource) binding.settingOpenMedia.subText = getString(openMediaIn.nameResource) binding.settingNoteDeletion.subText = getString(noteDeletionTime.nameResource) binding.settingGroupNotesWithoutNotebook.subText = getString(groupNotesWithoutNotebook.nameResource) + binding.settingMoveCheckedItems.subText = getString(moveCheckedItems.nameResource) binding.settingShowDate.subText = getString(showDate.nameResource) - + binding.settingFontSize.subText = getString(editorFontSize.nameResource) + binding.settingShowFab.subText = getString(showFabChangeMode.nameResource) with(DateTimeFormatter.ofPattern(getString(dateFormat.patternResource))) { binding.settingDateFormat.subText = format(LocalDate.now()) } with(DateTimeFormatter.ofPattern(getString(timeFormat.patternResource))) { binding.settingTimeFormat.subText = format(LocalTime.now()) } + + binding.settingThemeMode.subText = getString(themeMode.nameResource) + binding.settingDarkThemeMode.subText = getString(darkThemeMode.nameResource) + binding.settingColorScheme.subText = getString(colorScheme.nameResource) + binding.settingLayoutMode.subText = getString(layoutMode.nameResource) + } } } @@ -165,6 +173,20 @@ class SettingsFragment : BaseFragment(resId = R.layout.fragment_settings) { } } + /* Changes the sorting method of tags list. */ + private fun setupSortTagsMethodListener() = binding.settingSortTagsMethod.setOnClickListener { + showPreferenceDialog(R.string.preferences_sort_tags_method, appPreferences.sortTagsMethod) { selected -> + model.setPreference(selected) + } + } + + /* Changes the sorting method of notebooks list in the navigation drawer. */ + private fun setupSortNavdrawerMethodListener() = binding.settingSortNavdrawerMethod.setOnClickListener { + showPreferenceDialog(R.string.preferences_sort_navdrawer_method, appPreferences.sortNavdrawerNotebooksMethod) { selected -> + model.setPreference(selected) + } + } + private fun setupBackupStrategyListener() = binding.settingBackupStrategy.setOnClickListener { showPreferenceDialog(R.string.preferences_backup_strategy, appPreferences.backupStrategy) { selected -> model.setPreference(selected) @@ -172,7 +194,16 @@ class SettingsFragment : BaseFragment(resId = R.layout.fragment_settings) { } private fun setupGroupNotesWithoutNotebookListener() = binding.settingGroupNotesWithoutNotebook.setOnClickListener { - showPreferenceDialog(R.string.preferences_group_notes_without_notebook, appPreferences.groupNotesWithoutNotebook) { selected -> + showPreferenceDialog( + R.string.preferences_group_notes_without_notebook, + appPreferences.groupNotesWithoutNotebook + ) { selected -> + model.setPreference(selected) + } + } + + private fun setupMoveCheckedItemsListener() = binding.settingMoveCheckedItems.setOnClickListener { + showPreferenceDialog(R.string.preferences_move_checked_items, appPreferences.moveCheckedItems) { selected -> model.setPreference(selected) } } @@ -195,9 +226,21 @@ class SettingsFragment : BaseFragment(resId = R.layout.fragment_settings) { } } + private fun setupShowFontSizeListener() = binding.settingFontSize.setOnClickListener { + showPreferenceDialog(R.string.preferences_font_size, appPreferences.editorFontSize) { selected -> + model.setPreference(selected) + } + } + + private fun setupShowFabChangeModeListener() = binding.settingShowFab.setOnClickListener { + showPreferenceDialog(R.string.preferences_show_fab_change_mode, appPreferences.showFabChangeMode) { selected -> + model.setPreference(selected) + } + } + private fun setupTimeFormatListener() = binding.settingTimeFormat.setOnClickListener { val localTime = LocalTime.now() - val items = TimeFormat.values() + val items = TimeFormat.entries .map { DateTimeFormatter.ofPattern(getString(it.patternResource)).format(localTime) } @@ -210,7 +253,7 @@ class SettingsFragment : BaseFragment(resId = R.layout.fragment_settings) { private fun setupDateFormatListener() = binding.settingDateFormat.setOnClickListener { val localDate = LocalDate.now() - val items = DateFormat.values() + val items = DateFormat.entries .map { DateTimeFormatter.ofPattern(getString(it.patternResource)).format(localDate) } diff --git a/app/src/main/java/org/qosp/notes/ui/settings/SettingsUtils.kt b/app/src/main/java/org/qosp/notes/ui/settings/SettingsUtils.kt index 3a808b5a..7c06db74 100644 --- a/app/src/main/java/org/qosp/notes/ui/settings/SettingsUtils.kt +++ b/app/src/main/java/org/qosp/notes/ui/settings/SettingsUtils.kt @@ -5,6 +5,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import me.msoul.datastore.EnumPreference import org.qosp.notes.R import org.qosp.notes.preferences.HasNameResource +import org.qosp.notes.preferences.HasSupportRequirement inline fun Fragment.showPreferenceDialog( titleRes: Int, @@ -16,6 +17,9 @@ inline fun Fragment.showPreferenceDialog( val enumValues = enumValues() val selectedIndex = enumValues.indexOf(selected) val items = items ?: enumValues + .filter { + it !is HasSupportRequirement || it.isSupported() + } .map { if (it is HasNameResource) requireContext().getString(it.nameResource) else "" } diff --git a/app/src/main/java/org/qosp/notes/ui/settings/SettingsViewModel.kt b/app/src/main/java/org/qosp/notes/ui/settings/SettingsViewModel.kt index 5a0be083..ce315765 100644 --- a/app/src/main/java/org/qosp/notes/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/settings/SettingsViewModel.kt @@ -2,26 +2,34 @@ package org.qosp.notes.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import me.msoul.datastore.EnumPreference -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.data.repo.NoteRepository +import org.qosp.notes.data.sync.nextcloud.NextcloudConfig +import org.qosp.notes.preferences.CloudService import org.qosp.notes.preferences.PreferenceRepository -import javax.inject.Inject +import org.qosp.notes.preferences.SyncMode -@HiltViewModel -class SettingsViewModel @Inject constructor( +class SettingsViewModel( private val preferenceRepository: PreferenceRepository, - syncManager: SyncManager, + private val noteRepository: NoteRepository ) : ViewModel() { val appPreferences = preferenceRepository.getAll() - val loggedInUsername = syncManager.config.map { it?.username } + val loggedInUsername = NextcloudConfig.fromPreferences(preferenceRepository).map { it?.username } fun setPreference(pref: T) where T : Enum, T : EnumPreference { + when (pref) { + is CloudService -> { + if (pref in listOf(CloudService.FILE_STORAGE, CloudService.DISABLED)) { + setPreference(SyncMode.ALWAYS) + } + } + } viewModelScope.launch(Dispatchers.IO) { preferenceRepository.set(pref) } @@ -35,6 +43,9 @@ class SettingsViewModel @Inject constructor( return preferenceRepository.getEncryptedString(key) } + fun setEncryptedString(key: String, value: String) = + viewModelScope.launch { preferenceRepository.putEncryptedStrings(key to value) } + fun clearNextcloudCredentials() = viewModelScope.launch { preferenceRepository.putEncryptedStrings( PreferenceRepository.NEXTCLOUD_INSTANCE_URL to "", @@ -42,4 +53,22 @@ class SettingsViewModel @Inject constructor( PreferenceRepository.NEXTCLOUD_USERNAME to "", ) } + + /** + * Removes all IdMappings for file storage if the current cloud service is FILE_STORAGE + * and the new storage location is different from the previous one. + * + * @param newLocation The new storage location URI as a string + * @param previousLocation The previous storage location URI as a string + */ + fun removeFileStorageIdMappingsIfNeeded(newLocation: String, previousLocation: String) { + if (newLocation != previousLocation) { + viewModelScope.launch(Dispatchers.IO) { + val currentCloudService = preferenceRepository.getAll().first().cloudService + if (currentCloudService == CloudService.FILE_STORAGE) { + noteRepository.deleteIdMappingsForCloudService(CloudService.FILE_STORAGE) + } + } + } + } } diff --git a/app/src/main/java/org/qosp/notes/ui/sync/SyncSettingsFragment.kt b/app/src/main/java/org/qosp/notes/ui/sync/SyncSettingsFragment.kt index 07dd39cc..8ef4755b 100644 --- a/app/src/main/java/org/qosp/notes/ui/sync/SyncSettingsFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/sync/SyncSettingsFragment.kt @@ -1,12 +1,15 @@ package org.qosp.notes.ui.sync +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.util.Log import android.view.View import androidx.appcompat.widget.Toolbar -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import dagger.hilt.android.AndroidEntryPoint +import androidx.core.net.toUri +import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.qosp.notes.R +import org.qosp.notes.data.sync.fs.toFriendlyString import org.qosp.notes.databinding.FragmentSyncSettingsBinding import org.qosp.notes.preferences.AppPreferences import org.qosp.notes.preferences.CloudService @@ -16,14 +19,14 @@ import org.qosp.notes.ui.settings.SettingsViewModel import org.qosp.notes.ui.settings.showPreferenceDialog import org.qosp.notes.ui.sync.nextcloud.NextcloudAccountDialog import org.qosp.notes.ui.sync.nextcloud.NextcloudServerDialog +import org.qosp.notes.ui.utils.StorageLocationContract import org.qosp.notes.ui.utils.collect import org.qosp.notes.ui.utils.liftAppBarOnScroll import org.qosp.notes.ui.utils.viewBinding -@AndroidEntryPoint class SyncSettingsFragment : BaseFragment(R.layout.fragment_sync_settings) { private val binding by viewBinding(FragmentSyncSettingsBinding::bind) - private val model: SettingsViewModel by activityViewModels() + private val model: SettingsViewModel by activityViewModel() override val hasMenu = false override val toolbar: Toolbar @@ -32,8 +35,27 @@ class SyncSettingsFragment : BaseFragment(R.layout.fragment_sync_settings) { get() = getString(R.string.preferences_header_syncing) private var appPreferences = AppPreferences() - private var nextcloudUrl = "" + private var storageLocation: Uri? = null + + private val locationListener = registerForActivityResult(StorageLocationContract) { uri -> + uri?.let { + // Get the previous location before setting the new one + val previousLocation = storageLocation?.toString() ?: "" + val newLocation = it.toString() + + // Set the new location + model.setEncryptedString(PreferenceRepository.STORAGE_LOCATION, newLocation) + Log.i(TAG, "Storing location: $it") + + // Remove IdMappings if needed + model.removeFileStorageIdMappingsIfNeeded(newLocation, previousLocation) + + val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context?.contentResolver?.takePersistableUriPermission(it, takeFlags) + } + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -43,8 +65,6 @@ class SyncSettingsFragment : BaseFragment(R.layout.fragment_sync_settings) { requireContext().resources.getDimension(R.dimen.app_bar_elevation) ) - setProviderSettingsVisibility(appPreferences.cloudService) - setupPreferenceObservers() setupSyncServiceListener() setupSyncModeListener() @@ -54,25 +74,38 @@ class SyncSettingsFragment : BaseFragment(R.layout.fragment_sync_settings) { setupNextcloudServerListener() setupNextcloudAccountListener() setupClearNextcloudCredentialsListener() + setupTrustCertificatesListener() + + setupLocalLocationListener() } + private fun View.show(visible: Boolean) = if (visible) visibility = View.VISIBLE else visibility = View.GONE + private fun setupPreferenceObservers() { - model.appPreferences.collect(viewLifecycleOwner) { - appPreferences = it - - with(appPreferences) { - binding.settingSyncProvider.subText = getString(cloudService.nameResource) - setProviderSettingsVisibility(cloudService) - binding.settingSyncMode.subText = getString(syncMode.nameResource) - binding.settingBackgroundSync.subText = getString(backgroundSync.nameResource) - binding.settingNotesSyncableByDefault.subText = getString(newNotesSyncable.nameResource) - } + model.appPreferences.collect(viewLifecycleOwner) { prefs -> + appPreferences = prefs + + // Update visibility of layouts based on cloud service + binding.layoutGenericSettings.show(prefs.cloudService == CloudService.NEXTCLOUD) + binding.layoutNextcloudSettings.show(prefs.cloudService == CloudService.NEXTCLOUD) + binding.layoutStorageSettings.show(prefs.cloudService == CloudService.FILE_STORAGE) + binding.settingSyncMode.show(prefs.cloudService == CloudService.NEXTCLOUD) + binding.settingBackgroundSync.show(prefs.cloudService == CloudService.NEXTCLOUD) + binding.settingNotesSyncableByDefault.show(prefs.cloudService == CloudService.NEXTCLOUD) + binding.settingTrustSelfSignedCertificate.show(prefs.cloudService == CloudService.NEXTCLOUD) + + binding.settingSyncProvider.subText = getString(prefs.cloudService.nameResource) + binding.settingSyncMode.subText = getString(prefs.syncMode.nameResource) + binding.settingBackgroundSync.subText = getString(prefs.backgroundSync.nameResource) + binding.settingNotesSyncableByDefault.subText = getString(prefs.newNotesSyncable.nameResource) + binding.settingTrustSelfSignedCertificate.subText = getString(prefs.trustSelfSignedCertificate.nameResource) } // ENCRYPTED model.getEncryptedString(PreferenceRepository.NEXTCLOUD_INSTANCE_URL).collect(viewLifecycleOwner) { nextcloudUrl = it - binding.settingNextcloudServer.subText = nextcloudUrl.ifEmpty { getString(R.string.preferences_nextcloud_set_server_url) } + binding.settingNextcloudServer.subText = + nextcloudUrl.ifEmpty { getString(R.string.preferences_nextcloud_set_server_url) } } model.loggedInUsername.collect(viewLifecycleOwner) { @@ -82,6 +115,17 @@ class SyncSettingsFragment : BaseFragment(R.layout.fragment_sync_settings) { getString(R.string.preferences_nextcloud_set_your_credentials) } } + + model.getEncryptedString(PreferenceRepository.STORAGE_LOCATION).collect(viewLifecycleOwner) { u -> + val uri = u.toUri() + storageLocation = uri + val appName = if (u.isNotBlank()) context?.let { uri.toFriendlyString(it) } else null + binding.settingStorageLocation.subText = appName ?: getString(R.string.preferences_file_storage_select) + } + } + + private fun setupLocalLocationListener() = binding.settingStorageLocation.setOnClickListener { + locationListener.launch(storageLocation) } private fun setupNextcloudServerListener() = binding.settingNextcloudServer.setOnClickListener { @@ -111,7 +155,18 @@ class SyncSettingsFragment : BaseFragment(R.layout.fragment_sync_settings) { } private fun setupNewNotesSyncableListener() = binding.settingNotesSyncableByDefault.setOnClickListener { - showPreferenceDialog(R.string.preferences_new_notes_synchronizable, appPreferences.newNotesSyncable) { selected -> + showPreferenceDialog( + R.string.preferences_new_notes_synchronizable, + appPreferences.newNotesSyncable + ) { selected -> + model.setPreference(selected) + } + } + private fun setupTrustCertificatesListener() = binding.settingTrustSelfSignedCertificate.setOnClickListener { + showPreferenceDialog( + R.string.preferences_trust_self_signed_certificate, + appPreferences.trustSelfSignedCertificate + ) { selected -> model.setPreference(selected) } } @@ -119,8 +174,4 @@ class SyncSettingsFragment : BaseFragment(R.layout.fragment_sync_settings) { private fun setupClearNextcloudCredentialsListener() = binding.settingNextcloudClearCredentials.setOnClickListener { model.clearNextcloudCredentials() } - private fun setProviderSettingsVisibility(currentProvider: CloudService) { - binding.layoutNextcloudSettings.isVisible = currentProvider == CloudService.NEXTCLOUD - binding.layoutGenericSettings.isVisible = currentProvider != CloudService.DISABLED - } } diff --git a/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudAccountDialog.kt b/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudAccountDialog.kt index 44724587..9224b675 100644 --- a/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudAccountDialog.kt +++ b/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudAccountDialog.kt @@ -5,33 +5,27 @@ import android.view.LayoutInflater import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.qosp.notes.R -import org.qosp.notes.data.sync.core.NoConnectivity -import org.qosp.notes.data.sync.core.ServerNotSupported -import org.qosp.notes.data.sync.core.Success -import org.qosp.notes.data.sync.core.SyncManager -import org.qosp.notes.data.sync.core.Unauthorized +import org.qosp.notes.data.sync.nextcloud.BackendValidationResult +import org.qosp.notes.data.sync.nextcloud.BackendValidationResult.CertificateError +import org.qosp.notes.data.sync.nextcloud.BackendValidationResult.Incompatible +import org.qosp.notes.data.sync.nextcloud.BackendValidationResult.InvalidConfig +import org.qosp.notes.data.sync.nextcloud.BackendValidationResult.Success import org.qosp.notes.databinding.DialogNextcloudAccountBinding import org.qosp.notes.ui.common.BaseDialog import org.qosp.notes.ui.common.setButton import org.qosp.notes.ui.utils.requestFocusAndKeyboard -import javax.inject.Inject -@AndroidEntryPoint class NextcloudAccountDialog : BaseDialog() { - private val model: NextcloudViewModel by activityViewModels() + private val model: NextcloudViewModel by activityViewModel() private var username = "" private var password = "" - @Inject - lateinit var syncManager: SyncManager - override fun createBinding(inflater: LayoutInflater) = DialogNextcloudAccountBinding.inflate(layoutInflater) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -52,7 +46,11 @@ class NextcloudAccountDialog : BaseDialog() { password = binding.editTextPassword.text.toString() if (username.isBlank() or password.isBlank()) { - Toast.makeText(requireContext(), getString(R.string.message_credentials_cannot_be_blank), Toast.LENGTH_SHORT).show() + Toast.makeText( + requireContext(), + getString(R.string.message_credentials_cannot_be_blank), + Toast.LENGTH_SHORT + ).show() return@setButton } @@ -61,13 +59,13 @@ class NextcloudAccountDialog : BaseDialog() { lifecycleScope.launch { val result = model.authenticate(username, password) val messageResId = when (result) { - NoConnectivity -> R.string.message_internet_not_available - ServerNotSupported -> R.string.message_server_not_compatible + Incompatible -> R.string.message_server_not_compatible Success -> R.string.message_logged_in_successfully - Unauthorized -> R.string.message_invalid_credentials - else -> R.string.message_something_went_wrong + InvalidConfig -> R.string.message_invalid_credentials + CertificateError -> R.string.message_certificates_invalid + BackendValidationResult.NotesNotInstalled -> R.string.message_notes_not_installed } - Toast.makeText(requireContext(), getString(messageResId), Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), getString(messageResId), Toast.LENGTH_LONG).show() if (result == Success) dismiss() } } diff --git a/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudServerDialog.kt b/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudServerDialog.kt index 4d554cc0..d2b29bba 100644 --- a/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudServerDialog.kt +++ b/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudServerDialog.kt @@ -7,17 +7,15 @@ import android.view.View import android.webkit.URLUtil import android.widget.Toast import androidx.core.os.bundleOf -import androidx.fragment.app.activityViewModels -import dagger.hilt.android.AndroidEntryPoint +import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.qosp.notes.R import org.qosp.notes.databinding.DialogNextcloudServerBinding import org.qosp.notes.ui.common.BaseDialog import org.qosp.notes.ui.common.setButton import org.qosp.notes.ui.utils.requestFocusAndKeyboard -@AndroidEntryPoint class NextcloudServerDialog : BaseDialog() { - private val model: NextcloudViewModel by activityViewModels() + private val model: NextcloudViewModel by activityViewModel() private var url: String = "" override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudViewModel.kt b/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudViewModel.kt index de3a13bc..d624e3db 100644 --- a/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/sync/nextcloud/NextcloudViewModel.kt @@ -3,22 +3,18 @@ package org.qosp.notes.ui.sync.nextcloud import android.webkit.URLUtil import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.qosp.notes.data.sync.core.BaseResult -import org.qosp.notes.data.sync.core.Success -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.data.sync.nextcloud.BackendValidationResult import org.qosp.notes.data.sync.nextcloud.NextcloudConfig +import org.qosp.notes.data.sync.nextcloud.ValidateNextcloud import org.qosp.notes.preferences.PreferenceRepository -import javax.inject.Inject -@HiltViewModel -class NextcloudViewModel @Inject constructor( +class NextcloudViewModel( private val preferenceRepository: PreferenceRepository, - private val syncManager: SyncManager, + private val validateNextcloud: ValidateNextcloud, ) : ViewModel() { val username = preferenceRepository.getEncryptedString(PreferenceRepository.NEXTCLOUD_USERNAME) @@ -33,25 +29,20 @@ class NextcloudViewModel @Inject constructor( ) } - suspend fun authenticate(username: String, password: String): BaseResult { + suspend fun authenticate(username: String, password: String): BackendValidationResult { val config = NextcloudConfig( username = username, password = password, remoteAddress = preferenceRepository.getEncryptedString(PreferenceRepository.NEXTCLOUD_INSTANCE_URL).first() ) - val response: BaseResult = withContext(Dispatchers.IO) { - val loginResult = syncManager.authenticate(config) - if (loginResult == Success) syncManager.isServerCompatible(config) else loginResult - } - - return response.also { - if (it == Success) { - preferenceRepository.putEncryptedStrings( - PreferenceRepository.NEXTCLOUD_USERNAME to username, - PreferenceRepository.NEXTCLOUD_PASSWORD to password, - ) - } + val response = withContext(Dispatchers.IO) { validateNextcloud(config) } + if (response == BackendValidationResult.Success) { + preferenceRepository.putEncryptedStrings( + PreferenceRepository.NEXTCLOUD_USERNAME to username, + PreferenceRepository.NEXTCLOUD_PASSWORD to password, + ) } + return response } } diff --git a/app/src/main/java/org/qosp/notes/ui/tags/TagsFragment.kt b/app/src/main/java/org/qosp/notes/ui/tags/TagsFragment.kt index bf237f77..98bd1c7e 100755 --- a/app/src/main/java/org/qosp/notes/ui/tags/TagsFragment.kt +++ b/app/src/main/java/org/qosp/notes/ui/tags/TagsFragment.kt @@ -8,13 +8,14 @@ import android.view.View import androidx.appcompat.widget.Toolbar import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible -import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager -import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.map +import org.koin.androidx.viewmodel.ext.android.viewModel import org.qosp.notes.R import org.qosp.notes.databinding.FragmentTagsBinding +import org.qosp.notes.preferences.SortTagsMethod import org.qosp.notes.ui.common.BaseFragment import org.qosp.notes.ui.common.recycler.onBackPressedHandler import org.qosp.notes.ui.tags.dialog.EditTagDialog @@ -26,12 +27,13 @@ import org.qosp.notes.ui.utils.navigateSafely import org.qosp.notes.ui.utils.viewBinding import org.qosp.notes.ui.utils.views.BottomSheet -@AndroidEntryPoint class TagsFragment : BaseFragment(R.layout.fragment_tags) { private val binding by viewBinding(FragmentTagsBinding::bind) + val model: TagsViewModel by viewModel() + + protected var mainMenu: Menu? = null private val args: TagsFragmentArgs by navArgs() - private val model: TagsViewModel by viewModels() private lateinit var adapter: TagsRecyclerAdapter @@ -45,11 +47,7 @@ class TagsFragment : BaseFragment(R.layout.fragment_tags) { setupRecyclerView() - model - .getData(args.noteId.takeIf { it >= 0L }) - .collect(viewLifecycleOwner) { - adapter.submitList(it) - } + enlistTags() binding.recyclerTags.liftAppBarOnScroll( binding.layoutAppBar.appBar, @@ -70,16 +68,34 @@ class TagsFragment : BaseFragment(R.layout.fragment_tags) { true } } + + binding.layoutAppBar.toolbarSelection.apply { + setOnMenuItemClickListener { + + true + } + } + } + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.tags, menu) + mainMenu = menu + selectSortMethodItem() } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_create_tag -> EditTagDialog.build(null).show(childFragmentManager, null) + R.id.action_sort_tags_created_asc -> activityModel.setSortTagsMethod(SortTagsMethod.CREATION_ASC) + R.id.action_sort_tags_created_desc -> activityModel.setSortTagsMethod(SortTagsMethod.CREATION_DESC) + R.id.action_sort_tags_name_asc -> activityModel.setSortTagsMethod(SortTagsMethod.TITLE_ASC) + R.id.action_sort_tags_name_desc -> activityModel.setSortTagsMethod(SortTagsMethod.TITLE_DESC) } + enlistTags() + selectSortMethodItem() return super.onOptionsItemSelected(item) } @@ -88,6 +104,29 @@ class TagsFragment : BaseFragment(R.layout.fragment_tags) { super.onDestroyView() } + private fun enlistTags() { + // Reading the settings: which tags sorting method is active? + val sort = model.getSortTagsMethod() + + // Applying tags sorting. + val sortedTagsList = model + .getData(args.noteId.takeIf { it >= 0L }) + .map { + when (sort) { + SortTagsMethod.CREATION_ASC.name -> it.sortedBy { it.tag.id } + SortTagsMethod.CREATION_DESC.name -> it.sortedByDescending { it.tag.id } + SortTagsMethod.TITLE_ASC.name -> it.sortedBy { it.tag.name } + SortTagsMethod.TITLE_DESC.name -> it.sortedByDescending { it.tag.name } + else -> it.sortedBy { it.tag.name } + } + } + + // Displaying the tags. + sortedTagsList.collect(viewLifecycleOwner) { + adapter.submitList(it) + } + } + private fun setupRecyclerView() { binding.recyclerTags.layoutManager = LinearLayoutManager(requireContext()) @@ -161,4 +200,17 @@ class TagsFragment : BaseFragment(R.layout.fragment_tags) { postponeEnterTransition() binding.recyclerTags.doOnPreDraw { startPostponedEnterTransition() } } + + private fun selectSortMethodItem() { + mainMenu?.findItem( + when (model.getSortTagsMethod()) { + SortTagsMethod.TITLE_ASC.name -> R.id.action_sort_tags_name_asc + SortTagsMethod.TITLE_DESC.name -> R.id.action_sort_tags_name_desc + SortTagsMethod.CREATION_ASC.name -> R.id.action_sort_tags_created_asc + SortTagsMethod.CREATION_DESC.name -> R.id.action_sort_tags_created_desc + else -> R.id.action_sort_tags_name_asc + } + )?.isChecked = true + } + } diff --git a/app/src/main/java/org/qosp/notes/ui/tags/TagsViewModel.kt b/app/src/main/java/org/qosp/notes/ui/tags/TagsViewModel.kt index 221585cd..cf4dbe8b 100755 --- a/app/src/main/java/org/qosp/notes/ui/tags/TagsViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/tags/TagsViewModel.kt @@ -2,27 +2,33 @@ package org.qosp.notes.ui.tags import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import me.msoul.datastore.defaultOf import org.qosp.notes.data.model.Tag import org.qosp.notes.data.repo.TagRepository -import javax.inject.Inject +import org.qosp.notes.preferences.PreferenceRepository +import org.qosp.notes.preferences.SortTagsMethod data class TagData(val tag: Tag, val inNote: Boolean) -@HiltViewModel -class TagsViewModel @Inject constructor(private val tagRepository: TagRepository) : ViewModel() { +class TagsViewModel( + private val tagRepository: TagRepository, + private val preferenceRepository: PreferenceRepository +) : ViewModel() { fun getData(noteId: Long? = null): Flow> { return when (noteId) { null -> tagRepository.getAll().map { tags -> tags.map { TagData(it, false) } } + else -> tagRepository.getByNoteId(noteId).flatMapLatest { noteTags -> tagRepository.getAll().map { tags -> tags.map { TagData(it, it in noteTags) } @@ -31,6 +37,16 @@ class TagsViewModel @Inject constructor(private val tagRepository: TagRepository } } + fun getSortTagsMethod(): String { + return runBlocking { + return@runBlocking preferenceRepository + .getAll() + .map { it.sortTagsMethod } + .first() + .name + } + } + suspend fun insert(tag: Tag): Long { return withContext(Dispatchers.IO) { tagRepository.insert(tag) @@ -54,4 +70,10 @@ class TagsViewModel @Inject constructor(private val tagRepository: TagRepository tagRepository.deleteTagFromNote(tagId, noteId) } } + + data class TagDataList( + val tagsList: MutableList = mutableListOf(), + val sortTagsMethod: SortTagsMethod = defaultOf() + ) + } diff --git a/app/src/main/java/org/qosp/notes/ui/tags/dialog/EditTagDialog.kt b/app/src/main/java/org/qosp/notes/ui/tags/dialog/EditTagDialog.kt index 5eae77f7..ce5c1ba9 100644 --- a/app/src/main/java/org/qosp/notes/ui/tags/dialog/EditTagDialog.kt +++ b/app/src/main/java/org/qosp/notes/ui/tags/dialog/EditTagDialog.kt @@ -6,10 +6,9 @@ import android.view.LayoutInflater import android.view.View import android.widget.Toast import androidx.core.os.bundleOf -import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.qosp.notes.R import org.qosp.notes.data.model.Tag import org.qosp.notes.databinding.DialogEditTagBinding @@ -17,9 +16,8 @@ import org.qosp.notes.ui.common.BaseDialog import org.qosp.notes.ui.common.setButton import org.qosp.notes.ui.utils.requestFocusAndKeyboard -@AndroidEntryPoint class EditTagDialog : BaseDialog() { - private val model: TagDialogViewModel by activityViewModels() + private val model: TagDialogViewModel by activityViewModel() private lateinit var tag: Tag override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/qosp/notes/ui/tags/dialog/TagDialogViewModel.kt b/app/src/main/java/org/qosp/notes/ui/tags/dialog/TagDialogViewModel.kt index 098b4223..ab286943 100644 --- a/app/src/main/java/org/qosp/notes/ui/tags/dialog/TagDialogViewModel.kt +++ b/app/src/main/java/org/qosp/notes/ui/tags/dialog/TagDialogViewModel.kt @@ -2,16 +2,13 @@ package org.qosp.notes.ui.tags.dialog import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.qosp.notes.data.model.Tag import org.qosp.notes.data.repo.TagRepository -import javax.inject.Inject -@HiltViewModel -class TagDialogViewModel @Inject constructor(private val tagRepository: TagRepository) : ViewModel() { +class TagDialogViewModel(private val tagRepository: TagRepository) : ViewModel() { fun insertTag(tag: Tag) { viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/main/java/org/qosp/notes/ui/tasks/TasksAdapter.kt b/app/src/main/java/org/qosp/notes/ui/tasks/TasksAdapter.kt index 09ef058c..661fd1a0 100644 --- a/app/src/main/java/org/qosp/notes/ui/tasks/TasksAdapter.kt +++ b/app/src/main/java/org/qosp/notes/ui/tasks/TasksAdapter.kt @@ -7,7 +7,8 @@ import androidx.recyclerview.widget.RecyclerView import io.noties.markwon.Markwon import org.qosp.notes.data.model.NoteTask import org.qosp.notes.databinding.LayoutTaskBinding -import java.util.* +import java.lang.Float.min +import java.util.Collections class TasksAdapter( private val inPreview: Boolean, @@ -15,6 +16,8 @@ class TasksAdapter( private val markwon: Markwon, ) : RecyclerView.Adapter() { + private var fontSize: Float = -1.0f + var tasks: MutableList = mutableListOf() override fun getItemCount(): Int = tasks.size @@ -22,6 +25,14 @@ class TasksAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder { val binding: LayoutTaskBinding = LayoutTaskBinding.inflate(LayoutInflater.from(parent.context), parent, false) + + // apply font size preference + binding.editText.textSize = fontSize + // checkboxes are only downscaled, because upscaled looks blurry + val checkBoxScaleRatio: Float = if (fontSize > 0) min(fontSize / 16, 1.0F) else 1.0F // 16 because by default edit_text uses MaterialComponents.Body1 = 16sp + binding.checkBox.scaleX = checkBoxScaleRatio + binding.checkBox.scaleY = checkBoxScaleRatio + return TaskViewHolder(parent.context, binding, listener, inPreview, markwon) } @@ -44,6 +55,10 @@ class TasksAdapter( } } + fun setFontSize(fs: Float) { + fontSize = fs + } + private class DiffCallback(val oldList: List, val newList: List) : DiffUtil.Callback() { override fun getOldListSize(): Int = oldList.size diff --git a/app/src/main/java/org/qosp/notes/ui/theme/Color.kt b/app/src/main/java/org/qosp/notes/ui/theme/Color.kt new file mode 100644 index 00000000..a48420f1 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/ui/theme/Color.kt @@ -0,0 +1,221 @@ +package org.qosp.notes.ui.theme + +import androidx.compose.ui.graphics.Color + + +val primaryLight = Color(0xFF546524) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFD7EB9B) +val onPrimaryContainerLight = Color(0xFF3D4C0D) +val secondaryLight = Color(0xFF2D6A45) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFB0F1C2) +val onSecondaryContainerLight = Color(0xFF10512F) +val tertiaryLight = Color(0xFF006874) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFF9EEFFD) +val onTertiaryContainerLight = Color(0xFF004F58) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF93000A) +val backgroundLight = Color(0xFFFBFAED) +val onBackgroundLight = Color(0xFF1B1C15) +val surfaceLight = Color(0xFFFBFAED) +val onSurfaceLight = Color(0xFF1B1C15) +val surfaceVariantLight = Color(0xFFE3E4D3) +val onSurfaceVariantLight = Color(0xFF46483C) +val outlineLight = Color(0xFF76786B) +val outlineVariantLight = Color(0xFFC6C8B8) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF303129) +val inverseOnSurfaceLight = Color(0xFFF2F1E5) +val inversePrimaryLight = Color(0xFFBBCF81) +val surfaceDimLight = Color(0xFFDBDBCF) +val surfaceBrightLight = Color(0xFFFBFAED) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF5F4E8) +val surfaceContainerLight = Color(0xFFEFEEE2) +val surfaceContainerHighLight = Color(0xFFE9E9DD) +val surfaceContainerHighestLight = Color(0xFFE3E3D7) + +val primaryLightMediumContrast = Color(0xFF2D3B00) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF637431) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF003F21) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF3D7952) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF003C44) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF187884) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF740006) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFCF2C27) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFBFAED) +val onBackgroundLightMediumContrast = Color(0xFF1B1C15) +val surfaceLightMediumContrast = Color(0xFFFBFAED) +val onSurfaceLightMediumContrast = Color(0xFF10120B) +val surfaceVariantLightMediumContrast = Color(0xFFE3E4D3) +val onSurfaceVariantLightMediumContrast = Color(0xFF35372C) +val outlineLightMediumContrast = Color(0xFF515447) +val outlineVariantLightMediumContrast = Color(0xFF6C6E61) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF303129) +val inverseOnSurfaceLightMediumContrast = Color(0xFFF2F1E5) +val inversePrimaryLightMediumContrast = Color(0xFFBBCF81) +val surfaceDimLightMediumContrast = Color(0xFFC7C7BC) +val surfaceBrightLightMediumContrast = Color(0xFFFBFAED) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFF5F4E8) +val surfaceContainerLightMediumContrast = Color(0xFFE9E9DD) +val surfaceContainerHighLightMediumContrast = Color(0xFFDEDDD1) +val surfaceContainerHighestLightMediumContrast = Color(0xFFD2D2C6) + +val primaryLightHighContrast = Color(0xFF253000) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF404F0F) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF00341A) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF145431) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF003238) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF00515A) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF600004) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF98000A) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFBFAED) +val onBackgroundLightHighContrast = Color(0xFF1B1C15) +val surfaceLightHighContrast = Color(0xFFFBFAED) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFE3E4D3) +val onSurfaceVariantLightHighContrast = Color(0xFF000000) +val outlineLightHighContrast = Color(0xFF2B2D22) +val outlineVariantLightHighContrast = Color(0xFF484A3E) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF303129) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFBBCF81) +val surfaceDimLightHighContrast = Color(0xFFB9B9AE) +val surfaceBrightLightHighContrast = Color(0xFFFBFAED) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFF2F1E5) +val surfaceContainerLightHighContrast = Color(0xFFE3E3D7) +val surfaceContainerHighLightHighContrast = Color(0xFFD5D5C9) +val surfaceContainerHighestLightHighContrast = Color(0xFFC7C7BC) + +val primaryDark = Color(0xFFBBCF81) +val onPrimaryDark = Color(0xFF283500) +val primaryContainerDark = Color(0xFF3D4C0D) +val onPrimaryContainerDark = Color(0xFFD7EB9B) +val secondaryDark = Color(0xFF95D5A7) +val onSecondaryDark = Color(0xFF00391D) +val secondaryContainerDark = Color(0xFF10512F) +val onSecondaryContainerDark = Color(0xFFB0F1C2) +val tertiaryDark = Color(0xFF82D3E0) +val onTertiaryDark = Color(0xFF00363D) +val tertiaryContainerDark = Color(0xFF004F58) +val onTertiaryContainerDark = Color(0xFF9EEFFD) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF13140D) +val onBackgroundDark = Color(0xFFE3E3D7) +val surfaceDark = Color(0xFF13140D) +val onSurfaceDark = Color(0xFFE3E3D7) +val surfaceVariantDark = Color(0xFF46483C) +val onSurfaceVariantDark = Color(0xFFC6C8B8) +val outlineDark = Color(0xFF909284) +val outlineVariantDark = Color(0xFF46483C) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE3E3D7) +val inverseOnSurfaceDark = Color(0xFF303129) +val inversePrimaryDark = Color(0xFF546524) +val surfaceDimDark = Color(0xFF13140D) +val surfaceBrightDark = Color(0xFF393A32) +val surfaceContainerLowestDark = Color(0xFF0D0F08) +val surfaceContainerLowDark = Color(0xFF1B1C15) +val surfaceContainerDark = Color(0xFF1F2019) +val surfaceContainerHighDark = Color(0xFF292B23) +val surfaceContainerHighestDark = Color(0xFF34352D) + +val primaryDarkMediumContrast = Color(0xFFD1E595) +val onPrimaryDarkMediumContrast = Color(0xFF1F2900) +val primaryContainerDarkMediumContrast = Color(0xFF869851) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFAAEBBC) +val onSecondaryDarkMediumContrast = Color(0xFF002D15) +val secondaryContainerDarkMediumContrast = Color(0xFF609E74) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFF98E9F7) +val onTertiaryDarkMediumContrast = Color(0xFF002A30) +val tertiaryContainerDarkMediumContrast = Color(0xFF499CA9) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFD2CC) +val onErrorDarkMediumContrast = Color(0xFF540003) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF13140D) +val onBackgroundDarkMediumContrast = Color(0xFFE3E3D7) +val surfaceDarkMediumContrast = Color(0xFF13140D) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF46483C) +val onSurfaceVariantDarkMediumContrast = Color(0xFFDDDDCD) +val outlineDarkMediumContrast = Color(0xFFB2B3A4) +val outlineVariantDarkMediumContrast = Color(0xFF909183) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFE3E3D7) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF292B23) +val inversePrimaryDarkMediumContrast = Color(0xFF3E4D0E) +val surfaceDimDarkMediumContrast = Color(0xFF13140D) +val surfaceBrightDarkMediumContrast = Color(0xFF44453C) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF070803) +val surfaceContainerLowDarkMediumContrast = Color(0xFF1D1E17) +val surfaceContainerDarkMediumContrast = Color(0xFF272921) +val surfaceContainerHighDarkMediumContrast = Color(0xFF32332B) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3D3E36) + +val primaryDarkHighContrast = Color(0xFFE5F9A7) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFB8CB7E) +val onPrimaryContainerDarkHighContrast = Color(0xFF080D00) +val secondaryDarkHighContrast = Color(0xFFBEFFCF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFF91D1A3) +val onSecondaryContainerDarkHighContrast = Color(0xFF000F05) +val tertiaryDarkHighContrast = Color(0xFFCDF7FF) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFF7ECFDC) +val onTertiaryContainerDarkHighContrast = Color(0xFF000E10) +val errorDarkHighContrast = Color(0xFFFFECE9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFAEA4) +val onErrorContainerDarkHighContrast = Color(0xFF220001) +val backgroundDarkHighContrast = Color(0xFF13140D) +val onBackgroundDarkHighContrast = Color(0xFFE3E3D7) +val surfaceDarkHighContrast = Color(0xFF13140D) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF46483C) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFF0F1E1) +val outlineVariantDarkHighContrast = Color(0xFFC3C4B4) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFE3E3D7) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF3E4D0E) +val surfaceDimDarkHighContrast = Color(0xFF13140D) +val surfaceBrightDarkHighContrast = Color(0xFF505148) +val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) +val surfaceContainerLowDarkHighContrast = Color(0xFF1F2019) +val surfaceContainerDarkHighContrast = Color(0xFF303129) +val surfaceContainerHighDarkHighContrast = Color(0xFF3B3C34) +val surfaceContainerHighestDarkHighContrast = Color(0xFF46473F) + diff --git a/app/src/main/java/org/qosp/notes/ui/theme/Theme.kt b/app/src/main/java/org/qosp/notes/ui/theme/Theme.kt new file mode 100644 index 00000000..804099ab --- /dev/null +++ b/app/src/main/java/org/qosp/notes/ui/theme/Theme.kt @@ -0,0 +1,278 @@ +package org.qosp.notes.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +@Immutable +data class ColorFamily( + val color: Color, + val onColor: Color, + val colorContainer: Color, + val onColorContainer: Color +) + +val unspecified_scheme = ColorFamily( + Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified +) + +@Composable +fun QuillpadTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + 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 -> darkScheme + else -> lightScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content + ) +} + diff --git a/app/src/main/java/org/qosp/notes/ui/theme/Type.kt b/app/src/main/java/org/qosp/notes/ui/theme/Type.kt new file mode 100644 index 00000000..f459b553 --- /dev/null +++ b/app/src/main/java/org/qosp/notes/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package org.qosp.notes.ui.theme + +import androidx.compose.material3.Typography + +val AppTypography = Typography() diff --git a/app/src/main/java/org/qosp/notes/ui/utils/ActivityResultUtils.kt b/app/src/main/java/org/qosp/notes/ui/utils/ActivityResultUtils.kt index 801a619c..996335be 100644 --- a/app/src/main/java/org/qosp/notes/ui/utils/ActivityResultUtils.kt +++ b/app/src/main/java/org/qosp/notes/ui/utils/ActivityResultUtils.kt @@ -4,18 +4,18 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract import android.provider.MediaStore -import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContract import java.time.Instant -fun ActivityResultLauncher.launch() { - launch(null) -} object None -object ChooseFilesContract : ActivityResultContract>() { +private const val TAG = "ActivityResultUtils" + +object ChooseFilesContract : ActivityResultContract>() { override fun createIntent(context: Context, input: None?): Intent { return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) @@ -35,12 +35,30 @@ object ChooseFilesContract : ActivityResultContract>() { } } -object ExportNotesContract : ActivityResultContract() { +object StorageLocationContract : ActivityResultContract() { + override fun createIntent(context: Context, input: Uri?): Intent { + return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + input?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, input) + } + } + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { + return if (intent != null && resultCode == Activity.RESULT_OK) { + intent.data + } else null + } +} + +object ExportNotesContract : ActivityResultContract() { override fun createIntent(context: Context, input: None?): Intent { return Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "application/zip" - putExtra(Intent.EXTRA_TITLE, "quillnote_backup_${Instant.now().epochSecond}.zip") + putExtra(Intent.EXTRA_TITLE, "quillpad_backup_${Instant.now().epochSecond}.zip") } } @@ -49,7 +67,7 @@ object ExportNotesContract : ActivityResultContract() { } } -object RestoreNotesContract : ActivityResultContract() { +object RestoreNotesContract : ActivityResultContract() { override fun createIntent(context: Context, input: None?): Intent { return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) diff --git a/app/src/main/java/org/qosp/notes/ui/utils/ConnectionManager.kt b/app/src/main/java/org/qosp/notes/ui/utils/ConnectionManager.kt index 75697877..38a6343e 100644 --- a/app/src/main/java/org/qosp/notes/ui/utils/ConnectionManager.kt +++ b/app/src/main/java/org/qosp/notes/ui/utils/ConnectionManager.kt @@ -3,32 +3,22 @@ package org.qosp.notes.ui.utils import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities -import android.os.Build import androidx.core.content.getSystemService +import org.qosp.notes.preferences.CloudService import org.qosp.notes.preferences.SyncMode class ConnectionManager(private val context: Context) { - fun isConnectionAvailable(syncMode: SyncMode): Boolean { + fun isConnectionAvailable(syncMode: SyncMode?, cloudService: CloudService?): Boolean { + if (cloudService != CloudService.NEXTCLOUD) return true val connectivityManager = context.getSystemService() ?: return false - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val network = connectivityManager.activeNetwork ?: return false - val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false - when { - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> syncMode == SyncMode.ALWAYS - else -> false - } - } else { - val type = connectivityManager.activeNetworkInfo?.type ?: return false - when (type) { - ConnectivityManager.TYPE_WIFI -> true - ConnectivityManager.TYPE_ETHERNET -> true - ConnectivityManager.TYPE_MOBILE -> syncMode == SyncMode.ALWAYS - else -> false - } + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + return when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> syncMode == SyncMode.ALWAYS + else -> true } } } diff --git a/app/src/main/java/org/qosp/notes/ui/utils/FlowUtils.kt b/app/src/main/java/org/qosp/notes/ui/utils/FlowUtils.kt index 964ddbf9..dfb2f8b4 100644 --- a/app/src/main/java/org/qosp/notes/ui/utils/FlowUtils.kt +++ b/app/src/main/java/org/qosp/notes/ui/utils/FlowUtils.kt @@ -4,14 +4,18 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch inline fun Flow.collect(lifecycleOwner: LifecycleOwner, crossinline action: suspend (value: T) -> Unit) { lifecycleOwner.lifecycleScope.launch { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - this@collect.collect(action) + this@collect.collect { action(it) } } } } + +inline fun Flow.collect(scope: CoroutineScope, crossinline action: suspend (value: T) -> Unit) { + scope.launch { this@collect.collect { action(it) } } +} diff --git a/app/src/main/java/org/qosp/notes/ui/utils/Toaster.kt b/app/src/main/java/org/qosp/notes/ui/utils/Toaster.kt new file mode 100644 index 00000000..8cace76c --- /dev/null +++ b/app/src/main/java/org/qosp/notes/ui/utils/Toaster.kt @@ -0,0 +1,23 @@ +package org.qosp.notes.ui.utils + +import android.widget.Toast +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +class Toaster { + private val _messages: MutableSharedFlow> = MutableSharedFlow() + private var lastMessage = "" + + val messages: Flow> = _messages + + fun showShort(message: String) = sendChecking(message) { _messages.tryEmit(message to Toast.LENGTH_SHORT) } + + fun showLong(message: String) = sendChecking(message) { _messages.tryEmit(message to Toast.LENGTH_LONG) } + + private fun sendChecking(message: String, sendFunc: () -> Unit) { + if (message != lastMessage && message.isNotEmpty()) { + lastMessage = message + sendFunc() + } + } +} diff --git a/app/src/main/java/org/qosp/notes/ui/utils/ViewUtils.kt b/app/src/main/java/org/qosp/notes/ui/utils/ViewUtils.kt index 2358d650..2154b897 100644 --- a/app/src/main/java/org/qosp/notes/ui/utils/ViewUtils.kt +++ b/app/src/main/java/org/qosp/notes/ui/utils/ViewUtils.kt @@ -73,9 +73,9 @@ fun View.liftAppBarOnScroll( viewTreeObserver.addOnScrollChangedListener(listener) appBar.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View?) { } + override fun onViewAttachedToWindow(v: View) { } - override fun onViewDetachedFromWindow(v: View?) { + override fun onViewDetachedFromWindow(v: View) { viewTreeObserver.removeOnScrollChangedListener(listener) } }) diff --git a/app/src/main/java/org/qosp/notes/ui/utils/coil/AlbumArtFetcher.kt b/app/src/main/java/org/qosp/notes/ui/utils/coil/AlbumArtFetcher.kt index 5905a900..5eb17ad6 100644 --- a/app/src/main/java/org/qosp/notes/ui/utils/coil/AlbumArtFetcher.kt +++ b/app/src/main/java/org/qosp/notes/ui/utils/coil/AlbumArtFetcher.kt @@ -7,19 +7,22 @@ import android.graphics.Paint import android.graphics.drawable.ColorDrawable import android.net.Uri import android.os.Build +import android.util.Log import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.toDrawable -import coil.bitmap.BitmapPool import coil.decode.DataSource import coil.decode.DecodeUtils -import coil.decode.Options import coil.fetch.DrawableResult import coil.fetch.FetchResult import coil.fetch.Fetcher -import coil.size.OriginalSize -import coil.size.PixelSize +import coil.request.Options +import coil.size.Dimension +import coil.size.Scale import coil.size.Size +import coil.size.isOriginal +import coil.size.pxOrElse import org.qosp.notes.R import org.qosp.notes.ui.attachments.getAlbumArtBitmap import org.qosp.notes.ui.utils.getDrawableCompat @@ -27,50 +30,54 @@ import kotlin.math.roundToInt // Modified VideoFrameFetcher to load album art images // TODO: Resizing doesn't work, need to fix it -class AlbumArtFetcher(private val context: Context) : Fetcher { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) +class AlbumArtFetcher( + private val context: Context, + private val data: Uri, + private val options: Options +) : Fetcher { - override suspend fun fetch(pool: BitmapPool, data: Uri, size: Size, options: Options): FetchResult { + private fun logv(string: String) = Log.i("AlbulmFetcher", string) - val defaultDrawable = context.getDrawableCompat(R.drawable.ic_music) ?: ColorDrawable(Color.BLACK) - DrawableCompat.setTint( - defaultDrawable, - Color.GRAY - ) + override suspend fun fetch(): FetchResult { - val defaultResult = DrawableResult( - defaultDrawable, - false, - DataSource.DISK, - ) + val defaultDrawable = + context.getDrawableCompat(R.drawable.ic_music) ?: ColorDrawable(Color.BLACK) + DrawableCompat.setTint(defaultDrawable, Color.GRAY) + val defaultResult = DrawableResult(defaultDrawable, false, DataSource.DISK) + logv("Getting URL $data") val rawBitmap = getAlbumArtBitmap(context, data) ?: return defaultResult + logv("Got Audio bitmap : ${rawBitmap.height}x${rawBitmap.width}") val srcWidth = rawBitmap.width val srcHeight = rawBitmap.height - val destSize = when (size) { - is PixelSize -> { - if (srcWidth > 0 && srcHeight > 0) { - val rawScale = DecodeUtils.computeSizeMultiplier( - srcWidth = srcWidth, - srcHeight = srcHeight, - dstWidth = size.width, - dstHeight = size.height, - scale = options.scale - ) - val scale = if (options.allowInexactSize) rawScale.coerceAtMost(1.0) else rawScale - val width = (scale * srcWidth).roundToInt() - val height = (scale * srcHeight).roundToInt() - PixelSize(width, height) - } else { - OriginalSize - } + val dstSize = if (srcWidth > 0 && srcHeight > 0) { + val dstWidth = options.size.widthPx(options.scale) { srcWidth } + val dstHeight = options.size.heightPx(options.scale) { srcHeight } + val rawScale = DecodeUtils.computeSizeMultiplier( + srcWidth = srcWidth, + srcHeight = srcHeight, + dstWidth = dstWidth, + dstHeight = dstHeight, + scale = options.scale + ) + val scale = if (options.allowInexactSize) { + rawScale.coerceAtMost(1.0) + } else { + rawScale } - is OriginalSize -> OriginalSize + val width = (scale * srcWidth).roundToInt() + val height = (scale * srcHeight).roundToInt() + Size(width, height) + } else { + // We were unable to decode the video's dimensions. + // Fall back to decoding the video frame at the original size. + // We'll scale the resulting bitmap after decoding if necessary. + Size.ORIGINAL } - val bitmap = normalizeBitmap(pool, rawBitmap, destSize, options) + val bitmap = normalizeBitmap(rawBitmap, dstSize) val isSampled = if (srcWidth > 0 && srcHeight > 0) { DecodeUtils.computeSizeMultiplier( @@ -87,52 +94,34 @@ class AlbumArtFetcher(private val context: Context) : Fetcher { return DrawableResult(bitmap.toDrawable(context.resources), isSampled, DataSource.DISK) } - override fun key(data: Uri) = data.toString() - - private fun normalizeBitmap( - pool: BitmapPool, - inBitmap: Bitmap, - size: Size, - options: Options - ): Bitmap { + private fun normalizeBitmap(inBitmap: Bitmap, size: Size): Bitmap { // Fast path: if the input bitmap is valid, return it. if (isConfigValid(inBitmap, options) && isSizeValid(inBitmap, options, size)) { return inBitmap } // Slow path: re-render the bitmap with the correct size + config. - val scale: Float - val dstWidth: Int - val dstHeight: Int - when (size) { - is PixelSize -> { - scale = DecodeUtils.computeSizeMultiplier( - srcWidth = inBitmap.width, - srcHeight = inBitmap.height, - dstWidth = size.width, - dstHeight = size.height, - scale = options.scale - ).toFloat() - dstWidth = (scale * inBitmap.width).roundToInt() - dstHeight = (scale * inBitmap.height).roundToInt() - } - is OriginalSize -> { - scale = 1f - dstWidth = inBitmap.width - dstHeight = inBitmap.height - } - } + val scale = DecodeUtils.computeSizeMultiplier( + srcWidth = inBitmap.width, + srcHeight = inBitmap.height, + dstWidth = size.width.pxOrElse { inBitmap.width }, + dstHeight = size.height.pxOrElse { inBitmap.height }, + scale = options.scale + ).toFloat() + val dstWidth = (scale * inBitmap.width).roundToInt() + val dstHeight = (scale * inBitmap.height).roundToInt() val safeConfig = when { Build.VERSION.SDK_INT >= 26 && options.config == Bitmap.Config.HARDWARE -> Bitmap.Config.ARGB_8888 else -> options.config } - val outBitmap = pool.get(dstWidth, dstHeight, safeConfig) + val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + val outBitmap = createBitmap(dstWidth, dstHeight, safeConfig) outBitmap.applyCanvas { scale(scale, scale) drawBitmap(inBitmap, 0f, 0f, paint) } - pool.put(inBitmap) + inBitmap.recycle() return outBitmap } @@ -142,7 +131,29 @@ class AlbumArtFetcher(private val context: Context) : Fetcher { } private fun isSizeValid(bitmap: Bitmap, options: Options, size: Size): Boolean { - return options.allowInexactSize || size is OriginalSize || - size == DecodeUtils.computePixelSize(bitmap.width, bitmap.height, size, options.scale) + if (options.allowInexactSize) return true + val multiplier = DecodeUtils.computeSizeMultiplier( + srcWidth = bitmap.width, + srcHeight = bitmap.height, + dstWidth = size.width.pxOrElse { bitmap.width }, + dstHeight = size.height.pxOrElse { bitmap.height }, + scale = options.scale + ) + return multiplier == 1.0 + } +} + +internal inline fun Size.widthPx(scale: Scale, original: () -> Int): Int { + return if (isOriginal) original() else width.toPx(scale) +} + +internal inline fun Size.heightPx(scale: Scale, original: () -> Int): Int { + return if (isOriginal) original() else height.toPx(scale) +} + +internal fun Dimension.toPx(scale: Scale) = pxOrElse { + when (scale) { + Scale.FILL -> Int.MIN_VALUE + Scale.FIT -> Int.MAX_VALUE } } diff --git a/app/src/main/java/org/qosp/notes/ui/utils/coil/CoilImagesPlugin.kt b/app/src/main/java/org/qosp/notes/ui/utils/coil/CoilImagesPlugin.kt index 1a051520..79000194 100644 --- a/app/src/main/java/org/qosp/notes/ui/utils/coil/CoilImagesPlugin.kt +++ b/app/src/main/java/org/qosp/notes/ui/utils/coil/CoilImagesPlugin.kt @@ -17,8 +17,11 @@ import io.noties.markwon.image.AsyncDrawableLoader import io.noties.markwon.image.AsyncDrawableScheduler import io.noties.markwon.image.DrawableUtils import io.noties.markwon.image.ImageSpanFactory +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking import org.commonmark.node.Image -import org.qosp.notes.data.sync.core.SyncManager +import org.qosp.notes.data.sync.nextcloud.NextcloudConfig +import org.qosp.notes.preferences.PreferenceRepository import java.util.concurrent.atomic.AtomicBoolean /** @@ -53,7 +56,7 @@ class CoilImagesPlugin internal constructor(coilStore: CoilStore, imageLoader: I AsyncDrawableScheduler.schedule(textView) } - private class CoilAsyncDrawableLoader internal constructor( + private class CoilAsyncDrawableLoader( private val coilStore: CoilStore, private val imageLoader: ImageLoader, ) : @@ -125,15 +128,17 @@ class CoilImagesPlugin internal constructor(coilStore: CoilStore, imageLoader: I } companion object { - fun create(context: Context, syncManager: SyncManager): CoilImagesPlugin { + fun create(context: Context, preferenceRepository: PreferenceRepository): CoilImagesPlugin { return create( object : CoilStore { override fun load(drawable: AsyncDrawable): ImageRequest { return ImageRequest.Builder(context) .data(drawable.destination) .apply { - syncManager.config.value?.authenticationHeaders?.forEach { (key, value) -> - addHeader(key, value) + runBlocking { + (NextcloudConfig.fromPreferences(preferenceRepository).firstOrNull()) + ?.authenticationHeaders + ?.forEach { (key, value) -> addHeader(key, value) } } } .build() diff --git a/app/src/main/java/org/qosp/notes/ui/utils/views/ExtendedEditText.kt b/app/src/main/java/org/qosp/notes/ui/utils/views/ExtendedEditText.kt index a14fdce4..d705f002 100644 --- a/app/src/main/java/org/qosp/notes/ui/utils/views/ExtendedEditText.kt +++ b/app/src/main/java/org/qosp/notes/ui/utils/views/ExtendedEditText.kt @@ -106,7 +106,6 @@ class ExtendedEditText : AppCompatEditText { if (textWatchers != null) { if (watcher != null) textWatchers.remove(watcher) } - super.removeTextChangedListener(watcher) } @@ -181,8 +180,8 @@ class ExtendedEditText : AppCompatEditText { } inner class UndoRedoTextWatcher : TextWatcher { - var textBefore: CharSequence? = null - var textSetAtLeastOnce = false + private var textBefore: CharSequence? = null + private var textSetAtLeastOnce = false override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { textBefore = s.subSequence(start, start + count) diff --git a/app/src/main/java/org/qosp/notes/ui/utils/views/PreferenceView.kt b/app/src/main/java/org/qosp/notes/ui/utils/views/PreferenceView.kt index 0b5d8f13..e18ab63c 100644 --- a/app/src/main/java/org/qosp/notes/ui/utils/views/PreferenceView.kt +++ b/app/src/main/java/org/qosp/notes/ui/utils/views/PreferenceView.kt @@ -10,13 +10,6 @@ import androidx.core.view.isVisible import org.qosp.notes.R class PreferenceView(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) { - var subText: String = "" - get() = subTextView.text.toString() - set(value) { - field = value - subTextView.text = value - subTextView.isVisible = value.isNotEmpty() - } fun setIcon(@DrawableRes id: Int) { imageView.setImageResource(id) @@ -26,6 +19,13 @@ class PreferenceView(context: Context, attrs: AttributeSet?) : LinearLayout(cont private val textView: AppCompatTextView private val subTextView: AppCompatTextView + var subText: String = "" + set(value) { + subTextView.text = value + subTextView.isVisible = value.isNotBlank() + field = value + } + init { inflate(context, R.layout.layout_about_item, this) imageView = findViewById(R.id.about_item_image_view) diff --git a/app/src/main/res/drawable-v24/ic_launcher_background.xml b/app/src/main/res/drawable-v24/ic_launcher_background.xml index eedaeadf..90713410 100644 --- a/app/src/main/res/drawable-v24/ic_launcher_background.xml +++ b/app/src/main/res/drawable-v24/ic_launcher_background.xml @@ -1,25 +1,10 @@ - - - - - - - - - - + android:width="108dp" + android:height="108dp" + android:viewportWidth="28.575" + android:viewportHeight="28.575"> + diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml old mode 100755 new mode 100644 index 863a4b3c..887b8457 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -1,29 +1,16 @@ - - - - - - - - - - - + android:width="108dp" + android:height="108dp" + android:viewportWidth="28.575" + android:viewportHeight="28.575"> + + diff --git a/app/src/main/res/drawable-v24/ic_launcher_monochrome.xml b/app/src/main/res/drawable-v24/ic_launcher_monochrome.xml new file mode 100644 index 00000000..f0c9b4a4 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_monochrome.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_highlight.xml b/app/src/main/res/drawable/ic_highlight.xml new file mode 100644 index 00000000..7e592c1f --- /dev/null +++ b/app/src/main/res/drawable/ic_highlight.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index 287ae53f..14a3376d 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -35,8 +35,9 @@ + android:id="@+id/action_support" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:text="@string/about_support" + app:subText="@string/about_support_subtext" + app:iconSrc="@drawable/ic_heart"/> + + - - - - + - - - - - + + - + + - + + + + + + + - - - + + + + + + + + + + - + - - + + - - + - - - - - + - - - + + - + diff --git a/app/src/main/res/layout/fragment_sync_settings.xml b/app/src/main/res/layout/fragment_sync_settings.xml index 872f4bb1..0428ce88 100644 --- a/app/src/main/res/layout/fragment_sync_settings.xml +++ b/app/src/main/res/layout/fragment_sync_settings.xml @@ -1,92 +1,72 @@ - - + - - - - - - - + app:iconSrc="@drawable/ic_sync_settings"> + - - - - - - + - - - - - - - + + + + + + + + + + + + - - + - \ No newline at end of file + diff --git a/app/src/main/res/menu/editor_bottom.xml b/app/src/main/res/menu/editor_bottom.xml index 1ba6fa30..7e3188fd 100755 --- a/app/src/main/res/menu/editor_bottom.xml +++ b/app/src/main/res/menu/editor_bottom.xml @@ -26,18 +26,21 @@ android:title="@string/action_insert_quote" android:icon="@drawable/ic_quote" app:showAsAction="always"/> + - - - + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/menu/main_selected_notes.xml b/app/src/main/res/menu/main_selected_notes.xml index 022ee5ef..9afd2a65 100644 --- a/app/src/main/res/menu/main_selected_notes.xml +++ b/app/src/main/res/menu/main_selected_notes.xml @@ -5,6 +5,14 @@ android:id="@+id/action_pin_selected" android:title="@string/action_pin_unpin" app:showAsAction="never"/> + + + - + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index bbd3e021..04091bd1 100755 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index df038ca9..36deb4a6 100755 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -68,7 +68,7 @@ android:name="org.qosp.notes.ui.editor.EditorFragment" android:label="" android:layout="@layout/fragment_editor"> - + + + + الملاحظات + الأرشيف + المحذوفة + الإعدادات + حول + العلامات + جميع الملاحظات + دفاتر الملاحظات + دفاتر ملاحظاتك + + + عام + وضع المظهر + داكن + فاتح + اتبع النظام + وضع المظهر الداكن + قياسي + أسود + نظام الألوان + أزرق + وردي + أخضر + برتقالي + بنفسجي + أصفر + أحمر + اتبع النظام + + العرض + + دفتر الملاحظات + وضع التخطيط + شبكة + قائمة + ترتيب حسب + العنوان (تصاعدي) + العنوان (تنازلي) + تاريخ الإنشاء (تصاعدي) + تاريخ الإنشاء (تنازلي) + تاريخ التعديل (تصاعدي) + تاريخ التعديل (تنازلي) + + محتوى الملاحظة + حجم خط محرر النص/العرض + افتراضي + 10 + 15 + 20 + 25 + 30 + 35 + 40 + 45 + 50 + موضع زر تحرير/عرض الملاحظة + زر عائم + الشريط العلوي + إظهار تاريخ الإنشاء/التعديل + تنسيق التاريخ + تنسيق الوقت + + أخرى + فتح الوسائط في + مشغل داخلي + مشغل خارجي + تجميع الملاحظات غير الموجودة في أي دفتر + نقل العناصر المحددة (غير المحددة) في القوائم + حذف الملاحظات في سلة المهملات + فوراً + بعد 7 أيام + بعد 14 يوماً + بعد 30 يوماً + + النسخ الاحتياطي + إنشاء نسخة احتياطية + تصدير جميع الملاحظات إلى ملف نسخة احتياطية. + الاستعادة + تحميل الملاحظات من ملف نسخة احتياطية. + استراتيجية النسخ الاحتياطي للمرفقات + نسخ احتياطي لكل شيء + نسخ احتياطي للوصف والمسار المحلي فقط + عدم نسخ احتياطي للمرفقات + + المزامنة + الانتقال إلى إعدادات المزامنة + المزامنة حالياً مع %s + لا يتم المزامنة حالياً + خدمة المزامنة + معطلة + Nextcloud + تذكر أن Nextcloud Notes لا يدعم ميزات مثل العلامات والمرفقات والتذكيرات وأكثر. يجب تثبيت Nextcloud Notes على خادم Nextcloud وإلا فإن تسجيل الدخول سيفشل مع "حدث خطأ ما"! + حساب Nextcloud + رابط مثيل Nextcloud + تعيين بياناتك الشخصية + تعيين رابط الخادم + اختر مجلد من التخزين المحلي أو من مزود سحابي. سيتم حفظ جميع الملاحظات كملفات Markdown. + موقع التخزين + اختيار موقع التخزين + Wi-Fi + Wi-Fi أو البيانات + المزامنة عند + المزامنة في الخلفية + مُفعلة + مُعطلة + الملاحظات الجديدة قابلة للمزامنة + + الإصدار + الموقع الإلكتروني + المطور + المساهمة + الدعم + إنشاء وصيانة مشاريع مفتوحة المصدر يستغرق وقتاً طويلاً ولا يوفر أي ربح.\nاشتر للمطورين كوب قهوة! + المكتبات + عرض مكتبات الطرف الثالث والتراخيص. + + افتراضي + نعم + لا + موافق + ستظهر ملاحظاتك هنا. + بدون عنوان + اسم المستخدم + كلمة المرور + حفظ + إلغاء + حذف + تم! + حذف نهائياً + تحديد الكل + إنشاء قائمة + إظهار الملاحظات المخفية + إلغاء الأرشفة + تصدير + إخفاء + إظهار + أرشفة + وضع التحرير/العرض + تثبيت + إلغاء التثبيت + استعادة + مشاركة + تثبيت / إلغاء التثبيت + نقل إلى… + تكرار + تحديد المزيد… + معاينة مدمجة + معاينة كاملة + الشاشة مضاءة دائماً + + تحرير الوصف + وصف المرفق + تسجيل صوت + جاري التسجيل (%1$s) + إرفاق ملفات + التقاط صورة + مقطع مسجل + + أخرى + دفتر جديد + اسم الدفتر + بدون دفتر + ليس لديك أي دفاتر ملاحظات. + إنشاء دفتر + إعادة تسمية الدفتر + دفتر جديد + دفتر باسم %1$s موجود بالفعل. + + ستظهر ملاحظاتك المؤرشفة هنا. + + إفراغ سلة المهملات + هل أنت متأكد؟ + ستفقد جميع الملاحظات المحذوفة. + لا يمكن تحرير الملاحظات الموجودة في السلة. + ستظهر ملاحظاتك المحذوفة هنا لمدة %1$d أيام. + الملاحظات مُعدة للحذف فورياً.\nيمكنك تغيير هذا في الإعدادات. + الملاحظات مُعدة لعدم الحذف أبداً.\nيمكنك تغيير هذا في الإعدادات. + تم حذف الملاحظات نهائياً. + تم حذف الملاحظة نهائياً. + + التذكيرات + تذكير + اسم التذكير + تحديد التاريخ + تحديد الوقت + تذكير جديد + لديك تذكير. + لا يمكن تعيين تذكير لتاريخ سابق + + علامة جديدة + اسم العلامة + ليس لديك أي علامات. + إعادة تسمية العلامة + علامة باسم %1$s موجودة بالفعل. + + البحث… + بحث + ستُعرض نتائج البحث هنا. + لم يتم العثور على نتائج. + + العنوان + اكتب ملاحظة! + مهمة + تحويل إلى ملاحظة + تحويل إلى قائمة + عدم المزامنة + تغيير اللون + تفعيل markdown + تعطيل markdown + تفعيل الشاشة مضاءة دائماً + تعطيل الشاشة مضاءة دائماً + إدراج نص غامق markdown + إدراج نص مائل markdown + إدراج نص مشطوب markdown + إدراج نص مميز markdown + إدراج عنوان markdown + إدراج اقتباس markdown + إدراج كود markdown + إلغاء تحديد جميع العناصر + إزالة جميع المهام المحددة + أُنشئت في %1$s\nآخر تعديل في %2$s + الملاحظات المستعادة + الملاحظة المستعادة + الملاحظات المؤرشفة + الملاحظة المؤرشفة + تم نقل الملاحظات إلى السلة + تم نقل الملاحظة إلى السلة + تم تجاهل الملاحظة الفارغة + إدراج رابط + إدراج + إدراج جدول + عدد الصفوف والأعمدة غير صالح + إدراج صورة + الوصف + مسار الصورة + النص + الرابط + عدد الأعمدة + عدد الصفوف + لا يمكن معاينة الجدول. + + جاري إنشاء نسخة احتياطية من ملاحظاتك… + اكتمل النسخ الاحتياطي! + فشل النسخ الاحتياطي + جاري استعادة ملاحظاتك… + اكتملت الاستعادة + فشلت الاستعادة + + التذكيرات + النسخ الاحتياطية + تشغيل الوسائط + + هذا ليس رابط HTTPS صالح. + أنت مسجل حالياً باسم %s. + أنت غير مسجل حالياً. + المصادقة + حساب غير متصل + جاري الاتصال… + حدث خطأ ما. + تعذر المصادقة مع الخادم بسبب بيانات اعتماد غير صالحة. + تم تسجيل الدخول بنجاح! + إصدار الخادم غير متوافق مع هذا التطبيق. + اتصال الإنترنت غير متوفر. + لا يمكن أن تكون بيانات الاعتماد فارغة. + مسح بيانات الاعتماد ورابط الخادم + تشغيل / إيقاف مؤقت + إيقاف + + كتابة ملاحظة + إنشاء قائمة + أبداً + قائمة العلامات + ترتيب العلامات حسب + درج التنقل + ترتيب دفاتر الملاحظات حسب + متابعة + + +%d عناصر + +%d عنصر + +%d عنصران + +%d عناصر + +%d عنصراً + +%d عنصر + + + تم تحديد %d ملاحظات + تم تحديد %d ملاحظة + تم تحديد %d ملاحظتان + تم تحديد %d ملاحظات + تم تحديد %d ملاحظة + تم تحديد %d ملاحظة + + + تم تحديد %d دفاتر + تم تحديد %d دفتر + تم تحديد %d دفتران + تم تحديد %d دفاتر + تم تحديد %d دفتراً + تم تحديد %d دفتر + + + تم تحديد %d علامات + تم تحديد %d علامة + تم تحديد %d علامتان + تم تحديد %d علامات + تم تحديد %d علامة + تم تحديد %d علامة + + تخزين الملفات + diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml new file mode 100644 index 00000000..074db877 --- /dev/null +++ b/app/src/main/res/values-ca/strings.xml @@ -0,0 +1,272 @@ + + + + Notes + Arxiu + Eliminades + Configuració + Quant a + Etiquetes + Totes les notes + Blocs de notes + Els teus blocs de notes + + General + Mode del tema + Fosc + Clar + Segueix el sistema + Mode del tema fosc + Estàndard + Negre + Visualització + Mode de disseny + Graella + Llista + Esquema de color + Blau + Rosa + Verd + Taronja + Lila + Groc + Vermell + Ordena per + Títol (ascendent) + Títol (descendent) + Data de creació (ascendent) + Data de creació (descendent) + Data de modificació (ascendent) + Data de modificació (descendent) + Mostra la data de creació/modificació + Posició del botó Edita/Mostra nota + Botó flotant + Barra superior + Format de data + Format d\'hora + Altres + Agrupa les notes que no estan en cap bloc de notes + Mou els elements (des)marcats a les llistes + Obre el contingut multimèdia a + Reproductor intern + Reproductor extern + Suprimeix les notes de la paperera + Immediatament + Després de 7 dies + Després de 14 dies + Després de 30 dies + Còpia de seguretat + Crea una còpia de seguretat + Exporta totes les notes a un fitxer de còpia de seguretat. + Restaura + Carrega notes des d\'un fitxer de còpia de seguretat. + Estratègia de còpia de seguretat per adjunts + Fes còpia de seguretat de tot + Només fes còpia de la descripció i el camí local + No facis còpia dels adjunts + Sincronització + Vés a la configuració de sincronització + S\'està sincronitzant amb %s + Actualment no es sincronitza + Servei de sincronització + Desactivat + Nextcloud + Tingueu en compte que Notes del Nextcloud no admet funcions com etiquetes, adjunts, recordatoris, etc. + Compte de Nextcloud + URL de la instància Nextcloud + Establiu les credencials + Establiu l\'URL del servidor + Wi-Fi + Wi-Fi o dades + Activa la sincronització quan estigui actiu + Sincronització en segon pla + Activada + Desactivada + Noves notes sincronitzables + + Versió + Lloc web + Desenvolupador + Col·labora + Suport + Crear i mantenir projectes de codi obert requereix temps i no proporciona cap guany.\nConvideu els desenvolupadors a una cervesa! + Biblioteques + Visualitza les llicències i biblioteques de tercers. + + Per defecte + + No + D\'acord + Les notes apareixeran aquí. + Sense títol + Nom d\'usuari + Contrasenya + Desa + Cancel·la + Elimina + Fet! + Elimina permanentment + Seleccionciona-ho tot + Crea una llista + Mostra notes ocultes + Desarxiva + Exporta + Amaga + Mostra + Arxiva + Mode Edició/Visualització + Fixa + Desfixa + Restaura + Comparteix + Fixa/Desfixa + Mou a… + Duplica + Selecciona més… + Vista preliminar compacta + Vista preliminar completa + + Edita la descripció + Descripció de l\'adjunt + Grava àudio + S\'està gravant (%1$s) + Adjunta fitxers + Fes una foto + Clip gravat + + Altres + Bloc de notes nou + Nom del bloc de notes + Sense bloc de notes + No teniu cap bloc de notes. + Crea un bloc de notes + Canvia el nom del bloc de notes + Bloc de notes nou + Ja existeix un bloc de notes amb el nom %1$s. + + Les notes arxivades apareixeran aquí. + + Buida la paperera + Esteu segur? + Es perdran totes les notes eliminades. + Les notes a la paperera no es poden editar. + Les notes eliminades es mostraran aquí durant els propers %1$d dies. + Les notes estan programades per a eliminar-se immediatament.\nPodeu canviar això a la configuració. + Les notes estan programades per a no eliminar-se mai.\nPodeu canviar això a la configuració. + Notes eliminades permanentment. + Nota eliminada permanentment. + + Recordatoris + Recordatori + Nom del recordatori + Estableix una data + Estableix una hora + Recordatori nou + Teniu un recordatori. + No podeu establir un recordatori en una data passada + + Nova etiqueta + Nom de l\'etiqueta + No teniu etiquetes. + Canvia el nom de l\'etiqueta + Ja existeix una etiqueta amb el nom %1$s. + + Cerca… + Cerca + Els resultats de la cerca es mostren aquí. + No s\'han trobat resultats. + + Títol + Preneu nota + Tasca + Converteix en nota + Converteix en llista + No sincronitzis + Canvia el color + Activa Markdown + Desactiva Markdown + Insereix negreta (Markdown) + Insereix cursiva (Markdown) + Insereix ratllat (Markdown) + Insereix ressaltat (Markdown) + Insereix capçalera (Markdown) + Insereix cita (Markdown) + Insereix codi (Markdown) + Elimina totes les tasques marcades + Creada el %1$s\nModificada per última vegada el %2$s + Notes restaurades + Nota restaurada + Notes arxivades + Nota arxivada + Notes mogudes a la paperera + Nota moguda a la paperera + Note buida descartada + Insereix un enllaç + Insereix + Insereix una taula + El nombre de files i columnes no és vàlid + Insereix una imatge + Descripció + Camí de la imatge + Text + URL + Nombre de columnes + Nombre de files + No es pot previsualitzar la taula. + + Fent una còpia de seguretat de les notes… + Còpia de seguretat completada! + Ha fallat la còpia de seguretat + Restaurant les teves notes… + Restauració completada + Ha fallat la restauració + + Recordatoris + Còpies de seguretat + Reproducció multimèdia + + Això no és una adreça URL HTTPS vàlida. + Heu iniciat sessió com a %s. + No heu iniciat sessió. + Autentica + Compte fora de línia + Connectant… + Alguna cosa ha anat malament. + No s\'ha pogut autenticar amb el servidor a causa de credencials no vàlides. + Sessió iniciada correctament. + La versió del servidor no és compatible amb aquesta aplicació. + No hi ha connexió a internet. + Les credencials no poden estar buides. + Esborra les credencials i l\'URL del servidor + Reprodueix/Pausa + Atura + + Creeu una nota + Fes una llista + Mai + + +%d element + + +%d elements + + + %d nota seleccionada + + %d notes seleccionades + + + %d bloc de notes seleccionat + + %d blocs de notes seleccionats + + + %d etiqueta seleccionada + + %d etiquetes seleccionades + + Ordena els quaderns per + Continua + Llista d\'etiquetes + Ordena les etiquetes per + Calaix de navegació + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml new file mode 100644 index 00000000..d8128334 --- /dev/null +++ b/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,284 @@ + + + + Poznámky + Archiv + Smazané + Nastavení + O aplikaci + Štítky + Všechny poznámky + Zápisníky + Vaše Zápisníky + + + Obecné + Barevný režim + Tmavý + Světlý + Podle systému + Temný režim + Standardní + Černý + Barvy aplikace + Modré + Růžové + Zelené + Oranžové + Fialové + Žluté + Červené + Podle systému + + Náhled + + Zápisník + Rozložení + Mřížka + Seznam + Seřadit podle + Název (vzestupný) + Název (sestupný) + Datum vytvoření (vzestupný) + Datum vytvoření (sestupný) + Datum změny (vzestupný) + Datum změny (sestupný) + + Obsah poznámky + Velikost fontu v editoru a náhledu + Výchozí + Poloha tlačítka Editovat/Zobrazit + Plovoucí tlačítko + Horní panel + Zobrazit datum vytvoření a změny + Formát data + Formát času + + Ostatní + Otevření médií + Interní přehrávač + Externí přehrávač + Seskupit poznámky, které nejsou v zápisníku + Posunout (ne)odškrtnuté řádky v kontrolním seznamu + Vymazání poznámek z koše + Okamžitě + Po 7 dnech + Po 14 dnech + Po 30 dnech + + Záloha + Vytvořit zálohu + Exportovat poznámky do souboru. + Obnovit + Obnovit poznámky ze zálohy. + Jak zálohovat přílohy + Zálohovat vše + Zálohovat pouze popisek a lokální cestu + Nezálohovat přílohy + + Synchronizace + Nastavení synchronizace + Aktuálně synchronizováno s %s + Aktuálně není synchronizováno + Služba pro synchronizaci + Vypnuto + Nextcloud + Nezapomeňte, že Nextcloud Notes nepodporuje funkce jako štítky, přílohy, připomínky a další. + Účet Nextcloud + Odkaz na Nextcloud + Zadej své přihlašovací údaje. + Zadej URL svého Nextcloud serveru. + Wi-Fi + Wi-Fi nebo Data + Synchronizace přes + Synchronizace na pozadí + Povoleno + Zakázáno + Synchronizovat nové poznámky + + Verze + Web + Vývojář + Přispět + Podpora + Vývoj a udržování open source projektu je časově náročná a bez finančního zisku.\nKup vývojářům pivo! + Knihovny + Zobrazit knihovny třetích stran a licence. + + Výchozí + Ano + Ne + Budiž + Vaše poznámky se zobrazí zde. + Bez názvu + Uživatelské jméno + Heslo + Uložit + Zrušit + Smazat + Hotovo! + Smazat navždy + Označit vše + Vytvořit seznam + Zobrazit skryté poznámky + Vrátit z archivu + Exportovat + Skrýt + Zobrazit + Archivovat + Upravit / Zobrazit + Připnout + Odepnout + Obnovit + Sdílet + Připnout / Odepnout + Přesunout do… + Duplikovat + Vybrat další… + Kompaktní náhled + Plný náhled + + Upravit popisek + Popisek přílohy + Nahrát zvuk + Nahrávka (%1$s) + Přiložit soubory + Vyfotit + Zvukové nahrávky + + Ostatní + Nový zápisník + Jméno zápisníku + Žádný zápisník + Nemáte žádný zápisník. + Vytvořit zápisník + Přejmenovat zápisník + Nový zápisník + Zápisník %1$s již existuje. + + Zde se zobrazí vaše archivované poznámky. + + Vysypat koš + Jste si jisti? + Všechny smazané poznámky budou ztraceny. + Poznámky v koši nemohou být upravovány. + Vaše smazané poznámky zde budou po dobu %1$d dní. + Poznámky budou okamžitě smazány.\nToto chování změníte v Nastavení. + Poznámky se nikdy nesmažou.\nToto chování změníte v Nastavení. + Poznámky navždy smazány.. + Poznámka navždy smazána. + + Připomenutí + Připomenutí + Název připomenutí + Nastavit datum + Nastavit čas + Nové připomenutí + Máte připomenutí. + Nelze nastavit připomenutí v minulosti. + + Nový štítek + Název štítku + Nemáte žádné štítky. + Přejmenovat štítek + Štítek s názvem %1$s již existuje. + + Vyhledat… + Vyhledat + Zde najdete výsledky hledání. + Žádné výsledky. + + Název + Poznamenej si! + Úkol + Převézt na poznámku + Převézt na seznam + Nesynchronizovat + Změnit barvu + Povolit markdown + Zakázat markdown + Tučně + Kurzíva + Přeškrtnutí + Zvýraznění + Nadpis + Uvozovky + Zdrojový kód + Odstranit vybrané úkoly + Vytvořeno %1$s\nNaposledy upraveno %2$s + Obnovené poznámky + Obnovená poznámka + Poznámky archivovány + Poznámka archivována + Poznámky přesunuty do koše + Poznámka přesunuta do koše + Prázdná poznámka odhozena + Vložit odkaz + Vložit + Vložit tabulku + Neplatný počet řádků a sloupců + Vložit obrázek + Popisek + Cesta k obrázku + Text + URL + Počet sloupců + Počet řádek + Nelze zobrazit náhled tabulky. + + Zálohování vašich poznámek… + Zálohováno! + Záloha selhala + Obnovování vašich poznámek… + Obnoveno! + Obnovování selhalo + + Připomenutí + Zálohy + Přehrávání medií + + Tohle není platná HTTPS adresa. + Jste přihlášeni jako %s. + Nejste přihlášeni. + Ověřit se + Offline účet + Připojování… + Něco se nepovedlo. + Nelze se ověřit u serveru z důvodu chybných přihlašovacích údajů. + Přihlášení úspěšné! + Verze serveru není kompatibilní s touto aplikací. + Připojení k internetu není k dispozici. + Přihlašovací údaje musí být vyplněné. + Vymazat přihlašovací údaje a URL + Přehrát / Pozastavit + Zastavit + + Poznamenej si + Vytvoř seznam + Nikdy + + +%d položka + +%d položky + + + + Označena %d poznámka + Označeny %d poznámky + + + + Označen %d zápisník + Označeny %d zápisníky + + + + Označen %d štítek + Označeny %d štítky + + + Pokračovat + Tagy + Řadit tagy podle + Zápisníky + Řadit zápisníky podle + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 00000000..f58beccb --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,295 @@ + + + + Notizen + Archiv + Papierkorb + Einstellungen + Über + Tags + Alle Notizen + Notizbücher + Deine Notizbücher + + + Allgemein + Darstellungsmodus + Dunkel + Hell + Systemeinstellung + Dunkles Design + Standard + Schwarz + Farbschema + Blau + Rosa + Grün + Orange + Violett + Gelb + Rot + Systemeinstellung + + Ansicht + + Notizbuch + Layoutmodus + Raster + Liste + Sortieren nach + Titel (aufsteigend) + Titel (absteigend) + Erstelldatum (aufsteigend) + Erstelldatum (absteigend) + Änderungsdatum (aufsteigend) + Änderungsdatum (absteigend) + + Notizinhalt + Schriftgröße im Editor/bei Anzeige + Standard + Position des Bearbeiten/Anzeigen-Buttons + Schwebender Button + Obere Leiste + Erstellungs-/Änderungsdatum anzeigen + Datumsformat + Zeitformat + + Sonstiges + Medien öffnen in + Interner Player + Externer Player + Notizbuch für nicht zugewiesene Notizen erstellen + Erledigte und unerledigte Elemente in Listen automatisch sortieren + Notizen im Papierkorb löschen + Sofort + Nach 7 Tagen + Nach 14 Tagen + Nach 30 Tagen + + Sicherung + Sicherung erstellen + Alle Notizen als Sicherungsdatei exportieren. + Wiederherstellen + Notizen aus Sicherungsdatei importieren. + Sicherungsstrategie für Anhänge + Alles sichern + Nur Beschreibung und lokalen Pfad sichern + Anhänge nicht sichern + + Synchronisierung + Zu den Synchronisierungseinstellungen + Synchronisiere gerade mit %s + Keine Synchronisierung aktiv + Synchronisierungsdienst + Deaktiviert + Nextcloud + Aktuell unterstützt Nextcloud Notes keine Tags, Anhänge, Erinnerungen und weitere Funktionen. Nextcloud Notes muss auf dem Nextcloud Server installiert sein, sonnst scheitert die Anmeldung mit Es ist ein Problem aufgetreten.! + Nextcloud-Konto + URL der Nextcloud-Instanz + Zugangsdaten festlegen + Server-URL festlegen + WLAN + WLAN oder mobile Daten + Synchronisierung bei + Hintergrundsynchronisierung + Aktiviert + Deaktiviert + Neue Notizen synchronisieren + + Version + Webseite + Entwickler + Mitwirken + Unterstützen + Die Entwicklung und Pflege von Open-Source-Projekten ist zeitaufwendig und wird nicht vergütet.\nSpendiere den Entwickler:innen doch einen Kaffee! + Bibliotheken + Bibliotheken und Lizenzen Dritter ansehen. + + Standard + Ja + Nein + OK + Deine Notizen werden hier angezeigt. + Unbenannt + Benutzername + Passwort + Speichern + Abbrechen + Löschen + Fertig! + Endgültig löschen + Alle auswählen + Liste erstellen + Ausgeblendete Notizen anzeigen + Wiederherstellen + Exportieren + Ausblenden + Anzeigen + Archivieren + Bearbeiten/Ansichtsmodus + Anheften + Loslösen + Wiederherstellen + Teilen + Anheften / Loslösen + Verschieben nach… + Duplizieren + Mehr auswählen… + Kompakte Vorschau + Vollständige Vorschau + Bildschirm aktiv halten + + Beschreibung bearbeiten + Beschreibung des Anhangs + Audio aufnehmen + Aufnahme läuft (%1$s) + Dateien anhängen + Foto aufnehmen + Audioaufnahme + + Sonstige + Neues Notizbuch + Name des Notizbuches + Kein Notizbuch + Du hast keine Notizbücher. + Notizbuch erstellen + Notizbuch umbenennen + Neues Notizbuch + Ein Notizbuch mit dem Namen %1$s existiert bereits. + + Deine archivierten Notizen werden hier angezeigt. + + Papierkorb leeren + Bist du sicher? + Alle gelöschten Notizen werden endgültig gelöscht. + Notizen im Papierkorb können nicht bearbeitet werden. + Deine gelöschten Notizen werden hier für %1$d Tage angezeigt. + Notizen werden sofort gelöscht.\nDu kannst diese Einstellung ändern. + Notizen werden nie automatisch gelöscht.\nDu kannst diese Einstellung ändern. + Notizen endgültig gelöscht. + Notiz endgültig gelöscht. + + Erinnerungen + Erinnerung + Name der Erinnerung + Datum festlegen + Zeit festlegen + Neue Erinnerung + Du hast eine Erinnerung. + Erinnerung kann nicht für ein Datum in der Vergangenheit erstellt werden + + Neuer Tag + Name des Tags + Du hast keine Tags. + Tag umbenennen + Ein Tag mit dem Namen %1$s existiert bereits. + + Suchen… + Suchen + Suchergebnisse werden hier angezeigt. + Keine Ergebnisse gefunden. + + Titel + Notiere etwas! + Aufgabe + In Notiz umwandeln + In Liste umwandeln + Nicht synchronisieren + Farbe ändern + Markdown aktivieren + Markdown deaktivieren + Bildschirm aktiv halten + Bildschirm nicht aktiv halten + Fett formatieren + Kursiv formatieren + Durchgestrichen formatieren + Hervorhebung einfügen + Überschrift einfügen + Zitat einfügen + Code einfügen + Alle Elemente abwählen + Alle markierten Aufgaben entfernen + Erstellt am %1$s\nZuletzt bearbeitet am %2$s + Wiederhergestellte Notizen + Wiederhergestellte Notiz + Archivierte Notizen + Archivierte Notiz + Notizen in den Papierkorb verschoben + Notiz in den Papierkorb verschoben + Leere Notiz verworfen + Link einfügen + Einfügen + Tabelle einfügen + Ungültige Anzahl an Zeilen und Spalten + Bild einfügen + Beschreibung + Bildpfad + Text + URL + Anzahl der Spalten + Anzahl der Zeilen + Tabellenvorschau nicht möglich. + + Notizen werden gesichert… + Sicherung erfolgreich! + Sicherung fehlgeschlagen + Notizen werden wiederhergestellt… + Wiederherstellung erfolgreich + Wiederherstellung fehlgeschlagen + + Erinnerungen + Sicherungen + Medienwiedergabe + + Dies ist keine gültige HTTPS-URL. + Du bist aktuell als %s angemeldet. + Du bist aktuell nicht angemeldet. + Authentifizieren + Offline-Konto + Verbinde… + Etwas ist schiefgelaufen. + Authentifizierung mit dem Server ist aufgrund falscher Zugangsdaten fehlgschlagen. + Erfolgreich angemeldet! + Die Server-Version ist nicht mit dieser App kompatibel. + Keine Internetverbindung verfügbar. + Zugangsdaten dürfen nicht leer sein. + Zugangsdaten und Server-URL löschen + Abspielen / Pause + Stopp + + Notiz erstellen + Liste erstellen + Nie + Tag-Liste + Tags sortieren nach + Navigationsleiste + Notizbücher sortieren nach + Weiter + + +%d Element + +%d Elemente + + + %d Notiz ausgewählt + %d Notizen ausgewählt + + + %d Notizbuch ausgewählt + %d Notizbücher ausgewählt + + + %d Tag ausgewählt + %d Tags ausgewählt + + Dateispeicher + Wählen Sie einen Ordner aus dem lokalen Speicher oder von einem + Cloud-Anbieter aus. Alle Notizen werden als Markdown-Dateien gespeichert. + + Speicherort + Speicherort auswählen + Selbst-signierten Zertifikaten vertrauen + Zertifikatsfehler. Bitte aktivieren Sie \"Selbst-signierten Zertifikaten vertrauen\", um fortzufahren. + Die Notes App ist auf dem Nextcloud-Server nicht installiert. + Fehlerprotokolle senden + Anwendungsprotokolle zur Analyse an die Entwickler:innen senden + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 48a65c9d..80985792 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -62,7 +62,6 @@ Απενεργοποιημένη Nextcloud Εξαγωγή των σημειώσεων σε ένα αρχείο. - Η λειτουργία συχγρονισμού βρίσκεται ακόμα σε πειραματικό στάδιο. \nΕνδέχεται να αντιμετωπίσετε προβλήματα. Το Nextcloud Notes δεν υποστηρίζει λειτουργίες όπως ετικέτες, συνημμένα, υπενθυμίσεις, λίστες και άλλα. Λογαριασμός Nextcloud URL διακομιστή Nextcloud @@ -80,8 +79,7 @@ Προγραμματιστής Συνεισφέρετε Υποστηρίξτε - Η δημιουργία και η συντήρηση προγραμμάτων ανοιχτού κώδικα (ΕΛΛΑΚ) είναι χρονοβόρα και δεν αποφέρει κάποιο κέρδος. - \nΚεράστε τους προγραμματιστές μια μπύρα! + Η δημιουργία και η συντήρηση προγραμμάτων ανοιχτού κώδικα (ΕΛΛΑΚ) είναι χρονοβόρα και δεν αποφέρει κάποιο κέρδος. \nΚεράστε τους προγραμματιστές μια μπύρα! Βιβλιοθήκες Προβολή βιβλιοθηκών και των αδειών τους. Ναι @@ -135,6 +133,7 @@ Οι σημειώσεις στον κάδο μπορούν μόνο να προβληθούν. Οι διαγραμμένες σας σημειώσεις θα εμφανίζονται εδώ για %1$d μέρες. Έχετε επιλέξει οι σημειώσεις να διαγράφονται οριστικά.\nΜπορείτε να το αλλάξετε αυτό στις ρυθμίσεις. + Οι σημειώσεις έχουν ρυθμιστεί να μην διαγράφονται ποτέ.\nΜπορείτε να το αλλάξετε στις Ρυθμίσεις. Σημειώσεις διαγράφηκαν οριστικά. Η σημείωση διαγράφηκε οριστικά. Υπενθυμίσεις @@ -166,9 +165,11 @@ Εισαγωγή έντονου markdown Εισαγωγή italics markdown Εισαγωγή strikethrough markdown + Εισαγωγή markdown επισήμανσης Εισαγωγή markdown επικεφαλίδας Εισαγωγή markdown παράθεσης Εισαγωγή markdown κώδικα + Καταργήστε όλες τις επιλεγμένες εργασίες Δημιουργήθηκε στις %1$s\nΤροποποιήθηκε στις %2$s Ανακτήθηκαν σημειώσεις Ανακτήθηκε σημείωση @@ -219,24 +220,35 @@ Απόχρωση σκούρου θέματος Κανονική Μάυρη - + Ποτέ + Συμπαγής προεπισκόπηση + Πλήρης προεπισκόπηση +%d στοιχείο +%d στοιχεία - Επιλέχθηκε %d σημείωση Επιλέχθηκαν %d σημειώσεις - Επιλέχθηκε %d τετράδιο Επιλέχθηκαν %d τετράδια - Επιλέχθηκε %d ετικέτα Επιλέχθηκαν %d ετικέτες - \ No newline at end of file + Αποθήκευση αρχείων + Επιλέξτε έναν φάκελο από τον τοπικό χώρο αποθήκευσης ή από έναν πάροχο + cloud. Όλες οι σημειώσεις θα αποθηκευτούν ως αρχεία Markdown. + + Τοποθεσία αποθήκευσης + Επιλέξτε Θέση αποθήκευσης + Ρύθμιση συστήματος + Συνεχίζω + Λίστα ετικετών + Ταξινόμηση ετικετών κατά + Συρτάρι πλοήγησης + Ταξινόμηση σημειωματάρια κατά + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index eb8e80c4..ecbcd0bc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,6 +1,5 @@ - Notas Archivo @@ -11,7 +10,6 @@ Todas las notas Libretas Tus libretas - General Tema @@ -66,7 +64,6 @@ Servicio de sincronización Deshabilitado Nextcloud - Por el momento la sincronización es una característica experimental.\nPodrían aparecer errores. Recuerda que las Nextcloud Notes no soporta etiquetas, adjuntos, recordatorios, listas de tareas y más. Cuenta Nextcloud URL de la instancia Nextcloud @@ -79,7 +76,9 @@ Habilitada Deshabilitada Sincronizar notas nuevas - + Modo de tema oscuro + Estándar + Negro Versión Sitio web @@ -89,7 +88,6 @@ Crear y mantener proyectos de código abierto consume tiempo y no está retribuido.\n¡Invita a los desarrolladores a una cerveza! Librerías Ver librerías y licencias de terceros. - Por defecto @@ -120,7 +118,8 @@ Mover a… Duplicar Seleccionar más… - + Vista previa compacta + Vista previa completa Editar descripción Descripción del adjunto @@ -129,7 +128,6 @@ Adjuntar archivos Hacer una foto Grabación de audio - Otro Nueva libreta @@ -140,10 +138,8 @@ Renombrar libreta Nueva libreta Ya existe una libreta con el nombre %1$s. - Las notas archivadas se mostrarán aquí. - Vaciar papelera ¿Estás seguro? @@ -151,9 +147,9 @@ Las notas de la papelera no se pueden editar. Las notas eliminadas se mostrarán aquí durante %1$d días. Las notas se eliminarán al instante.\nPuedes cambiar esto en los ajustes. + Las notas están configuradas para que nunca se eliminen.\nPuede cambiar esto en Configuración. Notas eliminadas permanentemente. Nota eliminada permanentemente. - Recordatorios Recordatorio @@ -163,20 +159,17 @@ Nuevo recordatorio Tienes un recordatorio. No se puede crear un recordatorio para una fecha pasada - Nueva etiqueta Nombre de etiqueta No hay etiquetas. Renombrar etiqueta Ya existe una etiqueta con el nombre %1$s. - Buscar… Buscar Los resultados de la búsqueda se mostrarán aquí. No se encontraron resultados. - Título ¡Tomar nota! @@ -190,9 +183,11 @@ Insertar formato negrita Insertar formato cursiva Insertar formato tachado + Insertar descuento destacado Insertar formato cabecera Insertar formato de cita Insertar formato de código + Eliminar todas las tareas marcadas Creada el %1$s\nModificadas el %2$s Notas restauradas Nota restaurada @@ -213,7 +208,6 @@ Número de columnas Número de filas Cannot preview table. - Creando copia de seguridad de tus notas… ¡Copia de seguridad completada! @@ -221,12 +215,10 @@ Restaurando notas… Restauración completa Restauración fallida - Recordatorios Copias de seguridad Reproducción multimedia - Está no es una URL HTTPS válida. Sesión iniciada como %s. @@ -243,25 +235,41 @@ Limpiar credenciales y URL del servidor Reproducir / Pausar Detener - Tomar nota Crear lista + Nunca +%d elemento + +%d elementos %d nota seleccionada + %d notas seleccionadas %d libreta seleccionada + %d libretas seleccionadas %d etiqueta seleccionada + %d etiquetas seleccionadas - + Almacenamiento de archivos + Seleccione una carpeta del almacenamiento local o de un proveedor de nube. + Todas las notas se almacenarán como archivos Markdown. + + Ubicación de almacenamiento + Seleccione la ubicación de almacenamiento + Del sistema + Continuar + Lista de etiquetas + Ordenar etiquetas por + Panel de navegación + Ordenar cuadernos por + Desmarcar todos los elementos diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml new file mode 100644 index 00000000..f43e16d6 --- /dev/null +++ b/app/src/main/res/values-fa/strings.xml @@ -0,0 +1,16 @@ + + + بایگانی + یادداشت‌ها + فروسِتُردگان + پیکربندی + درباره + همه یادداشت‌ها + دفترچه‌ها + دفترچه‌های شما + کلی + زرد + قرمز + دفترچه + سیستم + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ce6f167e..1183600b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,3 +1,4 @@ + Notes @@ -9,19 +10,16 @@ Toutes les notes Carnets Vos carnets - Général Thème Sombre Clair Thème du système - Affichage Mise en page Grille Liste - Palette de couleurs Bleu Rose @@ -30,7 +28,6 @@ Violet Jaune Rouge - Trier par Titre (croissant) Titre (décroissant) @@ -38,13 +35,15 @@ Date de création (décroissant) Date de modification (croissant) Date de modification (décroissant) - Afficher la date de création et modification Format de la date Format de l\'heure - + Modifiez / Affichez la position du bouton de note + Bouton flottant + Barre supérieure Autre Grouper les notes qui ne sont pas dans un carnet + Déplacer les tâches (dé)cochées dans les listes Ouvrir les médias dans Lecteur interne Lecteur externe @@ -53,7 +52,6 @@ Après 7 jours Après 14 jours Après 30 jours - Sauvegarde Créer une sauvegarde Exporter toutes les notes dans un fichier de sauvegarde. @@ -63,7 +61,6 @@ Tout sauvegarder Sauvegarder la description et le chemin d\'accès local Ne pas sauvegarder les pièces jointes - Synchronisation Paramètres de synchronisation Synchronisation avec %s @@ -71,7 +68,6 @@ Service de synchronisation Désactivé Nextcloud - La synchronisation est actuellement expérimentale et pourrait contenir des bugs. Sachez que Nextcloud Notes ne supporte pas les étiquettes, pièces jointes, rappels, listes de tâches et d\'autres fonctionnalités encore. Compte Nextcloud Adresse du serveur Nextcloud @@ -84,19 +80,18 @@ Activé Désactivé Synchroniser les nouvelles notes - - + Mode thème sombre + Standard + Noire Version Site web Développeur Contribuer Soutenir - "Créer et maintenir des projets open-source prend du temps et ne fait pas de bénéfice. Achetez une bière aux développeurs! " + Créer et maintenir des projets open-source prend du temps et ne fait pas de bénéfice. Offrez un café aux développeurs ! Bibliothèques Voir les bibliothèques utilisées et leurs licences. - - Par défaut Oui @@ -126,19 +121,17 @@ Épingler / Désépingler Déplacer vers… Dupliquer - Sélectionner plus - - + Sélectionner plus… + Aperçu compact + Aperçu complet Modifier la description Description de la pièce jointe - Enregistrer de l\'audio + Enregistrer un audio Enregistrement en cours (%1$s) Joindre des fichiers Prendre une photo Son enregistré - - Autre Nouveau carnet @@ -149,52 +142,41 @@ Renommer le carnet Nouveau carnet Un carnet nommé %1$s existe déja. - - Vos notes archivées apparaîtront ici. - - Vider la corbeille - Êtes-vous sûr? + Êtes-vous sûr ? Toutes les notes supprimées vont être perdues. Les notes dans la corbeille ne peuvent être modifiées. Vos notes supprimées apparaîtront ici pendant %1$d jours. Les notes sont configurées pour être immédiatement supprimées.\nVous pouvez changer cela dans les Paramètres.. + Les notes sont configurées pour ne jamais être supprimées.\nVous pouvez modifier cela dans Paramètres. Les notes ont été définitivement supprimées. - La note a été définitvement supprimée. - - + La note a été définitivement supprimée. Rappels Rappel Nom du rappel Définir la date Définir l\'heure - Nouveau reappel + Nouveau rappel Vous avez un rappel. Impossible d\'utiliser une date antérieure pour un rappel - - Nouvelle étiquette Nom de l\'étiquette Vous n\'avez pas créé d\'étiquettes. Renommer l\'étiquette Une étiquette nommée %1$s existe déja. - - Recherche… Rechercher Les résultats de la recherche apparaîtront ici. Pas de résultats. - - Titre - Prenez une note! + Prenez une note ! Tâche Convertir en note Convertir en liste @@ -205,9 +187,11 @@ Insérer le markdown de mise en gras Insérer le markdown de mise en italique Insérer le markdown pour barrer le texte + Insérer le markdown en surbrillance Insérer le markdown d\'en-tête Insérer le markdown de mise en citation Insérer le markdown de formatage de code + Supprimer toutes les tâches cochées Création: %1$s\nDernière modification: %2$s Notes restaurées Note restaurée @@ -228,21 +212,17 @@ Nombre de colonnes Nombre de lignes Impossible de prévisualiser le tableau. - Sauvegarde des notes… - Sauvegarde terminée! + Sauvegarde terminée ! Sauvegarde échouée Restauration des notes… - Restauration terminée! + Restauration terminée Restauration échouée - - Rappels Sauvegardes Lecture de médias - Ceci n\'est pas une URL HTTPS valide. Connecté en tant que %s. @@ -252,35 +232,61 @@ Connexion… Il y a eu un problème. Impossible de s\'authentifier : les identifiants sont invalides. - Connexion réussie! + Connexion réussie ! La version du serveur n\'est pas compatible avec cette application. Aucune connexion internet disponible. Les identifiants ne peuvent pas être vides. Effacer les identifiants et l\'adresse Lecture / Pause Arrêter - Prendre une note Créer une liste - + Jamais +%d élément + +%d éléments - %d note sélectionnée + %d notes sélectionnées - %d carnet sélectionné + %d carnets sélectionnés - %d étiquette sélectionnée + %d étiquettes sélectionnées - \ No newline at end of file + Stockage de fichiers + Sélectionnez un dossier à partir du stockage local ou d\'un fournisseur de + cloud. Toutes les notes seront stockées sous forme de fichiers Markdown. + + Emplacement de stockage + Sélectionnez l\'emplacement de stockage + Thème du système + Désactiver l\'écran toujours allumé + Activer l\'écran toujours allumé + Écran toujours allumé + Continuer + Liste des tags + Trier les tags par + Tiroir de navigation + Trier les carnets par + Par défaut + Contenu de note + Taille de la police (édition/lecture) + Décocher tous les éléments + Carnet + Mode Édition/Lecture + Faire confiance au certificat auto-signé + Erreur de certificat. Veuillez activer \"Faire confiance au certificat auto-signé\" pour continuer... + Envoyer les journaux d\'erreurs + Envoyer les journaux de l\'application au développeur pour enquête + L\'application Notes n\'est pas installée dans votre Nextcloud. + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9d04ddaa..afb91013 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,27 +1,25 @@ + Note Archivio Cestino Impostazioni - Info + Informazioni Etichette - Tutte le Note - Quaderni - I tuoi Quaderni - + Tutte le note + Taccuini + I tuoi taccuini Generali Tema Scuro Chiaro - Segui sistema - - Vista + Segui il sistema + Visualizzazione Disposizione Griglia - Lista - + Elenco Schema colore Blu Rosa @@ -30,7 +28,6 @@ Viola Giallo Rosso - Ordina per Titolo (ascendente) Titolo (discendente) @@ -38,135 +35,121 @@ Data di creazione (discendente) Ultima modifica (ascendente) Ultima modifica (discendente) - Mostra data di creazione/modifica Formato data Formato ora - Altro - Raggruppa note di nessun quaderno - Apri media in + Raggruppa le note che non appartengono ad alcun taccuino + Apri file multimediali in Lettore interno Lettore esterno - Cancella note nel cestino - Subito + Elimina le note nel cestino + Immediatamente Dopo 7 giorni Dopo 14 giorni Dopo 30 giorni - Backup Crea un backup - Esporta le note in un file di backup. + Esporta tutte le note in un file di backup. Ripristina - Carica note da un file di backup. + Carica le note da un file di backup. Metodo di backup per gli allegati - Tutto + Backup di tutto Solo descrizione e percorso locale - Nessuno - - Sincro - Imposta sincro - In sincro con %s - Nessuna sincro - Servizio sincro - Nessuno + Non eseguire il backup degli allegati + Sincronizzazione + Vai alle impostazioni di sincronizzazione + Attualmente sincronizzato con %s + Attualmente non sincronizzato + Servizio di sincronizzazione + Disabilitato Nextcloud - Funzione sincro in fase sperimentale.\nPossono esserci dei bachi. - Tieni presente che Nextcloud Notes non supporta funzioni come etichette, allegati, promemoria, compiti ed altro. - Profilo Nextcloud - Istanza URL Nextcloud - Imposta credenziali - Imposta URL del server + Tenere presente che Note di Nextcloud non supporta funzionaltà come etichette, allegati, promemoria, compiti e altro. Note di Nextcloud necessita di una installazione sul server Nextcloud altrimenti l\'accesso non potrà essere eseguito con il messaggio \"Qualcosa non ha funzionato\". + Account Nextcloud + URL dell\'istanza Nextcloud + Impostazione credenziali + Impostazione URL del server Wi-Fi Wi-Fi o rete cellulare - Sincro tramite - Sincro in background - Attivato - Disattivato - Sincro per nuove note - - + Sincronizzazione tramite + Sincronizzazione in background + Abilitata + Disabilitata + Nuove note sincronizzabili + Modalità tema scuro + Standard + Nero Versione - Sito + Sito web Sviluppatore - Contribuisci - Supporta - Creare e mantenere progetti open-source richiede tempo e non offre profitto.\nOffri una birra agli sviluppatori! - + Contribuire + Supporto + Creare e mantenere progetti open-source richiede tempo e non genera profitti.\nOffri un caffè agli sviluppatori! Librerie - Vedi librerie e licenze di terze parti. - - + Visualizza le librerie di terze parti e le relative licenze. Predefinito - Si + No - Ok + OK Le tue note appariranno qui. - Senza nome + Senza titolo Nome utente - Chiave + Password Salva Annulla - Cestina - Fatto! - Cancella - Seleziona Tutto - Crea una lista + Elimina + Fatto + Elimina definitivamente + Seleziona tutto + Crea un elenco Mostra note nascoste - Disarchivia + Rimuovi dall\'archivio Esporta Nascondi Mostra Archivia Fissa - Rilascia - Recupera + Stacca + Rispristina Condividi - Fissa / Rilascia - Muovi… + Fissa / Stacca + Sposta in… Duplica - Seleziona… - - + Seleziona altro… + Anteprima compatta + Anteprima completa Modifica descrizione Descrizione allegato Registra audio - Registrando (%1$s) + Registrazione (%1$s) Allega file - Fai una foto - Registrazione - - + Scatta una foto + Filmato registrato Altro - Nuovo quaderno - Nome quaderno - Nessun Quaderno - Non hai quaderni. - Crea un quaderno - Rinomina quaderno - Nuovo quaderno - Quaderno di nome %1$s esistente. - - + Nuovo taccuino + Nome taccuino + Nessun taccuino + Non ci sono taccuini. + Crea un taccuino + Rinomina taccuino + Nuovo taccuino + Un taccuino con nome %1$s esiste già. - Le tue note archiviate appariranno qui. - - + Le note archivite verranno mostrate qui. - Svuota cestino - Confermare? - Le note cestinate andranno perse. - Le note nel cestino non si possono modificare. - Le tue note cestinate appariranno qui per %1$d giorni. - Note impostate per essere cancellate subito.\nPuoi cambiarlo in Impostazioni. - Note cancellate. - Nota cancellata. - - + Svuota il cestino + Procedere? + Tutte le note cancellate andranno perdute. + Le note nel cestino non possono essere modificate. + Le note cancellate verranno mostrate qui per %1$d giorni. + Le note sono impostate per essere cancellate immediatamente.\nÈ possibile modificare questa funzionalità nelle impostazioni. + Le note sono impostate per non essere mai cancellate.\nÈ possibile modificare questa funzionalità nelle impostazioni. + Note eliminate in modo permanente. + Nota eliminata in modo permanente. Promemoria Promemoria @@ -175,52 +158,48 @@ Imposta ora Nuovo promemoria Hai un promemoria. - Promemoria per il passato non applicabile - - + Impossibile impostare un promemoria per una data passata Nuova etichetta Nome etichetta - Non hai etichette. + Non ci sono etichette. Rinomina etichetta - Etichetta di nome %1$s esistente. - - + L\'etichetta %1$s esiste già. Cerca… Cerca - I risultati della ricerca appariranno qui. + I risultati della ricerca verranno visualizzati qui. Nessun risultato trovato. - - Titolo Prendi nota! - Compito + Attività Converti in nota - Converti in lista + Converti in elenco Non sincronizzare Cambia colore Abilita markdown Disabilita markdown - Inserisci markdown grassetto - Inserisci markdown corsivo - Inserisci markdown sottolineato - Inserisci markdown titolo - Inserisci markdown quotazione - Inserisci markdown codice - Creata il %1$s\nUltima modifica il %2$s - Note recuperate - Nota recuperata + Inserisci grassetto markdown + Inserisci corsivo markdown + Inserisci barrato markdown + Inserisci evidenziazione markdown + Inserisci intestazione markdown + Inserisci citazione markdown + Inserisci codice markdown + Rimuovi tutte le attività selezionate + Creazione %1$s\nUltima modifica %2$s + Note rispristinate + Nota ripristinata Note archiviate Nota archiviata - Note cestinate - Nota cestinata + Note spostate nel cestino + Nota spostata nel cestino Nota vuota scartata Inserisci collegamento Inserisci Inserisci tabella - Numero di righe o colonne non valido + Numero di righe e colonne non valido Inserisci immagine Descrizione Percorso immagine @@ -229,59 +208,83 @@ Numero di colonne Numero di righe Impossibile visualizzare l\'anteprima della tabella. - - Ripristinando le tue note… - Backup completato! - Backup fallito - Recuperando le tue note… - Recupero completato - Recupero fallito - - + Backup delle note in corso… + Backup completato + Backup non riuscito + Ripristino delle note in corso… + Ripristino completato + Ripristino non riuscito Promemoria Backup - Riproduzione media - + Riproduzione contenuti multimediali - Url HTTPS non valido. - Sei collegato come %s. - Attualmente non collegato. + Questo non è un URL HTTPS valido. + Accesso eseguito come %s. + Accesso non eseguito. Autenticazione - Profilo non in linea - In connessione… + Account non in linea + Connessione in corso… Qualcosa non ha funzionato. - Credenziali non valide. - Collegato con successo! - Versione del server non compatibile con questa app. - Connessione internet con disponibile. - Credenziali vuote non valide. - Pulisci credenziali e server URL - Avvia / Pausa + Impossibile autenticarsi sul server a causa delle credenziali non valide. + Accesso avvenuto con successo. + La versione del server non compatibile con questa app. + Connessione internet non disponibile. + Le credenziali non possono essere vuote. + Pulisci credenziali e URL del server + Riproduci / Pausa Ferma - Prendi una nota - Fai una lista - + Fai un elenco + Mai +%d oggetto + +%d oggetti +%d oggetti - Selezionata %d nota + Selezionate %d note Selezionate %d note - - Selezionato %d quaderno - Selezionati %d quaderni + Selezionato %d taccuino + Selezionati %d taccuini + Selezionati %d taccuini - Selezionata %d etichetta + Selezionate %d etichette Selezionate %d etichette + Segui il sistema + Continua + Elenco etichette + Ordina etichette per + Cassetto di navigazione + Ordina taccuini per + Taccuino + Contenuto della nota + Dimensione carattere editor di testo/visualizzazione + Predefinito + Posizione del pulsante Modifica/Visualizza nota + Pulsante fluttuante + Barra superiore + Sposta gli elementi selezionati/non selezionati negli elenchi + Modalità Modifica/Visualizza + Schermo sempre acceso + Abilita schermo sempre acceso + Disabilita schermo sempre acceso + Deseleziona tutti gli elementi + Archivio file + Selezionare una cartella dall\'archivio locale o da un fornitore di servizi cloud. Tutte le note verranno memorizzate in Markdown. + Posizione dell\'archivio + Selezionare la posizione dell\'archivio + Considera attendibile in certificato autofirmato + Errore del certificato: abilitare \"Considera attendibile in certificato autofirmato\" per continuare. + Invia i log dell\'applicazione allo sviluppatore per le verifiche + Invia i log dell\'errore + L\'applicazione Note non è installata nell\'istanza Nextcloud in uso. diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 00000000..b60300a3 --- /dev/null +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,270 @@ + + + + Notater + Arkiv + Slettet + Innstillinger + Om + Etiketter + Alle notater + Notatbøker + Dine notatbøker + + Generelt + Drakt + Mørk + Lys + System + Mørkt draktvalg + Forvalg + Svart + Visning + Visningsmodus + Rutenett + Liste + Fargepalett + Blå + Rosa + Grønn + Oransje + Lilla + Gul + Rød + Sorter etter + Navn (stigende) + Navn (synkende) + Opprettelsesdato (stigende) + Opprettelsesdato (synkende) + Endringsdato (stigende) + Endringsdato (synkende) + Vis opprettelses-/endringsdato + Datoformat + Tidsformat + Annet + Gruppenotater som ikke finnes i noen notatbok + Åpne media i + Intern avspiller + Annet program + Slett notater i papirkurven + Umiddelbart + Etter 7 dager + Etter 14 dager + Etter 30 dager + Sikkerhetskopiering + Opprett en sikkerhetskopi + Eksporter alle notater til en sikkerhetskopifil. + Gjenopprett + Last inn notater fra en sikkerhetskopifil. + Sikkerhetskopieringsstrategi for vedlegg + Sikkerhetskopier alt + Kun sikkerhetskopier beskrivelse og lokal sti + Ikke sikkerhetskopier vedlegg + Synkronisering + Åpne synkroniseringsinnstillingene + Synkroniserer nå med %s + Synkroniserer ikke + Synkroniseringstjeneste + Avskrudd + Nextcloud + Husk at Nextcloud ikke støtter etiketter, vedlegg, påminnelser, gjøremålslister, osv. + Nextcloud-konto + Nettadresse til Nextcloud-instans + Sett opp dine identitetsdetaljer + Sett tjenerens nettadresse + Wi-Fi + Wi-Fi eller mobildata + Synkroniser når på + Bakgrunnssynkronisering + + Av + Nye synkroniserbare notater + + Versjon + Nettside + Utvikler + Bidra + Støtte + Dette tar tid og koster krefter.\nSpander drikke. + Bibliotek + Vis frie tredjepartsbibliotek og lisenser. + + Forvalg + Ja + Nei + OK + Notatene dine vil vises her. + Uten navn + Brukernavn + Passord + Lagre + Avbryt + Slett + Ferdig + Slett for godt + Velg alle + Opprett en liste + Vis skjulte notater + Opphev arkivering + Eksporter + Skjul + Vis + Arkiver + Fest + Løsne + Gjenopprett + Del + Fest/løsne + Flytt til … + Dupliser + Velg flere … + Kompakt forhåndsvisning + Full forhåndsvisning + + Rediger beskrivelse + Vedleggsbeskrivelse + Ta opp lyd + Tar opp (%1$s) + Legg ved filer + Ta et bilde + Innspilte klipp + + Annet + Ny notatbok + Notatboksnavn + Ingen notatbøker + Du har ingen notatbøker. + Opprett en notatbok + Gi notatboken nytt navn + Ny notatbok + En notatbok som heter %1$s finnes allerede. + + Dine arkiverte notater vil vises her. + + Tøm papirkurven + Er du sikker? + Alle slettede notater vil gå tapt. + Notater i papirkurven kan ikke redigeres. + Dine slettede notater vil vises her i %1$d dager. + Notater blir slettet umiddelbart.\nDu kan endre dette i innstillingene. + Notater er satt til å aldri bli slettet.\nDu kan endre dette i Innstillinger. + Notater slettet for godt. + Notat slettet for godt. + + Påminnelser + Påminnelse + Påminnelsesnavn + Sett dato + Sett tid + Ny påminnelse + Du har en påminnelse. + Kan ikke sette en påminnelse for forbigått dato + + Nytt etikettnavn + Etikettnavn + Du har ingen etiketter. + Gi etiketten nytt navn + En etikett som heter %1$s finnes allerede. + + Søk … + Søk + Søkeresultater vil vises her. + Resultatløst. + + Navn + Lag et notat + Gjøremål + Konverter til notat + Konverter til liste + Ikke synkroniser + Endre farge + Markdown + Skru av markdown + Sett inn fet markdown + Sett inn skråskriftsmarkdown + Sett inn gjennomstreket markdown + Sett inn uthevingsmarkering markdown + Sett inn overskrift-markdown + Sett inn sitat-markdown + Sett inn kode-markdown + Fjern alle avmerkede oppgaver + Opprettet %1$s\nSist endret %2$s + Gjenopprettet notater + Gjenopprettet notatet + Arkiverte notater + Arkiverte notatet + Flyttet notater til papirkurven + Flyttet notatet til papirkurven + Tomt notat forkastet + Sett inn lenke + Sett inn + Sett inn tabell + Ugyldig antall rader og kolonner + Sett inn bilde + Beskrivelse + Bildesti + Tekst + Nettadresse + Antall kolonner + Antall rader + Kan ikke forhåndsvise tabell. + + Sikkerhetskopierer notatene dine … + Sikkerhetskopiert + Kunne ikke sikkerhetskopiere + Sikkerhetskopierer notatene dine … + Gjenopprettet + Kunne ikke gjenopprette + + Påminnelser + Sikkerhetskopier + Mediaavspilling + + Dette er ikke en gyldig HTTPS-nettadresse. + Du er innlogget som %s. + Du er ikke innlogget. + Identitetsbekreft + Frakoblet konto + Kobler til … + Noe gikk galt. + Kunne ikke identietetsbekrefte hos tjeneren fordi enten brukernavn eller passord var feil. + Innlogget + Tjenerversjonen er ikke kompatibel med dette programmet. + Du må koble deg til Internett først. + Brukernavn og passord må fylles ut. + Tøm brukernavn, passord, og tjenernettadresse + Spill av/pause + Stopp + + Lag et notat + Lag en liste + Aldri + + +%d element + +%d elementer + + + Selected %d notat + Selected %d notater + + + %d notatbok valgt + %d notatbøker valgt + + + %d etikett valgt + %d etiketter valgt + + Fillagring + Velg en mappe fra lokal lagring eller fra en skyleverandør. Alle notater + vil bli lagret som Markdown-filer. + + Lagringssted + Velg Lagringssted + System + Fortsette + Tag liste + Sorter tagger etter + Navigasjonsskuff + Sorter notatbøker etter + diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml new file mode 100644 index 00000000..6e7c07c7 --- /dev/null +++ b/app/src/main/res/values-night-v31/themes.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index efae718b..31b59748 100755 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -8,11 +8,18 @@ #1EFFFFFF #99000000 - #121212 - #202020 + + + @android:color/transparent + + + + @android:color/transparent + + #202020 #59FFFFFF @@ -38,6 +45,8 @@ #65452A #68643A + #96FFFF00 + @style/Widget.Custom.PopupMenu @style/DialogTheme diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml new file mode 100644 index 00000000..726ee776 --- /dev/null +++ b/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,283 @@ + + + + Notities + Archief + Verwijderd + Instellingen + Over + Labels + Alle Notities + Notitieblokken + Jouw Notitieblokken + + + Algemeen + Thema + Donker + Licht + Volg systeemstandaard + Donker thema + Standaard + Zwart + Kleurenschema + Blauw + Roze + Groen + Oranje + Paars + Geel + Rood + Volg systeem + + Uiterlijk + + Notitieblok + Indeling + Raster + Lijst + Sorteren op + Titel (oplopend) + Titel (aflopend) + Aanmaakdatum (oplopend) + Aanmaakdatum (aflopend) + Laatst gewijzigd (oplopend) + Laatst gewijzigd (aflopend) + + Notitie inhoud + Tekstverwerker/lettergrootte + Standaard + Notitieknop positie bekijken/bewerken + Zwevende knop + Bovenste balk + Datum van aanmaak/laatste wijziging tonen + Datumnotatie + Tijdnotatie + + Overigen + Open media in + Interne speler + Externe speler + Groepeer notities zonder notitieblok. + Verplaats aan- en uitgevinkte items in de lijst + Verwijder notities in de prullenbak + Onmiddellijk + Na 7 dagen + Na 14 dagen + Na 30 dagen + + Back-up + Maak een back-up + Exporteer alle notities naar een back-up bestand. + Herstellen + Laad notities vanuit een back-up bestand. + Back-up strategie voor bijlagen + Maak van alles een back-up + Maak alleen een back-up van de omschrijving en het lokale pad + Maak geen back-up van bijlagen + + Synchroniseren + Ga naar synchronisatie instellingen + Synchroniseert nu met %s + Niet aan het synchroniseren + Synchronisatieservice + Uitgeschakeld + Nextcloud + Houd er rekening mee dat Nextcloud Notes geen ondersteuning biedt voor functies zoals o.a. labels, bijlagen en herinneringen. + Nextcloud account + URL van Nextcloud server + Stel je inloggegevens in + Voer de URL van de server in + Wi-Fi + Wi-Fi of mobiele data + Synchroniseren wanneer verbonden met + Synchronisatie op de achtergrond + Ingeschakeld + Uitgeschakeld + Nieuwe notities synchroniseerbaar + + Versie + Website + Ontwikkelaar + Bijdragen + Steun + Het creëren en onderhouden van open-sourceprojecten is tijdrovend en levert geen winst op. Trakteer de ontwikkelaars op een biertje! + Libraries + Bekijk libraries en licenties van derden. + + Standaard + Ja + Nee + Oké + Je notities verschijnen hier + Naamloos + Gebruikersnaam + Wachtwoord + Opslaan + Annuleren + Verwijderen + Oké! + Definitief verwijderen + Alles selecteren + Maak een lijst + Toon verborgen notities + Uit archief halen + Exporteren + Verbergen + Toon + Archiveren + Toon- en bewerkmodus + Vastpinnen + Losmaken + Herstel + Delen + Vastpinnen / Losmaken + Verplaatsen naar… + Dupliceren + Selecteer meer… + Compacte weergave + Volledige weergave + Scherm altijd aan + + Omschrijving aanpassen + Omschrijving bijlage + Audio opnemen + Opnemen (%1$s) + Bijlagen toevoegen + Neem een foto + Opgenomen clip + + Overig + Nieuw notitieblok + Naam van notitieblok + Geen notitieblok + Je hebt geen notitieblokken. + Maak een notitieblok + Hernoem notitieblok + Nieuw notitieblok + Een notitieblok met de naam %1$s bestaat al. + + Gearchiveerde notities verschijnen hier. + + Prullenbak leegmaken + Weet je het zeker? + Alle notities in de prullenbak worden definitief verwijderd. + Notities in de prullenbak kunnen niet worden bewerkt. + Verwijderde notities blijven hier %1$d dagen zichtbaar. + Notities worden direct verwijderd.\nJe kunt dit wijzigen onder Instellingen. + Notities worden nooit verwijderd.\nJe kunt dit wijzigen onder Instellingen. + Verwijder notities definitief + Verwijder notitie definitief + + Herinneringen + Herinnering + Naam van de herinnering + Stel een datum in + Stel de tijd in + Nieuwe herinnering + Je hebt een herinnering. + Geen herinnering mogelijk voor een datum in het verleden. + + Nieuw label + Labelnaam + Je hebt geen labels. + Label hernoemen + Label met de naam %1$s bestaat al. + + Zoeken... + Zoeken + Zoekresultaten worden hier getoond. + Geen resultaten gevonden. + + Titel + Maak een notitie! + Taak + Converteer naar notitie + Converteer naar lijst + Niet synchroniseren + Verander kleur + Markdown inschakelen + Markdown uitschakelen + \"Scherm altijd aan\" inschakelen + \"Scherm altijd aan\" uitschakelen + Vetgedrukte markdown invoegen + Cursieve markdown invoegen + Doorgestreepte markdown invoegen + Gemarkeerde markdown invoegen + Markdown kop invoegen + Markdown citaat invoegen + Markdown code invoegen + Alle aangevinkte taken verwijderen + Aangemaakt op %1$s\nLaatst gewijzigd op %2$s + Herstelde notities + Herstelde notitie + Gearchiveerde notities + Gearchiveerde notitie + Notities verplaatst naar prullenbak + Notitie verplaatst naar prullenbak + Lege notitie verwijderd + Link invoegen + Invoegen + Tabel invoegen + Ongeldig aantal rijen en kolommen. + Afbeelding invoegen + Omschrijving + Pad naar afbeelding + Tekst + URL + Aantal kolommen + Aantal rijen + Kan geen voorbeeld van tabel bekijken. + + Back-up maken van je notities… + Back-up afgerond! + Back-up mislukt + Notities herstellen… + Herstellen afgerond + Herstellen mislukt + + Herinneringen + Back-ups + Media afspelen + + Dit is geen geldige HTTPS url. + Je bent aangemeld als %s. + Je bent momenteel niet aangemeld. + Authenticatie + Offline account + Verbinding maken… + Er ging iets mis. + Authenticatie met de server mislukt vanwege ongeldige inloggegevens. + Aanmelden gelukt! + De serverversie is niet compatibel met deze app. + Internetconnectie is niet beschikbaar. + Inloggegevens mogen niet leeg zijn. + Wis de inloggegevens en de server-URL + Afspelen / Pauzeren + Stop + + Maak een notitie + Maak een lijst + Nooit + + +%d item + +%d items + + + %d notitie geselecteerd + %d notities geselecteerd + + + %d notitieblok geselecteerd + %d notitieblokken geselecteerd + + + %d label geselecteerd + %d labels geselecteerd + + Doorgaan + Lijst met tags + Tags sorteren op + Navigatielade + Notitieboeken sorteren op + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index c1dfeafd..a5b53add 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1,3 +1,4 @@ + Notatki @@ -9,19 +10,16 @@ Wszystkie Notatki Notatniki Twoje Notatniki - Ogólne Motyw Ciemny Jasny Zgodny z systemem - Widok Układ Siatka Lista - Schemat kolorystyczny Niebieski Różowy @@ -30,7 +28,6 @@ Fioletowy Żółty Czerwony - Sortuj według Tytuł (rosnąco) Tytuł (malejąco) @@ -38,11 +35,9 @@ Data utworzenia (malejąco) Data modyfikacji (rosnąco) Data modyfikacji (malejąco) - Pokaż datę utworzenia/modyfikacji Format daty Format czasu - Inne Grupuj notatki które nie są w żadnym notatniku Otwieraj multimedia w @@ -53,7 +48,6 @@ Po 7 dniach Po 14 dniach Po 30 dniach - Kopia zapasowa Utwórz kopię zapasową Eksportuj wszystkie notatni do pliku z kopią. @@ -63,7 +57,6 @@ Kopiuj wszystko Kopiuj jedynie opis i ścieżkę lokalną Nie kopiuj załączników - Synchronizacja Przejdź do ustawień synchronizacji Aktualnie synchronizuje z %s @@ -71,7 +64,6 @@ Usługa synchronizacji Wyłączona Nextcloud - Synchronizacja jest w fazie eksperynetalnej.\nMożesz napotkać błędy. Pamiętaj że notatki Nextcloud nie wspierają funkcji takich jak znaczniki, załączniki, przypomnienia, listy zadań itd. Konto Nextcloud Adres URL instancji Nextcloud @@ -84,8 +76,9 @@ Włączona Wyłączona Włącz synchronizację dla nowych notatek - - + Tryb ciemnego motywu + Standard + Czarny Wersja Strona internetowa @@ -95,8 +88,6 @@ Tworzenie i rozwijanie projektów open-source jest czasochłonne i nie przynosi dochodów.\nPostaw więc twórcom piwo! Biblioteki Zobacz biblioteki i licencje stron trzecich. - - Domyślne Tak @@ -127,8 +118,8 @@ Przenieś do… Duplikuj Zaznacz więcej… - - + Kompaktowy podgląd + Pełny podgląd Edytuj opis Opis załącznika @@ -137,8 +128,6 @@ Dołącz pliki Zrób zdjęcie Nagranie - - Inne Nowy notatnik @@ -149,12 +138,8 @@ Zmień nazwę notatnika Nowy notatnik Notatnik z nazwą %1$s już istnieje. - - Twoje zarchiwizowane notatki pojawią się tutaj. - - Pusty kosz Jesteś pewnien? @@ -162,10 +147,9 @@ Notatki w koszu nie mogą być zmieniane. Twoje usunięte notatki pojawią się tutaj na %1$d dni. Notatki są usuwane natychmiastowo.\nMożesz zmienić to w ustawieniach. + Notatki są ustawione tak, aby nigdy nie były usuwane.\nMożesz to zmienić w Ustawieniach. Usunięto notatki na stałe. Usunięto notatkę na stałe. - - Przypomnienia Przypomnienie @@ -175,23 +159,17 @@ Nowe przypomnienie Masz przypomnienie. Nie można ustawić przypomnienia na przeszłą datę - - Nowy znacznik Nazwa znacznika Nie masz znaczników. Zmień nazwę znacznika Znacznik o nazwie %1$s już istnieje. - - Szukaj… Szukaj Wyniki wyszukiwania będą widoczne tutaj. Brak wyników. - - Tytuł Zanotuj coś! @@ -205,9 +183,11 @@ Wstaw pogrubienie Wstaw kursywę Wstaw przekreślenie + Wstaw znacznik wyróżnienia Wstaw nagłówek Wstaw cytat Wstaw kod + Fjern alle avmerkede oppgaver Utworzono %1$s\nZmodyfikowano %2$s Przywrócono notatki Przywrócono notatkę @@ -228,7 +208,6 @@ Liczba kolum Liczba wierszy Nie można wyświetlić podglądu tabeli. - Kopiowanie twoich notatek… Stworzono kopię zapasową! @@ -236,13 +215,10 @@ Przywracanie twoich notatek… Przywracanie zakończone Wystąpił błąd przy przywracaniu - - Przypomnienia Kopie zapasowe Multimedia - To nie jest poprawny adres HTTPS. Jesteś zalogowany jako %s. @@ -259,29 +235,44 @@ Wyczyść dane logowania i adres serwera Play / Pauza Zatrzymaj - Nowa notatka Nowa lista - - + Nigdy +%d element - +%d elementy + + + - Zaznaczono %d notatkę - Zaznaczono %d notatki + + + - Zaznaczono %d notatnik - Zaznaczono %d notatniki + + + - Zaznaczono %d znacznik - Zaznaczono %d znaczniki + + + + Nośnik danych + Wybierz folder z pamięci lokalnej lub od dostawcy usług w chmurze. + Wszystkie notatki będą przechowywane jako pliki Markdown. + + Miejsce przechowywania + Wybierz lokalizację przechowywania + Zgodny z systemem + Fortsette + Tag liste + Sorter tagger etter + Navigasjonsskuff + Sorter notatbøker etter diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 967399aa..0a53a6aa 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,3 +1,4 @@ + Anotações @@ -9,19 +10,19 @@ Todas Anotações Caderno de Notas Seu Caderno de Notas - Geral Modo de tema Escuro Claro Seguir tema do sistema - + Modo do tema escuro + Tema Padrão + Preto puro Visualização Modo de exibição Grelha Lista - Esquema de cores Azul Rosa @@ -30,7 +31,6 @@ Roxo Amarelo Vermelho - Ordenar por Título (crescente) Título (decrescente) @@ -38,11 +38,9 @@ Data criado (decrescente) Data modificado (crescente) Data modificado (decrescente) - Mostrar data que foi criado/modificado Formato de data Formato de hora - Outro Agrupar anotações que não estão em nenhum caderno de notas Abrir mídia em @@ -53,7 +51,6 @@ Após 7 dias Após 14 dias Após 30 dias - Cópia de segurança Criar uma cópia de segurança Exportar todas as anotações em um arquivo de cópia de segurança. @@ -63,7 +60,6 @@ Criar cópia de tudo Criar cópia apenas da descrição e do caminho Não criar cópia dos anexos - Sincronização Ir para configurações de sincronização Sincronizando atualmente com %s @@ -71,8 +67,7 @@ Serviço de sincronia Desabilitado Nextcloud - Função de sincronização atualmente é experimental. Você pode encontrar bugs. - Tenha em mente que o Nextcloud Notes não suporta recursos como etiquetas, anexos, lembretes, listas de tarefas e mais. + Tenha em mente que o Nextcloud Notes não suporta recursos como etiquetas, anexos, lembretes, listas de tarefas e mais. O Nextcloud Notes precisa estar instalado no servidor Nextcloud ou o login falhará com o erro Algo deu errado! Conta Nextcloud URL de instancia do Nextcloud Coloque suas credenciais @@ -84,21 +79,15 @@ Habilitado Desabilitado Novas anotações sincronizáveis - - Versão Site Desenvolvedor Contribua Suporte - Criar e manter projetos de código aberto consome tempo e - não gera renda.\nPague uma cerveja aos desenvolvedores! - + Criar e manter projetos de código aberto consome tempo e não gera renda.\nPague uma cerveja aos desenvolvedores! Bibliotecas Veja as bibliotecas de terceiro e suas licenças. - - Padrão Sim @@ -129,8 +118,8 @@ Mover para… Duplicar Selecionar mais… - - + Visualização compacta + Visualização completa Editar descrição Descrição do anexo @@ -139,8 +128,6 @@ Anexar arquivos Tirar uma foto Clipe Gravado - - Outro Novo caderno de notas @@ -151,12 +138,8 @@ Renomear caderno de notas Novo caderno de notas Caderno de notas com o nome %1$s já existe. - - Suas anotações arquivadas aparecerão aqui. - - Esvaziar lixeira Tem certeza? @@ -164,10 +147,9 @@ Anotações na lixeira não podem ser editadas. Suas anotações excluídas aparecerão aqui por %1$d dias. As anotações estão definidas para serem excluídas instantaneamente.\nVocê pode mudar isso em Configurações. + As notas são definidas para nunca serem excluídas.\nVocê pode alterar isso em Configurações. Anotações excluídas permanentemente. Anotação excluída permanentemente. - - Lembretes Lembrete @@ -177,23 +159,17 @@ Novo lembrete Você tem um lembrete. Não é possível definir um lembrete para uma data passada - - Nova etiqueta Nome da etiqueta Você não tem etiquetas. Renomear etiqueta Etiqueta com o nome %1$s já existe. - - Pesquisar… Pesquisar Resultados de busca aparecerão aqui. Nenhum resultado encontrado. - - Título Faça uma anotação! @@ -207,9 +183,11 @@ Inserir marcador de negrito Inserir marcador de itálico Inserir marcador de tachado + Inserir marcador de destaque Inserir marcador de cabeçalho Inserir marcador de citação Inserir marcador de código + Remover todas as tarefas marcadas Criado em %1$s\nUltima modificação em %2$s Anotações restauradas Anotação restaurada @@ -230,7 +208,6 @@ Número de colunas Número de linhas Não é possível visualizar a tabela. - Criando cópia de segurança das suas anotações… Cópia de segurança concluída! @@ -238,13 +215,10 @@ Restaurando suas anotações… Restauração concluída A restauração falhou - - Lembrete Cópias de segurança Reprodução de mídia - Isto não é uma URL HTTPS válida. Você atualmente está conectado como %s. @@ -261,28 +235,58 @@ Limpar credenciais e URL do servidor Reproduzir / Pausar Parar - Fazer uma anotação Fazer uma lista - + Nunca +%d item + +%d itens +%d itens - %d anotação selecionada + %d anotações selecionadas %d anotações selecionadas - - %d caderno de notas selecionado - %d cadernos de notas selecionados + %d bloco de notas selecionado + %d blocos de notas selecionados + %d blocos de notas selecionados - %d etiqueta selecionada + %d etiquetas selecionadas %d etiquetas selecionadas + Armazenamento de arquivo + Selecione uma pasta do armazenamento local ou de um provedor de nuvem. + Todas as notas serão armazenadas como arquivos Markdown. + + Local de armazenamento + Selecione o local de armazenamento + Seguir tema do sistema + Continuar + Lista de tags + Classificar tags por + Gaveta de navegação + Classificar cadernos por + Habilitar tela sempre ativa + Aplicativo Notes não instalado no seu Nextcloud. + Erro de Certificado. Por favor, ative \"Certificado autoassinado confiável\" para continuar... + Certificado autoassinado confiável + Editar/Ver posição do botão de nota + Botão flutuante + Mover itens (não)selecionados nesta lista + Desmarcar todos os itens + Bloco de notas + Conteúdo da Nota + Padrão + Barra do Topo + Editor de Texto/Tamanho da Fonte + Tela sempre ativa + Desabilitar tela sempre ativa + Modo Editar/Vizualizar + Enviar logs de erro + Enviar os logs do aplicativo ao desenvolvedor para investigação diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 00000000..3ef5b885 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,296 @@ + + + + Заметки + Архив + Удалённые + Настройки + О программе + Метки + Все заметки + Блокноты + Ваши блокноты + + Общее + Тема + Тёмная + Светлая + Системная + Тёмная тема + Стандарт + Чёрный + Вид + Режим компоновки + Сетка + Список + Цветовая схема + Синий + Розовый + Зелёный + Оранжевый + Фиолетовый + Жёлтый + Красный + Сортировать по + Заголовок (по возрастанию) + Заголовок (по убыванию) + Дата создания (по возрастанию) + Дата создания (по убыванию) + Дата изменения (по возрастанию) + Дата изменения (по убыванию) + Показать дату создания/изменения + Формат даты + Формат времени + Другие + Группировать заметки вне блокнотов + Открыть файл в + Встроенном плеере + Внешнем плеере + Удалить заметки в корзине + Сразу + Спустя 7 дней + Спустя 14 дней + Спустя 30 дней + Сделать рез.копию + Создать рез.копию + Создать файл-рез.копию заметок. + Восстановить + Загрузить заметки из файла с рез.копией. + Стратегия резервирования для вложений + Резервировать всё + Только описание и путь для рез.копии + Не резервировать вложения + Синхронизация + Настройки синхронизации + Синхронизируемся с %s + Синхронизация не ведётся + Служба синхронизации + Отключена + Nextcloud + Помните, что заметки с Nextcloud не поддерживают меток, вложений, напоминаний, списков задач и прочее. На Nextcloud сервере нужно установить Nextcloud Notes или же вход не удастся с ошибкой Что-то пошло не так! + Уч.запись Nextcloud + URL сервера Nextcloud + Ввести данные + Установить URL сервера + Wi-Fi + Wi-Fi или Моб.связь + Синхр-ть при запуске + Фоновая синх-ия + Включено + Выключено + Новые заметки готовы к синхронизации + + Версия + Веб-сайт + Разработчик + Внести вклад + Поддержать + Создание и поддержка открытых проектов требует времени и труда. Поддержите разработчика!\nПожертвуйте разработчику на кофе! + Библиотеки + Список используемых библиотек и лицезий. + + По умолчанию + Да + Нет + Ок + Тут будут показаны заметки. + Без имени + Имя + Пароль + Сохранить + Отменить + Удалить + Готово! + Удалить безврзвратно + Выбрать все + Создать список + Показать спрятанные заметки + Разархивировать + Экспорт + Спрятать + Показать + Архивировать + Прикрепить + Открепить + Восстановить + Поделиться + Прикрепить / Открепить + Переместить в… + Дублировать + Выбрать ещё… + Компактный предварительный просмотр + Полный предварительный просмотр + + Редактировать описание + Описание вложения + Запись аудио + Записывание (%1$s) + Прикрепить файлы + Сделать фото + Записанный клип + + Другой + Новый блокнот + Имя блокнота + Нет блокнотов + У вас нет блокнотов. + Создать блокнот + Переименовать блокнот + Новый блокнот + Блокнот %1$s уже существует. + + Тут будут появлятся архивные заметки. + + Очистить корзину + Вы уверены? + Все удалённые заметки будут безвозвратно потеряны. + Заметки в корзине недоступны к изменению. + Ваши удалённые заметки появятся тут через %1$d дней. + Вы настроили безвозвратное удаление заметок.\nМожете изменить это поведение в настройках. + Заметки никогда не удаляются.\nВы можете изменить это в настройках. + Заметки удалены безвозвратно. + Заметка удалена безвозвратно. + + Напоминания + Напоминание + Имя напоминания + Выбрать дату + Выбрать время + Новое напоминание + У вас есть напоминание. + Недопустимы напоминания задним числом + + Новая метка + Имя метки + У вас нет меток. + Переименовать метку + Метка %1$s уже существует. + + Поиск… + Поиск + Результаты поиска показываются тут. + Не найдено. + + Заголовок + Создать заметку! + Задача + Преобразовать в заметку + Преобразовать в список + Не синхронизировать + Изменить цвет + Включить разметку + Выключить разметку + Добавить полужирным + Добавить курсивом + Добавить перечёркнутым + Вставить уценку выделения + Добавить заголовком + Добавить цитированием + Добавить как код + Удалить все отмеченные задачи + Создано %1$s\nПоследняя правка %2$s + Восстановленные заметки + Восстановленная заметка + Архивированные заметки + Архивированная заметка + Заметки в корзине + Заметка в корзине + Заметка пустая и была стёрта + Вставить ссылку + Вставить + Вставить таблицу + Недопустимое число строк/столбцов + Вставить изображение + Описание + Путь к изображению + Текст + URL + Число столбцов + Число строк + Предпросмотр таблицы невозможен. + + Создаём рез.копию… + Успешное создание рез.копии! + Ошибка создания рез.копии + Восстанавливаем… + Восстановление выполнено + Ошибка восстановления + + Напоминания + Рез.копии + Проигрывание медиа + + Недопустимый HTTPS адрес. + Вы зашли под %s. + Вы не вошли. + Авторизация + Оффлайн уч.запись + Подключение… + Что-то пошло не так. + Авторизация не удалась - проверьте введённые данные. + Успешная авторизация! + Версия приложения не совместима с приложением. + Отсутствует соединение с сетью. + Поля не могут быть пусты. + Очистить поля и адрес сервера + Включить / Пауза + Стоп + + Создать заметку + Создать список + Никогда + + +%d эл-т + +%d эл-та + +%d эл-тов + +%d эл-тов + + + Выбрана %d заметка + Выбрано %d заметки + Выбрано %d заметок + Выбрано %d заметок + + + Выбран %d блокнот + Выбрано %d блокнота + Выбрано %d блокнотов + Выбрано %d блокнотов + + + Выбрана %d метка + Выбраны %d метки + Выбрано %d меток + Выбрано %d меток + + Файловое хранилище + Выберите папку из локального хранилища или из облачного провайдера. Все + заметки будут храниться в виде файлов Markdown. + + Место хранения + Выберите место хранения + Системная + Продолжить + Список тегов + Сортировать теги по + Панель навигации + Сортировать блокноты по + Блокнот + Текст заметки + Размер шрифта редактора текста + По умолчанию + Позиция кнопок Просмотра/Правки + Плавающая кнопка + Верхняя панель + Переместить (не) выделенные элементы в списках + Режим Правки/Просмотра + Экран всегда включён + Сделать экран всегда включённым + Отключить экран всегда горящим + Снять флажок со всех элементов + Доверьтесь самоподписанному сертификату + Ошибка сертификата. Пожалуйста, включите «Доверить самоподписанному сертификату» для продолжения. + Отправить журнал ошибок + Отправить журнал приложения разработчикам для расследования + Приложение Notes не установлено в вашем Nextcloud. + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml new file mode 100644 index 00000000..8500c4bf --- /dev/null +++ b/app/src/main/res/values-sv/strings.xml @@ -0,0 +1,270 @@ + + + Arkiv + Raderat + Inställningar + Om + Taggar + Alla Anteckningar + Anteckningsböcker + Dina Anteckningsböcker + Temaläge + Följ systemet + Mörkt + Ljust + Mörkt temaläge + Standard + Svart + Färgschema + Blått + Rosa + Grönt + Orange + Lila + Gult + Rött + Följ systemet + Vy + Layoutläge + Rutnät + Lista + Sortera efter + Titel (stigande) + Titel (fallande) + Datum skapat (stigande) + Datum skapat (fallande) + Datum ändrat (stigande) + Anteckningsinnehåll + Textredigerare/visa teckenstorlek + Standard + Flytande knapp + Övre fältet + Visa datum skapat/ändrat + Datumformat + Tidsformat + Annat + Öppna media i + Intern spelare + Gruppera anteckningar som inte är i någon anteckningsbok + Flytta (o)markerade objekt i listorna + Ta bort anteckningar i papperskorgen + Omedelbart + Efter 7 dagar + Efter 30 dagar + Skapa en säkerhetskopia + Exportera alla anteckningar till en säkerhetskopia. + Återställ + Säkerhetskopiera + Säkerhetskopieringsstrategi för bilagor + Säkerhetskopiera allt + Säkerhetskopiera endast beskrivning och lokal sökväg + Säkerhetskopiera inte bilagor + Synkronisering + Synkroniserar för närvarande med %s + Synkroniserar inte för närvarande + Synkroniseringstjänst + Inaktiverad + Nextcloud + Nextcloud konto + Nextcloud-instans URL + Ange dina inloggningsuppgifter + Ange serverns URL + Välj en mapp från lokal lagring eller från en molnleverantör. Alla anteckningar kommer att lagras som Markdown-filer. + Lagringsplats + Välj lagringsplats + Wi-Fi + Wi-Fi eller Data + Synkronisera när påslagen + Bakgrundssynkronisering + Inaktiverad + Nya anteckningar synkroniserbara + Lita på självsignerat certifikat + Version + Webbplats + Utvecklare + Bidra + Stöd + Skicka programloggarna till utvecklaren för undersökning + Bibliotek + Visa tredjepartsbibliotek och licenser. + Standard + Ja + Nej + Okej + Dina anteckningar kommer att visas här. + Namnlös + Användarnamn + Lösenord + Spara + Avbryt + Radera + Klart! + Radera permanent + Markera alla + Skapa en lista + Visa dolda anteckningar + Avarkivera + Exportera + Dölj + Visa + Arkivera + Fäst + Lossa + Återställ + Dela + Fäst / Lossa + Flytta till… + Duplicera + Välj mer… + Kompakt förhandsvisning + Skärmen alltid på + Redigera beskrivning + Beskrivning av bilagan + Spela in ljud + Spelar in (%1$s) + Bifoga filer + Inspelat klipp + Annat + Ny anteckningsbok + Anteckningsbokens namn + Ingen anteckningsbok + Skapa en anteckningsbok + Byt namn på anteckningsbok + Ny anteckningsbok + Anteckningsbok med namnet %1$s finns redan. + Töm papperskorg + Är du säker? + Alla raderade anteckningar kommer att gå förlorade. + Anteckningar i papperskorgen kan inte redigeras. + Anteckningar är inställda att raderas direkt.\nDu kan ändra detta i Inställningar. + Anteckningar är inställda att aldrig raderas.\nDu kan ändra detta i Inställningar. + Raderade anteckningar permanent. + Raderade anteckning permanent. + Påminnelser + Påminnelse + Ange datum + Ange tid + Ny påminnelse + Du har en påminnelse. + Ny tagg + Taggnamn + Du har inga taggar. + Byt namn på taggen + Tagg med namnet %1$s finns redan. + Sök… + Sök + Sökresultaten kommer att visas här. + Inga resultat hittades. + Titel + Gör en anteckning! + Uppgift + Konvertera till anteckning + Synkronisera inte + Ändra färg + Aktivera markdown + Inaktivera markdown + Aktivera skärmen alltid på + Infoga kursiv markdown + Infoga genomstruken markdown + Infoga rubrik markdown + Infoga citattecken markdown + Infoga kod markdown + Avmarkera alla objekt + Ta bort alla markerade uppgifter + Återställda anteckningar + Återställd anteckning + Arkiverade anteckningar + Arkiverad anteckning + Flyttade anteckningar till papperskorgen + Flyttade anteckning till papperskorgen + Infoga länk + Infoga + Infoga tabell + Ogiltigt antal rader och kolumner + Infoga bild + Beskrivning + Bildsökväg + Text + URL + Antal kolumner + Antal rader + Säkerhetskopierar dina anteckningar… + Säkerhetskopiering klar! + Säkerhetskopieringen misslyckades + Återställer dina anteckningar… + Återställningen är klar + Påminnelser + Säkerhetskopior + Mediauppspelning + Detta är inte en giltig HTTPS-URL. + Du är för närvarande inte inloggad. + Autentisera + Offline-konto + Ansluter… + Något gick fel. + Kunde inte autentisera med servern på grund av ogiltiga inloggningsuppgifter. + Inloggad! + Notes appen är inte installerad i din Nextcloud. + Serverversionen är inte kompatibel med den här appen. + Internetanslutning är inte tillgänglig. + Inloggningsuppgifter kan inte vara tomma. + Rensa inloggningsuppgifter och server-URL + Spela / Pausa + Stopp + Gör en anteckning + Gör en lista + Aldrig + Tagglista + Sortera taggar efter + Navigeringslåda + Sortera anteckningsböcker efter + Fortsätt + + +%d objekt + +%d objekt + + + Vald %d anteckning + Valda %d anteckningar + + + Vald %d anteckningsbok + Valda %d anteckningsböcker + + + Vald %d tagg + Valda %d taggar + + Fillagring + Anteckningar + Redigera/Visa anteckningsknappens plats + Tänk på att Nextcloud Notes inte stöder funktioner som taggar, bilagor, påminnelser med mera. Nextcloud Notes måste installeras på Nextcloud-servern, annars misslyckas inloggningen med Något gick fel! + Allmän + Att skapa och underhålla öppen-källkodsprojekt är tidskrävande och ger ingen vinst.\nBjud utvecklarna på kaffe! + Anteckningsbok + Aktiverad + Skicka felloggar + Datum ändrat (fallande) + Aktivera \"Lita på självsignerat certifikat\" för att fortsätta.. + Extern spelare + Ladda anteckningar från en säkerhetskopia. + Gå till synkroniseringsinställningar + Efter 14 dagar + Redigera/Visa-läge + Du har inga anteckningsböcker. + Kan inte att ställa in en påminnelse för ett tidigare datum + Infoga fet markdown + Tom anteckning kasserad + Du är för närvarande inloggad som %s. + Fullständig förhandsvisning + Dina arkiverade anteckningar kommer att visas här. + Påminnelsenamn + Skapad %1$s\nSenast ändrad %2$s + Återställningen misslyckades + Ta ett foto + Konvertera till lista + Dina raderade anteckningar kommer att visas här i %1$d dagar. + Infoga markerad markdown + Kan inte förhandsgranska tabellen. + Inaktivera skärmen alltid på + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..ef7dde26 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,299 @@ + + + + Notlar + Arşiv + Silinenler + Ayarlar + Hakkında + Etiketler + Tüm Notlar + Defterler + Defterleriniz + + + Genel + Tema modu + Koyu + Açık + Sistemi takip et + Koyu tema modu + Standart + Siyah + Renk şeması + Mavi + Pembe + Yeşil + Turuncu + Mor + Sarı + Kırmızı + Sistemi takip et + + Görünüm + + Defter + Düzen modu + Izgara + Liste + Sıralama yöntemi + Başlık (artan) + Başlık (azalan) + Oluşturma tarihi (artan) + Oluşturma tarihi (azalan) + Değiştirme tarihi (artan) + Değiştirme tarihi (azalan) + + Not içeriği + Metin düzenleyici/görüntüleme yazı tipi boyutu + Varsayılan + 10 + 15 + 20 + 25 + 30 + 35 + 40 + 45 + 50 + Not düzenleme/görüntüleme düğmesi konumu + Kayan düğme + Üst çubuk + Oluşturma/değiştirme tarihini göster + Tarih biçimi + Saat biçimi + + Diğer + Medyayı şurada aç + Dahili oynatıcı + Harici oynatıcı + Herhangi bir defterde olmayan notları grupla + Listelerde işaretli/işaretsiz öğeleri taşı + Çöp kutusundaki notları sil + Anında + 7 gün sonra + 14 gün sonra + 30 gün sonra + + Yedekleme + Yedek oluştur + Tüm notları bir yedek dosyasına aktar. + Geri yükle + Notları bir yedek dosyasından yükle. + Ekler için yedekleme stratejisi + Her şeyi yedekle + Yalnızca açıklama ve yerel yolu yedekle + Ekleri yedekleme + + Senkronizasyon + Senkronizasyon ayarlarına git + Şu anda %s ile senkronize ediliyor + Şu anda senkronize edilmiyor + Senkronizasyon servisi + Devre dışı + Nextcloud + Nextcloud Notes’un etiketler, ekler, hatırlatıcılar gibi özellikleri desteklemediğini unutmayın. Nextcloud Notes’un Nextcloud Sunucusuna yüklenmiş olması gerekir, aksi takdirde giriş Bir şeyler ters gitti hatasıyla başarısız olur! + Nextcloud hesabı + Nextcloud örnek URL’si + Kimlik bilgilerinizi ayarlayın + Sunucu URL’sini ayarlayın + Wi-Fi + Wi-Fi veya Veri + Şu durumda senkronize et + Arka planda senkronizasyon + Etkin + Devre dışı + Yeni notlar senkronize edilebilir + + Sürüm + Web sitesi + Geliştirici + Katkıda bulun + Destek + Açık kaynak projeler oluşturmak ve sürdürmek zaman alır ve kâr getirmez.\nGeliştiricilere bir bira ısmarlayın! + Kütüphaneler + Üçüncü taraf kütüphaneleri ve lisansları görüntüle. + + Varsayılan + Evet + Hayır + Tamam + Notlarınız burada görünecek. + Başlıksız + Kullanıcı adı + Şifre + Kaydet + İptal + Sil + Tamamlandı! + Kalıcı olarak sil + Tümünü seç + Liste oluştur + Gizli notları göster + Arşivden çıkar + Dışa aktar + Gizle + Göster + Arşivle + Düzenleme/Görüntüleme modu + Sabitle + Sabitlemeyi kaldır + Geri yükle + Paylaş + Sabitle / Sabitlemeyi kaldır + Şuraya taşı… + Çoğalt + Daha fazla seç… + Kompakt önizleme + Tam önizleme + Ekran her zaman açık + + Açıklamayı düzenle + Ek açıklaması + Ses kaydet + Kaydediliyor (%1$s) + Dosya ekle + Fotoğraf çek + Kaydedilen Klip + + Diğer + Yeni defter + Defter adı + Defter Yok + Hiç defteriniz yok. + Defter oluştur + Defteri yeniden adlandır + Yeni defter + %1$s adında bir defter zaten var. + + Arşivlenmiş notlarınız burada görünecek. + + Çöp kutusunu boşalt + Emin misiniz? + Tüm silinmiş notlar kaybolacak. + Çöp kutusundaki notlar düzenlenemez. + Silinen notlarınız %1$d gün boyunca burada görünecek. + Notlar anında silinecek şekilde ayarlanmış.\nBunu Ayarlar’dan değiştirebilirsiniz. + Notlar asla silinmeyecek şekilde ayarlanmış.\nBunu Ayarlar’dan değiştirebilirsiniz. + Notlar kalıcı olarak silindi. + Not kalıcı olarak silindi. + + Hatırlatıcılar + Hatırlatıcı + Hatırlatıcı adı + Tarih ayarla + Saat ayarla + Yeni hatırlatıcı + Bir hatırlatıcınız var. + Geçmiş bir tarih için hatırlatıcı ayarlanamaz. + + Yeni etiket + Etiket adı + Hiç etiketiniz yok. + Etiketi yeniden adlandır + %1$s adında bir etiket zaten var. + + Ara… + Ara + Arama sonuçları burada görüntülenecek. + Sonuç bulunamadı. + + Başlık + Not al! + Görev + Nota dönüştür + Listeye dönüştür + Senkronize etme + Renk değiştir + Markdown’u etkinleştir + Markdown’u devre dışı bırak + Ekranı her zaman açık tutmayı etkinleştir + Ekranı her zaman açık tutmayı devre dışı bırak + Kalın markdown ekle + İtalik markdown ekle + Üstü çizili markdown ekle + Vurgu markdown ekle + Başlık markdown ekle + Alıntı markdown ekle + Kod markdown ekle + Tüm öğelerin işaretini kaldır + Tüm işaretli görevleri kaldır + %1$s tarihinde oluşturuldu\nSon değiştirilme: %2$s + Notlar geri yüklendi + Not geri yüklendi + Notlar arşivlendi + Not arşivlendi + Notlar çöp kutusuna taşındı + Not çöp kutusuna taşındı + Boş not atıldı + Bağlantı ekle + Ekle + Tablo ekle + Geçersiz satır ve sütun sayısı + Görüntü ekle + Açıklama + Görüntü yolu + Metin + URL + Sütun sayısı + Satır sayısı + Tablo önizlemesi yapılamıyor. + + Notlarınız yedekleniyor… + Yedekleme tamamlandı! + Yedekleme başarısız + Notlarınız geri yükleniyor… + Geri yükleme tamamlandı + Geri yükleme başarısız + + Hatırlatıcılar + Yedeklemeler + Medya oynatma + + Bu geçerli bir HTTPS URL’si değil. + Şu anda %s olarak giriş yaptınız. + Şu anda giriş yapmadınız. + Kimlik doğrula + Çevrimdışı hesap + Bağlanıyor… + Bir şeyler ters gitti. + Geçersiz kimlik bilgileri nedeniyle sunucuyla kimlik doğrulaması yapılamadı. + Başarıyla giriş yapıldı! + Sunucu sürümü bu uygulamayla uyumlu değil. + İnternet bağlantısı mevcut değil. + Kimlik bilgileri boş olamaz. + Kimlik bilgilerini ve sunucu URL’sini temizle + Oynat / Duraklat + Durdur + + Not al + Liste yap + Asla + Etiket listesi + Etiketleri şuna göre sırala + Gezinme çekmecesi + Defterleri şuna göre sırala + Devam et + + +%d öğe + +%d öğe + + + %d not seçildi + %d not seçildi + + + %d defter seçildi + %d defter seçildi + + + %d etiket seçildi + %d etiket seçildi + + Dosya Depolama + Yerel depolamadan veya bir bulut sağlayıcıdan bir klasör seçin. Tüm notlar + Markdown dosyaları olarak saklanacaktır. + + Depolama yeri + Depolama yerini seçin + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 00000000..a3a393b8 --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,281 @@ + + + + Нотатки + Архів + Смітник + Налаштування + Про додаток + Помітки + Усі нотатки + Записники + Власні записники + + Загальне + Тема + Темна + Світла + За системою + Темний режим + Темненький + Темнючий + Перегляд + Розподіл нотатків + Сітка + Список + Колір підсвічення + Блакитний + Рожевий + Зелений + Помаранчевий + Лавандовий + Жовтий + Червоний + Упорядкувати за + Назвою (за зростанням) + Назвою (за спаданням) + Часом створення (за зростанням) + Часом створення (за спаданням) + Часом останньої зміни (за зростанням) + Часом останньої зміни (за спаданням) + Показувати час створення та зміни + Величінь дати + Величінь часу + Інше + Об\'єднувати нотатки котрих немає у жодному записнику, в окремий записник + Відтворювати медія за допомогою + Вбудованого програвача + Зовнішнього програвача + Видаляти нотатки зі смітника + Миттєво + Після 7 діб + Після 14 діб + Після 30 діб + Резервне копіювання + Створити резервну копію + Зберегти усі нотатки у резервний файл на вашому пристрої. + Відновити + Відновити усі нотатки з резервного файлу. + Стратегія резервування вкладень типу авдіо/відео. + Резервувати усе + Резервувати тільки опис та локальне посилання на вкладення + Не резервувати вкладення + Синхронізація + Перейти до налаштування синхронізації + Синхронізація з %s + Поки що не синхронізовуємося + Сервіс синхронізації + Вимкнений + Nextcloud + Майте на увазі — Nextcloud Notes не підтримує помітки, вкладення, нагадування, списки завдань та деякі інші функції. + Обліковка Nextcloud + Посилання на Nextcloud + Укажіть ваші данні входу + Вказати адресу сервера + Wi-Fi + Wi-Fi чи мобільна мережа + Синхронізувати коли під\'єднані до + Синхронізація на фоні + Увімкнено + Вимкнено + Доступні нові нотатки для синхронізації + + Версія + Сайт + Розробник + Допомогти + Підтримати + Створення та підтримка проєктів із відкритим джерельним кодом займає час та не приносить грошей.\nПридбати розробнику філіжанку кави! + Вжиті бібліотеки + Подивитися сторонні бібліотеки та ліцензії. + + Загальне + Так + Ні + Добре + Ваші нотатки з\'являться ось тут. + Без назви + Ім\'я користувача + Пароль + Зберегти + Відмінити + Видалити + Зроблено! + Видалити назавжди + Обрати всі + Створити список + Показати приховані нотатки + Розархівувати + Експорт + Ховати + Показувати + Архівувати + Пришпилити + Відшпилити + Відновити + Поділитися + Пришпилити / відшпилити + Перемістити у… + Подвоїти + Обрати більше… + Компактний попередній перегляд + Повний попередній перегляд + + Змінити опис + Опис вкладення + Записати авдіо + Записуємо (%1$s) + Додати файл + Взяти світлину + Запис авдіо + + Загальний + Новий записник + Назва записника + Без записника + У вас немає жодного записника. + Створити записник + Вказати нове ім\'я + Новий записник + Записник з ім\'ям %1$s вже існує. + + Ваші архівовані нотатки з\'являтимуться ось тут. + + Вичистити + Ви впевнені? + Усі видалені нотатки будуть втрачені назавжди. + Нотатки у смітнику не можуть бути змінені. + Видалені вами нотатки, існуватимуть у смітнику зо %1$d діб. + Смітник налаштований на миттєве видалення нотатків.\nВи можете змінити це у налаштуваннях. + Нотатки налаштовані так, що вони ніколи не видаляються.\nВи можете змінити це в налаштуваннях. + Нотатки видалено назавжди. + Нотаток видалено назавжди. + + Нагадування + Нагадування + Нагадайте мені про… + Виставити дату + Виставити час + Нове нагадування + У вас нове нагадування. + Неможливо створити нагадування для дати у минулому + + Нова помітка + Назва + У вас немає жодної помітки. + Змінити назву + Помітка з назвою %1$s вже існує. + + Пошук… + Пошук + Результати пошуку з\'являться ось тут. + Нічого не знайдено. + + Назва + Створи нотаток! + Завдання + Перетворити на нотаток + Перетворити на список + Не синхронізовувати + Змінити колір + Увімкнути оформлення тексту + Вимкнути оформлення тексту + Додати жир + Додати курсив + Додати викреслення + Вставити позначку виділення + Додати заголовок + Додати цитату + Додати код + Видалити всі позначені завдання + Створено %1$s\nВостаннє змінено %2$s + Відновлено нотатки + Відновлено нотаток + Архівовано нотатки + Архівовано нотаток + Нотатки переміщені у смітник + Нотаток переміщено у смітник + Пустий нотаток відправлено на переробку паперу + Вставити посилання + Вставити + Вставити таблицю + Хибна кількість рядків та колонок + Додати світлину + Опис + Посилання на світлину + Текст + URL + Кількість колонок + Кількість рядків + Неможливо показати таблицю. + + Резервуємо ваші нотатки… + Резервування закінчено! + Резервування, ой леле а схибило + Відновлюємо ваші нотатки… + Відновлення закінчено! + Відновлення, ой леле а схибило + + Нагадування + Резервування + Відтворення медіа + + Це хибна HTTPS адреса. + Наразі, ви зайшли у Nextcloud з обліковкою %s. + Наразі, входу у Nextcloud не виконано. + Авторизація + Локальна обліковка + З\'єднання… + Щось пішло не за планом. + Неможливо авторизуватися на сервері, так як ім\'я користувача та пароль не збігаються. + Успішно увійшли в обліковку! + Версія сервера не підтримується цим додатком. + Бракує доступу до мережі інтернет. + Вхідні дані не можуть бути пустими. + Очистити вхідні дані та посилання на сервер + Грати / Павза + Зупинити + + Створити нотаток + Створити список + Ніколи + + +%d елемент + + + + + + Обрано %d нотаток + + + + + + Обрано %d записник + + + + + + Обрано %d помітку + + + + + Зберігання файлів + Виберіть папку з локального сховища або з хмарного постачальника. Усі + нотатки зберігатимуться як файли Markdown. + + Місце зберігання + Виберіть місце зберігання + За системою + Продовжити + Список тегів + Сортувати теги за + Панель навігації + Розсортуйте зошити за + Записник + Зміст нотатки + Звичайний + diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 00000000..a5ced06c --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 00000000..87d61fda --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,279 @@ + + + + Ghi chú + Lưu trữ + Đã xóa + Thiết đặt + Giới thiệu + Thẻ + Tất cả ghi chú + Sổ tay + Sổ tay của bạn + + + Tổng quan + Chế độ chủ đề + Tối + Sáng + Theo hệ thống + Chế độ chủ đề tối + Tiêu chuẩn + Đen + Bảng màu + Lam + Hồng + Lục + Cam + Tím + Vàng + Đỏ + Theo hệ thống + + Xem + + Sổ tay + Chế độ bố cục + Lưới + Danh sách + Sắp xếp theo + Tiêu đề (tăng dần) + Tiêu đề (giảm dần) + Ngày tạo (tăng dần) + Ngày tạo (giảm dần) + Ngày sửa đổi (tăng dần) + Ngày sửa đổi (giảm dần) + + Nội dung ghi chú + Trình soạn thảo văn bản/cỡ chữ hiển thị + Mặc định + Vị trí nút chỉnh sửa/xem ghi chú + Nút nổi + Thanh trên cùng + Hiển thị ngày tạo/sửa đổi + Định dạng ngày + Định dạng thời gian + + Khác + Mở phương tiện trong + Trình phát nội bộ + Trình phát bên ngoài + Nhóm các ghi chú không có trong sổ tay nào + Di dời (bỏ) các mục đã chọn trong danh sách + Xóa ghi chú trong thùng + Ngay lập tức + Sau 7 ngày + Sau 14 ngày + Sau 30 ngày + + Sao lưu + Tạo bản sao lưu + Xuất tất cả ghi chú sang tệp sao lưu. + Khôi phục + Tải ghi chú từ tệp sao lưu. + Chiến lược sao lưu cho tệp đính kèm + Sao lưu mọi thứ + Chỉ sao lưu mô tả và đường dẫn cục bộ + Không sao lưu tệp đính kèm + + Đang đồng bộ hóa + Đi tới cài đặt đồng bộ hóa + Hiện đang đồng bộ hóa với %s + Hiện không đồng bộ + Dịch vụ đồng bộ hóa + Đã tắt + Nextcloud + Hãy nhớ rằng Nextcloud Notes không hỗ trợ các tính năng như thẻ, tệp đính kèm, lời nhắc và hơn thế nữa. + Tài khoản Nextcloud + URL phiên bản Nextcloud + Đặt thông tin xác thực của bạn + Đặt URL của máy chủ + Wi-Fi + Wi-Fi hoặc dữ liệu + Đồng bộ hóa khi bật + Đồng bộ hóa nền + Đã bật + Đã tắt + Ghi chú mới có thể đồng bộ hóa + + Phiên bản + Trang web + Nhà phát triển + Đóng góp + Hỗ trợ + Việc tạo và duy trì các dự án nguồn mở tốn nhiều thời gian và không mang lại lợi nhuận.\nHãy mua bia cho nhà phát triển! + Thư viện + Xem thư viện và giấy phép của bên thứ ba. + + Mặc định + + Không + Được + Ghi chú của bạn sẽ xuất hiện ở đây. + Không có tiêu đề + Tên người dùng + Mật khẩu + Lưu + Hủy + Xóa + Xong! + Xóa vĩnh viễn + Chọn tất cả + Tạo danh sách + Hiển thị ghi chú ẩn + Hủy lưu trữ + Xuất + Ẩn + Hiển thị + Lưu trữ + Chế độ chỉnh sửa/xem + Ghim + Bỏ ghim + Khôi phục + Chia sẻ + Ghim / Bỏ ghim + Di chuyển tới… + Nhân đôi + Chọn thêm… + Xem trước nhỏ gọn + Xem trước đầy đủ + Màn hình luôn bật + + Chỉnh sửa mô tả + Mô tả tệp đính kèm + Ghi âm + Đang ghi (%1$s) + Đính kèm tập tin + Chụp ảnh + Clip đã ghi + + Khác + Sổ tay mới + Tên sổ tay + Không có sổ tay + Bạn không có sổ tay. + Tạo sổ tay + Đổi tên sổ tay + Sổ tay mới + Sổ tay có tên %1$s đã tồn tại. + + Ghi chú đã lưu trữ của bạn sẽ xuất hiện ở đây. + + Thùng rỗng + Bạn có chắc không? + Tất cả ghi chú đã xóa sẽ bị mất. + Không thể chỉnh sửa ghi chú bên trong thùng. + Các ghi chú đã xóa của bạn sẽ xuất hiện ở đây trong %1$d ngày. + Ghi chú được đặt thành xóa ngay lập tức.\nBạn có thể thay đổi điều này tại Thiết đặt. + Ghi chú được đặt thành không bao giờ bị xóa.\nBạn có thể thay đổi điều này tại Thiết đặt. + Đã xóa vĩnh viễn các ghi chú. + Đã xóa vĩnh viễn ghi chú. + + Lời nhắc nhở + Nhắc nhở + Tên lời nhắc + Đặt ngày + Đặt thời gian + Lời nhắc mới + Bạn có một lời nhắc nhở. + Không thể đặt lời nhắc cho ngày đã qua + + Thẻ mới + Tên thẻ + Bạn không có thẻ nào. + Đổi tên thẻ + Thẻ có tên %1$s đã tồn tại. + + Tìm kiếm… + Tìm kiếm + Kết quả tìm kiếm sẽ được hiển thị ở đây. + Không tìm thấy kết quả nào. + + Tiêu đề + Hãy ghi chú! + Nhiệm vụ + Chuyển thành ghi chú + Chuyển thành danh sách + Không đồng bộ + Thay đổi màu sắc + Bật markdown + Tắt markdown + Kích hoạt màn hình luôn bật + Tắt màn hình luôn bật + Chèn markdown in đậm + Chèn markdown in nghiêng + Chèn markdown gạch ngang + Chèn markdown nổi bật + Chèn markdown tiêu đề + Chèn markdown ngoặc kép + Chèn markdown mã + Xóa tất cả các tác vụ đã đánh dấu + Được tạo lúc %1$s\nSửa đổi lần cuối lúc %2$s + Đã khôi phục các ghi chú + Đã khôi phục ghi chú + Đã lưu trữ các ghi chú + Đã lưu trữ ghi chú + Đã chuyển các ghi chú vào thùng rác + Đã chuyển ghi chú vào thùng rác + Ghi chú trống bị loại bỏ + Chèn liên kết + Chèn + Chèn bảng + Số hàng và cột không hợp lệ + Chèn hình ảnh + Mô tả + Đường dẫn hình ảnh + Văn bản + URL + Số cột + Số hàng + Không thể xem trước bảng. + + Đang sao lưu ghi chú của bạn… + Sao lưu hoàn tất! + Sao lưu không thành công + Đang khôi phục ghi chú của bạn… + Khôi phục hoàn tất + Khôi phục không thành công + + Lời nhắc + Sao lưu + Phát lại phương tiện + + Đây không phải là url HTTPS hợp lệ. + Bạn hiện đang đăng nhập với tên %s. + Bạn hiện chưa đăng nhập. + Xác thực + Tài khoản ngoại tuyến + Đang kết nối… + Đã xảy ra lỗi. + Không thể xác thực với máy chủ do thông tin xác thực không hợp lệ. + Đăng nhập thành công! + Phiên bản máy chủ không tương thích với ứng dụng này. + Kết nối Internet không khả dụng. + Thông tin xác thực không được để trống. + Xóa thông tin đăng nhập và URL máy chủ + Phát / Tạm dừng + Dừng lại + + Tạo ghi chú + Lập danh sách + Không bao giờ + + +%d mục + + + Đã chọn %d ghi chú + + + Đã chọn %d sổ tay + + + Đã chọn %d thẻ + + Tiếp tục + Danh sách thẻ + Sắp xếp thẻ theo + Ngăn điều hướng + Sắp xếp sổ ghi chép theo + diff --git a/app/src/main/res/values-zh-rCN/no_translate_strings.xml b/app/src/main/res/values-zh-rCN/no_translate_strings.xml new file mode 100644 index 00000000..d781ec5f --- /dev/null +++ b/app/src/main/res/values-zh-rCN/no_translate_strings.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 00000000..db2891d3 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,281 @@ + + + + 笔记 + 存档 + 废纸篓 + 设置 + 关于 + 标签 + 所有笔记 + 笔记本 + 您的笔记本 + + + 常规 + 主题模式 + 深色 + 浅色 + 跟随系统 + 深色主题模式 + 标准 + 暗黑 + 配色方案 + 蓝色 + 粉色 + 绿色 + 橙色 + 紫色 + 黄色 + 红色 + 跟随系统 + + 视图 + + 笔记本 + 布局模式 + 格子 + 列表 + 排序方式 + 标题 (升序) + 标题 (降序) + 创建日期 (升序) + 创建日期 (降序) + 修改日期 (升序) + 修改日期 (降序) + + 备注内容 + 文本编辑/查看字体大小 + 默认 + 编辑/查看字符按钮位置 + 浮动按钮 + 顶部栏 + 显示 创建/修改日期 + 日期格式 + 时间格式 + 其他 + 对于不在笔记本中的所有笔记进行分组 + 打开媒体 + 内部播放器 + 外部播放器 + 删除废纸篓中的笔记 + 立即 + 7天后 + 14天后 + 30天后 + 备份 + 创建备份 + 将所有笔记导出到备份文件。 + 恢复 + 从备份文件加载笔记。 + 附件的备份策略 + 备份所有内容 + 仅备份说明和本地路径 + 不备份附件 + 同步 + 转到同步设置 + 当前正在与 %s 同步 + 当前未同步 + 同步服务 + 禁用 + Nextcloud云服务 + 请记住,Nextcloud Notes 不支持标签、附件、提醒等功能。并且这个功能需要Nextcloud服务端装有Nextcloud Notes插件! + Nextcloud 帐户 + Nextcloud 实例网址 + 设置您的证书 + 设置服务器的网址 + 无线局域网 + 无线局域网与流量数据 + 启用时同步 + 后台同步 + 启用 + 禁用 + 可同步的新笔记 + + 版本 + 网址 + 开发者 + 贡献 + 支持 + 创建和维护开源项目非常耗时,而且不会带来 任何收益。\n请开发人员喝啤酒! + 关于库 + 查看第三方库和许可证。 + + 默认 + 是的 + + 好的 + 您的笔记将显示在此处。 + 无标题 + 用户名 + 密码 + 保存 + 取消 + 删除 + 完成! + 彻底删除 + 全选 + 创建列表 + 显示隐藏的笔记 + 取消存档 + 导出 + 隐藏 + 显示 + 存档 + 置顶 + 取消置顶 + 恢复 + 共享 + 置顶 / 取消置顶 + 移动到… + 生成副本 + 选择更多… + 精简预览 + 完整预览 + + 编辑说明 + 附件说明 + 录制音频 + 正在录制音频 (%1$s) + 附加文件 + 拍照 + 录制的音频剪辑 + + 其他 + 新笔记本 + 笔记本名称 + 无笔记本 + 您没有笔记本。 + 创建一本笔记本 + 重命名笔记本 + 新笔记本 + 名称为 %1$s 的笔记本已存在。 + + 您存档的笔记将显示在此处。 + + 清空废纸篓 + 是否确定? + 所有已删除的笔记都将丢失。 + 无法编辑废纸篓中的注释。 + 您删除的笔记将显示在此处 %1$d 天。 + 笔记设置为立即删除。\n您可以在设置中更改此设置。 + 笔记设置为永不删除。\n您可以在“设置”中更改此设置。 + 彻底删除笔记。 + 彻底删除笔记。 + + 提醒 + 提醒 + 提醒事项名称 + 设置日期 + 设置时间 + 新提醒 + 您有一个提醒。 + 无法为过去的日期设置提醒 + + 新标签 + 标签名称 + 您没有添加标签。 + 重命名标签 + 名称为 %1$s 的标签已存在。 + + 搜索… + 搜索 + 搜索结果将显示在此处。 + 未找到结果。 + + 标题 + 记笔记! + 任务 + 转换为笔记 + 转换为清单 + 不同步 + 更改颜色 + 启用 Markdown + 禁用 Markdown + 插入 Markdown粗体 + 插入 Markdown斜体 + 插入 Markdown删除线 + 插入 Markdown高亮显示 + 插入 Markdown标题 + 插入 Markdown引文 + 插入 Markdown代码 + 删除所有选中的任务 + 创建于 %1$s\n上次修改时间 %2$s + 恢复的笔记 + 恢复的笔记 + 已存档的笔记 + 已存档的笔记 + 把笔记移至废纸篓 + 把笔记移至废纸篓 + 已自动丢弃空白笔记 + 插入链接 + 插入 + 插入表格 + 无效的行数和列数 + 插入图像 + 描述 + 图像路径 + 文本 + 网址 + 列数 + 行数 + 无法预览表格。 + + 备份您的笔记… + 备份完成! + 备份失败 + 恢复您的笔记… + 恢复已完成 + 恢复失败 + + 提醒 + 备份 + 媒体播放 + + 这不是有效的 HTTPS 网址。 + 您当前以 %s 身份登录。 + 您当前尚未登录。 + 身份验证 + 离线帐户 + 连接中… + 出了点问题。 + 由于证书无效,无法向服务器进行身份验证。 + 登录成功! + 服务器版本与此应用程序不兼容。 + 互联网连接不可用。 + 证书不能为空。 + 清除证书和服务器的URL网址 + 播放 / 暂停 + 停止 + + 记笔记 + 制作清单 + 从不 + + +%d 个项目 + + + 选定的 %d 条笔记 + + + 选定的 %d 本笔记本 + + + 选定的 %d 个标签 + + 继续 + 标签列表 + 按以下方式对标签进行排序 + 导航抽屉 + 按以下方式对笔记本进行排序 + 存储位置 + 选择存储位置 + 保持屏幕常亮 + 取消屏幕常亮 + 设置屏幕常亮 + 取消勾选所有项 + 勾选项目时动态移动所在列表 + 编辑/查看模式 + 本地存储 + 可以选择本地存储或者云存储的文件夹来同步笔记。所有笔记将以Markdown格式进行存储。 + diff --git a/app/src/main/res/values-zh-rTW/no_translate_strings.xml b/app/src/main/res/values-zh-rTW/no_translate_strings.xml new file mode 100644 index 00000000..d781ec5f --- /dev/null +++ b/app/src/main/res/values-zh-rTW/no_translate_strings.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 00000000..7112e7c0 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,277 @@ + + + + 筆記 + 歸檔 + 垃圾桶 + 設定 + 關於 + 標籤 + 所有筆記 + 筆記本 + 你的筆記本 + + + 一般 + 主題模式 + 深色 + 淺色 + 跟隨系統 + 深色主題模式 + 標準 + + 配色方案 + 藍色 + 粉色 + 綠色 + 橙色 + 紫色 + 黃色 + 紅色 + 跟隨系統 + + 檢視 + + 筆記本 + 布局模式 + 網格 + 列表 + 排序方式 + 標題 (升序) + 標題 (降序) + 製作日期 (升序) + 製作日期 (降序) + 修改日期 (升序) + 修改日期 (降序) + + 筆記內容 + 文字編輯器/檢視字體大小 + 預設 + 編輯/檢視筆記按鈕位置 + 浮動按鈕 + 置頂橫幅 + 顯示製作/修改日期 + 日期格式 + 時間格式 + + 其他 + 打開媒體於 + 內部播放器 + 外部播放器 + 將不屬於任何筆記本的筆記群組 + 移動清單中已(未)勾選的項目 + 刪除垃圾桶中的筆記 + 立即 + 7天後 + 14天後 + 30天後 + + 備份 + 製作備份 + 將所有筆記匯出到備份文件。 + 還原 + 從備份文件讀取筆記。 + 附件的備份策略 + 備份所有內容 + 僅備份說明和本地路徑 + 不備份附件 + + 同步 + 移到同步設置 + 目前正在與 %s 同步 + 目前未同步 + 同步服務 + 停用 + Nextcloud + 請留意Nextcloud Notes 不支援標籤、附件、提醒等功能。 + Nextcloud 帳戶 + Nextcloud 實例網址 + 設置您的認證 + 設置伺服器的網址 + 無線網路 + 無線網路或數據網路 + 使用何種網路時同步 + 背景同步 + 啟用 + 停用 + 可同步的新筆記 + + 版本 + 網址 + 開發者 + 貢獻 + 支援 + 製作和維護開源項目非常耗時,而且不會帶來 任何利潤。\n請開發人員喝啤酒! + 關於函式庫 + 查看第三方函式庫和授權。 + + 預設 + + + 好的 + 你的筆記將顯示在此處。 + 無標題 + 使用者 + 密碼 + 儲存 + 取消 + 刪除 + 完成! + 永久刪除 + 全選 + 製作列表 + 顯示隱藏的筆記 + 取消歸檔 + 匯出 + 隱藏 + 顯示 + 歸檔 + 編輯/檢視模式e + 置頂 + 取消置頂 + 還原 + 分享 + 置頂 / 取消置頂 + 移動到… + 製作副本 + 選擇更多… + 精簡預覽 + 完整預覽 + + 編輯說明 + 附件說明 + 錄製聲音 + 正在錄製聲音 (%1$s) + 附加文件 + 拍照 + 已錄製的聲音片段 + + 其他 + 新筆記本 + 筆記本名稱 + 無筆記本 + 沒有筆記本。 + 製作一本筆記本 + 重新命名筆記本 + 新筆記本 + 名稱為 %1$s 的筆記本已存在。 + + 您歸檔的筆記將顯示在此處。 + + 清空垃圾桶 + 是否確定? + 所有已刪除的筆記都將消失。 + 無法編輯垃圾桶中筆記的注釋。 + 您刪除的筆記將在此處顯示 %1$d 天。 + 筆記設置為立即刪除。\n您可以在設定中更改此設定。 + 筆記設置為永不刪除。\n您可以在設定中更改此設定。 + 徹底刪除筆記。 + 徹底刪除筆記。 + + 提醒 + 提醒 + 提醒事項名稱 + 設定日期 + 設定時間 + 新提醒 + 你有一個提醒。 + 無法為過去的日期設置提醒 + + 新標籤 + 標籤名稱 + 未添加標籤。 + 重新命名標籤 + 名稱為 %1$s 的標籤已存在。 + + 搜索… + 搜索 + 搜索結果將顯示在此處。 + 未找到結果。 + + 標題 + 寫點筆記! + 任務 + 轉換為筆記 + 轉換為清單 + 不同步 + 更改顏色 + 啟用Markdown + 停用Markdown + 插入Markdown粗體 + 插入Markdown斜體 + 插入Markdown刪除線 + 插入Markdown高亮顯示 + 插入Markdown標題 + 插入Markdown引文 + 插入Markdown代碼 + 移除所有勾選的任務 + 製作於 %1$s\n上次修改時間 %2$s + 還原的筆記 + 還原的筆記 + 已歸檔的筆記 + 已歸檔的筆記 + 把筆記移至垃圾桶 + 把筆記移至垃圾桶 + 忽略空筆記 + 插入連結 + 插入 + 插入表格 + 無效的行數和列數 + 插入影像 + 描述 + 影像路徑 + 文字 + 網址 + 列數 + 行數 + 無法預覽表格。 + + 備份您的筆記… + 備份完成 + 備份失敗 + 還原您的筆記… + 還原完成 + 還原失敗 + + 提醒 + 備份 + 媒體播放 + + 這不是有效的 HTTPS 網址。 + 您目前以 %s 身份登錄。 + 您目前尚未登錄。 + 身份驗證 + 離線帳戶 + 連接中… + 出了點問題。 + 由於認證無效,無法在服務器進行身份驗證。 + 登錄成功! + 伺服器版本與此應用程式不相容。 + 無網際網路連線 + 認證不能為空 + 清除認證和伺服器的網址 + 播放 / 暫停 + 停止 + + 作筆記 + 製作清單 + 從不 + + +%d 個項目 + + + 選擇 %d 條筆記 + + + 選擇的 %d 本筆記本 + + + 選擇的 %d 個標籤 + + 繼續 + 標籤列表 + 標籤排序依據 + 導航抽屜 + 筆記本排序依據 + 防止休眠 + diff --git a/app/src/main/res/values/attr.xml b/app/src/main/res/values/attr.xml index 471a1f93..2e3ee062 100755 --- a/app/src/main/res/values/attr.xml +++ b/app/src/main/res/values/attr.xml @@ -20,6 +20,7 @@ + @@ -41,5 +42,6 @@ + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..5d158494 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #202020 + #F5F5F5 + #121212 + diff --git a/app/src/main/res/values/datetime.xml b/app/src/main/res/values/datetime.xml index ac627862..7f02ea1e 100644 --- a/app/src/main/res/values/datetime.xml +++ b/app/src/main/res/values/datetime.xml @@ -5,6 +5,7 @@ d MMMM yyyy MM/d/yyyy d/MM/yyyy + yyyy-MM-dd HH:mm diff --git a/app/src/main/res/values/no_translate_strings.xml b/app/src/main/res/values/no_translate_strings.xml index 8d47de81..faa28529 100644 --- a/app/src/main/res/values/no_translate_strings.xml +++ b/app/src/main/res/values/no_translate_strings.xml @@ -1,12 +1,13 @@ - Quillnote - 1.4.3 - https://qosp.org - Michael Soultanidis - https://github.com/msoultanidis - https://github.com/msoultanidis/quillnote - https://qosp.org/#/support + Quillpad + https://quillpad.github.io + Arumugam J, Michael Soultanidis & Open Source Devs + + https://github.com/quillpad/quillpad/graphs/contributors + + https://github.com/quillpad/quillpad + https://buymeacoffee.com/quillpad https://… @@ -136,4 +137,4 @@ See the License for the specific language governing permissions and limitations under the License.\n```\n\n - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 957cac59..16fac4f4 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ Your Notebooks + General Theme mode Dark @@ -21,11 +22,6 @@ Standard Black - View - Layout mode - Grid - List - Color scheme Blue Pink @@ -34,6 +30,17 @@ Purple Yellow Red + Follow system + + + View + + + Notebook + + Layout mode + Grid + List Sort by Title (ascending) @@ -43,21 +50,47 @@ Date modified (ascending) Date modified (descending) + + Note content + + Text editor/view font size + Default + 10 + 15 + 20 + 25 + 30 + 35 + 40 + 45 + 50 + + Edit/View note button position + Floating button + Top bar + Show date created/modified Date format Time format + Other - Group notes which are not in any notebook + Open media in Internal player External player + + Group notes which are not in any notebook + + Move (un)checked items in the lists + Delete notes in bin Instantly After 7 days After 14 days After 30 days + Backup Create a backup Export all notes to a backup file. @@ -68,6 +101,7 @@ Only backup description and local path Do not backup attachments + Syncing Go to sync settings Currently syncing with %s @@ -75,12 +109,14 @@ Syncing service Disabled Nextcloud - Syncing functionality is currently experimental.\nYou may encounter bugs. - Keep in mind Nextcloud Notes does not support features such as tags, attachments, reminders, task lists and more. + Keep in mind Nextcloud Notes does not support features such as tags, attachments, reminders and more. Nextcloud Notes need to be installed on the Nextcloud Server or the Login will fail with "Something went wrong"! Nextcloud account Nextcloud instance URL Set your credentials Set the server\'s URL + Select a folder from local storage or from a cloud provider. All notes will be stored as Markdown files. + Storage location + Select Storage location Wi-Fi Wi-Fi or Data Sync when on @@ -88,7 +124,7 @@ Enabled Disabled New notes synchronizable - + Trust self-signed certificate Version @@ -96,8 +132,10 @@ Developer Contribute Support + Send Error Logs + Send the application logs to the developer for investigation Creating and maintaining open-source projects is time-consuming and provides - no profit.\nBuy the developers a beer! + no profit.\nBuy the developers a coffee! Libraries View third-party libraries and licenses. @@ -125,6 +163,7 @@ Hide Show Archive + Edit/View mode Pin Unpin Restore @@ -133,6 +172,9 @@ Move to… Duplicate Select more… + Compact preview + Full preview + Screen always on @@ -168,6 +210,7 @@ Notes inside the bin cannot be edited. Your deleted notes will appear here for %1$d days. Notes are set to be deleted instantly.\nYou can change this at Settings. + Notes are set to never be deleted.\nYou can change this at Settings. Deleted notes permanently. Deleted note permanently. @@ -208,12 +251,17 @@ Change color Enable markdown Disable markdown + Enable screen always on + Disable screen always on Insert bold markdown Insert italics markdown Insert strikethrough markdown + Insert highlight markdown Insert heading markdown Insert quotation markdown Insert code markdown + Uncheck all items + Remove all checked tasks Created at %1$s\nLast modified at %2$s Restored notes Restored note @@ -258,7 +306,11 @@ Connecting… Something went wrong. Could not authenticate with server due to invalid credentials. + Certificate error. Please enable \"Trust self-signed certificate\" to continue.. + Logged in successfully! + Notes app is not installed in your Nextcloud. Server version is not compatible with this app. Internet connection is not available. Credentials cannot be blank. @@ -269,6 +321,12 @@ Take a note Make a list + Never + Tag list + Sort tags by + Navigation drawer + Sort notebooks by + Continue +%d item @@ -289,4 +347,11 @@ Selected %d tag Selected %d tags - \ No newline at end of file + File Storage + Report Errors + There has been an error. Please report this to the + developers. + + You can add comments here: + sixface@msn.com + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 284a2f17..7f891e32 100755 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -8,10 +8,16 @@ #1E000000 #99000000 - #121212 - #FFFFFF + + @android:color/transparent + + + + @android:color/transparent + + #FFFFFF #59000000 @@ -37,6 +43,8 @@ #FFAB91 #FFF59D + #96FFFF00 + @style/Widget.Custom.PopupMenu @style/DialogTheme @@ -54,7 +62,10 @@ #000 #191919 #121212 - #090909 + + + @android:color/transparent + diff --git a/app/src/main/res/xml-v25/shortcuts.xml b/app/src/main/res/xml-v25/shortcuts.xml index 835c58b9..2fc87b61 100644 --- a/app/src/main/res/xml-v25/shortcuts.xml +++ b/app/src/main/res/xml-v25/shortcuts.xml @@ -7,9 +7,9 @@ android:shortcutShortLabel="@string/shortcut_take_note"> + android:data="quillpad://notes/editor_create?newNoteIsList=false"/> + android:data="quillpad://notes/editor_create?newNoteIsList=true"/> \ No newline at end of file diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 00000000..2ade8a5a --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..1de93f4a --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/test/java/org/qosp/notes/data/BackupTest.kt b/app/src/test/java/org/qosp/notes/data/BackupTest.kt new file mode 100644 index 00000000..930eaedc --- /dev/null +++ b/app/src/test/java/org/qosp/notes/data/BackupTest.kt @@ -0,0 +1,37 @@ +package org.qosp.notes.data + +import org.junit.Assert.* +import org.junit.Test +import org.qosp.notes.data.model.* +import org.qosp.notes.preferences.CloudService + +class BackupTest { + + @Test + fun serializer() { + val backup = Backup( + 1, + setOf( + Note( + title = "title", + content = "content", + isList = false, + taskList = listOf(NoteTask(33L, "reminder", false)), + attachments = listOf(Attachment(type = Attachment.Type.AUDIO, path = "sdt")), + tags = listOf(Tag("tag", 33L)), + reminders = listOf(Reminder("reminder", 2442L, 234)), + color = NoteColor.Blue + ) + ), + setOf(Notebook("nb")), + joins = setOf(NoteTagJoin(33, 44)), + idMappings = setOf( + IdMapping( + 33, 44, 12, + CloudService.NEXTCLOUD, null, false, false + ) + ) + ) + assertNotNull(backup.serialize()) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/qosp/notes/data/sync/core/SynchronizeNotesTest.kt b/app/src/test/java/org/qosp/notes/data/sync/core/SynchronizeNotesTest.kt new file mode 100644 index 00000000..5463c85b --- /dev/null +++ b/app/src/test/java/org/qosp/notes/data/sync/core/SynchronizeNotesTest.kt @@ -0,0 +1,337 @@ +package org.qosp.notes.data.sync.core + +import android.util.Log +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.qosp.notes.data.model.IdMapping +import org.qosp.notes.data.model.Note +import org.qosp.notes.data.repo.IdMappingRepository +import org.qosp.notes.preferences.CloudService + +class SynchronizeNotesTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + private lateinit var idMappingRepository: IdMappingRepository + + @InjectMockKs + private lateinit var synchronizeNotes: SynchronizeNotes + + @Before + fun setup() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + } + + @Test + fun `empty local and remote notes returns empty result`() = runTest { + // Given + val localNotes = emptyList() + val remoteNotes = emptyList() + coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns emptyList() + + // When + val result = synchronizeNotes(localNotes, remoteNotes, CloudService.NEXTCLOUD) + + // Then + assertEquals(0, result.localUpdates.size) + assertEquals(0, result.remoteUpdates.size) + coVerify { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } + } + + @Test + fun `local notes without mapping should be created remotely`() = runTest { + // Given + val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 100L) + val localNotes = listOf(localNote) + val remoteNotes = emptyList() + coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns emptyList() + + // When + val result = synchronizeNotes(localNotes, remoteNotes, CloudService.NEXTCLOUD) + + // Then + assertEquals(0, result.localUpdates.size) + assertEquals(1, result.remoteUpdates.size) + val action = result.remoteUpdates[0] as NoteAction.Create + assertEquals(localNote, action.note) + assertEquals("", action.remoteNote.idStr) + assertEquals(localNote.title, action.remoteNote.title) + assertEquals(localNote.modifiedDate, action.remoteNote.lastModified) + } + + @Test + fun `remote notes without mapping should be created locally`() = runTest { + // Given + val remoteNote = + SyncNote(id = 0L, idStr = "remote1", title = "Remote Note", lastModified = 100L, content = null) + val localNotes = emptyList() + val remoteNotes = listOf(remoteNote) + coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns emptyList() + + // When + val result = synchronizeNotes(localNotes, remoteNotes, CloudService.NEXTCLOUD) + + // Then + assertEquals(1, result.localUpdates.size) + assertEquals(0, result.remoteUpdates.size) + val action = result.localUpdates[0] as NoteAction.Create + assertEquals(remoteNote.title, action.note.title) + assertEquals(remoteNote.lastModified, action.note.modifiedDate) + assertEquals(remoteNote, action.remoteNote) + } + + @Test + fun `local note newer than remote note should update remote`() = runTest { + // Given + val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 200L) + val remoteNote = SyncNote(id = 0L, idStr = "2", title = "Remote Note", lastModified = 100L, content = null) + val mapping = IdMapping( + localNoteId = 1L, + remoteNoteId = 2L, + provider = CloudService.NEXTCLOUD, + extras = null, + isDeletedLocally = false + ) + + coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns listOf(mapping) + + // When + val result = synchronizeNotes(listOf(localNote), listOf(remoteNote), CloudService.NEXTCLOUD) + + // Then + assertEquals(0, result.localUpdates.size) + assertEquals(1, result.remoteUpdates.size) + val action = result.remoteUpdates[0] as NoteAction.Update + assertEquals(localNote, action.note) + assertEquals(remoteNote, action.remoteNote) + } + + @Test + fun `remote note newer than local note should update local`() = runTest { + // Given + val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 100L) + val remoteNote = SyncNote(id = 0L, idStr = "2", title = "Remote Note", lastModified = 200L, content = null) + val mapping = IdMapping( + localNoteId = 1L, + remoteNoteId = 2L, + provider = CloudService.NEXTCLOUD, + extras = null, + isDeletedLocally = false + ) + + coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns listOf(mapping) + + // When + val result = synchronizeNotes(listOf(localNote), listOf(remoteNote), CloudService.NEXTCLOUD) + + // Then + assertEquals(1, result.localUpdates.size) + assertEquals(0, result.remoteUpdates.size) + val action = result.localUpdates[0] as NoteAction.Update + assertEquals(localNote, action.note) + assertEquals(remoteNote, action.remoteNote) + } + + @Test + fun `deleted local note should delete remote note`() = runTest { + // Given + val remoteNote = SyncNote(id = 0L, idStr = "2", title = "Remote Note", lastModified = 100L, content = null) + val mapping = IdMapping( + localNoteId = 1L, + remoteNoteId = 2L, + provider = CloudService.NEXTCLOUD, + extras = null, + isDeletedLocally = false + ) + + coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns listOf(mapping) + + // When + val result = synchronizeNotes(emptyList(), listOf(remoteNote), CloudService.NEXTCLOUD) + + // Then + assertEquals(0, result.localUpdates.size) + assertEquals(1, result.remoteUpdates.size) + val action = result.remoteUpdates[0] as NoteAction.Delete + assertEquals(1L, action.note.id) + assertEquals(remoteNote, action.remoteNote) + } + + @Test + fun `deleted remote note should trigger local delete action`() = runTest { + // Given + val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 100L, content = "Some content") + val mapping = IdMapping( + localNoteId = 1L, + remoteNoteId = 2L, + provider = CloudService.NEXTCLOUD, + extras = null, + isDeletedLocally = false + ) + + coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns listOf(mapping) + + // When + val result = synchronizeNotes(listOf(localNote), emptyList(), CloudService.NEXTCLOUD) + + // Then + assertEquals(1, result.localUpdates.size) + assertEquals(0, result.remoteUpdates.size) + val action = result.localUpdates[0] as NoteAction.Delete + assertEquals(localNote, action.note) + // Check that the remoteNote in the action is a placeholder, as the actual remote note is gone + assertEquals("", action.remoteNote.idStr) + assertEquals(localNote.title, action.remoteNote.title) + assertEquals(localNote.modifiedDate, action.remoteNote.lastModified) + assertEquals(localNote.content, action.remoteNote.content) + } + + @Test + fun `file storage service should use storageUri instead of remoteNoteId`() = runTest { + // Given + val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 200L) + val remoteNote = SyncNote( + id = 0L, + idStr = "file://path/to/note.txt", + title = "Remote Note", + lastModified = 100L, + content = null + ) + val mapping = IdMapping( + localNoteId = 1L, + remoteNoteId = null, + provider = CloudService.FILE_STORAGE, + extras = null, + isDeletedLocally = false, + storageUri = "file://path/to/note.txt" + ) + + coEvery { idMappingRepository.getAllByProvider(CloudService.FILE_STORAGE) } returns listOf(mapping) + + // When + val result = synchronizeNotes(listOf(localNote), listOf(remoteNote), CloudService.FILE_STORAGE) + + // Then + assertEquals(0, result.localUpdates.size) + assertEquals(1, result.remoteUpdates.size) + val action = result.remoteUpdates[0] as NoteAction.Update + assertEquals(localNote, action.note) + assertEquals(remoteNote, action.remoteNote) + } + // Tests for TITLE sync method + + @Test + fun `title sync - empty local and remote notes returns empty result`() = runTest { + // Given + val localNotes = emptyList() + val remoteNotes = emptyList() + + // When + val result = synchronizeNotes(localNotes, remoteNotes, CloudService.NEXTCLOUD, SyncMethod.TITLE) + + // Then + assertEquals(0, result.localUpdates.size) + assertEquals(0, result.remoteUpdates.size) + } + + @Test + fun `title sync - local notes without matching remote title should be created remotely`() = runTest { + // Given + val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 100L) + val localNotes = listOf(localNote) + val remoteNotes = emptyList() + + // When + val result = synchronizeNotes(localNotes, remoteNotes, CloudService.NEXTCLOUD, SyncMethod.TITLE) + + // Then + assertEquals(0, result.localUpdates.size) + assertEquals(1, result.remoteUpdates.size) + val action = result.remoteUpdates[0] as NoteAction.Create + assertEquals(localNote, action.note) + assertEquals("", action.remoteNote.idStr) + assertEquals(localNote.title, action.remoteNote.title) + assertEquals(localNote.modifiedDate, action.remoteNote.lastModified) + } + + @Test + fun `title sync - remote notes without matching local title should be created locally`() = runTest { + // Given + val remoteNote = + SyncNote(id = 0L, idStr = "remote1", title = "Remote Note", lastModified = 100L, content = null) + val localNotes = emptyList() + val remoteNotes = listOf(remoteNote) + + // When + val result = synchronizeNotes(localNotes, remoteNotes, CloudService.NEXTCLOUD, SyncMethod.TITLE) + + // Then + assertEquals(1, result.localUpdates.size) + assertEquals(0, result.remoteUpdates.size) + val action = result.localUpdates[0] as NoteAction.Create + assertEquals(remoteNote.title, action.note.title) + assertEquals(remoteNote.lastModified, action.note.modifiedDate) + assertEquals(remoteNote, action.remoteNote) + } + + @Test + fun `title sync - local note newer than remote note with same title should update remote`() = runTest { + // Given + val localNote = Note(id = 1L, title = "Same Title", modifiedDate = 200L) + val remoteNote = SyncNote(id = 0L, idStr = "remote1", title = "Same Title", lastModified = 100L, content = null) + + // When + val result = synchronizeNotes(listOf(localNote), listOf(remoteNote), CloudService.NEXTCLOUD, SyncMethod.TITLE) + + // Then + assertEquals(0, result.localUpdates.size) + assertEquals(1, result.remoteUpdates.size) + val action = result.remoteUpdates[0] as NoteAction.Update + assertEquals(localNote, action.note) + assertEquals(remoteNote, action.remoteNote) + } + + @Test + fun `title sync - remote note newer than local note with same title should update local`() = runTest { + // Given + val localNote = Note(id = 1L, title = "Same Title", modifiedDate = 100L) + val remoteNote = SyncNote(id = 0L, idStr = "remote1", title = "Same Title", lastModified = 200L, content = null) + + // When + val result = synchronizeNotes(listOf(localNote), listOf(remoteNote), CloudService.NEXTCLOUD, SyncMethod.TITLE) + + // Then + assertEquals(1, result.localUpdates.size) + assertEquals(0, result.remoteUpdates.size) + val action = result.localUpdates[0] as NoteAction.Update + assertEquals(localNote, action.note) + assertEquals(remoteNote, action.remoteNote) + } + + @Test + fun `title sync - notes with same title and similar timestamps should not create actions`() = runTest { + // Given + val localNote = Note(id = 1L, title = "Same Title", modifiedDate = 100L) + val remoteNote = SyncNote(id = 0L, idStr = "remote1", title = "Same Title", lastModified = 100L, content = null) + + // When + val result = synchronizeNotes(listOf(localNote), listOf(remoteNote), CloudService.NEXTCLOUD, SyncMethod.TITLE) + + // Then + assertEquals(0, result.localUpdates.size) + assertEquals(0, result.remoteUpdates.size) + } +} diff --git a/app/src/test/java/org/qosp/notes/di/KoinModulesTest.kt b/app/src/test/java/org/qosp/notes/di/KoinModulesTest.kt new file mode 100644 index 00000000..8691b519 --- /dev/null +++ b/app/src/test/java/org/qosp/notes/di/KoinModulesTest.kt @@ -0,0 +1,27 @@ +package org.qosp.notes.di + +import org.junit.Test +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.test.KoinTest +import org.koin.test.verify.verify + +class KoinModulesTest : KoinTest { + + @OptIn(KoinExperimentalAPI::class) + @Test + fun testUiModule() { + UIModule.uiModule.verify() + } + @OptIn(KoinExperimentalAPI::class) + @Test + fun testRepositoryModule() { + RepositoryModule.repoModule.verify() + } + + @OptIn(KoinExperimentalAPI::class) + @Test + fun testUtilModule() { + UtilModule.utilModule.verify() + } + +} diff --git a/app/src/test/java/org/qosp/notes/ui/utils/ToasterTest.kt b/app/src/test/java/org/qosp/notes/ui/utils/ToasterTest.kt new file mode 100644 index 00000000..ea234ab8 --- /dev/null +++ b/app/src/test/java/org/qosp/notes/ui/utils/ToasterTest.kt @@ -0,0 +1,53 @@ +package org.qosp.notes.ui.utils + +import android.widget.Toast +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class ToasterTest { + + private lateinit var toaster: Toaster + + @Before + fun setUp() { + toaster = Toaster() + } + + @Test + fun `showShort emits message with Toast LENGTH_SHORT`() = runTest { + val testMessage = "Short test message" + + val job = launch { + val (message, time) = withTimeout(1.seconds) { + toaster.messages.first() + } + assertEquals(testMessage, message) + assertEquals(Toast.LENGTH_SHORT, time) + } + + toaster.showShort(testMessage) + job.join() + } + + @Test + fun `showLong emits message with Toast LENGTH_LONG`() = runTest { + val testMessage = "Long test message" + + val job = launch { + val (message, time) = withTimeout(1.seconds) { + toaster.messages.first() + } + assertEquals(testMessage, message) + assertEquals(Toast.LENGTH_LONG, time) + } + + toaster.showLong(testMessage) + job.join() + } +} diff --git a/build.gradle b/build.gradle deleted file mode 100755 index d381b59a..00000000 --- a/build.gradle +++ /dev/null @@ -1,44 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - ext.kotlin_version = '1.5.31' - ext.hilt_version = "2.41" - ext.nav_version = "2.4.1" - ext.material_version = "1.4.0" - ext.room_version = "2.4.2" - ext.lifecycle_version = "2.4.1" - ext.datastore_version = "1.0.0" - ext.markwon_version = "4.6.2" - ext.work_version = "2.7.1" - ext.coil_version = "1.4.0" - ext.leakcanary_version = "2.7" - ext.photoview_version = "2.3.0" - ext.exoplayer_version = "2.17.1" - ext.retrofit_version = "2.9.0" - - repositories { - google() - mavenCentral() - maven { url "https://www.jitpack.io" } - } - dependencies { - classpath "com.android.tools.build:gradle:4.1.3" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" - classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version" - classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -allprojects { - repositories { - google() - mavenCentral() - maven { url "https://www.jitpack.io" } - } -} - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100755 index 00000000..65155b26 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,17 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.kotlinAndroid) apply false + alias(libs.plugins.kotlinSerialization) apply false + alias(libs.plugins.kotlinParcelize) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.navigationSafeArgs) apply false +} + +allprojects { + repositories { + google() + mavenCentral() + maven { setUrl("https://www.jitpack.io") } + } +} diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt new file mode 100644 index 00000000..84f52928 --- /dev/null +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -0,0 +1,19 @@ +Quillpad - ein Fork von Quillnote - ist komplett kostenlos, Open Source, vollkommen werbefrei, ohne unnötiger Berechtigungen und lädt niemals deine Notizen ohne dein Wissen dorthin hoch, wo du sie nicht haben willst. + +Erstelle dank Markdown hübsche Notizen wo auch immer du gerade bist. Ordne sie in Notizbüchern und weise ihnen Schlagwörter (Tags) zu. Organisiere dich mit Aufgabenlisten, setze Erinnerungen und behalte alles an einem Ort, indem du Dateien anhängst. + +Mit Quillpad kannst du: +- Notizen mit Markdown-Unterstützung verfassen +- Aufgabenlisten erstellen +- Notizen als Favoriten markieren +- Notizen verstecken, damit sie nicht von anderen gesehen werden können +- Erinnerungen setzen, um nichts zu verpassen +- Sprachnachrichten und andere Dateien anhängen +- verwandte Notizen in Notizbüchern gruppieren +- Schlagwörter (Tags) zuweisen +- Notizen archivieren +- in Notizen suchen +- mit Nextcloud synchronisieren (erfordert die Nextcloud-App "Nextcloud Notes") +- deine Notizen als Zip-Datei exportieren, um sie bei Bedarf wiederherzustellen +- zwischen Hell- und Dunkelmodus umschalten +- zwischen mehreren Farbthemen wählen diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/fastlane/metadata/android/de-DE/short_description.txt new file mode 100644 index 00000000..9b59eb9e --- /dev/null +++ b/fastlane/metadata/android/de-DE/short_description.txt @@ -0,0 +1 @@ +Markdown-Notizen, Todo-Listen, Synchronisation und mehr. Ein Fork von Quillnote diff --git a/fastlane/metadata/android/el/changelogs/1.txt b/fastlane/metadata/android/el/changelogs/1.txt index 132f5366..f38e8e8c 100644 --- a/fastlane/metadata/android/el/changelogs/1.txt +++ b/fastlane/metadata/android/el/changelogs/1.txt @@ -1 +1 @@ -Πρώτη έκδοση. \ No newline at end of file +Πρώτη έκδοση. diff --git a/fastlane/metadata/android/el/changelogs/2.txt b/fastlane/metadata/android/el/changelogs/2.txt index 0e6ac37f..cdb6b5d8 100644 --- a/fastlane/metadata/android/el/changelogs/2.txt +++ b/fastlane/metadata/android/el/changelogs/2.txt @@ -1 +1 @@ -Βελτιώσεις και επιδιορθώσεις σφαλμάτων. \ No newline at end of file +Βελτιώσεις και επιδιορθώσεις σφαλμάτων. diff --git a/fastlane/metadata/android/el/changelogs/3.txt b/fastlane/metadata/android/el/changelogs/3.txt index cd4ed4d2..82fd4afb 100644 --- a/fastlane/metadata/android/el/changelogs/3.txt +++ b/fastlane/metadata/android/el/changelogs/3.txt @@ -1,2 +1,2 @@ - Προστέθηκε Ελληνική μετάφραση. -- Αρκετές άλλες βελτιώσεις. \ No newline at end of file +- Αρκετές άλλες βελτιώσεις. diff --git a/fastlane/metadata/android/el/changelogs/4.txt b/fastlane/metadata/android/el/changelogs/4.txt index 60675da0..80013f01 100644 --- a/fastlane/metadata/android/el/changelogs/4.txt +++ b/fastlane/metadata/android/el/changelogs/4.txt @@ -5,4 +5,4 @@ - Οι ημερομηνίες μπορούν πλέον να μην φαίνονται αλλάζοντας μια ρύθμιση - Προστέθηκε ρύθμιση η οποία ομαδοποιεί τις σημειώσεις που δεν ανήκουν σε κανένα τετράδιο - Νέα στοιχεία μιας λίστας προστίθενται αυτόματα καθώς γράφετε πατώντας το Enter -- Αρκετές άλλες βελτιώσεις \ No newline at end of file +- Αρκετές άλλες βελτιώσεις diff --git a/fastlane/metadata/android/el/changelogs/5.txt b/fastlane/metadata/android/el/changelogs/5.txt index 2d37667b..8cd74aa5 100644 --- a/fastlane/metadata/android/el/changelogs/5.txt +++ b/fastlane/metadata/android/el/changelogs/5.txt @@ -1,2 +1,2 @@ - Προστέθηκε δυνατότητα αναίρεσης. -- Προστέθηκε AMOLED θέμα. \ No newline at end of file +- Προστέθηκε AMOLED θέμα. diff --git a/fastlane/metadata/android/el/changelogs/6.txt b/fastlane/metadata/android/el/changelogs/6.txt index f98a4705..0c7a483f 100644 --- a/fastlane/metadata/android/el/changelogs/6.txt +++ b/fastlane/metadata/android/el/changelogs/6.txt @@ -1 +1 @@ -- Διόρθωση του σκούρου θέματος \ No newline at end of file +- Διόρθωση του σκούρου θέματος diff --git a/fastlane/metadata/android/el/changelogs/7.txt b/fastlane/metadata/android/el/changelogs/7.txt index 7962e568..bffe414f 100644 --- a/fastlane/metadata/android/el/changelogs/7.txt +++ b/fastlane/metadata/android/el/changelogs/7.txt @@ -1 +1 @@ -- Διόρθωση σφαλμάτων. \ No newline at end of file +- Διόρθωση σφαλμάτων. diff --git a/fastlane/metadata/android/el/changelogs/8.txt b/fastlane/metadata/android/el/changelogs/8.txt index 7962e568..bffe414f 100644 --- a/fastlane/metadata/android/el/changelogs/8.txt +++ b/fastlane/metadata/android/el/changelogs/8.txt @@ -1 +1 @@ -- Διόρθωση σφαλμάτων. \ No newline at end of file +- Διόρθωση σφαλμάτων. diff --git a/fastlane/metadata/android/el/full_description.txt b/fastlane/metadata/android/el/full_description.txt index 21c7005b..d4549c2a 100644 --- a/fastlane/metadata/android/el/full_description.txt +++ b/fastlane/metadata/android/el/full_description.txt @@ -1,8 +1,6 @@ -Κρατήστε πανέμορφες markdown σημειώσεις όποτε έχετε έμπνευση. Τοποθετήστε τις σε τετράδια και προσθέστε τις κατάλληλες ετικέτες. -Μείνετε οργανωμένοι φτιάχνοντας λίστες και θέτοντας υπενθυμίσεις. +Το Quillpad είναι ένα πιρούνι μιας αρχικής εφαρμογής που ονομάζεται Quillnote. Το Quillpad είναι πλήρως δωρεάν και ανοιχτού κώδικα. Δεν θα σας εμφανίσει ποτέ διαφημίσεις, δεν θα σας ζητήσει περιττές άδειες ή θα ανεβάσει τις σημειώσεις σας οπουδήποτε χωρίς να το γνωρίζετε. -Το Quillnote είναι 100% δωρεάν και λογισμικό ανοιχτού κώδικα (ΕΛΛΑΚ). -Δεν θα σας προβάλλει ποτέ διαφημίσεις, δεν θα σας ζητήσει πότε άδειες που δεν χρειάζεται και δεν θα ανεβάσει πουθενά τις σημειώσεις σας χωρίς την δική σας συγκατάθεση. +Κρατήστε όμορφες σημειώσεις σήμανσης όποτε νιώθετε έμπνευση, τοποθετήστε τις σε σημειωματάρια και προσθέστε τις ανάλογα. Μείνετε οργανωμένοι δημιουργώντας λίστες εργασιών, ορίστε υπενθυμίσεις και κρατήστε τα πάντα σε ένα μέρος επισυνάπτοντας σχετικά αρχεία. Με το Quillnote, μπορείτε να: @@ -19,4 +17,4 @@ - Συγχρονίσετε τις σημειώσεις σας με το Nextcloud (πειραματικό στάδιο) - Κρατήσετε αντίγραφα ασφαλείας που μπορείτε να ανακτήσετε αργότερα - Διαλέξετε μεταξύ dark & light mode -- Διαλέξετε το χρώμα της αρεσκείας σας για τις σημειώσεις σας ή ακόμα και για την ίδια την εφαρμογή. \ No newline at end of file +- Διαλέξετε το χρώμα της αρεσκείας σας για τις σημειώσεις σας ή ακόμα και για την ίδια την εφαρμογή. diff --git a/fastlane/metadata/android/el/short_description.txt b/fastlane/metadata/android/el/short_description.txt index 86f6821f..a81a846b 100644 --- a/fastlane/metadata/android/el/short_description.txt +++ b/fastlane/metadata/android/el/short_description.txt @@ -1 +1 @@ -Κρατήστε πανέμορφες markdown σημειώσεις και λίστες. \ No newline at end of file +Κρατήστε πανέμορφες markdown σημειώσεις και λίστες. diff --git a/fastlane/metadata/android/en-US/changelogs/9.txt b/fastlane/metadata/android/en-US/changelogs/9.txt new file mode 100644 index 00000000..b84de1ac --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/9.txt @@ -0,0 +1,4 @@ +- Option to highlight text +- Can now set the trash to never delete notes +- UI improvements +- Bug fixes \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index a3f45405..484a6304 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,9 +1,8 @@ -Take beautiful markdown notes whenever you feel inspired. Place them in notebooks and tag them accordingly. Stay organized by making task lists, set reminders and keep everything in one place by attaching related files. +Quillpad is a fork of an original app called Quillnote. Quillpad is fully free and open-source. It will never show you ads, ask you for unnecessary permissions or upload your notes anywhere without you knowing. -Quillnote is fully free and open-source. It will never show you ads, ask you for unnecessary permissions or upload your notes anywhere without you knowing. - -With Quillnote, you can: +Take beautiful markdown notes whenever you feel inspired, place them in notebooks and tag them accordingly. Stay organized by making task lists, set reminders and keep everything in one place by attaching related files. +With Quillpad, you can: - Take notes with Markdown support - Make task lists - Pin your favorite notes to the top @@ -14,7 +13,7 @@ With Quillnote, you can: - Add tags to notes - Archive notes you want out of your way - Search through notes -- Sync with Nextcloud (experimental) +- Sync with Nextcloud (requires the Nextcloud Notes app installed on the Nextcloud server used for syncing) - Backup your notes to a zip file which you can restore at a later time -- Toggle between light and dark mode +- Toggle between Light and Dark mode - Choose between multiple color schemes diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png index 197d00a7..35d05bc4 100644 Binary files a/fastlane/metadata/android/en-US/images/featureGraphic.png and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png index 109c18a7..d2bef5a0 100644 Binary files a/fastlane/metadata/android/en-US/images/icon.png and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index e0f5dab1..17ae8346 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Take beautiful markdown notes and stay organized with task lists. \ No newline at end of file +Markdown notes, task lists, Nextcloud sync and more. A fork of the Quillnote app \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt new file mode 100644 index 00000000..54901211 --- /dev/null +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -0,0 +1,19 @@ +Quillpad è un fork di una applicazione chiamata Quillnote. Quillpad è completamente libera e open-source. Non mostrerà mai annunci, né chiederà permessi non necessari o di caricare le proprie note da qualche parte senza che tu lo sappia. + +Scrivi bellissime note in markdown in qualunque momento ti senta ispirato, mettile in un taccuino ed etichettale come preferisci. Rimani organizzato creando elenchi di attvità, imposta promemoria e tieni tutto in unico posto allegando i ralativi file. + +Con Quillpad, puoi: +- Prendere note con il supporto markdown +- Creare elenchi di attività +- Fissare le note preferite in cima all'elenco +- Nascondere le note che non vuoi far vedere agli altri +- Impostare promemoria per eventi che non vuoi perderti +- Aggiunere registrazioni vocali e altri tipi di allegati +- Raggruppare le note correlate in taccuini +- Aggiungere etichette alle note +- Archiviare le note che non ti servono +- Cercare nelle note +- Sincronizzare i contenuti con Nextcloud (richiede che l'app Note di Nextcloud Notes sia installata sul server Nextcloud utilizzato per la sincronizzazione) +- Eseguire il backup delle note in un file .zip che può essere ripristinato in un secondo tempo +- Scegliere tra le modalità Chiaro e Scuro +- Scegliere tra molteplici schemi di colore diff --git a/fastlane/metadata/android/it-IT/short_description.txt b/fastlane/metadata/android/it-IT/short_description.txt new file mode 100644 index 00000000..49e21d63 --- /dev/null +++ b/fastlane/metadata/android/it-IT/short_description.txt @@ -0,0 +1 @@ +Note, elenchi di attività, sincronizzazione Nextcloud e altro. Fork di Quillnote diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt new file mode 100644 index 00000000..40f205a3 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -0,0 +1,19 @@ +Quillpad — это ответвление оригинального приложения Quillnote. Quillpad полностью бесплатный и с открытым исходным кодом. Он никогда не будет показывать вам рекламу, запрашивать у вас ненужные разрешения или загружать ваши заметки куда-либо без вашего ведома. + +Делайте красивые заметки в формате Markdown по вдохновению, помещайте их в блокноты и соответствующим образом помечайте. Организуйте списки дел, устанавливайте напоминания и храните всё в одном месте, прикрепляя связанные файлы. + +С Quillpad вы можете: +- Делать заметки в формате Markdown +- Создавать списки задач +- Закрепить избранные заметки вверху +- Скрыть заметки, которые вы не хотите показывать другим +- Устанавливать напоминания о событиях, которые вы не хотите пропустить +- Добавлять голосовые записи и другие прикрепленные файлы +- Группировать связанные заметки в блокноты +- Добавлять метки к заметкам +- Архивировать заметки, которые вы хотите припрятать +- Поиск по заметкам +- Синхронизировать с Nextcloud (требуется приложение Nextcloud Notes, установленное на сервере Nextcloud, используемом для синхронизации) +- Сохранять резервные копии заметок в zip-файле, который вы сможете восстановить позже +- Переключаться между светлым и тёмным режимами оформления +- Выбирать между несколькими цветовыми схемами diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt new file mode 100644 index 00000000..107fb040 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/short_description.txt @@ -0,0 +1 @@ +Заметки в Markdown, список задач, синхронизация с Nextcloud и многое другое diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt new file mode 100644 index 00000000..b1bb837d --- /dev/null +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -0,0 +1,19 @@ +Quillpad 是一款源自名为 Quillnote 的原始应用的衍生版本。Quillpad 完全免费且开源,它绝不会向你展示广告,也不会要求你提供不必要的权限,更不会在你不知情的情况下上传你的笔记。 + +当你感到灵感涌现时,可以随时用漂亮的 Markdown 格式做笔记,将它们归类到笔记本中,并根据需要添加标签。通过创建任务清单、设置提醒以及将相关文件附加在一起,你可以保持条理清晰,一切井井有条。 + +使用 Quillpad,你可以: +- 用支持 Markdown 的方式做笔记 +- 创建任务清单 +- 将你最喜欢的笔记固定在顶部 +- 隐藏你不希望他人看到的笔记 +- 为不想错过的重要事件设置提醒 +- 添加语音录音和其他文件附件 +- 在笔记本中将相关笔记分组 +- 为笔记添加标签 +- 将不想被打扰的笔记归档 +- 在笔记中进行搜索 +- 与 Nextcloud 同步(需要在用于同步的 Nextcloud 服务器上安装 Nextcloud Notes 应用) +- 将笔记备份为 zip 文件,以便日后恢复 +- 在浅色和深色模式之间切换 +- 选择多种颜色方案 diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/fastlane/metadata/android/zh-CN/short_description.txt new file mode 100644 index 00000000..763c235a --- /dev/null +++ b/fastlane/metadata/android/zh-CN/short_description.txt @@ -0,0 +1 @@ +Markdown笔记、任务列表、Nextcloud同步等功能。Quillnote应用的衍生版本 diff --git a/gradle.properties b/gradle.properties index 4d15d015..8117ba36 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,16 +6,17 @@ # 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 +org.gradle.jvmargs=-Xmx4048m +org.gradle.caching=true # 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 +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 -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..6ef0c77a --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,181 @@ +[versions] +acra = "5.12.0" +activityCompose = "1.10.1" +activityKtx = "1.10.1" +androidGradlePlugin = "8.11.0" +androidxComposeBom = "2025.07.00" +androidxTest = "1.7.0" +appcompat = "1.7.1" +betterLinkMovementMethod = "2.2.0" +coil = "2.7.0" +coilGif = "2.7.0" +coilVideo = "2.7.0" +constraintlayout = "2.2.1" +coordinatorlayout = "1.3.0" +coreLibraryDesugaring = "2.1.5" +coreKtx = "1.16.0" +datastore = "1.1.7" +datastoreext = "1.0.0" +espressoCore = "3.7.0" +exoplayer = "2.19.1" +flowPreferences = "1.4.0" +fragmentKtx = "1.8.8" +junit = "4.13.2" +junitVersion = "1.3.0" +koin = "4.1.0" +kotlin = "2.2.0" +kotlinSerialization = "2.2.0" +kotlinSerializationJson = "1.9.0" +kotlinxCoroutines = "1.10.2" +ksp = "2.2.0-2.0.2" +leakcanaryAndroid = "2.14" +lifecycle = "2.9.2" +lifecycleViewmodelCompose = "2.9.2" +markwon = "4.6.2" +material = "1.12.0" +media = "1.7.0" +mockk = "1.14.5" +nav = "2.9.3" +navigationSafeArgs = "2.9.3" +okhttp = "5.1.0" +paletteKtx = "1.0.0" +photoview = "2.3.0" +recyclerview = "1.4.0" +retrofit = "3.0.0" +retrofit2Converter = "1.0.0" +room = "2.7.2" +securityCrypto = "1.1.0" +swiperefreshlayout = "1.1.0" +work = "2.10.3" +monitor = "1.8.0" +junitKtx = "1.3.0" +ui = "1.8.3" +uiToolingPreview = "1.8.3" +lifecycleRuntimeCompose = "2.9.2" +navigationCompose = "2.9.3" +yamlkt = "0.13.0" + +[libraries] +acra-sender-mail = { module = "ch.acra:acra-mail", version.ref = "acra" } +acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-material = { module = "androidx.compose.material3:material3" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTest" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTest" } +androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "uiToolingPreview" } +appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +betterLinkMovementMethod = { module = "me.saket:better-link-movement-method", version.ref = "betterLinkMovementMethod" } +coil = { module = "io.coil-kt:coil", version.ref = "coil" } +coilGif = { module = "io.coil-kt:coil-gif", version.ref = "coilGif" } +coilVideo = { module = "io.coil-kt:coil-video", version.ref = "coilVideo" } +constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } +coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "coordinatorlayout" } +coreLibraryDesugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "coreLibraryDesugaring" } +coreKtx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +datastorePreferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +datastoreext = { module = "me.msoul:datastoreext", version.ref = "datastoreext" } +exoplayerCore = { module = "com.google.android.exoplayer:exoplayer-core", version.ref = "exoplayer" } +exoplayerUi = { module = "com.google.android.exoplayer:exoplayer-ui", version.ref = "exoplayer" } +flowPreferences = { module = "com.github.tfcporciuncula.flow-preferences:flow-preferences", version.ref = "flowPreferences" } +fragmentKtx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } +junit = { module = "junit:junit", version.ref = "junit" } +junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junitKtx" } +koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" } +koin-core = { module = "io.insert-koin:koin-core" } +koin-core-coroutines = { module = "io.insert-koin:koin-core-coroutines" } +koin-android = { module = "io.insert-koin:koin-android" } +koin-android-compat = { module = "io.insert-koin:koin-android-compat" } +koin-androidx-workmanager = { module = "io.insert-koin:koin-androidx-workmanager" } +koin-androidx-navigation = { module = "io.insert-koin:koin-androidx-navigation" } +koin-test = { module = "io.insert-koin:koin-test" } +koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4" } +kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerializationJson" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +leakcanaryAndroid = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } +lifecycleCommonJava8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" } +lifecycleLiveDataKtx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } +lifecycleRuntimeKtx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +lifecycleService = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } +lifecycleViewModelKtx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +markwon = { module = "io.noties.markwon:core", version.ref = "markwon" } +markwonEditor = { module = "io.noties.markwon:editor", version.ref = "markwon" } +markwonLinkify = { module = "io.noties.markwon:linkify", version.ref = "markwon" } +markwonSimpleExt = { module = "io.noties.markwon:simple-ext", version.ref = "markwon" } +markwonStrikethrough = { module = "io.noties.markwon:ext-strikethrough", version.ref = "markwon" } +markwonTable = { module = "io.noties.markwon:ext-tables", version.ref = "markwon" } +markwonTasklist = { module = "io.noties.markwon:ext-tasklist", version.ref = "markwon" } +material = { module = "com.google.android.material:material", version.ref = "material" } +media = { module = "androidx.media:media", version.ref = "media" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } +monitor = { group = "androidx.test", name = "monitor", version.ref = "monitor" } +navigationFragmentKtx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "nav" } +navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "nav" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +paletteKtx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } +photoview = { module = "com.github.chrisbanes:PhotoView", version.ref = "photoview" } +recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit2Convertor = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2Converter" } +roomCompiler = { module = "androidx.room:room-compiler", version.ref = "room" } +roomKtx = { module = "androidx.room:room-ktx", version.ref = "room" } +roomRuntime = { module = "androidx.room:room-runtime", version.ref = "room" } +roomTesting = { module = "androidx.room:room-testing", version.ref = "room" } +securityCrypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } +swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" } +workRuntimeKtx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } +workTesting = { module = "androidx.work:work-testing", version.ref = "work" } +yamlkt = { module = "net.mamoe.yamlkt:yamlkt", version.ref = "yamlkt" } + +[bundles] +acra = ["acra-dialog", "acra-sender-mail"] +kotlin-deps = ["kotlin-serialization-json"] +kotlin-androidX = [ + "androidx-activity-ktx", + "appcompat", + "constraintlayout", + "coordinatorlayout", + "coreKtx", + "fragmentKtx", + "media", + "navigationFragmentKtx", + "navigationUiKtx", + "paletteKtx", + "recyclerview", + "swiperefreshlayout", +] +kotlin-lifecycle = [ + "lifecycleRuntimeKtx", + "lifecycleCommonJava8", + "lifecycleLiveDataKtx", + "lifecycleViewModelKtx", + "lifecycleService" +] +markwon = [ + "markwon", + "markwonEditor", + "markwonLinkify", + "markwonStrikethrough", + "markwonSimpleExt", + "markwonTable", + "markwonTasklist", + "betterLinkMovementMethod", +] + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "androidGradlePlugin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlinParcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +navigationSafeArgs = { id = "androidx.navigation.safeargs", version.ref = "navigationSafeArgs" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c0..1b33c55b 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e0e0f775..2a84e188 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Tue Mar 29 00:17:52 EEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c..23d15a93 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,115 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # 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 +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac 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"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # 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 - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # 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" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +132,120 @@ 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. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + 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 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 +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac 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 +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # 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 +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # 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\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg 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" +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 107acd32..db3a6ac2 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 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. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,34 @@ 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. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 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 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/graphics/Q_centered.svg b/graphics/Q_centered.svg new file mode 100644 index 00000000..de0b4462 --- /dev/null +++ b/graphics/Q_centered.svg @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/graphics/README.md b/graphics/README.md new file mode 100644 index 00000000..ea0e2ba9 --- /dev/null +++ b/graphics/README.md @@ -0,0 +1,17 @@ +## Colors +The main "brand" colors are: +- ![#f03c15](https://placehold.co/15x15/a4c639/a4c639.png) `#a4c639` [Android green (pre-2018)](https://en.wikipedia.org/wiki/Android_green) +- ![#1589F0](https://placehold.co/15x15/1a1a1a/1a1a1a.png) `#1a1a1a` 90% gray + +## Logo +When used as an icon, the gray background is used with a green quill.
+In other cases, the logo is used with a white background and green quill. + +It consists of a quill (`quill.svg`) used as part of the letter 'Q' (`Q_centered.svg`). +Currently, the height of the Q is 1.2 times it's width, however this may be subject to change.
+The quill is integrated into the circle by applying a slight stroke to it, which can then be removed from the circle using +a difference operation on both paths. This results in some slight negative space around the quill, making it more visible. + +The complete Q must always fit into a circle with a diameter of 72px (assuming Android icon guidelines) as shown in `adaptive_icon.svg`. + + diff --git a/graphics/adaptive_icon.svg b/graphics/adaptive_icon.svg new file mode 100644 index 00000000..c0a145ae --- /dev/null +++ b/graphics/adaptive_icon.svg @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/graphics/feature-graphic.svg b/graphics/feature-graphic.svg new file mode 100644 index 00000000..2b38b31b --- /dev/null +++ b/graphics/feature-graphic.svg @@ -0,0 +1,7683 @@ + + + +UILLPAD diff --git a/graphics/ic_launcher_background.svg b/graphics/ic_launcher_background.svg new file mode 100644 index 00000000..85f082cb --- /dev/null +++ b/graphics/ic_launcher_background.svg @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/graphics/ic_launcher_foreground.svg b/graphics/ic_launcher_foreground.svg new file mode 100644 index 00000000..7c738e94 --- /dev/null +++ b/graphics/ic_launcher_foreground.svg @@ -0,0 +1,275 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/graphics/ic_launcher_monochrome.svg b/graphics/ic_launcher_monochrome.svg new file mode 100644 index 00000000..3f6e521a --- /dev/null +++ b/graphics/ic_launcher_monochrome.svg @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/graphics/quill.svg b/graphics/quill.svg new file mode 100644 index 00000000..a75214da --- /dev/null +++ b/graphics/quill.svg @@ -0,0 +1,333 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/graphics/quillpad-icon.svg b/graphics/quillpad-icon.svg new file mode 100644 index 00000000..f1a6583e --- /dev/null +++ b/graphics/quillpad-icon.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + diff --git a/settings.gradle b/settings.gradle index f2d4780d..b45b27f0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,20 @@ +// Apply the Gradle version catalog +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + maven { + url = "https://www.jitpack.io" + } + } +} + include ':app' -rootProject.name = "Notes" \ No newline at end of file +rootProject.name = "quillpad" + +buildCache { + local { + directory = new File(rootDir, 'build-cache') + } +}