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

-
+[](https://gitter.im/quillpad/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
+[](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
-
-
-
-
+
+
+
+
+
## 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,
+
+
+
+
+
+### 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/).
+
+
+
+
+
+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"/>
+
+
-
+
-
-
+
+
-
+
+
+
-