diff --git a/.gitignore b/.gitignore index f6ab0018df367c1e38774ab4c60130aa84eeaa16..74a308126eb5d0d309cfd192de46b1651960f90d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ -.gradle/ -/local.properties -.DS_Store -/build -/captures -/.idea/ -*.iml -/projectFilesBackup/ -/app/release/ \ No newline at end of file +.gradle/ +/local.properties +.DS_Store +/build +/captures +/.idea/ +*.iml +/projectFilesBackup/ +/app/release/ +/app/schemas/ \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 809e91d4e5bcf3b8b5c1b3d7e53d847c7451966b..054cbfba5c3b2f8323d7b5d6efa2ba31794e6996 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: "registry.gitlab.e.foundation:5000/e/apps/docker-android-apps-cicd:legacy" +image: registry.gitlab.e.foundation/e/apps/docker-android-apps-cicd:latest stages: - build diff --git a/.gitmodules b/.gitmodules index 160a83c5c448c4190e7d48a1032cce74f632c708..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +0,0 @@ -[submodule "cert4android"] - path = cert4android - url = ../cert4android.git - branch = sprint diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5321fcb30dc965dc44c3fce04f88af9d4c75ba4f..0000000000000000000000000000000000000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: android - -jdk: oraclejdk8 - -android: - components: - - tools - - build-tools-27.0.3 - - android-28 - - extra-android-m2repository - -before_install: - - yes | sdkmanager "platforms;android-27" - - yes | sdkmanager "platforms;android-28" - -script: ./gradlew testDebug diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b3ec9e7e621d480637e1c34f4375ecd690e46c1..107335520e71ac05262be11f8142fe36e3bc4819 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ If you find a bug, feel free to [open an issue](https://github.com/stefan-nieder **Device**: e. g. Motorola Moto G 2015 -**System language**: English (US), German, ... +**System language**: English (US), German, … **App version:** e. g. v0.8.1 @@ -18,14 +18,14 @@ If you find a bug, feel free to [open an issue](https://github.com/stefan-nieder 1. open the app 2. click on a note 3. use the top left back-arrow - 4. ... + 4. … ### Copy & Paste **Android version:** e. g. 6.0.1 Marshmallow **Device**: e. g. Motorola Moto G 2015 - **System language**: English (US), German, ... + **System language**: English (US), German, … **App version:** e. g. v0.8.1 @@ -35,7 +35,7 @@ If you find a bug, feel free to [open an issue](https://github.com/stefan-nieder 1. open the app 2. click on a note 3. use the top left back-arrow - 4. ... + 4. … ## Adding new features diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000000000000000000000000000000000000..0a22d95cfc2405cb7c75c30e466457551e2a8554 --- /dev/null +++ b/FAQ.md @@ -0,0 +1,166 @@ +# Frequently asked questions + +- [Why aren't there any buttons to apply formatting?](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#why-arent-there-any-buttons-to-apply-formatting) +- [I have experienced an error](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#i-have-experienced-an-error) + - [`NextcloudApiNotRespondingException`](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#nextcloudapinotrespondingexception) + - [`UnknownErrorException: Read timed out`](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#unknownerrorexception-read-timed-out) + - [`IllegalStateException: Duplicate key`](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#illegalstateexception-duplicate-key) + - [`NextcloudFilesAppAccountNotFoundException`](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#nextcloudfilesappaccountnotfoundexception) + - [`TokenMismatchException`](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#tokenmismatchexception) + - [Workarounds](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#workarounds) +- [Why don't you make an option for…?](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#why-dont-you-make-an-option-for) +- [Why is there no support for pens?](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#why-is-there-no-support-for-pens) +- [Why has my bug report been closed?](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#why-has-my-bug-report-been-closed) +- [How can i activate the dark mode for widgets?](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#how-can-i-activate-the-dark-mode-for-widgets) + +## Why aren't there any buttons to apply formatting + +We use context based formatting to avoid distractions while writing. This is not "Word on Android". + +You have some shortcuts available in a context, e.g. +- when you select some text, you can make it bold, italic, insert a link, etc.: + + ![Selection formatting](https://user-images.githubusercontent.com/4741199/102229887-89cc3e80-3eec-11eb-8398-10073bbb7359.png) +- when you hit the selector thumb without selected text, you will have actions in the context of the current line, like making it a checkbox: + + ![Line formatting](https://user-images.githubusercontent.com/4741199/102230123-c5ff9f00-3eec-11eb-990e-c4c25e016b5d.png) + +This approach allows us to only show the actions that make sense for the current context. + +We plan to extend this system further in the future and might add toggles for headlines etc. + +## I have experienced an error + +Sorry. There are so many different environments, that it is impossible for us to test each and every constellation. + +First of all make sure you have updated to and tried with the latest available versions of this app, the [Nextcloud Android](https://play.google.com/store/apps/details?id=com.nextcloud.client) app and the [Notes server app](https://apps.nextcloud.com/apps/notes). + +### `NextcloudApiNotRespondingException` + +Try to disable the battery "optimization" for Notes Android and Nextcloud Android. Some manufacturers prevent the Notes Android app from communicating with the Nextcloud Android app properly. It is recommended to clear the storage of both apps as [explained below](#workarounds). +This is a [known issue of the SingleSignOn mechanism](https://github.com/nextcloud/Android-SingleSignOn#troubleshooting) which we only can work around but not solve on our side. + +### `UnknownErrorException: Read timed out` + +This issue is caused by a connection time out. This can be the case if there are infrastructural or environmental problems (like a misconfigured server or a bad network connection). +Probably you will experience it when importing an account, because at this moment, all your Notes will getting downloaded. Given you have a lots of notes, this might take longer than the connection is available. +Further synchronizations are usually not causing this issue, because the Notes app tries to synchronize only *changed* notes after the first import. +If your notes are not ten thousands of characters long, it is very unlikely that this causes a connection timeout. + +We improved the import of an account in version `3.4.12` to make it more reliable by [fetching notes step by step](https://github.com/stefan-niedermann/nextcloud-notes/issues/761#issuecomment-836989421). +If you are using an older version, you can as a workaround for the first import try to +1. move all your notes to a different folder on your Nextcloud instance +2. import your account on your smartphone +3. put your notes back to the original folder step by step and sync everytime you put some notes back + +### `IllegalStateException: Duplicate key` + +This is issue was caused by a bug which was present in the Notes Android app between `3.4.0` and `3.4.10`. It has been fixed in `3.4.11`, though it created a corrupt database state which is not recoverable automatically without data loss. It is therefore required to [clear the storage of the Notes Android app](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#workarounds) and import your account again from scratch. Make sure to backup unsynchronized changes before doing this. + +### `NextcloudFilesAppAccountNotFoundException` + +We are not yet sure what exactly causes this issue, but investigate it by [adding more debug logs to recent versions](https://github.com/stefan-niedermann/nextcloud-notes/issues/1256#issuecomment-859505153). In theory this might happen if an already imported account has been deleted in the Nextcloud app. +As a workaround you can remove the account (or clear the storage of the app as described below if you can't access the account manager anymore) and import it again. + +### `TokenMismatchException` + +The reason of this error is not yet clear. It often seems to be connected to changes of the authentication (for example enabling 2FA after some time). Please clear the storage of both, the Notes and the Nextcloud Android apps as described in the [workarounds](https://github.com/stefan-niedermann/nextcloud-notes/blob/master/FAQ.md#wrokarounds) section. + +### Workarounds + +In some cases, clearing the storage of the Notes Android app and restarting with a clean state will already be enough. Since we use the [Single Sign On mechanism](https://github.com/nextcloud/Android-SingleSignOn/) of Nextcloud, it might be necessary to clear the storage of **both** apps, Notes Android **and** Nextcloud Android. (the Nextcloud Android app manages some parts of the authentication and the network stack). + +- ⚠️ Not yet synchronized changes will be lost by performing this step. +- ⚠️ Uninstalling an app is **not** the same as clearing the storage, since Android keeps some data left on your device when uninstalling an app. + +You can achieve this by navigating to + +``` +Android settings + ↓ + Apps + ↓ +Nextcloud / Notes + ↓ + Storage + ↓ + Clear storage +``` + +Then set up your account in the Nextcloud Android app again and import the configured account in the Notes Android app. + +If the issue persists, [open a bug report in our issue tracker](https://github.com/stefan-niedermann/nextcloud-notes/issues/new?assignees=&labels=bug&template=bug_report.md&title=). + +## Why don't you make an option for…? + +We prefer good defaults over providing an option for each edge case. Our resources are quite limited, so we have to consider introducing new options very carefully. + +1. A feature is implemented quickly, but who will maintain it for the next 5 years? +2. Each option increases the test matrix exponentially and leads to huge efforts to test every combination +3. Each option increases the possible constellations, making it hard to track down issues +4. Each option increases the visual noise for people who will *not* use the options +5. Each option increases the maintenance efforts, making it harder over the time to work on actual features +6. Each option introduces new side effects, which might lead to undiscovered bugs or break existing features +7. The Android app aims to mirror feature parity with the corresponding server app + +## Why is there no support for pens? + +This topic has been requested multiple times and we'd love to support pens. There are some obstacles, though: + +### Choice of approach + +Handwritten notes can be implemented in various ways - for example as attachments or just recognizing the characters and translate them to text. The first approach depends on attachments support for the Notes server app (currently [work in progress](https://github.com/nextcloud/notes/issues/74)). + +### Licensing issues + +The Notes Android app was, is and will always be free. [Free not as in "free beer" but as in "freedom"](https://www.gnu.org/philosophy/free-sw.en.html). I therefore will not accept any solution that requires to include proprietary libraries (also not for the Google Play Store flavor). +Since i am not aware of any proper free SDK for pens, I recommend you to ask your manufacturer to publish a development SDK under a free license and ask them why they sell stuff to you which you in fact do not own. + +### Hardware issues + +Given a [free SDK](#licensing-issues) can be found, there is another issue: I don't own a device with a pen. I welcome [Pull Requests](https://github.com/stefan-niedermann/nextcloud-notes/pulls) with contributions to this topic, but i can and will not buy a new device just for this aspect, sorry. + +## Why has my bug report been closed? + +As stated in the bug templates, we reserve to close issues which do not fill the **complete issue template**. The information we ask for is urgently needed, even if it might not seem to be important or relevant to you. + +We have very limited resources and capacity and we really want to help you fixing different bugs, but we can impossibly know your environment, your different software versions, the store you used. +Therefore it is extremely important for you to describe the **exact steps to reproduce**. This includes information about your environment. + +Example for a bad description: + +> 1. The app crashes when i save a note + +Example for a good description: + +> 1. Open any existing note +> 2. Change category to another existing category +> 3. Click on the ⇦ in the top left +> 4. See app crash + +We also preserve to close issues where the **original reporter does not answer within a certain time frame**. We usually answer issues within a hour and expect you to respond to our questions within a week. + +This is necessary for two reasons: + +1. We have a rapid development cycle - bugs which have been reported weeks ago might no longer relevant +2. We are loosing the context of a report or a question over the time. We have many things to care about and digging into an issue deep and then relying on an response which is not coming is a waste of our limited free time + +## How can i activate the dark mode for widgets? + +Since `v3.2.0` the widgets are using the **global Android setting**. You can change it in the Android settings, depending on your manufacturer probably under the "Display" menu item: + +![Enable global Android dark mode](https://user-images.githubusercontent.com/4741199/111076875-8c8bff00-84ee-11eb-8052-b086c8e143b3.png) + +The main reason is a better and tighter integration in the default android theming mechanism. For example Android will switch the complete system UI to a dark mode when the battery is low. +The widgets have previously not respected those Android intentions, also they ignored the user setting mentioned above for a overall-same theme on the device. + +The dark mode of the app does *not* affect the appearance of the widgets because the context is different. +While the app is something one starts intentionally and runs in its own context, the widgets run always in the context of the launcher. +To provide a homogeneous interface in your launcher and take full benefits of OLED screens and battery saving mechanisms, Google implemented the global Android setting in Android 10 to affect everything in this context at once - the app drawer, the status bar and the widgets. + +According to the Play Store statistics when we release `v3.2.0`, more than `73%` of our (Play Store) users used Android 10 or higher and therefore the global setting is already present for them. +Further `24%` used Android 7 - Android 9 and can utilize [tweaks](https://www.androidauthority.com/night-mode-on-android-886864/) (or other workarounds if they prefer to stay on old and partially even by Google [abandoned Android versions without security fixes](https://endoflife.date/android) instead of using a modern Custom ROM or other alternatives). + +The efforts and benefits of a) maintaining a custom hacky dark mode vs. b) supporting natively the global dark mode inclusive auto-theming on low battery etc. is in absolutely no proportion anymore. + +The same applies to the widgets of the Nextcloud Deck App and the Nextcloud News App. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000000000000000000000000000000000000..e78a274fc62525483dd200bd35a1555c1b0589d4 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,38 @@ +# Nextcloud Notes Android Privacy Policy + +The "Nextcloud Notes Android" Android-App (in the following referred to as "App") does not collect or send any data from you or your device to a server of the developers or the [Nextcloud GmbH](https://nextcloud.com/). The App sends all data exclusively to the server configured by you with the intention to synchronize the contents of the App with those of the server. This data can contain IP-addresses, timestamps and further information as meta data. +It is important to mention that all contents of the App may also be transmitted to the configured server. This contents can also contain personal information depending on the use. The servers you configured are technically outside the access area of this App developers, so that we neither know nor can prevent what happens to your data there. Please consult the privacy policy of the respective server operator. + +The license of this project allows you to verify that no data is collected by the creators by reading the source code or asking someone else to do it. + +## Permissions + +This is a list of permissions required and asked by the App in order to properly work on your device: + +- `android.permission.INTERNET` + + Used by [Nextcloud Single Sign On library](https://github.com/nextcloud/Android-SingleSignOn/) to communicate with your Nextcloud instance and synchronize contents. + +- `android.permission.ACCESS_NETWORK_STATE` + + Used to provide offline support and make the "Sync only on Wi-Fi" option possible. + +- `android.permission.GET_ACCOUNTS` + + Used by [Nextcloud Single Sign On library](https://github.com/nextcloud/Android-SingleSignOn/) to read available accounts to import. + +- `android.permission.WAKE_LOCK` + + Used by [AndroidX WorkManager](https://developer.android.com/jetpack/androidx/releases/work) for background synchronization. + +- `android.permission.RECEIVE_BOOT_COMPLETED` + + Used by [AndroidX WorkManager](https://developer.android.com/jetpack/androidx/releases/work) for background synchronization. + +- `android.permission.FOREGROUND_SERVICE` + + Used by [AndroidX WorkManager](https://developer.android.com/jetpack/androidx/releases/work) for background synchronization. + +## Nextcloud privacy policy + +You can get more information on Nextcloud general privacy policy which is accessible at [nextcloud.com/privacy](https://nextcloud.com/privacy/). \ No newline at end of file diff --git a/SSO Announcment.md b/SSO Announcment.md deleted file mode 100644 index 27553224981fc5b4e786a6812d38681f26f54920..0000000000000000000000000000000000000000 --- a/SSO Announcment.md +++ /dev/null @@ -1,20 +0,0 @@ -# SSO Announcment - -The next version of Notes for android will depend on Nextcloud Single-Sign-On. - -## What do you need to do? - -It is recommended, to perform a full synchronisation with the old version of the Notes app, before you upgrade. - -You have likely installed an up-to-date version of the [Files app](https://play.google.com/store/apps/details?id=com.nextcloud.client). In this case, you will have nothing more to do. -In case you do not have it installed yet, you can get it for free from Play Store or F-Droid. - -When you upgrade the Notes app, you will be asked to select an account (which you previously configured at the files app). - -It is important that you **select** at the first run **the same account which you already were using** in the Notes app. This will make the first run smooth and make sure, that local edited, but not synced notes won't get lost. - -## Benefits for you - -- **:lock: Security benefits:** The notes app does no longer have to store a password itself. -- **:electric_plug: Reliability:** The complete network stack could be removed because all network activities are lead through the files app. This allows us to use e. g. the same stack for self signed certificates. -- **:bulb: Comfort:** You won't have to enter a server address, nor a username or a password. Just pick an existing account from the list. diff --git a/app/.classpath b/app/.classpath deleted file mode 100644 index 51769745b2c3fa7f59c0b88bad65762059ee0812..0000000000000000000000000000000000000000 --- a/app/.classpath +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/app/.gitignore b/app/.gitignore index 3543521e9fef8e7322940a87c2b45dd0061b0f45..2c10ad0be51dcd430ae0a38def9957072ed9e496 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,4 @@ -/build +/build +/play +/fdroid +/dev \ No newline at end of file diff --git a/app/.project b/app/.project deleted file mode 100644 index b3936bdbae536d4a40bc31f88cee7fd26125a808..0000000000000000000000000000000000000000 --- a/app/.project +++ /dev/null @@ -1,33 +0,0 @@ - - - OwnCloudNotes - - - - - - com.android.ide.eclipse.adt.ResourceManagerBuilder - - - - - com.android.ide.eclipse.adt.PreCompilerBuilder - - - - - org.eclipse.jdt.core.javabuilder - - - - - com.android.ide.eclipse.adt.ApkBuilder - - - - - - com.android.ide.eclipse.adt.AndroidNature - org.eclipse.jdt.core.javanature - - diff --git a/app/build.gradle b/app/build.gradle index 82808285cacc7a70f5ed3261c2f42a81044eac01..d5543a4876cccd85abaf709fdcf808169f0820c2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,33 +1,52 @@ apply plugin: 'com.android.application' android { - compileSdkVersion 28 - buildToolsVersion '29.0.1' + compileSdkVersion 31 + buildToolsVersion '31.0.0' compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } defaultConfig { applicationId "foundation.e.notes" - minSdkVersion 24 - targetSdkVersion 28 - versionCode 49 - versionName "1.0.1" + minSdkVersion 23 + targetSdkVersion 31 + versionCode 3004018 + versionName "3.4.18" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + javaCompileOptions { + annotationProcessorOptions { + arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] + } + } + } + + buildFeatures { + viewBinding true } + buildTypes { + debug { + applicationIdSuffix ".debug" + testCoverageEnabled true + } + release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } - lintOptions { - disable 'MissingTranslation' - abortOnError false + testOptions { + unitTests { + includeAndroidResources true + } } - dataBinding { - enabled = true + lint { + abortOnError false + disable 'MissingTranslation' } aaptOptions { @@ -38,26 +57,50 @@ android { dependencies { compileOnly files("../e-ui-sdk.jar") + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + + // Nextcloud SSO + implementation 'com.github.nextcloud:Android-SingleSignOn:0.6.1' + implementation 'com.github.stefan-niedermann:android-commons:0.2.5' + implementation 'com.github.stefan-niedermann.nextcloud-commons:sso-glide:1.6.2' + implementation 'com.github.stefan-niedermann.nextcloud-commons:exception:1.6.2' + implementation('com.github.stefan-niedermann.nextcloud-commons:markdown:1.6.2') { + exclude group: 'org.jetbrains', module: 'annotations-java5' + } + + // Glide + implementation 'com.github.bumptech.glide:glide:4.13.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.13.0' - implementation project(':cert4android') + // Android X + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'androidx.fragment:fragment:1.4.1' + implementation 'androidx.preference:preference:1.2.0' + implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.recyclerview:recyclerview-selection:1.1.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation 'androidx.work:work-runtime:2.7.1' + implementation 'com.google.android.material:material:1.5.0' - implementation 'io.reactivex:rxandroid:1.2.1' - implementation 'io.reactivex:rxjava:1.3.8' - implementation 'com.yydcdut:markdown-processor:0.1.3' - implementation 'com.yydcdut:rxmarkdown-wrapper:0.1.3' + // Database + implementation 'androidx.room:room-runtime:2.4.1' + annotationProcessor 'androidx.room:room-compiler:2.4.1' - implementation 'com.android.support.constraint:constraint-layout:1.1.3' - implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0' + // Retrofit + implementation 'com.squareup.retrofit2:retrofit:2.9.0' - implementation 'com.github.bumptech.glide:glide:4.10.0' - annotationProcessor 'com.github.bumptech.glide:compiler:4.10.0' + // Gson + implementation 'com.google.code.gson:gson:2.9.0' - implementation 'com.jakewharton:butterknife:10.2.0' - annotationProcessor 'com.jakewharton:butterknife-compiler:10.2.0' + // ReactiveX + implementation 'io.reactivex.rxjava2:rxjava:2.2.21' - implementation "androidx.appcompat:appcompat:1.1.0" - implementation "androidx.recyclerview:recyclerview:1.0.0" - implementation "com.google.android.material:material:1.0.0" + // Testing + testImplementation 'androidx.test:core:1.4.0' + testImplementation 'androidx.arch.core:core-testing:2.1.0' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:4.3.1' + testImplementation 'org.robolectric:robolectric:4.7.3' implementation fileTree(dir: 'libs', include: ['*.jar']) } diff --git a/app/proguard-project.txt b/app/proguard-project.txt deleted file mode 100644 index f2fe1559a217865a5454add526dcc446f892385b..0000000000000000000000000000000000000000 --- a/app/proguard-project.txt +++ /dev/null @@ -1,20 +0,0 @@ -# To enable ProGuard in your project, edit project.properties -# to define the proguard.config property as described in that file. -# -# Add project specific ProGuard rules here. -# By default, the flags in this file are appended to flags specified -# in ${sdk.dir}/tools/proguard/proguard-android.txt -# You can edit the include path and order by changing the ProGuard -# include property in project.properties. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# Add any project specific keep options here: - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} diff --git a/app/project.properties b/app/project.properties deleted file mode 100644 index f13bd4b19cb8f6c7bc48f71939e468f00148d61f..0000000000000000000000000000000000000000 --- a/app/project.properties +++ /dev/null @@ -1,13 +0,0 @@ -# This file is automatically generated by Android Tools. -# Do not modify this file -- YOUR CHANGES WILL BE ERASED! -# -# This file must be checked in Version Control Systems. -# -# To customize properties used by the Ant build system edit -# "ant.properties", and override values to adapt the script to your -# project structure. -# -# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): -#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt -# Project target. -target=android-19 diff --git a/app/src/androidTest/java/foundation/e/notes/ApplicationTest.java b/app/src/androidTest/java/foundation/e/notes/ApplicationTest.java deleted file mode 100644 index dcb04b13fe35ae12ae24f1b80849d4650b219ea6..0000000000000000000000000000000000000000 --- a/app/src/androidTest/java/foundation/e/notes/ApplicationTest.java +++ /dev/null @@ -1,13 +0,0 @@ -package foundation.e.notes; - -import android.app.Application; -import android.test.ApplicationTestCase; - -/** - * Testing Fundamentals - */ -public class ApplicationTest extends ApplicationTestCase { - public ApplicationTest() { - super(Application.class); - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/foundation/e/notes/model/NoteTest.java b/app/src/androidTest/java/foundation/e/notes/model/NoteTest.java deleted file mode 100644 index c989802d68ef11c3efa9266b9b1b461b7914ef04..0000000000000000000000000000000000000000 --- a/app/src/androidTest/java/foundation/e/notes/model/NoteTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package foundation.e.notes.model; - -import junit.framework.TestCase; - -import java.util.Calendar; - -/** - * Tests the Note Model - * Created by stefan on 06.10.15. - */ -public class NoteTest extends TestCase { - - public void testMarkDownStrip() { - CloudNote note = new CloudNote(0, Calendar.getInstance(), "#Title", "", false, null, null); - assertTrue("Title".equals(note.getTitle())); - note.setTitle("* Aufzählung"); - assertTrue("Aufzählung".equals(note.getTitle())); - } -} diff --git a/app/src/androidTest/java/foundation/e/notes/util/NoteUtilTest.java b/app/src/androidTest/java/foundation/e/notes/util/NoteUtilTest.java deleted file mode 100644 index 9c18735f189c7d523eb1fd36482c7b3f26e48829..0000000000000000000000000000000000000000 --- a/app/src/androidTest/java/foundation/e/notes/util/NoteUtilTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package foundation.e.notes.util; - -import junit.framework.TestCase; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; - -/** - * Tests the NoteUtil - * Created by stefan on 06.10.15. - */ -public class NoteUtilTest extends TestCase { - public void testRemoveMarkDown() { - assertEquals("Test", NoteUtil.removeMarkDown("Test")); - assertEquals("Foo\nBar", NoteUtil.removeMarkDown("Foo\nBar")); - assertEquals("Foo\nBar", NoteUtil.removeMarkDown("Foo\n Bar")); - assertEquals("Foo\nBar", NoteUtil.removeMarkDown("Foo \nBar")); - assertEquals("Foo-Bar", NoteUtil.removeMarkDown("Foo-Bar")); - assertEquals("Foo*Bar", NoteUtil.removeMarkDown("Foo*Bar")); - assertEquals("Foo/Bar", NoteUtil.removeMarkDown("Foo/Bar")); - assertEquals("FooTestBar", NoteUtil.removeMarkDown("Foo*Test*Bar")); - assertEquals("FooTestBar", NoteUtil.removeMarkDown("Foo**Test**Bar")); - assertEquals("FooTestBar", NoteUtil.removeMarkDown("Foo***Test***Bar")); - assertEquals("FooTest*Bar", NoteUtil.removeMarkDown("Foo*Test**Bar")); - assertEquals("Foo*TestBar", NoteUtil.removeMarkDown("Foo***Test**Bar")); - assertEquals("FooTestBar", NoteUtil.removeMarkDown("Foo_Test_Bar")); - assertEquals("FooTestBar", NoteUtil.removeMarkDown("Foo__Test__Bar")); - assertEquals("FooTestBar", NoteUtil.removeMarkDown("Foo___Test___Bar")); - assertEquals("Foo\nHeader\nBar", NoteUtil.removeMarkDown("Foo\n# Header\nBar")); - assertEquals("Foo\nHeader\nBar", NoteUtil.removeMarkDown("Foo\n### Header\nBar")); - assertEquals("Foo\nHeader\nBar", NoteUtil.removeMarkDown("Foo\n# Header #\nBar")); - assertEquals("Foo\nHeader\nBar", NoteUtil.removeMarkDown("Foo\n## Header ####\nBar")); - assertEquals("Foo\nNo Header #\nBar", NoteUtil.removeMarkDown("Foo\nNo Header #\nBar")); - assertEquals("Foo\nHeader\nBar", NoteUtil.removeMarkDown("Foo\nHeader\n=\nBar")); - assertEquals("Foo\nHeader\nBar", NoteUtil.removeMarkDown("Foo\nHeader\n-----\nBar")); - assertEquals("Foo\nHeader\n--=--\nBar", NoteUtil.removeMarkDown("Foo\nHeader\n--=--\nBar")); - assertEquals("Foo\nAufzählung\nBar", NoteUtil.removeMarkDown("Foo\n* Aufzählung\nBar")); - assertEquals("Foo\nAufzählung\nBar", NoteUtil.removeMarkDown("Foo\n+ Aufzählung\nBar")); - assertEquals("Foo\nAufzählung\nBar", NoteUtil.removeMarkDown("Foo\n- Aufzählung\nBar")); - assertEquals("Foo\nAufzählung\nBar", NoteUtil.removeMarkDown("Foo\n - Aufzählung\nBar")); - assertEquals("Foo\nAufzählung *\nBar", NoteUtil.removeMarkDown("Foo\n* Aufzählung *\nBar")); - } - - public void testIsEmptyLine() { - try { - Method m = NoteUtil.class.getDeclaredMethod("isEmptyLine"); - m.setAccessible(true); - assertTrue((Boolean) m.invoke(null, " ")); - assertTrue((Boolean) m.invoke(null, "\n")); - assertTrue((Boolean) m.invoke(null, "\n ")); - assertTrue((Boolean) m.invoke(null, " \n")); - assertTrue((Boolean) m.invoke(null, " \n ")); - assertFalse((Boolean) m.invoke(null, "a \n ")); - } catch (NoSuchMethodException e) { - e.printStackTrace(); - } catch (IllegalAccessException e) { - e.printStackTrace(); - } catch (InvocationTargetException e) { - e.printStackTrace(); - } - } - - public void testGetLineWithoutMarkDown() { - try { - Method m = NoteUtil.class.getDeclaredMethod("isEmptyLine"); - m.setAccessible(true); - assertEquals("Test", (String) m.invoke(null, "Test", 0)); - assertEquals("Test", (String) m.invoke(null, "\nTest", 0)); - assertEquals("Foo", (String) m.invoke(null, "Foo\nBar", 0)); - assertEquals("Bar", (String) m.invoke(null, "Foo\nBar", 1)); - } catch (NoSuchMethodException e) { - e.printStackTrace(); - } catch (IllegalAccessException e) { - e.printStackTrace(); - } catch (InvocationTargetException e) { - e.printStackTrace(); - } - } - - public void testGenerateNoteTitle() { - assertEquals("Test", NoteUtil.generateNoteTitle("Test")); - assertEquals("Test", NoteUtil.generateNoteTitle("Test\n")); - assertEquals("Test", NoteUtil.generateNoteTitle("Test\nFoo")); - assertEquals("Test", NoteUtil.generateNoteTitle("\nTest")); - assertEquals("Test", NoteUtil.generateNoteTitle("\n\nTest")); - } - - public void testGenerateNoteExcerpt() { - assertEquals("", NoteUtil.generateNoteExcerpt("Test")); - assertEquals("Foo", NoteUtil.generateNoteExcerpt("Test\nFoo")); - assertEquals("Foo Bar", NoteUtil.generateNoteExcerpt("Test\nFoo\nBar")); - assertEquals("", NoteUtil.generateNoteExcerpt("")); - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/foundation/e/notes/util/NotesClientUtilTest.java b/app/src/androidTest/java/foundation/e/notes/util/NotesClientUtilTest.java deleted file mode 100644 index 740669bc0c339d9d88301a11a43f10f110718f75..0000000000000000000000000000000000000000 --- a/app/src/androidTest/java/foundation/e/notes/util/NotesClientUtilTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package foundation.e.notes.util; - -import junit.framework.TestCase; - -/** - * Tests the NotesClientUtil - * Created by stefan on 24.09.15. - */ -public class NotesClientUtilTest extends TestCase { - public void testFormatURL() { - assertEquals("https://example.com/", NotesClientUtil.formatURL("example.com/")); - assertEquals("http://example.com/", NotesClientUtil.formatURL("http://example.com/")); - assertEquals("https://example.com/", NotesClientUtil.formatURL("example.com/index.php")); - assertEquals("https://example.com/", NotesClientUtil.formatURL("example.com/index.php/")); - assertEquals("https://example.com/", NotesClientUtil.formatURL("example.com/index.php/apps")); - assertEquals("https://example.com/", NotesClientUtil.formatURL("example.com/index.php/apps/notes")); - assertEquals("https://example.com/", NotesClientUtil.formatURL("example.com/index.php/apps/notes/api")); - assertEquals("https://example.com/", NotesClientUtil.formatURL("example.com/index.php/apps/notes/api/v0.2")); - assertEquals("https://example.com/", NotesClientUtil.formatURL("example.com/index.php/apps/notes/api/v0.2/notes")); - assertEquals("https://example.com/nextcloud/", NotesClientUtil.formatURL("example.com/nextcloud")); - assertEquals("http://example.com:443/nextcloud/", NotesClientUtil.formatURL("http://example.com:443/nextcloud/index.php/apps/notes/api/v0.2/notes")); - } - - public void testIsHttp() { - assertTrue(NotesClientUtil.isHttp("http://example.com")); - assertTrue(NotesClientUtil.isHttp("http://www.example.com/")); - assertFalse(NotesClientUtil.isHttp("https://www.example.com/")); - assertFalse(NotesClientUtil.isHttp(null)); - } - - public void testIsValidURLTest() { - assertTrue(NotesClientUtil.isValidURL(null, "https://demo.owncloud.org/")); - assertFalse(NotesClientUtil.isValidURL(null, "https://www.example.com/")); - assertFalse(NotesClientUtil.isValidURL(null, "htp://www.example.com/")); - assertFalse(NotesClientUtil.isValidURL(null, null)); - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c6aed0d46538fe7898c130baf3a8be3952bf44b3..ab5087fce2f0fa2d4784e26752690918b039c82b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + xmlns:tools="http://schemas.android.com/tools" + package="it.niedermann.owncloud.notes"> @@ -9,17 +10,21 @@ + android:theme="@style/AppTheme" + tools:targetApi="n"> + + android:name=".SplashscreenActivity" + android:label="@string/app_name" + android:theme="@style/SplashTheme" + android:exported="true"> @@ -31,9 +36,10 @@ android:resource="@xml/shortcuts" /> + android:exported="false"> + @@ -43,26 +49,53 @@ android:resource="@xml/searchable" /> + android:value=".android.activity.NotesListViewActivity" /> + android:name=".importaccount.ImportAccountActivity" + android:label="@string/add_account" /> + + + + + + + + + + + + + + + + + + android:parentActivityName=".main.MainActivity" + android:windowSoftInputMode="stateHidden" + android:exported="true"> @@ -72,47 +105,44 @@ - - - + + + + + + + + + + + - + android:parentActivityName=".main.MainActivity" /> + + - + + + - - - - - + android:name=".widget.singlenote.SingleNoteWidget" + android:label="@string/widget_single_note_title" + android:exported="true"> - - - @@ -121,9 +151,12 @@ android:name="android.appwidget.provider" android:resource="@xml/single_note_widget_provider_info" /> + + android:name=".widget.notelist.NoteListWidget" + android:label="@string/widget_note_list_title" + android:exported="true"> + @@ -134,30 +167,24 @@ + + + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" + android:exported="true"> - - - - - - \ No newline at end of file + diff --git a/app/src/main/java/foundation/e/notes/android/AlwaysAutoCompleteTextView.java b/app/src/main/java/foundation/e/notes/android/AlwaysAutoCompleteTextView.java deleted file mode 100644 index cdc62c5ca2db5d3a11080286cdbdc23210febce2..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/AlwaysAutoCompleteTextView.java +++ /dev/null @@ -1,56 +0,0 @@ -package foundation.e.notes.android; - -import android.content.Context; -import androidx.appcompat.widget.AppCompatAutoCompleteTextView; -import android.util.AttributeSet; -import android.util.Log; -import android.view.WindowManager; - -/** - * Extension of the {@link AppCompatAutoCompleteTextView}, but this one is always open, i.e. you can see the list of suggestions even the TextView is empty. - */ -public class AlwaysAutoCompleteTextView extends AppCompatAutoCompleteTextView { - - private int myThreshold; - - public AlwaysAutoCompleteTextView(Context context) { - super(context); - } - - public AlwaysAutoCompleteTextView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - } - - public AlwaysAutoCompleteTextView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - @Override - public void setThreshold(int threshold) { - if (threshold < 0) { - threshold = 0; - } - myThreshold = threshold; - } - - @Override - public boolean enoughToFilter() { - return getText().length() >= myThreshold; - } - - @Override - public int getThreshold() { - return myThreshold; - } - - public void showFullDropDown() { - try { - performFiltering(getText(), 0); - showDropDown(); - } catch (WindowManager.BadTokenException e) { - // https://github.com/stefan-niedermann/nextcloud-notes/issues/366 - e.printStackTrace(); - Log.e(AlwaysAutoCompleteTextView.class.getSimpleName(), "Exception", e); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/notes/android/activity/AboutActivity.java b/app/src/main/java/foundation/e/notes/android/activity/AboutActivity.java deleted file mode 100644 index 3ccb1b849f1c73ce3877d8791a206a360a105f5a..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/activity/AboutActivity.java +++ /dev/null @@ -1,26 +0,0 @@ -package foundation.e.notes.android.activity; - -import android.graphics.drawable.ColorDrawable; -import android.os.Build; -import android.os.Bundle; -import android.view.Window; -import android.view.WindowManager; - -import androidx.annotation.ColorInt; -import androidx.appcompat.app.AppCompatActivity; - -import butterknife.ButterKnife; -import foundation.e.notes.R; -import foundation.e.notes.android.fragment.about.AboutFragment; - -public class AboutActivity extends AppCompatActivity { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_about); - ButterKnife.bind(this); - getFragmentManager().beginTransaction().replace(R.id.container, new AboutFragment()).commit(); - } - -} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/notes/android/activity/AccountActivity.java b/app/src/main/java/foundation/e/notes/android/activity/AccountActivity.java deleted file mode 100644 index e0cd549dd98c9e145fb029a479e78951fd3bbc17..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/activity/AccountActivity.java +++ /dev/null @@ -1,149 +0,0 @@ -package foundation.e.notes.android.activity; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.content.Intent; -import android.content.SharedPreferences; -import android.graphics.drawable.ColorDrawable; -import android.os.Build; -import android.preference.PreferenceManager; - -import androidx.annotation.ColorInt; -import androidx.appcompat.app.AppCompatActivity; - -import android.os.Bundle; -import android.view.View; -import android.view.Window; -import android.view.WindowManager; -import android.widget.Button; - -import foundation.e.notes.R; - -import static foundation.e.notes.android.activity.SettingsActivity.SETTINGS_IS_DEVICE_ACCOUNT; -import static foundation.e.notes.android.activity.SettingsActivity.SETTINGS_KEY_ETAG; -import static foundation.e.notes.android.activity.SettingsActivity.SETTINGS_KEY_LAST_MODIFIED; -import static foundation.e.notes.android.activity.SettingsActivity.SETTINGS_PASSWORD; -import static foundation.e.notes.android.activity.SettingsActivity.SETTINGS_URL; -import static foundation.e.notes.android.activity.SettingsActivity.SETTINGS_USERNAME; -import static foundation.e.notes.android.activity.SettingsActivity.CREDENTIALS_CHANGED; - -/** - * @author Nihar Thakkar - */ - -public class AccountActivity extends AppCompatActivity implements View.OnClickListener { - - private final static String key_login_account = "login_account"; - private final static String login_account_eelo = "login_account_eelo"; - private final static String login_account_manual = "login_account_manual"; - public final static String eelo_account_type = "e.foundation.webdav.eelo"; - - private final static int pick_account_request_code = 1; - - private AccountManager accountManager; - private SharedPreferences sharedPreferences; - private Button btn_eelo_Login; - private Button btn_manualLogin; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_account); - - accountManager = AccountManager.get(this); - sharedPreferences = PreferenceManager - .getDefaultSharedPreferences(getApplicationContext()); - - if (!(getIntent().getBooleanExtra("preference", false))) { - Account[] eeloAccounts = accountManager.getAccountsByType(eelo_account_type); - if (eeloAccounts.length > 0) { - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.putString(SettingsActivity.SETTINGS_URL, accountManager - .getUserData(eeloAccounts[0], "oc_base_url") + "/"); - editor.putString(SettingsActivity.SETTINGS_USERNAME, accountManager - .getUserData(eeloAccounts[0], "email_address")); - editor.putString(SettingsActivity.SETTINGS_PASSWORD, accountManager - .getPassword(eeloAccounts[0])); - editor.putBoolean(SettingsActivity.SETTINGS_IS_DEVICE_ACCOUNT, true); - editor.remove(SettingsActivity.SETTINGS_KEY_ETAG); - editor.remove(SettingsActivity.SETTINGS_KEY_LAST_MODIFIED); - editor.apply(); - - Intent resultIntent = new Intent(); - resultIntent.putExtra(key_login_account, login_account_eelo); - resultIntent.putExtra(NotesListViewActivity.CREDENTIALS_CHANGED, SettingsActivity.CREDENTIALS_CHANGED); - setResult(RESULT_OK, resultIntent); - finish(); - } - } - - initview(); - - } - - private void initview() { - btn_eelo_Login = (Button) findViewById(R.id.eelo_account_login_button); - btn_eelo_Login.setOnClickListener(this); - - btn_manualLogin = (Button) findViewById(R.id.manual_account_login_button); - btn_manualLogin.setOnClickListener(this); - - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == pick_account_request_code) { - if (resultCode == RESULT_OK) { - String accountName = data.getStringExtra(AccountManager.KEY_ACCOUNT_NAME); - for (Account account : accountManager.getAccountsByType(eelo_account_type)) { - if (account.name.equals(accountName)) { - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.putString(SettingsActivity.SETTINGS_URL, accountManager - .getUserData(account, "oc_base_url") + "/"); - editor.putString(SettingsActivity.SETTINGS_USERNAME, accountManager - .getUserData(account, "email_address")); - editor.putString(SettingsActivity.SETTINGS_PASSWORD, accountManager - .getPassword(account)); - editor.putBoolean(SettingsActivity.SETTINGS_IS_DEVICE_ACCOUNT, true); - editor.remove(SettingsActivity.SETTINGS_KEY_ETAG); - editor.remove(SettingsActivity.SETTINGS_KEY_LAST_MODIFIED); - editor.apply(); - - Intent resultIntent = new Intent(); - resultIntent.putExtra(key_login_account, login_account_eelo); - resultIntent.putExtra(NotesListViewActivity.CREDENTIALS_CHANGED, SettingsActivity.CREDENTIALS_CHANGED); - setResult(RESULT_OK, resultIntent); - finish(); - break; - } - } - } - } - } - - @Override - public void onClick(View view) { - - if (view == btn_manualLogin) { - if (getIntent().getBooleanExtra("preference", false)) { - startActivity(new Intent(AccountActivity.this, SettingsActivity.class)); - } else { - Intent resultIntent = new Intent(); - resultIntent.putExtra(key_login_account, login_account_manual); - setResult(RESULT_OK, resultIntent); - } - finish(); - } else if (view == btn_eelo_Login) { - String[] accountTypes = new String[]{eelo_account_type}; - Intent intent = AccountManager.newChooseAccountIntent( - null, - null, - accountTypes, - null, - null, null, null); - - startActivityForResult(intent, pick_account_request_code); - } - - } -} diff --git a/app/src/main/java/foundation/e/notes/android/activity/EditNoteActivity.java b/app/src/main/java/foundation/e/notes/android/activity/EditNoteActivity.java deleted file mode 100644 index 8c21059eef0fb4e6704a50200ccee2470f0defb8..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/activity/EditNoteActivity.java +++ /dev/null @@ -1,224 +0,0 @@ -package foundation.e.notes.android.activity; - -import android.app.Fragment; -import android.content.Intent; -import android.content.SharedPreferences; -import android.graphics.drawable.ColorDrawable; -import android.os.Build; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; -import android.view.Window; -import android.view.WindowManager; - -import androidx.annotation.ColorInt; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; - -import java.util.Calendar; - -import foundation.e.notes.android.fragment.BaseNoteFragment; -import foundation.e.notes.android.fragment.NoteEditFragment; -import foundation.e.notes.android.fragment.NotePreviewFragment; -import foundation.e.notes.R; -import foundation.e.notes.model.Category; -import foundation.e.notes.model.CloudNote; -import foundation.e.notes.model.DBNote; -import foundation.e.notes.util.NoteUtil; -import foundation.e.notes.util.ExceptionHandler; - -public class EditNoteActivity extends AppCompatActivity implements BaseNoteFragment.NoteFragmentListener { - - public static final String ACTION_SHORTCUT = "it.niedermann.owncloud.notes.shortcut"; - private static final String INTENT_GOOGLE_ASSISTANT = "com.google.android.gm.action.AUTO_SEND"; - private static final String MIMETYPE_TEXT_PLAIN = "text/plain"; - public static final String PARAM_NOTE_ID = "noteId"; - public static final String PARAM_CATEGORY = "category"; - - private BaseNoteFragment fragment; - - @Override - protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler(this)); - - if (savedInstanceState == null) { - launchNoteFragment(); - } else { - fragment = (BaseNoteFragment) getFragmentManager().findFragmentById(android.R.id.content); - } - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - } - - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - Log.d(getClass().getSimpleName(), "onNewIntent: " + intent.getLongExtra(PARAM_NOTE_ID, 0)); - setIntent(intent); - if (fragment != null) { - getFragmentManager().beginTransaction().detach(fragment).commit(); - fragment = null; - } - launchNoteFragment(); - } - - private long getNoteId() { - return getIntent().getLongExtra(PARAM_NOTE_ID, 0); - } - - /** - * Starts the note fragment for an existing note or a new note. - * The actual behavior is triggered by the activity's intent. - */ - private void launchNoteFragment() { - long noteId = getNoteId(); - if (noteId > 0) { - launchExistingNote(noteId); - } else { - launchNewNote(); - } - } - - /** - * Starts a {@link NoteEditFragment} or {@link NotePreviewFragment} for an existing note. - * The type of fragment (view-mode) is chosen based on the user preferences. - * - * @param noteId ID of the existing note. - */ - private void launchExistingNote(long noteId) { - final String prefKeyNoteMode = getString(R.string.pref_key_note_mode); - final String prefKeyLastMode = getString(R.string.pref_key_last_note_mode); - final String prefValueEdit = getString(R.string.pref_value_mode_edit); - final String prefValuePreview = getString(R.string.pref_value_mode_preview); - final String prefValueLast = getString(R.string.pref_value_mode_last); - - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); - String mode = preferences.getString(prefKeyNoteMode, prefValueEdit); - String lastMode = preferences.getString(prefKeyLastMode, prefValueEdit); - boolean editMode = true; - if (prefValuePreview.equals(mode) || (prefValueLast.equals(mode) && prefValuePreview.equals(lastMode))) { - editMode = false; - } - launchExistingNote(noteId, editMode); - } - - /** - * Starts a {@link NoteEditFragment} or {@link NotePreviewFragment} for an existing note. - * - * @param noteId ID of the existing note. - * @param edit View-mode of the fragment: - * true for {@link NoteEditFragment}, - * false for {@link NotePreviewFragment}. - */ - private void launchExistingNote(long noteId, boolean edit) { - // save state of the fragment in order to resume with the same note and originalNote - Fragment.SavedState savedState = null; - if (fragment != null) { - savedState = getFragmentManager().saveFragmentInstanceState(fragment); - } - if (edit) { - fragment = NoteEditFragment.newInstance(noteId); - } else { - fragment = NotePreviewFragment.newInstance(noteId); - } - - if (savedState != null) { - fragment.setInitialSavedState(savedState); - } - getFragmentManager().beginTransaction().replace(android.R.id.content, fragment).commit(); - } - - /** - * Starts the {@link NoteEditFragment} with a new note. - * Content ("share" functionality), category and favorite attribute can be preset. - */ - private void launchNewNote() { - Intent intent = getIntent(); - - String category = null; - boolean favorite = false; - if (intent.hasExtra(PARAM_CATEGORY)) { - Category categoryPreselection = (Category) intent.getSerializableExtra(PARAM_CATEGORY); - category = categoryPreselection.category; - favorite = categoryPreselection.favorite != null ? categoryPreselection.favorite : false; - } - - String content = ""; - if ( - MIMETYPE_TEXT_PLAIN.equals(intent.getType()) && - (Intent.ACTION_SEND.equals(intent.getAction()) || - INTENT_GOOGLE_ASSISTANT.equals(intent.getAction())) - ) { - content = intent.getStringExtra(Intent.EXTRA_TEXT); - } - - CloudNote newNote = new CloudNote(0, Calendar.getInstance(), NoteUtil.generateNonEmptyNoteTitle(content, this), content, favorite, category, null); - fragment = NoteEditFragment.newInstanceWithNewNote(newNote); - getFragmentManager().beginTransaction().replace(android.R.id.content, fragment).commit(); - } - - @Override - public void onBackPressed() { - close(); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.menu_note_activity, menu); - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - close(); - return true; - case R.id.menu_preview: - launchExistingNote(getNoteId(), false); - return true; - case R.id.menu_edit: - launchExistingNote(getNoteId(), true); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - - /** - * Send result and closes the Activity - */ - public void close() { - /* TODO enhancement: store last mode in note - * for cross device functionality per note mode should be stored on the server. - */ - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); - final String prefKeyLastMode = getString(R.string.pref_key_last_note_mode); - if (fragment instanceof NoteEditFragment) { - preferences.edit().putString(prefKeyLastMode, getString(R.string.pref_value_mode_edit)).apply(); - } else { - preferences.edit().putString(prefKeyLastMode, getString(R.string.pref_value_mode_preview)).apply(); - } - fragment.onCloseNote(); - finish(); - } - - @Override - public void onNoteUpdated(DBNote note) { - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setTitle(note.getTitle()); - if (!note.getCategory().isEmpty()) { - actionBar.setSubtitle(NoteUtil.extendCategory(note.getCategory())); - } - } - } - -} diff --git a/app/src/main/java/foundation/e/notes/android/activity/ExceptionActivity.java b/app/src/main/java/foundation/e/notes/android/activity/ExceptionActivity.java deleted file mode 100644 index 7a961e5bc0549c67c4754f28d45e2efc6949d8bb..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/activity/ExceptionActivity.java +++ /dev/null @@ -1,64 +0,0 @@ -package foundation.e.notes.android.activity; - -import android.content.ClipData; -import android.content.ClipboardManager; -import android.os.Bundle; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -import java.io.PrintWriter; -import java.io.StringWriter; -import java.util.Objects; - -import butterknife.BindView; -import butterknife.ButterKnife; -import butterknife.OnClick; -import foundation.e.notes.BuildConfig; -import foundation.e.notes.R; - -public class ExceptionActivity extends AppCompatActivity { - - Throwable throwable; - - @BindView(R.id.message) - TextView message; - @BindView(R.id.stacktrace) - TextView stacktrace; - - public static final String KEY_THROWABLE = "T"; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - setContentView(R.layout.activity_exception); - ButterKnife.bind(this); - super.onCreate(savedInstanceState); - throwable = ((Throwable) getIntent().getSerializableExtra(KEY_THROWABLE)); - throwable.printStackTrace(); - Objects.requireNonNull(getSupportActionBar()).setTitle(getString(R.string.simple_error)); - this.message.setText(throwable.getMessage()); - this.stacktrace.setText("Version: " + BuildConfig.VERSION_NAME + "\n\n" + getStacktraceOf(throwable)); - } - - private String getStacktraceOf(Throwable e) { - StringWriter sw = new StringWriter(); - e.printStackTrace(new PrintWriter(sw)); - return sw.toString(); - } - - - @OnClick(R.id.copy) - void copyStacktraceToClipboard() { - final ClipboardManager clipboardManager = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); - ClipData clipData = ClipData.newPlainText(getString(R.string.simple_exception), this.stacktrace.getText()); - clipboardManager.setPrimaryClip(clipData); - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show(); - } - - @OnClick(R.id.close) - void close() { - finish(); - } -} diff --git a/app/src/main/java/foundation/e/notes/android/activity/NotesListViewActivity.java b/app/src/main/java/foundation/e/notes/android/activity/NotesListViewActivity.java deleted file mode 100644 index 771991ba4198a0bca0b0fd3a0dca52e5710094b2..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/activity/NotesListViewActivity.java +++ /dev/null @@ -1,954 +0,0 @@ -package foundation.e.notes.android.activity; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.app.SearchManager; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.content.res.ColorStateList; -import android.content.res.Configuration; -import android.graphics.Canvas; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Icon; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.preference.PreferenceManager; -import android.provider.Settings; -import android.util.Log; -import android.util.TypedValue; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewTreeObserver; -import android.view.Window; -import android.view.WindowManager; -import android.widget.LinearLayout; -import android.widget.RelativeLayout; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBarDrawerToggle; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.appcompat.view.ActionMode; -import androidx.appcompat.widget.AppCompatImageView; -import androidx.appcompat.widget.SearchView; -import androidx.appcompat.widget.Toolbar; -import androidx.core.content.ContextCompat; -import androidx.core.view.GravityCompat; -import androidx.drawerlayout.widget.DrawerLayout; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.RequestOptions; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.snackbar.Snackbar; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import butterknife.BindView; -import butterknife.ButterKnife; -import foundation.e.notes.R; -import foundation.e.notes.model.Category; -import foundation.e.notes.model.DBNote; -import foundation.e.notes.model.Item; -import foundation.e.notes.model.ItemAdapter; -import foundation.e.notes.model.NavigationAdapter; -import foundation.e.notes.persistence.LoadNotesListTask; -import foundation.e.notes.persistence.NoteSQLiteOpenHelper; -import foundation.e.notes.persistence.NoteServerSyncHelper; -import foundation.e.notes.util.ExceptionHandler; -import foundation.e.notes.util.ICallback; -import foundation.e.notes.util.NoteUtil; -import foundation.e.notes.util.NotesClientUtil; - -import static foundation.e.notes.android.activity.AccountActivity.eelo_account_type; -import static foundation.e.notes.android.activity.SettingsActivity.SETTINGS_IS_DEVICE_ACCOUNT; -import static foundation.e.notes.android.activity.SettingsActivity.SETTINGS_PASSWORD; -import static foundation.e.notes.android.activity.SettingsActivity.SETTINGS_USERNAME; - -/** - * @author Nihar Thakkar - */ - -import static foundation.e.notes.android.activity.EditNoteActivity.ACTION_SHORTCUT; - -public class NotesListViewActivity extends AppCompatActivity implements ItemAdapter.NoteClickListener { - - public final static String CREATED_NOTE = "foundation.e.notes.created_notes"; - public final static String CREDENTIALS_CHANGED = "foundation.e.notes.CREDENTIALS_CHANGED"; - public static final String ADAPTER_KEY_RECENT = "recent"; - public static final String ADAPTER_KEY_STARRED = "starred"; - public static final String ACTION_FAVORITES = "foundation.e.notes.favorites"; - public static final String ACTION_RECENT = "foundation.e.notes.recent"; - - - private static final String SAVED_STATE_NAVIGATION_SELECTION = "navigationSelection"; - private static final String SAVED_STATE_NAVIGATION_ADAPTER_SLECTION = "navigationAdapterSelection"; - private static final String SAVED_STATE_NAVIGATION_OPEN = "navigationOpen"; - - private final static int create_note_cmd = 0; - private final static int show_single_note_cmd = 1; - private final static int server_settings = 2; - private final static int about = 3; - private final static int login_account = 4; - - private final static String key_login_account = "login_account"; - private final static String login_account_eelo = "login_account_eelo"; - private final static String key_email_address = "email_address"; - - @BindView(R.id.notesListActivityActionBar) - Toolbar toolbar; - @BindView(R.id.drawerLayout) - DrawerLayout drawerLayout; - @BindView(R.id.current_account_image) - AppCompatImageView currentAccountImage; - @BindView(R.id.header_view) - RelativeLayout headerView; - @BindView(R.id.account) - TextView account; - @BindView(R.id.swiperefreshlayout) - SwipeRefreshLayout swipeRefreshLayout; - @BindView(R.id.fab_create) - FloatingActionButton fabCreate; - @BindView(R.id.navigationList) - RecyclerView listNavigationCategories; - @BindView(R.id.navigationMenu) - RecyclerView listNavigationMenu; - @BindView(R.id.recycler_view) - RecyclerView listView; - @BindView(R.id.parent) - CoordinatorLayout coordinatorLayout; - - private ActionBarDrawerToggle drawerToggle; - private ItemAdapter adapter = null; - private NavigationAdapter adapterCategories; - private NavigationAdapter.NavigationItem itemRecent, itemFavorites, itemUncategorized; - private Category navigationSelection = new Category(null, null); - private String navigationOpen = ""; - private ActionMode mActionMode; - private NoteSQLiteOpenHelper db = null; - private SearchView searchView = null; - private ICallback syncCallBack = new ICallback() { - @Override - public void onFinish() { - adapter.clearSelection(); - if (mActionMode != null) { - mActionMode.finish(); - } - refreshLists(); - swipeRefreshLayout.setRefreshing(false); - new Thread(() -> { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N_MR1) { - ShortcutManager shortcutManager = getApplicationContext().getSystemService(ShortcutManager.class); - if (!shortcutManager.isRateLimitingActive()) { - List newShortcuts = new ArrayList<>(); - - for (DBNote note : db.getRecentNotes()) { - Intent intent = new Intent(getApplicationContext(), EditNoteActivity.class); - intent.putExtra(EditNoteActivity.PARAM_NOTE_ID, note.getId()); - intent.setAction(ACTION_SHORTCUT); - - newShortcuts.add(new ShortcutInfo.Builder(getApplicationContext(), note.getId() + "") - .setShortLabel(note.getTitle()) - .setIcon(Icon.createWithResource(getApplicationContext(), note.isFavorite() ? R.drawable.ic_star_yellow_24dp : R.drawable.ic_star_border_white_24dp)) - .setIntent(intent) - .build()); - } - Log.d(getClass().getSimpleName(), "Update dynamic shortcuts"); - shortcutManager.removeAllDynamicShortcuts(); - shortcutManager.addDynamicShortcuts(newShortcuts); - } - } - }).run(); - } - - @Override - public void onScheduled() { - } - }; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler(this)); - // First Run Wizard - if (!NoteServerSyncHelper.isConfigured(this)) { - Intent settingsIntent = new Intent(this, AccountActivity.class); - startActivityForResult(settingsIntent, login_account); - } - - if (PreferenceManager.getDefaultSharedPreferences(this) - .getBoolean(SettingsActivity.SETTINGS_IS_DEVICE_ACCOUNT, false)) { - migrateUrl(); - if (hasAccountBeenRemoved() || hasAccountPasswordChanged()) { - Intent settingsIntent = new Intent(this, AccountActivity.class); - startActivityForResult(settingsIntent, login_account); - } - } - - String categoryAdapterSelectedItem = ADAPTER_KEY_RECENT; - if (savedInstanceState == null) { - if (ACTION_RECENT.equals(getIntent().getAction())) { - categoryAdapterSelectedItem = ADAPTER_KEY_RECENT; - } else if (ACTION_FAVORITES.equals(getIntent().getAction())) { - categoryAdapterSelectedItem = ADAPTER_KEY_STARRED; - navigationSelection = new Category(null, true); - } - } else { - navigationSelection = (Category) savedInstanceState.getSerializable(SAVED_STATE_NAVIGATION_SELECTION); - navigationOpen = savedInstanceState.getString(SAVED_STATE_NAVIGATION_OPEN); - categoryAdapterSelectedItem = savedInstanceState.getString(SAVED_STATE_NAVIGATION_ADAPTER_SLECTION); - } - - setContentView(R.layout.drawer_layout); - ButterKnife.bind(this); - - db = NoteSQLiteOpenHelper.getInstance(this); - - setupActionBar(); - setupNotesList(); - setupNavigationList(categoryAdapterSelectedItem); - setupNavigationMenu(); - - //SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - //boolean ssoAnnouncmentShown = prefs.getBoolean("sp_sso_announchment_shown", false); - //if (!ssoAnnouncmentShown) { - // AlertDialog dialog = new AlertDialog.Builder(this) - // .setTitle(R.string.sso_announcment_title) - // .setCancelable(false) - // .setMessage(R.string.sso_announcment_message) - // .setNegativeButton(R.string.sso_announcment_more_info, (a, b) -> { - // startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/stefan-niedermann/nextcloud-notes/blob/master/SSO%20Announcment.md"))); - // }) - // .setPositiveButton(R.string.sso_announcment_understood, (a, b) -> { - // SharedPreferences.Editor editor = prefs.edit(); - // editor.putBoolean("sp_sso_announchment_shown", true); - // editor.apply(); - // }) - // .show(); - // dialog.getButton(DialogInterface.BUTTON_POSITIVE).setTextColor(getResources().getColor(R.color.fg_default)); - // dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setTextColor(getResources().getColor(R.color.fg_default)); - //} - - } - - private void migrateUrl() { - String syncUrl = PreferenceManager.getDefaultSharedPreferences(this) - .getString(SettingsActivity.SETTINGS_URL, ""); - if (syncUrl.equals("https://drive.ecloud.global/")) { - PreferenceManager.getDefaultSharedPreferences(this) - .edit() - .putString(SettingsActivity.SETTINGS_URL, "https://ecloud.global/") - .apply(); - } - } - - private boolean hasAccountBeenRemoved() { - AccountManager accountManager = AccountManager.get(this); - String userName = PreferenceManager.getDefaultSharedPreferences(this) - .getString(SettingsActivity.SETTINGS_USERNAME, ""); - Account[] eeloAccounts = accountManager.getAccountsByType(eelo_account_type); - boolean hasAccountBeenRemoved = true; - - for (Account account : eeloAccounts) { - if (accountManager.getUserData(account, key_email_address).equals(userName)) { - hasAccountBeenRemoved = false; - break; - } - } - return hasAccountBeenRemoved; - } - - private boolean hasAccountPasswordChanged() { - AccountManager accountManager = AccountManager.get(this); - String password = PreferenceManager.getDefaultSharedPreferences(this) - .getString(SettingsActivity.SETTINGS_PASSWORD, ""); - Account[] eeloAccounts = accountManager.getAccountsByType(eelo_account_type); - boolean hasAccountPasswordChanged = true; - - for (Account account : eeloAccounts) { - if (accountManager.getPassword(account).equals(password)) { - hasAccountPasswordChanged = false; - break; - } - } - return hasAccountPasswordChanged; - } - - @Override - protected void onResume() { - // refresh and sync every time the activity gets visible - refreshLists(); - db.getNoteServerSyncHelper().addCallbackPull(syncCallBack); - if (db.getNoteServerSyncHelper().isSyncEnabled()) { - if (db.getNoteServerSyncHelper().isSyncPossible()) { - synchronize(); - } - } else { - Snackbar.make(coordinatorLayout, getString(R.string.error_sync_disabled), - Snackbar.LENGTH_LONG).setAction(R.string.action_enable_sync, new - View.OnClickListener() { - @Override - public void onClick(View view) { - startActivity(new Intent(Settings.ACTION_SYNC_SETTINGS)); - } - }).show(); - } - super.onResume(); - } - - @Override - protected void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - drawerToggle.syncState(); - } - - @Override - public void onConfigurationChanged(@NonNull Configuration newConfig) { - super.onConfigurationChanged(newConfig); - drawerToggle.syncState(); - } - - @Override - protected void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - outState.putSerializable(SAVED_STATE_NAVIGATION_SELECTION, navigationSelection); - outState.putString(SAVED_STATE_NAVIGATION_ADAPTER_SLECTION, adapterCategories.getSelectedItem()); - outState.putString(SAVED_STATE_NAVIGATION_OPEN, navigationOpen); - } - - private void setupActionBar() { - setSupportActionBar(toolbar); - drawerToggle = new ActionBarDrawerToggle(this, drawerLayout, toolbar, R.string.action_drawer_open, R.string.action_drawer_close); - drawerToggle.setDrawerIndicatorEnabled(false); - drawerLayout.addDrawerListener(drawerToggle); - drawerToggle.setHomeAsUpIndicator(lineageos.platform.R.drawable.ic_hamburger); - - if (toolbar.getNavigationIcon() != null) - toolbar.getNavigationIcon().setTint(ContextCompat.getColor(this, lineageos.platform.R.color.color_default_accent)); - - drawerToggle.setToolbarNavigationClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - drawerLayout.openDrawer(GravityCompat.START); - } - }); - } - - private void setupNotesList() { - initList(); - // Pull to Refresh - swipeRefreshLayout.setColorSchemeColors(getColor(R.color.accent_color)); - swipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() { - @Override - public void onRefresh() { - if (db.getNoteServerSyncHelper().isSyncEnabled()) { - if (db.getNoteServerSyncHelper().isSyncPossible()) { - synchronize(); - } else { - swipeRefreshLayout.setRefreshing(false); - Toast.makeText(getApplicationContext(), getString(R.string.error_sync, getString(NotesClientUtil.LoginStatus.NO_NETWORK.str)), Toast.LENGTH_LONG).show(); - } - } else { - swipeRefreshLayout.setRefreshing(false); - Snackbar.make(coordinatorLayout, getString(R.string.error_sync_disabled), - Snackbar.LENGTH_LONG).setAction(R.string.action_enable_sync, new - View.OnClickListener() { - @Override - public void onClick(View view) { - startActivity(new Intent(Settings.ACTION_SYNC_SETTINGS)); - } - }).show(); - } - } - }); - - // Floating Action Button - fabCreate.setOnClickListener((View view) -> { - Intent createIntent = new Intent(getApplicationContext(), EditNoteActivity.class); - createIntent.putExtra(EditNoteActivity.PARAM_CATEGORY, navigationSelection); - startActivityForResult(createIntent, create_note_cmd); - }); - } - - private void setupNavigationList(final String selectedItem) { - itemRecent = new NavigationAdapter.NavigationItem(ADAPTER_KEY_RECENT, getString(R.string.label_all_notes), null, lineageos.platform.R.drawable.ic_recent); - itemFavorites = new NavigationAdapter.NavigationItem(ADAPTER_KEY_STARRED, getString(R.string.label_favorites), null, lineageos.platform.R.drawable.ic_star_filled); - adapterCategories = new NavigationAdapter(new NavigationAdapter.ClickListener() { - @Override - public void onItemClick(NavigationAdapter.NavigationItem item) { - selectItem(item, true); - } - - private void selectItem(NavigationAdapter.NavigationItem item, boolean closeNavigation) { - adapterCategories.setSelectedItem(item.id); - - // update current selection - if (itemRecent == item) { - navigationSelection = new Category(null, null); - } else if (itemFavorites == item) { - navigationSelection = new Category(null, true); - } else if (itemUncategorized == item) { - navigationSelection = new Category("", null); - } else { - navigationSelection = new Category(item.label, null); - } - - // auto-close sub-folder in Navigation if selection is outside of that folder - if (navigationOpen != null) { - int slashIndex = navigationSelection.category == null ? -1 : navigationSelection.category.indexOf('/'); - String rootCategory = slashIndex < 0 ? navigationSelection.category : navigationSelection.category.substring(0, slashIndex); - if (!navigationOpen.equals(rootCategory)) { - navigationOpen = null; - } - } - - // update views - if (closeNavigation) { - drawerLayout.closeDrawers(); - } - refreshLists(true); - } - - @Override - public void onIconClick(NavigationAdapter.NavigationItem item) { - if (item.icon == NavigationAdapter.ICON_MULTIPLE && !item.label.equals(navigationOpen)) { - navigationOpen = item.label; - selectItem(item, false); - } else if (item.icon == NavigationAdapter.ICON_MULTIPLE || item.icon == NavigationAdapter.ICON_MULTIPLE_OPEN && item.label.equals(navigationOpen)) { - navigationOpen = null; - refreshLists(); - } else { - onItemClick(item); - } - } - }); - adapterCategories.setSelectedItem(selectedItem); - listNavigationCategories.setAdapter(adapterCategories); - } - - private class LoadCategoryListTask extends AsyncTask> { - @Override - protected List doInBackground(Void... voids) { - List categories = db.getCategories(); - if (!categories.isEmpty() && categories.get(0).label.isEmpty()) { - itemUncategorized = categories.get(0); - itemUncategorized.label = getString(R.string.action_uncategorized); - itemUncategorized.icon = NavigationAdapter.ICON_NOFOLDER; - } else { - itemUncategorized = null; - } - - Map favorites = db.getFavoritesCount(); - int numFavorites = favorites.containsKey("1") ? favorites.get("1") : 0; - int numNonFavorites = favorites.containsKey("0") ? favorites.get("0") : 0; - itemFavorites.count = numFavorites; - itemRecent.count = numFavorites + numNonFavorites; - - ArrayList items = new ArrayList<>(); - items.add(itemRecent); - items.add(itemFavorites); - NavigationAdapter.NavigationItem lastPrimaryCategory = null, lastSecondaryCategory = null; - for (NavigationAdapter.NavigationItem item : categories) { - int slashIndex = item.label.indexOf('/'); - String currentPrimaryCategory = slashIndex < 0 ? item.label : item.label.substring(0, slashIndex); - String currentSecondaryCategory = null; - boolean isCategoryOpen = currentPrimaryCategory.equals(navigationOpen); - - if (isCategoryOpen && !currentPrimaryCategory.equals(item.label)) { - String currentCategorySuffix = item.label.substring(navigationOpen.length() + 1); - int subSlashIndex = currentCategorySuffix.indexOf('/'); - currentSecondaryCategory = subSlashIndex < 0 ? currentCategorySuffix : currentCategorySuffix.substring(0, subSlashIndex); - } - - boolean belongsToLastPrimaryCategory = lastPrimaryCategory != null && currentPrimaryCategory.equals(lastPrimaryCategory.label); - boolean belongsToLastSecondaryCategory = belongsToLastPrimaryCategory && lastSecondaryCategory != null && lastSecondaryCategory.label.equals(currentPrimaryCategory + "/" + currentSecondaryCategory); - - if (isCategoryOpen && !belongsToLastPrimaryCategory && currentSecondaryCategory != null) { - lastPrimaryCategory = new NavigationAdapter.NavigationItem("category:" + currentPrimaryCategory, currentPrimaryCategory, 0, NavigationAdapter.ICON_MULTIPLE_OPEN); - items.add(lastPrimaryCategory); - belongsToLastPrimaryCategory = true; - } - - if (belongsToLastPrimaryCategory && belongsToLastSecondaryCategory) { - lastSecondaryCategory.count += item.count; - lastSecondaryCategory.icon = NavigationAdapter.ICON_SUB_MULTIPLE; - } else if (belongsToLastPrimaryCategory) { - if (isCategoryOpen) { - item.label = currentPrimaryCategory + "/" + currentSecondaryCategory; - item.id = "category:" + item.label; - item.icon = NavigationAdapter.ICON_SUB_FOLDER; - items.add(item); - lastSecondaryCategory = item; - } else { - lastPrimaryCategory.count += item.count; - lastPrimaryCategory.icon = NavigationAdapter.ICON_MULTIPLE; - lastSecondaryCategory = null; - } - } else { - if (isCategoryOpen) { - item.icon = NavigationAdapter.ICON_MULTIPLE_OPEN; - } else { - item.label = currentPrimaryCategory; - item.id = "category:" + item.label; - } - items.add(item); - lastPrimaryCategory = item; - lastSecondaryCategory = null; - } - } - return items; - } - - @Override - protected void onPostExecute(List items) { - adapterCategories.setItems(items); - } - } - - private void setupNavigationMenu() { - final NavigationAdapter.NavigationItem itemTrashbin = new NavigationAdapter.NavigationItem("trashbin", getString(R.string.action_trashbin), null, lineageos.platform.R.drawable.ic_bin); - final NavigationAdapter.NavigationItem itemSettings = new NavigationAdapter.NavigationItem("settings", getString(R.string.action_settings), null, lineageos.platform.R.drawable.ic_settings); - final NavigationAdapter.NavigationItem itemAbout = new NavigationAdapter.NavigationItem("about", getString(R.string.simple_about), null, lineageos.platform.R.drawable.ic_info); - - ArrayList itemsMenu = new ArrayList<>(); - itemsMenu.add(itemTrashbin); - itemsMenu.add(itemSettings); - itemsMenu.add(itemAbout); - - NavigationAdapter adapterMenu = new NavigationAdapter(new NavigationAdapter.ClickListener() { - @Override - public void onItemClick(NavigationAdapter.NavigationItem item) { - if (item == itemSettings) { - Intent settingsIntent = new Intent(getApplicationContext(), PreferencesActivity.class); - startActivityForResult(settingsIntent, server_settings); - } else if (item == itemAbout) { - Intent aboutIntent = new Intent(getApplicationContext(), AboutActivity.class); - startActivityForResult(aboutIntent, about); - } else if (item == itemTrashbin) { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); - String url = preferences.getString(SettingsActivity.SETTINGS_URL, SettingsActivity.DEFAULT_SETTINGS); - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url + "index.php/apps/files/?dir=/&view=trashbin"))); - } - } - - @Override - public void onIconClick(NavigationAdapter.NavigationItem item) { - onItemClick(item); - } - }); - - - this.updateUsernameInDrawer(); - final NotesListViewActivity that = this; - this.headerView.setOnClickListener((View v) -> { - Intent settingsIntent = new Intent(that, SettingsActivity.class); - startActivityForResult(settingsIntent, server_settings); - }); - - adapterMenu.setItems(itemsMenu); - listNavigationMenu.setAdapter(adapterMenu); - } - - public void initList() { - adapter = new ItemAdapter(this); - listView.setAdapter(adapter); - listView.setLayoutManager(new LinearLayoutManager(this)); - ItemTouchHelper touchHelper = new ItemTouchHelper(new SimpleCallback(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) { - @Override - public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { - return false; - } - - /** - * Disable swipe on sections - * - * @param recyclerView RecyclerView - * @param viewHolder RecyclerView.ViewHoler - * @return 0 if section, otherwise super() - */ - @Override - public int getSwipeDirs(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { - if (viewHolder instanceof ItemAdapter.SectionViewHolder) return 0; - return super.getSwipeDirs(recyclerView, viewHolder); - } - - /** - * Delete note if note is swiped to left or right - * - * @param viewHolder RecyclerView.ViewHoler - * @param direction int - */ - @Override - public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { - switch (direction) { - case ItemTouchHelper.LEFT: { - final DBNote dbNote = (DBNote) adapter.getItem(viewHolder.getAdapterPosition()); - db.deleteNoteAndSync((dbNote).getId()); - adapter.remove(dbNote); - refreshLists(); - Log.v("Note", "Item deleted through swipe ----------------------------------------------"); - Snackbar.make(swipeRefreshLayout, R.string.action_note_deleted, Snackbar.LENGTH_LONG) - .setAction(R.string.action_undo, (View v) -> { - db.addNoteAndSync(dbNote); - refreshLists(); - Snackbar.make(swipeRefreshLayout, R.string.action_note_restored, Snackbar.LENGTH_SHORT) - .show(); - }) - .show(); - break; - } - case ItemTouchHelper.RIGHT: { - final DBNote dbNote = (DBNote) adapter.getItem(viewHolder.getAdapterPosition()); - db.toggleFavorite(dbNote, syncCallBack); - refreshLists(); - break; - } - } - } - - @Override - public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { - ItemAdapter.NoteViewHolder noteViewHolder = (ItemAdapter.NoteViewHolder) viewHolder; - // show swipe icon on the side - noteViewHolder.showSwipe(dX > 0); - // move only swipeable part of item (not leave-behind) - getDefaultUIUtil().onDraw(c, recyclerView, noteViewHolder.noteSwipeable, dX, dY, actionState, isCurrentlyActive); - } - - @Override - public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { - getDefaultUIUtil().clearView(((ItemAdapter.NoteViewHolder) viewHolder).noteSwipeable); - } - }); - touchHelper.attachToRecyclerView(listView); - } - - private void refreshLists() { - refreshLists(false); - } - - private void refreshLists(final boolean scrollToTop) { - String subtitle = ""; - if (navigationSelection.category != null) { - if (navigationSelection.category.isEmpty()) { - subtitle = getString(R.string.action_uncategorized); - } else { - subtitle = NoteUtil.extendCategory(navigationSelection.category); - } - } else if (navigationSelection.favorite != null && navigationSelection.favorite) { - subtitle = getString(R.string.label_favorites); - } else { - subtitle = getString(R.string.app_name); - } - setTitle(subtitle); - CharSequence query = null; - if (searchView != null && !searchView.isIconified() && searchView.getQuery().length() != 0) { - query = searchView.getQuery(); - } - - LoadNotesListTask.NotesLoadedListener callback = (List notes, boolean showCategory) -> { - adapter.setShowCategory(showCategory); - adapter.setItemList(notes); - if (scrollToTop) { - listView.scrollToPosition(0); - } - }; - new LoadNotesListTask(getApplicationContext(), callback, navigationSelection, query).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - new LoadCategoryListTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - public ItemAdapter getItemAdapter() { - return adapter; - } - - public SwipeRefreshLayout getSwipeRefreshLayout() { - return swipeRefreshLayout; - } - - /** - * Adds the Menu Items to the Action Bar. - * - * @param menu Menu - * @return boolean - */ - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.menu_list_view, menu); - // Associate searchable configuration with the SearchView - final MenuItem item = menu.findItem(R.id.search); - searchView = (SearchView) item.getActionView(); - - final LinearLayout searchEditFrame = searchView.findViewById(R.id - .search_edit_frame); - - searchEditFrame.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - int oldVisibility = -1; - - @Override - public void onGlobalLayout() { - int currentVisibility = searchEditFrame.getVisibility(); - - if (currentVisibility != oldVisibility) { - if (currentVisibility == View.VISIBLE) { - fabCreate.hide(); - } else { - new Handler().postDelayed(() -> { - fabCreate.show(); - }, 150); - } - - oldVisibility = currentVisibility; - } - } - - }); - - searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { - @Override - public boolean onQueryTextSubmit(String query) { - return false; - } - - @Override - public boolean onQueryTextChange(String newText) { - refreshLists(); - return true; - } - }); - return true; - } - - @Override - protected void onNewIntent(Intent intent) { - if (Intent.ACTION_SEARCH.equals(intent.getAction())) { - searchView.setQuery(intent.getStringExtra(SearchManager.QUERY), true); - } - super.onNewIntent(intent); - } - - /** - * Handles the Results of started Sub Activities (Created Note, Edited Note) - * - * @param requestCode int to distinguish between the different Sub Activities - * @param resultCode int Return Code - * @param data Intent - */ - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - // Check which request we're responding to - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == create_note_cmd) { - // Make sure the request was successful - if (resultCode == RESULT_OK) { - //not need because of db.synchronisation in createActivity - - Bundle bundle = data.getExtras(); - if (bundle != null) { - DBNote createdNote = (DBNote) data.getExtras().getSerializable(CREATED_NOTE); - if (createdNote != null) { - adapter.add(createdNote); - } else { - Log.w(NotesListViewActivity.class.getSimpleName(), "createdNote is null"); - } - } else { - Log.w(NotesListViewActivity.class.getSimpleName(), "bundle is null"); - } - } - listView.scrollToPosition(0); - } else if (requestCode == login_account) { - if (resultCode == RESULT_OK) { - if (data.getStringExtra(key_login_account).equals(login_account_eelo)) { - // Create new Instance with new URL and credentials - db = NoteSQLiteOpenHelper.getInstance(this); - if (db.getNoteServerSyncHelper().isSyncPossible()) { - adapter.removeAll(); - synchronize(); - } else { - Toast.makeText(getApplicationContext(), getString(R.string.error_sync, getString(NotesClientUtil.LoginStatus.NO_NETWORK.str)), Toast.LENGTH_LONG).show(); - } - } else { - Intent settingsIntent = new Intent(this, SettingsActivity.class); - startActivityForResult(settingsIntent, server_settings); - } - } else { - finish(); - } - } else if (requestCode == server_settings) { - if (resultCode == RESULT_OK) { - // Create new Instance with new URL and credentials - db = NoteSQLiteOpenHelper.getInstance(this); - if (db.getNoteServerSyncHelper().isSyncPossible()) { - this.updateUsernameInDrawer(); - adapter.removeAll(); - synchronize(); - } else { - Toast.makeText(getApplicationContext(), getString(R.string.error_sync, getString(NotesClientUtil.LoginStatus.NO_NETWORK.str)), Toast.LENGTH_LONG).show(); - } - } else { - finish(); - } - } - } - - private void updateUsernameInDrawer() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); - String username = preferences.getString(SettingsActivity.SETTINGS_USERNAME, SettingsActivity.DEFAULT_SETTINGS); - String url = preferences.getString(SettingsActivity.SETTINGS_URL, SettingsActivity.DEFAULT_SETTINGS); - if (url != null) { - String croppedUrl = url.replace("https://", "").replace("http://", ""); - if (!SettingsActivity.DEFAULT_SETTINGS.equals(username) && !SettingsActivity.DEFAULT_SETTINGS.equals(url)) { - this.account.setText(username + "@" + croppedUrl.substring(0, croppedUrl.length() - 1)); - Glide - .with(this) - .load(url + "/index.php/avatar/" + Uri.encode(username) + "/64") - .error(R.mipmap.ic_launcher) - .apply(RequestOptions.circleCropTransform()) - .into(this.currentAccountImage); - } - } else { - Log.w(NotesListViewActivity.class.getSimpleName(), "url is null"); - } - } - - @Override - public void onNoteClick(int position, View v) { - if (mActionMode != null) { - if (!adapter.select(position)) { - v.setSelected(false); - adapter.deselect(position); - } else { - v.setSelected(true); - } - int size = adapter.getSelected().size(); - mActionMode.setTitle(String.valueOf(getResources().getQuantityString(R.plurals.ab_selected, size, size))); - int checkedItemCount = adapter.getSelected().size(); - boolean hasCheckedItems = checkedItemCount > 0; - - if (hasCheckedItems && mActionMode == null) { - // TODO differ if one or more items are selected - // if (checkedItemCount == 1) { - // mActionMode = startActionMode(new - // SingleSelectedActionModeCallback()); - // } else { - // there are some selected items, start the actionMode - mActionMode = startSupportActionMode(new MultiSelectedActionModeCallback()); - // } - } else if (!hasCheckedItems && mActionMode != null) { - // there no selected items, finish the actionMode - mActionMode.finish(); - } - } else { - DBNote note = (DBNote) adapter.getItem(position); - Intent intent = new Intent(getApplicationContext(), EditNoteActivity.class); - intent.putExtra(EditNoteActivity.PARAM_NOTE_ID, note.getId()); - startActivityForResult(intent, show_single_note_cmd); - - } - } - - @Override - public void onNoteFavoriteClick(int position, View view) { - DBNote note = (DBNote) adapter.getItem(position); - NoteSQLiteOpenHelper db = NoteSQLiteOpenHelper.getInstance(view.getContext()); - db.toggleFavorite(note, syncCallBack); - adapter.notifyItemChanged(position); - refreshLists(); - } - - @Override - public boolean onNoteLongClick(int position, View v) { - boolean selected = adapter.select(position); - if (selected) { - v.setSelected(true); - mActionMode = startSupportActionMode(new MultiSelectedActionModeCallback()); - int checkedItemCount = adapter.getSelected().size(); - mActionMode.setTitle(getResources().getQuantityString(R.plurals.ab_selected, checkedItemCount, checkedItemCount)); - } - return selected; - } - - @Override - public void onBackPressed() { - if (searchView == null || searchView.isIconified()) { - super.onBackPressed(); - } else { - searchView.setIconified(true); - } - } - - private void synchronize() { - swipeRefreshLayout.setRefreshing(true); - db.getNoteServerSyncHelper().addCallbackPull(syncCallBack); - db.getNoteServerSyncHelper().scheduleSync(false); - } - - /** - * Handler for the MultiSelect Actions - */ - private class MultiSelectedActionModeCallback implements ActionMode.Callback { - - @Override - public boolean onCreateActionMode(ActionMode mode, Menu menu) { - // inflate contextual menu - mode.getMenuInflater().inflate(R.menu.menu_list_context_multiple, menu); - return true; - } - - @Override - public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - return false; - } - - /** - * @param mode ActionMode - used to close the Action Bar after all work is done. - * @param item MenuItem - the item in the List that contains the Node - * @return boolean - */ - @Override - public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_delete: - List selection = adapter.getSelected(); - for (Integer i : selection) { - DBNote note = (DBNote) adapter.getItem(i); - db.deleteNoteAndSync(note.getId()); - // Not needed because of dbsync - //adapter.remove(note); - } - mode.finish(); // Action picked, so close the CAB - //after delete selection has to be cleared - searchView.setIconified(true); - refreshLists(); - return true; - default: - return false; - } - } - - @Override - public void onDestroyActionMode(ActionMode mode) { - adapter.clearSelection(); - mActionMode = null; - adapter.notifyDataSetChanged(); - } - } - -} diff --git a/app/src/main/java/foundation/e/notes/android/activity/PreferencesActivity.java b/app/src/main/java/foundation/e/notes/android/activity/PreferencesActivity.java deleted file mode 100644 index 0ebe351acb0fb371efb857003e1e1720ffe508a3..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/activity/PreferencesActivity.java +++ /dev/null @@ -1,32 +0,0 @@ -package foundation.e.notes.android.activity; - -import android.graphics.drawable.ColorDrawable; -import android.os.Build; -import android.os.Bundle; -import android.view.Window; -import android.view.WindowManager; - -import androidx.annotation.ColorInt; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -import foundation.e.notes.android.fragment.PreferencesFragment; -import foundation.e.notes.util.ExceptionHandler; - -/** - * Allows to change application settings. - */ - -public class PreferencesActivity extends AppCompatActivity { - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler(this)); - setResult(RESULT_CANCELED); - getFragmentManager().beginTransaction() - .replace(android.R.id.content, new PreferencesFragment()) - .commit(); - - } - -} diff --git a/app/src/main/java/foundation/e/notes/android/activity/SelectSingleNoteActivity.java b/app/src/main/java/foundation/e/notes/android/activity/SelectSingleNoteActivity.java deleted file mode 100644 index a4e71a77b3b79e28ba88c9d578feaa469b60f04a..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/activity/SelectSingleNoteActivity.java +++ /dev/null @@ -1,85 +0,0 @@ -package foundation.e.notes.android.activity; - -import android.app.Activity; -import android.appwidget.AppWidgetManager; -import android.content.Intent; -import android.content.SharedPreferences; -import android.graphics.drawable.ColorDrawable; -import android.os.Build; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.view.Menu; -import android.view.View; -import android.view.Window; -import android.view.WindowManager; - -import androidx.annotation.ColorInt; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import butterknife.BindView; -import butterknife.ButterKnife; -import foundation.e.notes.R; -import foundation.e.notes.android.appwidget.SingleNoteWidget; -import foundation.e.notes.model.DBNote; -import foundation.e.notes.model.Item; -import foundation.e.notes.model.ItemAdapter; -import foundation.e.notes.util.Notes; -import foundation.e.notes.util.ExceptionHandler; - -public class SelectSingleNoteActivity extends NotesListViewActivity { - - @BindView(R.id.fab_create) - View fabCreate; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler(this)); - setResult(Activity.RESULT_CANCELED); - - SwipeRefreshLayout swipeRefreshLayout = getSwipeRefreshLayout(); - - ButterKnife.bind(this); - fabCreate.setVisibility(View.GONE); - - androidx.appcompat.app.ActionBar ab = getSupportActionBar(); - if (ab != null) { - ab.setTitle(R.string.activity_select_single_note); - } - swipeRefreshLayout.setEnabled(false); - swipeRefreshLayout.setRefreshing(false); - - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - return true; - } - - @Override - public void onNoteClick(int position, View v) { - ItemAdapter adapter = getItemAdapter(); - Item item = adapter.getItem(position); - DBNote note = (DBNote) item; - long noteID = note.getId(); - final Bundle extras = getIntent().getExtras(); - - if (extras == null) { - finish(); - } - - int appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); - SharedPreferences.Editor sp = PreferenceManager.getDefaultSharedPreferences(this).edit(); - - sp.putLong(SingleNoteWidget.WIDGET_KEY + appWidgetId, noteID); - sp.apply(); - - Intent updateIntent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, null, - getApplicationContext(), SingleNoteWidget.class); - updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); - setResult(RESULT_OK, updateIntent); - getApplicationContext().sendBroadcast(updateIntent); - finish(); - } - -} diff --git a/app/src/main/java/foundation/e/notes/android/activity/SettingsActivity.java b/app/src/main/java/foundation/e/notes/android/activity/SettingsActivity.java deleted file mode 100644 index dab548e04a5a271f680b3aae10fdaa27c82be938..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/activity/SettingsActivity.java +++ /dev/null @@ -1,554 +0,0 @@ -package foundation.e.notes.android.activity; - -import android.content.Intent; -import android.content.SharedPreferences; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.net.http.SslCertificate; -import android.net.http.SslError; -import android.os.AsyncTask; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.preference.PreferenceManager; -import android.text.Editable; -import android.text.TextWatcher; -import android.util.Log; -import android.view.KeyEvent; -import android.view.View; -import android.view.Window; -import android.view.WindowManager; -import android.webkit.SslErrorHandler; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.Button; -import android.widget.EditText; -import android.widget.ProgressBar; -import android.widget.TextView; -import android.widget.Toast; - -import foundation.e.cert4android.CustomCertManager; -import foundation.e.cert4android.IOnCertificateDecision; -import butterknife.BindView; -import butterknife.ButterKnife; -import foundation.e.notes.R; -import foundation.e.notes.persistence.NoteSQLiteOpenHelper; -import foundation.e.notes.persistence.NoteServerSyncHelper; -import foundation.e.notes.util.ExceptionHandler; -import foundation.e.notes.util.NotesClientUtil; -import foundation.e.notes.util.NotesClientUtil.LoginStatus; - -import androidx.annotation.ColorInt; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; - -import com.google.android.material.snackbar.Snackbar; -import com.google.android.material.textfield.TextInputLayout; - -import java.io.ByteArrayInputStream; -import java.net.URLDecoder; -import java.security.cert.Certificate; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; - -import static android.os.Process.killProcess; -import static android.os.Process.myPid; - -/** - * @author Nihar Thakkar - *

- * Allows to set Settings like URL, Username and Password for Server-Synchronization - * Created by stefan on 22.09.15. - */ -public class SettingsActivity extends AppCompatActivity { - - public static final String SETTINGS_URL = "settingsUrl"; - public static final String SETTINGS_USERNAME = "settingsUsername"; - public static final String SETTINGS_PASSWORD = "settingsPassword"; - public static final String SETTINGS_KEY_ETAG = "notes_last_etag"; - public static final String SETTINGS_KEY_LAST_MODIFIED = "notes_last_modified"; - public static final String SETTINGS_IS_DEVICE_ACCOUNT = "is_device_account"; - public static final String DEFAULT_SETTINGS = ""; - public static final int CREDENTIALS_CHANGED = 3; - - public static final String LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":"; - public static final String WEBDAV_PATH_4_0_AND_LATER = "/remote.php/webdav"; - - private SharedPreferences preferences = null; - - @BindView(R.id.settings_url) - EditText field_url; - @BindView(R.id.settings_username_wrapper) - TextInputLayout username_wrapper; - @BindView(R.id.settings_username) - EditText field_username; - @BindView(R.id.settings_password) - EditText field_password; - @BindView(R.id.settings_password_wrapper) - TextInputLayout password_wrapper; - @BindView(R.id.settings_submit) - Button btn_submit; - @BindView(R.id.settings_url_warn_http) - View urlWarnHttp; - private String old_password = ""; - - private WebView webView; - - private boolean first_run = false; - private boolean useWebLogin = true; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler(this)); - setContentView(R.layout.activity_settings); - ButterKnife.bind(this); - - preferences = PreferenceManager - .getDefaultSharedPreferences(getApplicationContext()); - - if (!NoteServerSyncHelper.isConfigured(this)) { - first_run = true; - if (getSupportActionBar() != null) { - getSupportActionBar().setDisplayHomeAsUpEnabled(false); - } - } - - setupListener(); - - // Load current Preferences - field_url.setText(preferences.getString(SETTINGS_URL, DEFAULT_SETTINGS)); - field_username.setText(preferences.getString(SETTINGS_USERNAME, DEFAULT_SETTINGS)); - old_password = preferences.getString(SETTINGS_PASSWORD, DEFAULT_SETTINGS); - - field_password.setOnEditorActionListener(new TextView.OnEditorActionListener() { - @Override - public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { - login(); - return true; - } - }); - field_password.setOnFocusChangeListener(new View.OnFocusChangeListener() { - @Override - public void onFocusChange(View v, boolean hasFocus) { - setPasswordHint(hasFocus); - } - }); - setPasswordHint(false); - - handleSubmitButtonEnabled(); - - } - - private void setupListener() { - field_url.setOnFocusChangeListener(new View.OnFocusChangeListener() { - @Override - public void onFocusChange(View v, boolean hasFocus) { - new URLValidatorAsyncTask().execute(NotesClientUtil.formatURL(field_url.getText().toString())); - } - }); - field_url.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - String url = NotesClientUtil.formatURL(field_url.getText().toString()); - - if (NotesClientUtil.isHttp(url)) { - urlWarnHttp.setVisibility(View.VISIBLE); - } else { - urlWarnHttp.setVisibility(View.GONE); - } - - handleSubmitButtonEnabled(); - } - - @Override - public void afterTextChanged(Editable s) { - } - }); - - field_username.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - handleSubmitButtonEnabled(); - } - - @Override - public void afterTextChanged(Editable s) { - - } - }); - - btn_submit.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - login(); - } - }); - } - - private void setPasswordHint(boolean hasFocus) { - boolean unchangedHint = !hasFocus && field_password.getText().toString().isEmpty() && !old_password.isEmpty(); - password_wrapper.setHint(getString(unchangedHint ? R.string.settings_password_unchanged : R.string.settings_password)); - } - - - @Override - protected void onResume() { - super.onResume(); - - // Occurs in this scenario: User opens the app but doesn't configure the server settings, they then add the Create Note widget to home screen and configure - // server settings there. The stale SettingsActivity is then displayed hence finish() here to close it down. - if ((first_run) && (NoteServerSyncHelper.isConfigured(this))) { - finish(); - } - } - - /** - * Prevent pressing back button on first run - */ - @Override - public void onBackPressed() { - if (!first_run) { - super.onBackPressed(); - } - } - - private void legacyLogin() { - String url = field_url.getText().toString().trim(); - String username = field_username.getText().toString(); - String password = field_password.getText().toString(); - - if (password.isEmpty()) { - password = old_password; - } - - url = NotesClientUtil.formatURL(url); - - new LoginValidatorAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, url, username, password); - } - - private void login() { - if (useWebLogin) { - webLogin(); - } else { - legacyLogin(); - } - } - - /** - * Obtain the X509Certificate from SslError - * - * @param error SslError - * @return X509Certificate from error - */ - public static X509Certificate getX509CertificateFromError(SslError error) { - Bundle bundle = SslCertificate.saveState(error.getCertificate()); - X509Certificate x509Certificate; - byte[] bytes = bundle.getByteArray("x509-certificate"); - if (bytes == null) { - x509Certificate = null; - } else { - try { - CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); - Certificate cert = certFactory.generateCertificate(new ByteArrayInputStream(bytes)); - x509Certificate = (X509Certificate) cert; - } catch (CertificateException e) { - x509Certificate = null; - } - } - return x509Certificate; - } - - private void webLogin() { - setContentView(R.layout.activity_settings_webview); - webView = findViewById(R.id.login_webview); - webView.setVisibility(View.GONE); - - final ProgressBar progressBar = findViewById(R.id.login_webview_progress_bar); - - WebSettings settings = webView.getSettings(); - settings.setAllowFileAccess(false); - settings.setJavaScriptEnabled(true); - settings.setDomStorageEnabled(true); - settings.setUserAgentString(getWebLoginUserAgent()); - settings.setSaveFormData(false); - settings.setSavePassword(false); - - Map headers = new HashMap<>(); - headers.put("OCS-APIREQUEST", "true"); - - - webView.loadUrl(normalizeUrlSuffix(NotesClientUtil.formatURL(field_url.getText().toString())) + "index.php/login/flow", headers); - - webView.setWebViewClient(new WebViewClient() { - @Override - public boolean shouldOverrideUrlLoading(WebView view, String url) { - if (url.startsWith("nc://login/")) { - parseAndLoginFromWebView(url); - return true; - } - return false; - } - - @Override - public void onPageFinished(WebView view, String url) { - super.onPageFinished(view, url); - - progressBar.setVisibility(View.GONE); - webView.setVisibility(View.VISIBLE); - } - - @Override - public void onReceivedSslError(WebView view, final SslErrorHandler handler, SslError error) { - X509Certificate cert = getX509CertificateFromError(error); - - try { - final boolean[] accepted = new boolean[1]; - NoteServerSyncHelper.getInstance(NoteSQLiteOpenHelper.getInstance(getApplicationContext())) - .checkCertificate(cert.getEncoded(), true, new IOnCertificateDecision.Stub() { - @Override - public void accept() { - Log.d("Note", "cert accepted"); - handler.proceed(); - accepted[0] = true; - } - - @Override - public void reject() { - Log.d("Note", "cert rejected"); - handler.cancel(); - killProcess(myPid()); - } - }); - } catch (Exception e) { - Log.e("Note", "Cert could not be verified"); - handler.proceed(); - } - } - - }); - - // show snackbar after 60s to switch back to old login method - new Handler().postDelayed(() -> { - Snackbar.make(webView, R.string.fallback_weblogin_text, Snackbar.LENGTH_INDEFINITE) - .setAction(R.string.fallback_weblogin_back, (View.OnClickListener) v -> initLegacyLogin(field_url.getText().toString())).show(); - }, 45 * 1000); - } - - private String getWebLoginUserAgent() { - return Build.MANUFACTURER.substring(0, 1).toUpperCase(Locale.getDefault()) + - Build.MANUFACTURER.substring(1).toLowerCase(Locale.getDefault()) + " " + Build.MODEL; - } - - private void parseAndLoginFromWebView(String dataString) { - String prefix = "nc://login/"; - LoginUrlInfo loginUrlInfo = parseLoginDataUrl(prefix, dataString); - - if (loginUrlInfo != null) { - String url = normalizeUrlSuffix(loginUrlInfo.serverAddress); - - new LoginValidatorAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, url, loginUrlInfo.username, - loginUrlInfo.password); - } - } - - /** - * parses a URI string and returns a login data object with the information from the URI string. - * - * @param prefix URI beginning, e.g. cloud://login/ - * @param dataString the complete URI - * @return login data - * @throws IllegalArgumentException when - */ - private LoginUrlInfo parseLoginDataUrl(String prefix, String dataString) throws IllegalArgumentException { - if (dataString.length() < prefix.length()) { - throw new IllegalArgumentException("Invalid login URL detected"); - } - LoginUrlInfo loginUrlInfo = new LoginUrlInfo(); - - // format is basically xxx://login/server:xxx&user:xxx&password while all variables are optional - String data = dataString.substring(prefix.length()); - - // parse data - String[] values = data.split("&"); - - if (values.length < 1 || values.length > 3) { - // error illegal number of URL elements detected - throw new IllegalArgumentException("Illegal number of login URL elements detected: " + values.length); - } - - for (String value : values) { - if (value.startsWith("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) { - loginUrlInfo.username = URLDecoder.decode( - value.substring(("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length())); - } else if (value.startsWith("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) { - loginUrlInfo.password = URLDecoder.decode( - value.substring(("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length())); - } else if (value.startsWith("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) { - loginUrlInfo.serverAddress = URLDecoder.decode( - value.substring(("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length())); - } else { - // error illegal URL element detected - throw new IllegalArgumentException("Illegal magic login URL element detected: " + value); - } - } - - return loginUrlInfo; - } - - private String normalizeUrlSuffix(String url) { - if (url.toLowerCase(Locale.ROOT).endsWith(WEBDAV_PATH_4_0_AND_LATER)) { - return url.substring(0, url.length() - WEBDAV_PATH_4_0_AND_LATER.length()); - } - - if (!url.endsWith("/")) { - return url + "/"; - } - - return url; - } - - private void initLegacyLogin(String oldUrl) { - useWebLogin = false; - new URLValidatorAsyncTask().execute(NotesClientUtil.formatURL(field_url.getText().toString())); - - webView.setVisibility(View.INVISIBLE); - setContentView(R.layout.activity_settings); - - ButterKnife.bind(this); - setupListener(); - - field_url.setText(oldUrl); - username_wrapper.setVisibility(View.VISIBLE); - password_wrapper.setVisibility(View.VISIBLE); - } - - private void handleSubmitButtonEnabled() { - // drawable[2] is not null if url is valid, see URLValidatorAsyncTask::onPostExecute - if (useWebLogin || field_url.getCompoundDrawables()[2] != null && (username_wrapper.getVisibility() == View.GONE || - (username_wrapper.getVisibility() == View.VISIBLE && field_username.getText().length() > 0))) { - btn_submit.setEnabled(true); - } else { - btn_submit.setEnabled(false); - } - } - - /************************************ Async Tasks ************************************/ - - /** - * Checks if the given URL returns a valid status code and sets the Check next to the URL-Input Field to visible. - * Created by stefan on 23.09.15. - */ - private class URLValidatorAsyncTask extends AsyncTask { - - @Override - protected void onPreExecute() { - btn_submit.setEnabled(false); - field_url.setCompoundDrawables(null, null, null, null); - } - - @Override - protected Boolean doInBackground(String... params) { - CustomCertManager ccm = NoteServerSyncHelper.getInstance(NoteSQLiteOpenHelper.getInstance(getApplicationContext())).getCustomCertManager(); - return NotesClientUtil.isValidURL(ccm, params[0]); - } - - @Override - protected void onPostExecute(Boolean o) { - if (o) { - Drawable actionDoneDark = ContextCompat.getDrawable(getApplicationContext(), R.drawable.ic_check_grey600_24dp); - actionDoneDark.setBounds(0, 0, actionDoneDark.getIntrinsicWidth(), actionDoneDark.getIntrinsicHeight()); - field_url.setCompoundDrawables(null, null, actionDoneDark, null); - } else { - field_url.setCompoundDrawables(null, null, null, null); - } - handleSubmitButtonEnabled(); - } - } - - /** - * If Log-In-Credentials are correct, save Credentials to Shared Preferences and finish First Run Wizard. - */ - private class LoginValidatorAsyncTask extends AsyncTask { - String url, username, password; - - @Override - protected void onPreExecute() { - setInputsEnabled(false); - btn_submit.setText(R.string.settings_submitting); - } - - /** - * @param params url, username and password - * @return isValidLogin Boolean - */ - @Override - protected LoginStatus doInBackground(String... params) { - url = params[0]; - username = params[1]; - password = params[2]; - CustomCertManager ccm = NoteServerSyncHelper.getInstance(NoteSQLiteOpenHelper.getInstance(getApplicationContext())).getCustomCertManager(); - return NotesClientUtil.isValidLogin(ccm, url, username, password); - } - - @Override - protected void onPostExecute(LoginStatus status) { - if (LoginStatus.OK.equals(status)) { - SharedPreferences.Editor editor = preferences.edit(); - editor.putString(SETTINGS_URL, url); - editor.putString(SETTINGS_USERNAME, username); - editor.putString(SETTINGS_PASSWORD, password); - editor.putBoolean(SETTINGS_IS_DEVICE_ACCOUNT, false); - editor.remove(SETTINGS_KEY_ETAG); - editor.remove(SETTINGS_KEY_LAST_MODIFIED); - editor.apply(); - - final Intent data = new Intent(); - data.putExtra(NotesListViewActivity.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED); - setResult(RESULT_OK, data); - finish(); - } else { - Log.e("Note", "invalid login"); - btn_submit.setText(R.string.settings_submit); - setInputsEnabled(true); - Toast.makeText(getApplicationContext(), getString(R.string.error_invalid_login, getString(status.str)), Toast.LENGTH_LONG).show(); - } - } - - /** - * Sets all Input-Fields and Buttons to enabled or disabled depending on the given boolean. - * - * @param enabled - boolean - */ - private void setInputsEnabled(boolean enabled) { - btn_submit.setEnabled(enabled); - field_url.setEnabled(enabled); - field_username.setEnabled(enabled); - field_password.setEnabled(enabled); - } - } - - /** - * Data object holding the login url fields. - */ - public class LoginUrlInfo { - String serverAddress; - String username; - String password; - } - -} diff --git a/app/src/main/java/foundation/e/notes/android/appwidget/CreateNoteWidget.java b/app/src/main/java/foundation/e/notes/android/appwidget/CreateNoteWidget.java deleted file mode 100644 index 237549a009bd32798515e5540e9541b3830f62d1..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/appwidget/CreateNoteWidget.java +++ /dev/null @@ -1,51 +0,0 @@ -package foundation.e.notes.android.appwidget; - -import android.app.PendingIntent; -import android.appwidget.AppWidgetManager; -import android.appwidget.AppWidgetProvider; -import android.content.Context; -import android.content.Intent; -import android.widget.RemoteViews; - -import foundation.e.notes.R; -import foundation.e.notes.android.activity.EditNoteActivity; - -/** - * Implementation of App Widget functionality. - */ -public class CreateNoteWidget extends AppWidgetProvider { - - private static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, - int appWidgetId) { - - // Construct the RemoteViews object - RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_create_note); - Intent intent = new Intent(context, EditNoteActivity.class); - - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); - views.setOnClickPendingIntent(R.id.widget_create_note, pendingIntent); - - // Instruct the widget manager to update the widget - appWidgetManager.updateAppWidget(appWidgetId, views); - } - - @Override - public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { - - // There may be multiple widgets active, so update all of them - for (int appWidgetId : appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId); - } - } - - @Override - public void onEnabled(Context context) { - // Enter relevant functionality for when the first widget is created - } - - @Override - public void onDisabled(Context context) { - // Enter relevant functionality for when the last widget is disabled - } -} - diff --git a/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidget.java b/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidget.java deleted file mode 100644 index ff9cb900ebb3cc469eddf97af54f399383e3013d..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidget.java +++ /dev/null @@ -1,154 +0,0 @@ -package foundation.e.notes.android.appwidget; - -import android.app.PendingIntent; -import android.appwidget.AppWidgetManager; -import android.appwidget.AppWidgetProvider; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; -import android.preference.PreferenceManager; -import android.util.Log; -import android.widget.RemoteViews; - -import foundation.e.notes.android.activity.EditNoteActivity; -import foundation.e.notes.android.activity.NotesListViewActivity; -import foundation.e.notes.R; -import foundation.e.notes.util.Notes; - -public class NoteListWidget extends AppWidgetProvider { - public static final String WIDGET_MODE_KEY = "NLW_mode"; - public static final String WIDGET_CATEGORY_KEY = "NLW_cat"; - public static final int NLW_DISPLAY_ALL = 0; - public static final int NLW_DISPLAY_STARRED = 1; - public static final int NLW_DISPLAY_CATEGORY = 2; - - static void updateAppWidget(Context context, AppWidgetManager awm, int[] appWidgetIds) { - RemoteViews views; - boolean darkTheme; - - for (int appWidgetId : appWidgetIds) { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - int displayMode = sp.getInt(NoteListWidget.WIDGET_MODE_KEY + appWidgetId, -1); - - // onUpdate has been triggered before the user finished configuring the widget - if (displayMode == -1) { - return; - } - - String category = sp.getString(NoteListWidget.WIDGET_CATEGORY_KEY + appWidgetId, null); - darkTheme = Notes.getAppTheme(context); - - Intent serviceIntent = new Intent(context, NoteListWidgetService.class); - serviceIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); - serviceIntent.putExtra(NoteListWidget.WIDGET_MODE_KEY + appWidgetId, displayMode); - serviceIntent.setData(Uri.parse(serviceIntent.toUri(Intent.URI_INTENT_SCHEME))); - - if (displayMode == NLW_DISPLAY_CATEGORY) { - serviceIntent.putExtra(NoteListWidget.WIDGET_CATEGORY_KEY + appWidgetId, category); - } - - // Launch application when user taps the header icon or app title - Intent intent = new Intent("android.intent.action.MAIN"); - intent.setComponent(new ComponentName(context.getPackageName(), - NotesListViewActivity.class.getName())); - - // Open the main app if the user taps the widget header - PendingIntent openAppI = PendingIntent.getActivity(context, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT); - - // Launch create note activity if user taps "+" icon on header - PendingIntent newNoteI = PendingIntent.getActivity(context, 0, - (new Intent(context, EditNoteActivity.class)), - PendingIntent.FLAG_UPDATE_CURRENT); - - PendingIntent templatePI = PendingIntent.getActivity(context, 0, - (new Intent(context, EditNoteActivity.class)), - PendingIntent.FLAG_UPDATE_CURRENT); - - if (darkTheme) { - views = new RemoteViews(context.getPackageName(), foundation.e.notes.R.layout.widget_note_list_dark); - views.setTextViewText(foundation.e.notes.R.id.widget_note_list_title_tv_dark, getWidgetTitle(context, displayMode, category)); - views.setOnClickPendingIntent(foundation.e.notes.R.id.widget_note_header_icon_dark, openAppI); - views.setOnClickPendingIntent(foundation.e.notes.R.id.widget_note_list_title_tv_dark, openAppI); - views.setOnClickPendingIntent(foundation.e.notes.R.id.widget_note_list_create_icon_dark, newNoteI); - views.setPendingIntentTemplate(foundation.e.notes.R.id.note_list_widget_lv_dark, templatePI); - views.setRemoteAdapter(appWidgetId, foundation.e.notes.R.id.note_list_widget_lv_dark, serviceIntent); - views.setEmptyView(foundation.e.notes.R.id.note_list_widget_lv_dark, foundation.e.notes.R.id.widget_note_list_placeholder_tv_dark); - awm.notifyAppWidgetViewDataChanged(appWidgetId, R.id.note_list_widget_lv_dark); - } else { - views = new RemoteViews(context.getPackageName(), foundation.e.notes.R.layout.widget_note_list); - views.setTextViewText(foundation.e.notes.R.id.widget_note_list_title_tv, getWidgetTitle(context, displayMode, category)); - views.setOnClickPendingIntent(foundation.e.notes.R.id.widget_note_header_icon, openAppI); - views.setOnClickPendingIntent(foundation.e.notes.R.id.widget_note_list_title_tv, openAppI); - views.setOnClickPendingIntent(foundation.e.notes.R.id.widget_note_list_create_icon, newNoteI); - views.setPendingIntentTemplate(foundation.e.notes.R.id.note_list_widget_lv, templatePI); - views.setRemoteAdapter(appWidgetId, foundation.e.notes.R.id.note_list_widget_lv, serviceIntent); - views.setEmptyView(foundation.e.notes.R.id.note_list_widget_lv, foundation.e.notes.R.id.widget_note_list_placeholder_tv); - awm.notifyAppWidgetViewDataChanged(appWidgetId, R.id.note_list_widget_lv); - } - - awm.updateAppWidget(appWidgetId, views); - } - } - - @Override - public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { - super.onUpdate(context, appWidgetManager, appWidgetIds); - updateAppWidget(context, appWidgetManager, appWidgetIds); - } - - @Override - public void onReceive(Context context, Intent intent) { - super.onReceive(context, intent); - AppWidgetManager awm = AppWidgetManager.getInstance(context); - - if (intent.getAction() != null) { - if (intent.getAction().equals(AppWidgetManager.ACTION_APPWIDGET_UPDATE)) { - if (intent.hasExtra(AppWidgetManager.EXTRA_APPWIDGET_ID)) { - if (intent.getExtras() != null) { - updateAppWidget(context, awm, new int[]{intent.getExtras().getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, -1)}); - } else { - Log.w(NoteListWidget.class.getSimpleName(), "intent.getExtras() is null"); - } - } else { - updateAppWidget(context, awm, awm.getAppWidgetIds(new ComponentName(context, NoteListWidget.class))); - } - } - } else { - Log.w(NoteListWidget.class.getSimpleName(), "intent.getAction() is null"); - } - } - - @Override - public void onDeleted(Context context, int[] appWidgetIds) { - super.onDeleted(context, appWidgetIds); - - SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit(); - - for (int appWidgetId : appWidgetIds) { - editor.remove(WIDGET_MODE_KEY + appWidgetId); - editor.remove(WIDGET_CATEGORY_KEY + appWidgetId); - } - - editor.apply(); - } - - private static String getWidgetTitle(Context context, int displayMode, String category) { - switch (displayMode) { - case NoteListWidget.NLW_DISPLAY_ALL: - return context.getString(R.string.app_name); - case NoteListWidget.NLW_DISPLAY_STARRED: - return context.getString(R.string.label_favorites); - case NoteListWidget.NLW_DISPLAY_CATEGORY: - if (category.equals("")) { - return context.getString(foundation.e.notes.R.string.action_uncategorized); - } else { - return category; - } - } - - return null; - } -} diff --git a/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidgetConfiguration.java b/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidgetConfiguration.java deleted file mode 100644 index d72830af4e648a4971c5c206aac8de442b4e0b5a..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidgetConfiguration.java +++ /dev/null @@ -1,157 +0,0 @@ -package foundation.e.notes.android.appwidget; - -import android.app.Activity; -import android.appwidget.AppWidgetManager; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.AsyncTask; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.util.Log; -import android.widget.Toast; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import foundation.e.notes.android.activity.NotesListViewActivity; -import foundation.e.notes.model.NavigationAdapter; -import foundation.e.notes.persistence.NoteSQLiteOpenHelper; -import foundation.e.notes.persistence.NoteServerSyncHelper; - -public class NoteListWidgetConfiguration extends AppCompatActivity { - private static final String TAG = Activity.class.getSimpleName(); - - private int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID; - - private NavigationAdapter adapterCategories; - private NavigationAdapter.NavigationItem itemRecent, itemFavorites; - private NoteSQLiteOpenHelper db = null; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setResult(RESULT_CANCELED); - setContentView(foundation.e.notes.R.layout.activity_note_list_configuration); - - if (!(NoteServerSyncHelper.isConfigured(this))) { - Toast.makeText(this, foundation.e.notes.R.string.widget_not_logged_in, Toast.LENGTH_LONG).show(); - - // TODO Present user with app login screen - Log.w(TAG, "onCreate: user not logged in"); - finish(); - } - - db = NoteSQLiteOpenHelper.getInstance(this); - final Bundle extras = getIntent().getExtras(); - - if (extras != null) { - appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID); - } - - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - Log.d(TAG, "INVALID_APPWIDGET_ID"); - finish(); - } - - itemRecent = new NavigationAdapter.NavigationItem(NotesListViewActivity.ADAPTER_KEY_RECENT, - getString(foundation.e.notes.R.string.label_all_notes), - null, - foundation.e.notes.R.drawable.ic_access_time_grey600_24dp); - itemFavorites = new NavigationAdapter.NavigationItem(NotesListViewActivity.ADAPTER_KEY_STARRED, - getString(foundation.e.notes.R.string.label_favorites), - null, - foundation.e.notes.R.drawable.ic_star_yellow_24dp); - RecyclerView recyclerView; - RecyclerView.LayoutManager layoutManager; - - adapterCategories = new NavigationAdapter(new NavigationAdapter.ClickListener() { - @Override - public void onItemClick(NavigationAdapter.NavigationItem item) { - SharedPreferences.Editor sp = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()).edit(); - - if (item == itemRecent) { - sp.putInt(NoteListWidget.WIDGET_MODE_KEY + appWidgetId, NoteListWidget.NLW_DISPLAY_ALL); - } else if (item == itemFavorites) { - sp.putInt(NoteListWidget.WIDGET_MODE_KEY + appWidgetId, NoteListWidget.NLW_DISPLAY_STARRED); - } else { - String category = ""; - if (!item.label.equals(getString(foundation.e.notes.R.string.action_uncategorized))) { - category = item.label; - } - sp.putInt(NoteListWidget.WIDGET_MODE_KEY + appWidgetId, NoteListWidget.NLW_DISPLAY_CATEGORY); - sp.putString(NoteListWidget.WIDGET_CATEGORY_KEY + appWidgetId, category); - } - sp.apply(); - - Intent updateIntent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, null, - getApplicationContext(), NoteListWidget.class); - updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); - setResult(RESULT_OK, updateIntent); - getApplicationContext().sendBroadcast(updateIntent); - finish(); - } - - public void onIconClick(NavigationAdapter.NavigationItem item) { - onItemClick(item); - } - }); - - recyclerView = findViewById(foundation.e.notes.R.id.nlw_config_recyclerv); - recyclerView.setHasFixedSize(true); - layoutManager = new LinearLayoutManager(this); - recyclerView.setLayoutManager(layoutManager); - recyclerView.setAdapter(adapterCategories); - } - - @Override - protected void onResume() { - super.onResume(); - - new LoadCategoryListTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - private class LoadCategoryListTask extends AsyncTask> { - @Override - protected List doInBackground(Void... voids) { - NavigationAdapter.NavigationItem itemUncategorized; - List categories = db.getCategories(); - - if (!categories.isEmpty() && categories.get(0).label.isEmpty()) { - itemUncategorized = categories.get(0); - itemUncategorized.label = getString(foundation.e.notes.R.string.action_uncategorized); - itemUncategorized.icon = NavigationAdapter.ICON_NOFOLDER; - } - - Map favorites = db.getFavoritesCount(); - int numFavorites = favorites.containsKey("1") ? favorites.get("1") : 0; - int numNonFavorites = favorites.containsKey("0") ? favorites.get("0") : 0; - itemFavorites.count = numFavorites; - itemRecent.count = numFavorites + numNonFavorites; - - ArrayList items = new ArrayList<>(); - items.add(itemRecent); - items.add(itemFavorites); - - for (NavigationAdapter.NavigationItem item : categories) { - int slashIndex = item.label.indexOf('/'); - - item.label = slashIndex < 0 ? item.label : item.label.substring(0, slashIndex); - item.id = "category:" + item.label; - items.add(item); - } - return items; - } - - @Override - protected void onPostExecute(List items) { - adapterCategories.setItems(items); - } - } -} diff --git a/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidgetFactory.java b/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidgetFactory.java deleted file mode 100644 index d2bab203ef039c032af443a74f62908a8c08af37..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidgetFactory.java +++ /dev/null @@ -1,137 +0,0 @@ -package foundation.e.notes.android.appwidget; - -import android.appwidget.AppWidgetManager; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.widget.RemoteViews; -import android.widget.RemoteViewsService; - -import java.util.List; - -import foundation.e.notes.R; -import foundation.e.notes.android.activity.EditNoteActivity; -import foundation.e.notes.model.DBNote; -import foundation.e.notes.persistence.NoteSQLiteOpenHelper; -import foundation.e.notes.util.Notes; - -public class NoteListWidgetFactory implements RemoteViewsService.RemoteViewsFactory { - private final Context context; - private final int displayMode; - private final int appWidgetId; - private final boolean darkTheme; - private String category; - private final SharedPreferences sp; - private NoteSQLiteOpenHelper db; - private List dbNotes; - - NoteListWidgetFactory(Context context, Intent intent) { - this.context = context; - appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID); - sp = PreferenceManager.getDefaultSharedPreferences(this.context); - displayMode = sp.getInt(NoteListWidget.WIDGET_MODE_KEY + appWidgetId, -1); - darkTheme = Notes.getAppTheme(this.context); - category = sp.getString(NoteListWidget.WIDGET_CATEGORY_KEY + appWidgetId, ""); - } - - @Override - public void onCreate() { - db = NoteSQLiteOpenHelper.getInstance(context); - } - - @Override - public void onDataSetChanged() { - if (displayMode == NoteListWidget.NLW_DISPLAY_ALL) { - dbNotes = db.getNotes(); - } else if (displayMode == NoteListWidget.NLW_DISPLAY_STARRED) { - dbNotes = db.searchNotes(null,null, true); - } else if (displayMode == NoteListWidget.NLW_DISPLAY_CATEGORY) { - dbNotes = db.searchNotes(null, category, null); - } - } - - @Override - public void onDestroy() { - - } - - /** - * getCount() - * - * @return Total number of entries - */ - @Override - public int getCount() { - if (dbNotes == null) { - return 0; - } - - return dbNotes.size(); - } - - @Override - public RemoteViews getViewAt(int i) { - RemoteViews note_content; - - if (dbNotes == null || dbNotes.get(i) == null) { - return null; - } - - DBNote note = dbNotes.get(i); - final Intent fillInIntent = new Intent(); - final Bundle extras = new Bundle(); - - extras.putLong(EditNoteActivity.PARAM_NOTE_ID, note.getId()); - fillInIntent.putExtras(extras); - fillInIntent.setData(Uri.parse(fillInIntent.toUri(Intent.URI_INTENT_SCHEME))); - - if (darkTheme) { - note_content = new RemoteViews(context.getPackageName(), R.layout.widget_entry_dark); - note_content.setOnClickFillInIntent(R.id.widget_note_list_entry_dark, fillInIntent); - note_content.setTextViewText(R.id.widget_entry_content_tv_dark, note.getTitle()); - - if (note.isFavorite()) { - note_content.setImageViewResource(R.id.widget_entry_fav_icon_dark, R.drawable.ic_star_yellow_24dp); - } else { - note_content.setImageViewResource(R.id.widget_entry_fav_icon_dark, R.drawable.ic_star_border_white_24dp); - } - } else { - note_content = new RemoteViews(context.getPackageName(), R.layout.widget_entry); - note_content.setOnClickFillInIntent(R.id.widget_note_list_entry, fillInIntent); - note_content.setTextViewText(R.id.widget_entry_content_tv, note.getTitle()); - - if (note.isFavorite()) { - note_content.setImageViewResource(R.id.widget_entry_fav_icon, R.drawable.ic_star_yellow_24dp); - } else { - note_content.setImageViewResource(R.id.widget_entry_fav_icon, R.drawable.ic_star_border_white_24dp); - } - } - - return note_content; - - } - - @Override - public RemoteViews getLoadingView() { - return null; - } - - @Override - public int getViewTypeCount() { - return 1; - } - - @Override - public long getItemId(int i) { - return i; - } - - @Override - public boolean hasStableIds() { - return true; - } -} diff --git a/app/src/main/java/foundation/e/notes/android/appwidget/SingleNoteWidget.java b/app/src/main/java/foundation/e/notes/android/appwidget/SingleNoteWidget.java deleted file mode 100644 index fa7eaeede7d27299e8572cd76417febee902eba7..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/appwidget/SingleNoteWidget.java +++ /dev/null @@ -1,90 +0,0 @@ -package foundation.e.notes.android.appwidget; - -import android.app.PendingIntent; -import android.appwidget.AppWidgetManager; -import android.appwidget.AppWidgetProvider; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; -import android.preference.PreferenceManager; -import android.widget.RemoteViews; - -import foundation.e.notes.R; -import foundation.e.notes.android.activity.EditNoteActivity; -import foundation.e.notes.util.Notes; - -public class SingleNoteWidget extends AppWidgetProvider { - private static boolean darkTheme; - - public static final String WIDGET_KEY = "single_note_widget"; - - static void updateAppWidget(Context context, AppWidgetManager awm, int[] appWidgetIds) { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - Intent templateIntent = new Intent(context, EditNoteActivity.class); - templateIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); - - for (int appWidgetId : appWidgetIds) { - // onUpdate has been triggered before the user finished configuring the widget - if ((sp.getLong(WIDGET_KEY + appWidgetId, -1)) == -1) { - return; - } - - darkTheme = Notes.getAppTheme(context); - - PendingIntent templatePendingIntent = PendingIntent.getActivity(context, appWidgetId, templateIntent, - PendingIntent.FLAG_UPDATE_CURRENT); - - Intent serviceIntent = new Intent(context, SingleNoteWidgetService.class); - serviceIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); - serviceIntent.setData(Uri.parse(serviceIntent.toUri(Intent.URI_INTENT_SCHEME))); - - RemoteViews views; - - if (darkTheme) { - views = new RemoteViews(context.getPackageName(), foundation.e.notes.R.layout.widget_single_note_dark); - views.setPendingIntentTemplate(foundation.e.notes.R.id.single_note_widget_lv_dark, templatePendingIntent); - views.setRemoteAdapter(foundation.e.notes.R.id.single_note_widget_lv_dark, serviceIntent); - views.setEmptyView(foundation.e.notes.R.id.single_note_widget_lv_dark, foundation.e.notes.R.id.widget_single_note_placeholder_tv_dark); - awm.notifyAppWidgetViewDataChanged(appWidgetId, R.id.single_note_widget_lv_dark); - } else { - views = new RemoteViews(context.getPackageName(), foundation.e.notes.R.layout.widget_single_note); - views.setPendingIntentTemplate(foundation.e.notes.R.id.single_note_widget_lv, templatePendingIntent); - views.setRemoteAdapter(foundation.e.notes.R.id.single_note_widget_lv, serviceIntent); - views.setEmptyView(foundation.e.notes.R.id.single_note_widget_lv, foundation.e.notes.R.id.widget_single_note_placeholder_tv); - awm.notifyAppWidgetViewDataChanged(appWidgetId, R.id.single_note_widget_lv); - } - - awm.updateAppWidget(appWidgetId, views); - } - } - - @Override - public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { - super.onUpdate(context, appWidgetManager, appWidgetIds); - updateAppWidget(context, appWidgetManager, appWidgetIds); - } - - @Override - public void onReceive(Context context, Intent intent) { - super.onReceive(context, intent); - AppWidgetManager awm = AppWidgetManager.getInstance(context); - - updateAppWidget(context, AppWidgetManager.getInstance(context), - (awm.getAppWidgetIds(new ComponentName(context, SingleNoteWidget.class)))); - } - - @Override - public void onDeleted(Context context, int[] appWidgetIds) { - SharedPreferences.Editor editor = PreferenceManager - .getDefaultSharedPreferences(context).edit(); - - for (int appWidgetId : appWidgetIds) { - editor.remove(WIDGET_KEY + appWidgetId); - } - - editor.apply(); - super.onDeleted(context, appWidgetIds); - } -} diff --git a/app/src/main/java/foundation/e/notes/android/appwidget/SingleNoteWidgetFactory.java b/app/src/main/java/foundation/e/notes/android/appwidget/SingleNoteWidgetFactory.java deleted file mode 100644 index 0ed9f3b8cdaa8843e4b1b696406af78eeac5427e..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/appwidget/SingleNoteWidgetFactory.java +++ /dev/null @@ -1,136 +0,0 @@ -package foundation.e.notes.android.appwidget; - -import android.appwidget.AppWidgetManager; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.util.Log; -import android.widget.RemoteViews; -import android.widget.RemoteViewsService; - -import com.yydcdut.markdown.MarkdownProcessor; -import com.yydcdut.markdown.syntax.text.TextFactory; - -import foundation.e.notes.R; -import foundation.e.notes.android.activity.EditNoteActivity; -import foundation.e.notes.model.DBNote; -import foundation.e.notes.persistence.NoteSQLiteOpenHelper; -import foundation.e.notes.util.MarkDownUtil; -import foundation.e.notes.util.Notes; - -public class SingleNoteWidgetFactory implements RemoteViewsService.RemoteViewsFactory { - - private MarkdownProcessor markdownProcessor; - private final Context context; - private final int appWidgetId; - - private NoteSQLiteOpenHelper db; - private DBNote note; - private SharedPreferences sp; - private static Boolean darkTheme; - - private static final String TAG = SingleNoteWidget.class.getSimpleName(); - - SingleNoteWidgetFactory(Context context, Intent intent) { - this.context = context; - appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID); - sp = PreferenceManager.getDefaultSharedPreferences(this.context); - darkTheme = Notes.getAppTheme(this.context); - markdownProcessor = new MarkdownProcessor(this.context); - markdownProcessor.factory(TextFactory.create()); - markdownProcessor.config(MarkDownUtil.getMarkDownConfiguration(this.context, darkTheme).build()); - } - - @Override - public void onCreate() { - db = NoteSQLiteOpenHelper.getInstance(context); - } - - - @Override - public void onDataSetChanged() { - long noteID = sp.getLong(SingleNoteWidget.WIDGET_KEY + appWidgetId, -1); - - if (noteID >= 0) { - note = db.getNote(noteID); - - if (note == null) { - Log.e(TAG, "Error: note not found"); - } - } - } - - @Override - public void onDestroy() { - - } - - /** - * Returns the number of items in the data set. In this case, always 1 as a single note is - * being displayed. Will return 0 when the note can't be displayed. - */ - @Override - public int getCount() { - return (note != null) ? 1 : 0; - } - - /** - * Returns a RemoteView containing the note content in a TextView and - * a fillInIntent to handle the user tapping on the item in the list view. - * - * @param position The position of the item in the list - * @return The RemoteView at the specified position in the list - */ - @Override - public RemoteViews getViewAt(int position) { - if (note == null) { - return null; - } - - RemoteViews note_content; - - final Intent fillInIntent = new Intent(); - final Bundle extras = new Bundle(); - - extras.putLong(EditNoteActivity.PARAM_NOTE_ID, note.getId()); - fillInIntent.putExtras(extras); - fillInIntent.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); - if (darkTheme) { - note_content = new RemoteViews(context.getPackageName(), R.layout.widget_single_note_content_dark); - note_content.setOnClickFillInIntent(R.id.single_note_content_tv_dark, fillInIntent); - note_content.setTextViewText(R.id.single_note_content_tv_dark, markdownProcessor.parse(note.getContent())); - - } else { - note_content = new RemoteViews(context.getPackageName(), R.layout.widget_single_note_content); - note_content.setOnClickFillInIntent(R.id.single_note_content_tv, fillInIntent); - note_content.setTextViewText(R.id.single_note_content_tv, markdownProcessor.parse(note.getContent())); - } - - return note_content; - } - - - // TODO Set loading view - @Override - public RemoteViews getLoadingView() { - return null; - } - - @Override - public int getViewTypeCount() { - return 1; - } - - @Override - public long getItemId(int position) { - return position; - } - - @Override - public boolean hasStableIds() { - return true; - } -} diff --git a/app/src/main/java/foundation/e/notes/android/fragment/BaseNoteFragment.java b/app/src/main/java/foundation/e/notes/android/fragment/BaseNoteFragment.java deleted file mode 100644 index ade35d1a21e951bc5150eb654abdba04637774ee..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/fragment/BaseNoteFragment.java +++ /dev/null @@ -1,363 +0,0 @@ -package foundation.e.notes.android.fragment; - -import android.app.Activity; -import android.app.Fragment; -import android.app.FragmentManager; -import android.app.PendingIntent; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.graphics.drawable.Icon; -import android.os.Build; -import android.os.Bundle; -import android.text.SpannableString; -import android.text.TextUtils; -import android.util.Log; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewTreeObserver; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SearchView; -import androidx.appcompat.widget.ShareActionProvider; -import androidx.core.view.MenuItemCompat; -import androidx.core.view.ViewCompat; - -import foundation.e.notes.R; -import foundation.e.notes.model.CloudNote; -import foundation.e.notes.model.DBNote; -import foundation.e.notes.persistence.NoteSQLiteOpenHelper; -import foundation.e.notes.util.ICallback; -import foundation.e.notes.android.activity.EditNoteActivity; -import foundation.e.notes.util.DisplayUtils; - -import static androidx.core.content.pm.ShortcutManagerCompat.isRequestPinShortcutSupported; -import static foundation.e.notes.android.activity.EditNoteActivity.ACTION_SHORTCUT; - -public abstract class BaseNoteFragment extends Fragment implements CategoryDialogFragment.CategoryDialogListener { - - public interface NoteFragmentListener { - void close(); - - void onNoteUpdated(DBNote note); - } - - private static final int MENU_ID_PIN = -1; - public static final String PARAM_NOTE_ID = "noteId"; - public static final String PARAM_NEWNOTE = "newNote"; - private static final String SAVEDKEY_NOTE = "note"; - private static final String SAVEDKEY_ORIGINAL_NOTE = "original_note"; - - protected SearchView searchView; - protected MenuItem searchMenuItem; - - protected String searchQuery = null; - - protected DBNote note; - @Nullable - private DBNote originalNote; - private NoteSQLiteOpenHelper db; - private NoteFragmentListener listener; - - private TextView activeTextView; - private boolean isNew; - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - if (savedInstanceState != null) { - searchQuery = savedInstanceState.getString("searchQuery", ""); - } - - } - - protected void setActiveTextView(TextView textView) { - activeTextView = textView; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (savedInstanceState == null) { - isNew = true; - long id = getArguments().getLong(PARAM_NOTE_ID); - if (id > 0) { - note = originalNote = db.getNote(id); - } else { - CloudNote cloudNote = (CloudNote) getArguments().getSerializable(PARAM_NEWNOTE); - if (cloudNote == null) { - throw new IllegalArgumentException(PARAM_NOTE_ID + " is not given and argument " + PARAM_NEWNOTE + " is missing."); - } - note = db.getNote(db.addNoteAndSync(cloudNote)); - originalNote = null; - } - } else { - isNew = false; - note = (DBNote) savedInstanceState.getSerializable(SAVEDKEY_NOTE); - originalNote = (DBNote) savedInstanceState.getSerializable(SAVEDKEY_ORIGINAL_NOTE); - } - setHasOptionsMenu(true); - } - - @Override - public void onAttach(Activity activity) { - super.onAttach(activity); - try { - listener = (NoteFragmentListener) activity; - } catch (ClassCastException e) { - throw new ClassCastException(activity.getClass() + " must implement " + NoteFragmentListener.class); - } - db = NoteSQLiteOpenHelper.getInstance(activity); - } - - @Override - public void onResume() { - super.onResume(); - listener.onNoteUpdated(note); - } - - @Override - public void onPause() { - super.onPause(); - saveNote(null); - } - - @Override - public void onDetach() { - super.onDetach(); - listener = null; - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - saveNote(null); - outState.putSerializable(SAVEDKEY_NOTE, note); - outState.putSerializable(SAVEDKEY_ORIGINAL_NOTE, originalNote); - - if (searchView != null && !TextUtils.isEmpty(searchView.getQuery().toString())) { - outState.putString("searchQuery", searchView.getQuery().toString()); - } - } - - private void colorWithText(String newText) { - if (activeTextView != null && ViewCompat.isAttachedToWindow(activeTextView)) { - activeTextView.setText(DisplayUtils.searchAndColor(activeTextView.getText().toString(), new SpannableString - (activeTextView.getText()), newText, getResources().getColor(R.color.color_default_primary_text)), - TextView.BufferType.SPANNABLE); - } - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.menu_note_fragment, menu); - - if (isRequestPinShortcutSupported(getActivity()) && android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - menu.add(Menu.NONE, MENU_ID_PIN, 110, R.string.pin_to_homescreen); - } - } - - @Override - public void onPrepareOptionsMenu(Menu menu) { - super.onPrepareOptionsMenu(menu); - MenuItem itemFavorite = menu.findItem(R.id.menu_favorite); - prepareFavoriteOption(itemFavorite); - - searchMenuItem = menu.findItem(R.id.search); - searchView = (SearchView) searchMenuItem.getActionView(); - - if (!TextUtils.isEmpty(searchQuery) && isNew) { - searchMenuItem.expandActionView(); - searchView.setQuery(searchQuery, true); - searchView.clearFocus(); - } else { - searchMenuItem.collapseActionView(); - } - - - final LinearLayout searchEditFrame = searchView.findViewById(R.id - .search_edit_frame); - - searchEditFrame.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - int oldVisibility = -1; - - @Override - public void onGlobalLayout() { - int currentVisibility = searchEditFrame.getVisibility(); - - if (currentVisibility != oldVisibility) { - if (currentVisibility != View.VISIBLE) { - colorWithText(""); - searchQuery = ""; - } - - oldVisibility = currentVisibility; - } - } - - }); - - searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { - @Override - public boolean onQueryTextSubmit(String query) { - return false; - } - - @Override - public boolean onQueryTextChange(String newText) { - searchQuery = newText; - colorWithText(newText); - return true; - } - }); - - } - - private void prepareFavoriteOption(MenuItem item) { - item.setIcon(note.isFavorite() ? R.drawable.ic_star_white_24dp : R.drawable.ic_star_border_white_24dp); - item.setChecked(note.isFavorite()); - } - - /** - * Main-Menu-Handler - */ - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_cancel: - if (originalNote == null) { - db.deleteNoteAndSync(note.getId()); - } else { - db.updateNoteAndSync(originalNote, null, null); - } - listener.close(); - return true; - case R.id.menu_delete: - db.deleteNoteAndSync(note.getId()); - listener.close(); - return true; - case R.id.menu_favorite: - db.toggleFavorite(note, null); - listener.onNoteUpdated(note); - prepareFavoriteOption(item); - return true; - case R.id.menu_category: - showCategorySelector(); - return true; - case R.id.menu_share: - Intent shareIntent = new Intent(); - shareIntent.setAction(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, note.getTitle()); - shareIntent.putExtra(android.content.Intent.EXTRA_TEXT, note.getContent()); - - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - startActivity(Intent.createChooser(shareIntent, note.getTitle())); - } else { - ShareActionProvider actionProvider = (ShareActionProvider) MenuItemCompat.getActionProvider(item); - actionProvider.setShareIntent(shareIntent); - } - - return false; - case MENU_ID_PIN: - if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - ShortcutManager shortcutManager = getActivity().getSystemService(ShortcutManager.class); - - if (shortcutManager.isRequestPinShortcutSupported()) { - Intent intent = new Intent(getActivity(), EditNoteActivity.class); - intent.putExtra(EditNoteActivity.PARAM_NOTE_ID, note.getId()); - intent.setAction(ACTION_SHORTCUT); - - ShortcutInfo pinShortcutInfo = new ShortcutInfo.Builder(getActivity(), note.getId() + "") - .setShortLabel(note.getTitle()) - .setIcon(Icon.createWithResource(getActivity().getApplicationContext(), note.isFavorite() ? R.drawable.ic_star_yellow_24dp : R.drawable.ic_star_border_white_24dp)) - .setIntent(intent) - .build(); - - Intent pinnedShortcutCallbackIntent = - shortcutManager.createShortcutResultIntent(pinShortcutInfo); - - PendingIntent successCallback = PendingIntent.getBroadcast(getActivity(), /* request code */ 0, - pinnedShortcutCallbackIntent, /* flags */ 0); - - shortcutManager.requestPinShortcut(pinShortcutInfo, - successCallback.getIntentSender()); - } - } - - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - public void onCloseNote() { - if (originalNote == null && getContent().isEmpty()) { - db.deleteNoteAndSync(note.getId()); - } - } - - /** - * Save the current state in the database and schedule synchronization if needed. - * - * @param callback Observer which is called after save/synchronization - */ - protected void saveNote(@Nullable ICallback callback) { - Log.d(getClass().getSimpleName(), "saveData()"); - String newContent = getContent(); - if (note.getContent().equals(newContent)) { - Log.v(getClass().getSimpleName(), "... not saving, since nothing has changed"); - } else { - note = db.updateNoteAndSync(note, newContent, callback); - listener.onNoteUpdated(note); - } - } - - protected float getFontSizeFromPreferences(SharedPreferences sp) { - final String prefValueSmall = getString(R.string.pref_value_font_size_small); - final String prefValueMedium = getString(R.string.pref_value_font_size_medium); - final String prefValueLarge = getString(R.string.pref_value_font_size_large); - String fontSize = sp.getString(getString(R.string.pref_key_font_size), prefValueMedium); - - if (fontSize.equals(prefValueSmall)) { - return getResources().getDimension(R.dimen.note_font_size_small); - } else if (fontSize.equals(prefValueMedium)) { - return getResources().getDimension(R.dimen.note_font_size_medium); - } else { - return getResources().getDimension(R.dimen.note_font_size_large); - } - } - - protected abstract String getContent(); - - /** - * Opens a dialog in order to chose a category - */ - private void showCategorySelector() { - final String fragmentId = "fragment_category"; - FragmentManager manager = getFragmentManager(); - Fragment frag = manager.findFragmentByTag(fragmentId); - if (frag != null) { - manager.beginTransaction().remove(frag).commit(); - } - Bundle arguments = new Bundle(); - arguments.putString(CategoryDialogFragment.PARAM_CATEGORY, note.getCategory()); - CategoryDialogFragment categoryFragment = new CategoryDialogFragment(); - categoryFragment.setArguments(arguments); - categoryFragment.setTargetFragment(this, 0); - categoryFragment.show(manager, fragmentId); - } - - @Override - public void onCategoryChosen(String category) { - db.setCategory(note, category, null); - listener.onNoteUpdated(note); - } -} diff --git a/app/src/main/java/foundation/e/notes/android/fragment/CategoryDialogFragment.java b/app/src/main/java/foundation/e/notes/android/fragment/CategoryDialogFragment.java deleted file mode 100644 index 6448e31dea9e9115a83a547d158f66752bd54022..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/fragment/CategoryDialogFragment.java +++ /dev/null @@ -1,202 +0,0 @@ -package foundation.e.notes.android.fragment; - -import android.app.AlertDialog; -import android.app.Dialog; -import android.app.DialogFragment; -import android.app.Fragment; -import android.content.Context; -import android.content.DialogInterface; -import android.os.AsyncTask; -import android.os.Bundle; -import android.util.Log; -import android.view.View; -import android.view.WindowManager; -import android.widget.ArrayAdapter; -import android.widget.Filter; - -import java.util.ArrayList; -import java.util.List; - -import androidx.annotation.NonNull; -import butterknife.BindView; -import butterknife.ButterKnife; -import foundation.e.notes.R; -import foundation.e.notes.android.AlwaysAutoCompleteTextView; -import foundation.e.notes.model.NavigationAdapter; -import foundation.e.notes.persistence.NoteSQLiteOpenHelper; - -/** - * This {@link DialogFragment} allows for the selection of a category. - * It targetFragment is set it must implement the interface {@link CategoryDialogListener}. - * The calling Activity must implement the interface {@link CategoryDialogListener}. - */ -public class CategoryDialogFragment extends DialogFragment { - - /** - * Interface that must be implemented by the calling Activity. - */ - public interface CategoryDialogListener { - /** - * This method is called after the user has chosen a category. - * - * @param category Name of the category which was chosen by the user. - */ - void onCategoryChosen(String category); - } - - public static final String PARAM_CATEGORY = "category"; - - @BindView(R.id.editCategory) - AlwaysAutoCompleteTextView textCategory; - private FolderArrayAdapter adapter; - - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - View dialogView = getActivity().getLayoutInflater().inflate(R.layout.dialog_change_category, null); - ButterKnife.bind(this, dialogView); - if (savedInstanceState == null) { - textCategory.setText(getArguments().getString(PARAM_CATEGORY)); - } - adapter = new FolderArrayAdapter(getActivity(), android.R.layout.simple_spinner_dropdown_item); - textCategory.setAdapter(adapter); - new LoadCategoriesTask().execute(); - return new AlertDialog.Builder(getActivity(), R.style.ocAlertDialog) - .setTitle(R.string.change_category_title) - .setView(dialogView) - .setCancelable(true) - .setPositiveButton(R.string.action_edit_save, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - CategoryDialogListener listener; - Fragment target = getTargetFragment(); - if (target instanceof CategoryDialogListener) { - listener = (CategoryDialogListener) target; - } else { - listener = (CategoryDialogListener) getActivity(); - } - listener.onCategoryChosen(textCategory.getText().toString()); - } - }) - .setNegativeButton(R.string.simple_cancel, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - // do nothing - } - }) - .create(); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - if (getDialog().getWindow() != null) { - getDialog().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); - } else { - Log.w(CategoryDialogFragment.class.getSimpleName(), "can not set SOFT_INPUT_STATE_ALWAYAS_VISIBLE because getWindow() == null"); - } - } - - - private class LoadCategoriesTask extends AsyncTask> { - @Override - protected List doInBackground(Void... voids) { - NoteSQLiteOpenHelper db = NoteSQLiteOpenHelper.getInstance(getActivity()); - List items = db.getCategories(); - List categories = new ArrayList<>(); - for (NavigationAdapter.NavigationItem item : items) { - if (!item.label.isEmpty()) { - categories.add(item.label); - } - } - return categories; - } - - @Override - protected void onPostExecute(List categories) { - adapter.setData(categories); - if (textCategory.getText().length() == 0) { - textCategory.showFullDropDown(); - } else { - textCategory.dismissDropDown(); - } - } - } - - - private static class FolderArrayAdapter extends ArrayAdapter { - - private List originalData = new ArrayList<>(); - private Filter filter; - - private FolderArrayAdapter(@NonNull Context context, int resource) { - super(context, resource); - } - - public void setData(List data) { - originalData = data; - clear(); - addAll(data); - } - - @NonNull - @Override - public Filter getFilter() { - if (filter == null) { - filter = new FolderFilter(); - } - return filter; - } - - /* This implementation is based on ArrayAdapter.ArrayFilter */ - private class FolderFilter extends Filter { - @Override - protected FilterResults performFiltering(CharSequence prefix) { - final FilterResults results = new FilterResults(); - - if (prefix == null || prefix.length() == 0) { - final ArrayList list = new ArrayList<>(originalData); - results.values = list; - results.count = list.size(); - } else { - final String prefixString = prefix.toString().toLowerCase(); - final int count = originalData.size(); - final ArrayList newValues = new ArrayList<>(); - - for (int i = 0; i < count; i++) { - final String value = originalData.get(i); - final String valueText = value.toLowerCase(); - - // First match against the whole, non-splitted value - if (valueText.startsWith(prefixString)) { - newValues.add(value); - } else { - final String[] words = valueText.split("/"); - for (String word : words) { - if (word.startsWith(prefixString)) { - newValues.add(value); - break; - } - } - } - } - - results.values = newValues; - results.count = newValues.size(); - } - - return results; - } - - @Override - protected void publishResults(CharSequence constraint, FilterResults results) { - clear(); - addAll((List) results.values); - if (results.count > 0) { - notifyDataSetChanged(); - } else { - notifyDataSetInvalidated(); - } - } - } - } -} diff --git a/app/src/main/java/foundation/e/notes/android/fragment/NoteEditFragment.java b/app/src/main/java/foundation/e/notes/android/fragment/NoteEditFragment.java deleted file mode 100644 index 3d3f9785baab3864021b5e3d683d692552d40a03..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/fragment/NoteEditFragment.java +++ /dev/null @@ -1,229 +0,0 @@ -package foundation.e.notes.android.fragment; - -import android.content.Context; -import android.content.SharedPreferences; -import android.graphics.Typeface; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.preference.PreferenceManager; -import android.text.Editable; -import android.text.TextWatcher; -import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.view.inputmethod.InputMethodManager; -import android.widget.TextView; - -import androidx.annotation.Nullable; - -import com.yydcdut.markdown.syntax.edit.EditFactory; -import com.yydcdut.rxmarkdown.RxMDEditText; -import com.yydcdut.rxmarkdown.RxMarkdown; - -import butterknife.BindView; -import butterknife.ButterKnife; -import foundation.e.notes.R; -import foundation.e.notes.model.CloudNote; -import foundation.e.notes.util.ICallback; -import foundation.e.notes.util.MarkDownUtil; -import foundation.e.notes.util.StyleCallback; -import rx.Subscriber; - -public class NoteEditFragment extends BaseNoteFragment { - - private static final String LOG_TAG_AUTOSAVE = "AutoSave"; - - private static final long DELAY = 2000; // Wait for this time after typing before saving - private static final long DELAY_AFTER_SYNC = 5000; // Wait for this time after saving before checking for next save - @BindView(R.id.editContent) - RxMDEditText editContent; - private Handler handler; - private boolean saveActive, unsavedEdit; - private final Runnable runAutoSave = new Runnable() { - @Override - public void run() { - if (unsavedEdit) { - Log.d(LOG_TAG_AUTOSAVE, "runAutoSave: start AutoSave"); - autoSave(); - } else { - Log.d(LOG_TAG_AUTOSAVE, "runAutoSave: nothing changed"); - } - } - }; - private final TextWatcher textWatcher = new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(final CharSequence s, int start, int before, int count) { - } - - @Override - public void afterTextChanged(final Editable s) { - unsavedEdit = true; - if (!saveActive) { - handler.removeCallbacks(runAutoSave); - handler.postDelayed(runAutoSave, DELAY); - } - } - }; - - public static NoteEditFragment newInstance(long noteId) { - NoteEditFragment f = new NoteEditFragment(); - Bundle b = new Bundle(); - b.putLong(PARAM_NOTE_ID, noteId); - f.setArguments(b); - return f; - } - - public static NoteEditFragment newInstanceWithNewNote(CloudNote newNote) { - NoteEditFragment f = new NoteEditFragment(); - Bundle b = new Bundle(); - b.putSerializable(PARAM_NEWNOTE, newNote); - f.setArguments(b); - return f; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - handler = new Handler(Looper.getMainLooper()); - } - - @Override - public void onPrepareOptionsMenu(Menu menu) { - super.onPrepareOptionsMenu(menu); - menu.findItem(R.id.menu_edit).setVisible(false); - menu.findItem(R.id.menu_preview).setVisible(true); - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - return inflater.inflate(R.layout.activity_edit, container, false); - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - if(getView() != null) { - ButterKnife.bind(this, getView()); - - setActiveTextView(editContent); - - if (note.getContent().isEmpty()) { - editContent.requestFocus(); - - getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); - - InputMethodManager imm = (InputMethodManager) - getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); - imm.showSoftInput(getView(), InputMethodManager.SHOW_IMPLICIT); - - } - - // workaround for issue yydcdut/RxMarkdown#41 - note.setContent(note.getContent().replace("\r\n", "\n")); - - editContent.setText(note.getContent()); - editContent.setEnabled(true); - - RxMarkdown.live(editContent) - .config(MarkDownUtil.getMarkDownConfiguration(editContent.getContext()).build()) - .factory(EditFactory.create()) - .intoObservable() - .subscribe(new Subscriber() { - @Override - public void onCompleted() { - } - - @Override - public void onError(Throwable e) { - } - - @Override - public void onNext(CharSequence charSequence) { - editContent.setText(charSequence, TextView.BufferType.SPANNABLE); - } - }); - - editContent.setCustomSelectionActionModeCallback(new StyleCallback(this.editContent)); - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(getActivity().getApplicationContext()); - editContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, getFontSizeFromPreferences(sp)); - if (sp.getBoolean(getString(R.string.pref_key_font), false)) { - editContent.setTypeface(Typeface.MONOSPACE); - } - } else { - Log.e(NoteEditFragment.class.getSimpleName(), "getView() is null"); - } - } - - @Override - public void onResume() { - super.onResume(); - editContent.addTextChangedListener(textWatcher); - } - - @Override - public void onPause() { - super.onPause(); - editContent.removeTextChangedListener(textWatcher); - cancelTimers(); - } - - private void cancelTimers() { - handler.removeCallbacks(runAutoSave); - } - - /** - * Gets the current content of the EditText field in the UI. - * - * @return String of the current content. - */ - @Override - protected String getContent() { - return editContent.getText().toString(); - } - - @Override - protected void saveNote(@Nullable ICallback callback) { - super.saveNote(callback); - unsavedEdit = false; - } - - /** - * Saves the current changes and show the status in the ActionBar - */ - private void autoSave() { - Log.d(LOG_TAG_AUTOSAVE, "STARTAUTOSAVE"); - saveActive = true; - saveNote(new ICallback() { - @Override - public void onFinish() { - onSaved(); - } - - @Override - public void onScheduled() { - onSaved(); - } - - private void onSaved() { - // AFTER SYNCHRONIZATION - Log.d(LOG_TAG_AUTOSAVE, "FINISHED AUTOSAVE"); - saveActive = false; - - // AFTER "DELAY_AFTER_SYNC" SECONDS: allow next auto-save or start it directly - handler.postDelayed(runAutoSave, DELAY_AFTER_SYNC); - - } - }); - } -} diff --git a/app/src/main/java/foundation/e/notes/android/fragment/NotePreviewFragment.java b/app/src/main/java/foundation/e/notes/android/fragment/NotePreviewFragment.java deleted file mode 100644 index b9f3f3246fba89a8de0c36a115de6e400e24409c..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/fragment/NotePreviewFragment.java +++ /dev/null @@ -1,114 +0,0 @@ -package foundation.e.notes.android.fragment; - -import android.content.SharedPreferences; -import android.graphics.Typeface; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.text.method.LinkMovementMethod; -import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.yydcdut.markdown.syntax.text.TextFactory; -import com.yydcdut.rxmarkdown.RxMDTextView; -import com.yydcdut.rxmarkdown.RxMarkdown; - -import androidx.annotation.Nullable; -import butterknife.BindView; -import butterknife.ButterKnife; -import foundation.e.notes.R; -import foundation.e.notes.util.MarkDownUtil; -import rx.Subscriber; -import rx.android.schedulers.AndroidSchedulers; -import rx.schedulers.Schedulers; - -public class NotePreviewFragment extends BaseNoteFragment { - - @BindView(R.id.single_note_content) - RxMDTextView noteContent; - - public static NotePreviewFragment newInstance(long noteId) { - NotePreviewFragment f = new NotePreviewFragment(); - Bundle b = new Bundle(); - b.putLong(PARAM_NOTE_ID, noteId); - f.setArguments(b); - return f; - } - - @Override - public void onPrepareOptionsMenu(Menu menu) { - super.onPrepareOptionsMenu(menu); - menu.findItem(R.id.menu_edit).setVisible(true); - menu.findItem(R.id.menu_preview).setVisible(false); - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.activity_single_note, container, false); - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - ButterKnife.bind(this, getView()); - - setActiveTextView(noteContent); - - String content = note.getContent(); - - RxMarkdown.with(content, getActivity()) - .config( - MarkDownUtil.getMarkDownConfiguration(noteContent.getContext()) - /*.setOnTodoClickCallback(new OnTodoClickCallback() { - @Override - public CharSequence onTodoClicked(View view, String line, int lineNumber) { - String[] lines = TextUtils.split(note.getContent(), "\\r?\\n"); - if(lines.length >= lineNumber) { - lines[lineNumber] = line; - } - noteContent.setText(TextUtils.join("\n", lines), TextView.BufferType.SPANNABLE); - saveNote(null); - return line; - } - } - )*/.build() - ) - .factory(TextFactory.create()) - .intoObservable() - .subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Subscriber() { - @Override - public void onCompleted() { - } - - @Override - public void onError(Throwable e) { - Log.v(getClass().getSimpleName(), "RxMarkdown error", e); - } - - @Override - public void onNext(CharSequence charSequence) { - noteContent.setText(charSequence, TextView.BufferType.SPANNABLE); - } - }); - noteContent.setText(content); - noteContent.setMovementMethod(LinkMovementMethod.getInstance()); - - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(getActivity().getApplicationContext()); - noteContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, getFontSizeFromPreferences(sp)); - if (sp.getBoolean(getString(R.string.pref_key_font), false)) { - noteContent.setTypeface(Typeface.MONOSPACE); - } - } - - @Override - protected String getContent() { - return note.getContent(); - } -} diff --git a/app/src/main/java/foundation/e/notes/android/fragment/PreferencesFragment.java b/app/src/main/java/foundation/e/notes/android/fragment/PreferencesFragment.java deleted file mode 100644 index 31bdb431f632f0c7ba7db0cf903568ad3777f11d..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/fragment/PreferencesFragment.java +++ /dev/null @@ -1,36 +0,0 @@ -package foundation.e.notes.android.fragment; - -import android.app.Activity; -import android.os.Bundle; -import android.preference.Preference; -import android.preference.PreferenceFragment; -import android.preference.SwitchPreference; -import android.util.Log; -import android.widget.Toast; - -import foundation.e.cert4android.CustomCertManager; -import foundation.e.notes.R; -import foundation.e.notes.util.Notes; -import androidx.annotation.Nullable; - -public class PreferencesFragment extends PreferenceFragment { - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - addPreferencesFromResource(R.xml.preferences); - - Preference resetTrust = findPreference(getString(R.string.pref_key_reset_trust)); - resetTrust.setOnPreferenceClickListener((Preference preference) -> { - CustomCertManager.Companion.resetCertificates(getActivity()); - Toast.makeText(getActivity(), getString(R.string.settings_cert_reset_toast), Toast.LENGTH_SHORT).show(); - return true; - }); - - final SwitchPreference wifiOnlyPref = (SwitchPreference) findPreference(getString(R.string.pref_key_wifi_only)); - wifiOnlyPref.setOnPreferenceChangeListener((Preference preference, Object newValue) -> { - Boolean syncOnWifiOnly = (Boolean) newValue; - Log.v("Notes", "syncOnWifiOnly: " + syncOnWifiOnly); - return true; - }); - } -} diff --git a/app/src/main/java/foundation/e/notes/android/fragment/about/AboutFragment.java b/app/src/main/java/foundation/e/notes/android/fragment/about/AboutFragment.java deleted file mode 100644 index e6c13cbcce9f571eff2bf39d04701537398df924..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/fragment/about/AboutFragment.java +++ /dev/null @@ -1,23 +0,0 @@ -package foundation.e.notes.android.fragment.about; - -import android.os.Bundle; -import android.preference.PreferenceFragment; - -import androidx.annotation.Nullable; -import foundation.e.notes.BuildConfig; -import foundation.e.notes.R; - -public class AboutFragment extends PreferenceFragment { - - private static final String BUILD_VERSION = "build_version"; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - addPreferencesFromResource(R.xml.about_preferences); - - findPreference(BUILD_VERSION).setSummary(BuildConfig.VERSION_NAME); - - } -} diff --git a/app/src/main/java/foundation/e/notes/android/providers/AppContentProvider.java b/app/src/main/java/foundation/e/notes/android/providers/AppContentProvider.java deleted file mode 100644 index 30628bf0330f5296aa1490f30d80624bb1f359bb..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/android/providers/AppContentProvider.java +++ /dev/null @@ -1,48 +0,0 @@ -package foundation.e.notes.android.providers; - -import android.content.ContentProvider; -import android.content.ContentValues; -import android.database.Cursor; -import android.net.Uri; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * @author Nihar Thakkar - */ - -public class AppContentProvider extends ContentProvider { - - @Override - public boolean onCreate() { - return false; - } - - @Nullable - @Override - public Cursor query(@NonNull Uri uri, @Nullable String[] strings, @Nullable String s, @Nullable String[] strings1, @Nullable String s1) { - return null; - } - - @Nullable - @Override - public String getType(@NonNull Uri uri) { - return null; - } - - @Nullable - @Override - public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) { - return null; - } - - @Override - public int delete(@NonNull Uri uri, @Nullable String s, @Nullable String[] strings) { - return 0; - } - - @Override - public int update(@NonNull Uri uri, @Nullable ContentValues contentValues, @Nullable String s, @Nullable String[] strings) { - return 0; - } -} diff --git a/app/src/main/java/foundation/e/notes/model/Category.java b/app/src/main/java/foundation/e/notes/model/Category.java deleted file mode 100644 index 861c24027dcea32483678b27541a65b6c01f9799..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/model/Category.java +++ /dev/null @@ -1,18 +0,0 @@ -package foundation.e.notes.model; - -import androidx.annotation.Nullable; - -import java.io.Serializable; - -public class Category implements Serializable { - - @Nullable - public final String category; - @Nullable - public final Boolean favorite; - - public Category(@Nullable String category, @Nullable Boolean favorite) { - this.category = category; - this.favorite = favorite; - } -} diff --git a/app/src/main/java/foundation/e/notes/model/CloudNote.java b/app/src/main/java/foundation/e/notes/model/CloudNote.java deleted file mode 100644 index f218d0dd8521a1bf3b922ae17645897b4a00ba28..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/model/CloudNote.java +++ /dev/null @@ -1,103 +0,0 @@ -package foundation.e.notes.model; - -import java.io.Serializable; -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.Locale; - -import foundation.e.notes.util.NoteUtil; - -/** - * CloudNote represents a remote note from an OwnCloud server. - * It can be directly generated from the JSON answer from the server. - */ -public class CloudNote implements Serializable { - private long remoteId = 0; - private String title = ""; - private Calendar modified = null; - private String content = ""; - private boolean favorite = false; - private String category = ""; - private String etag = ""; - - public CloudNote(long remoteId, Calendar modified, String title, String content, boolean favorite, String category, String etag) { - this.remoteId = remoteId; - if (title != null) - setTitle(title); - setTitle(title); - setContent(content); - setFavorite(favorite); - setCategory(category); - setEtag(etag); - this.modified = modified; - } - - public long getRemoteId() { - return remoteId; - } - - public void setRemoteId(long remoteId) { - this.remoteId = remoteId; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = NoteUtil.removeMarkDown(title); - } - - @SuppressWarnings("WeakerAccess") - public Calendar getModified() { - return modified; - } - - public String getModified(String format) { - if (modified == null) - return null; - return new SimpleDateFormat(format, Locale.GERMANY).format(this.getModified().getTimeInMillis()); - } - - public void setModified(Calendar modified) { - this.modified = modified; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } - - public boolean isFavorite() { - return favorite; - } - - public void setFavorite(boolean favorite) { - this.favorite = favorite; - } - - public String getEtag() { - return etag; - } - - public void setEtag(String etag) { - this.etag = etag; - } - - public String getCategory() { - return category; - } - - public void setCategory(String category) { - this.category = category == null ? "" : category; - } - - @Override - public String toString() { - final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss"; - return "R" + getRemoteId() + " " + (isFavorite() ? " (*) " : " ") + getCategory() + " / " + getTitle() + " (" + getModified(DATE_FORMAT) + " / " + getEtag() + ")"; - } -} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/notes/model/DBNote.java b/app/src/main/java/foundation/e/notes/model/DBNote.java deleted file mode 100644 index 4681db55542644d66f8a2dd8d644283aa00ca37a..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/model/DBNote.java +++ /dev/null @@ -1,63 +0,0 @@ -package foundation.e.notes.model; - -import java.io.Serializable; -import java.util.Calendar; - -import foundation.e.notes.util.NoteUtil; - -/** - * DBNote represents a single note from the local SQLite database with all attributes. - * It extends CloudNote with attributes required for local data management. - */ -public class DBNote extends CloudNote implements Item, Serializable { - - private long id; - private DBStatus status; - private String excerpt = ""; - - public DBNote(long id, long remoteId, Calendar modified, String title, String content, boolean favorite, String category, String etag, DBStatus status) { - super(remoteId, modified, title, content, favorite, category, etag); - this.id = id; - setExcerpt(content); - this.status = status; - } - - public long getId() { - return id; - } - - public DBStatus getStatus() { - return status; - } - - public void setStatus(DBStatus status) { - this.status = status; - } - - public String getExcerpt() { - return excerpt; - } - - public void setExcerptDirectly(String content) { - excerpt = content; - } - - private void setExcerpt(String content) { - excerpt = NoteUtil.generateNoteExcerpt(content); - } - - public void setContent(String content) { - super.setContent(content); - setExcerpt(content); - } - - @Override - public boolean isSection() { - return false; - } - - @Override - public String toString() { - return "#" + getId() + "/" + super.toString() + " " + getStatus(); - } -} diff --git a/app/src/main/java/foundation/e/notes/model/Item.java b/app/src/main/java/foundation/e/notes/model/Item.java deleted file mode 100644 index 20c6b29ec168b70a1aa2ecb7d2ac15cd8815b999..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/model/Item.java +++ /dev/null @@ -1,5 +0,0 @@ -package foundation.e.notes.model; - -public interface Item { - boolean isSection(); -} diff --git a/app/src/main/java/foundation/e/notes/model/ItemAdapter.java b/app/src/main/java/foundation/e/notes/model/ItemAdapter.java deleted file mode 100644 index 9bc79e12c26160fc4e1cce3b6f6391298883d1c7..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/model/ItemAdapter.java +++ /dev/null @@ -1,233 +0,0 @@ -package foundation.e.notes.model; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import android.text.Html; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import java.util.ArrayList; -import java.util.List; - -import butterknife.BindView; -import butterknife.ButterKnife; -import foundation.e.notes.R; - -import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; - -public class ItemAdapter extends RecyclerView.Adapter { - - private static final int section_type = 0; - private static final int note_type = 1; - private final NoteClickListener noteClickListener; - private List itemList = null; - private boolean showCategory = true; - private List selected = null; - - public ItemAdapter(@NonNull NoteClickListener noteClickListener) { - this.itemList = new ArrayList<>(); - this.selected = new ArrayList<>(); - this.noteClickListener = noteClickListener; - } - - /** - * Updates the item list and notifies respective view to update. - * - * @param itemList List of items to be set - */ - public void setItemList(@NonNull List itemList) { - this.itemList = itemList; - notifyDataSetChanged(); - } - - /** - * Adds the given note to the top of the list. - * - * @param note Note that should be added. - */ - public void add(@NonNull DBNote note) { - itemList.add(0, note); - notifyItemInserted(0); - notifyItemChanged(0); - } - - /** - * Replaces a note with an updated version - * - * @param note Note with the changes. - * @param position position in the list of the node - */ - public void replace(@NonNull DBNote note, int position) { - itemList.set(position, note); - notifyItemChanged(position); - } - - /** - * Removes all items from the adapter. - */ - public void removeAll() { - itemList.clear(); - notifyDataSetChanged(); - } - - // Create new views (invoked by the layout manager) - @Override - public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View v; - if (viewType == section_type) { - v = LayoutInflater.from(parent.getContext()).inflate(R.layout.fragment_notes_list_section_item, parent, false); - return new SectionViewHolder(v); - } else { - v = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.fragment_notes_list_note_item, parent, false); - return new NoteViewHolder(v); - } - } - - // Replace the contents of a view (invoked by the layout manager) - @Override - public void onBindViewHolder(final RecyclerView.ViewHolder holder, int position) { - // - get element from your dataset at this position - // - replace the contents of the view with that element - Item item = itemList.get(position); - if (item.isSection()) { - SectionItem section = (SectionItem) item; - ((SectionViewHolder) holder).sectionTitle.setText(section.geTitle()); - } else { - final DBNote note = (DBNote) item; - final NoteViewHolder nvHolder = ((NoteViewHolder) holder); - nvHolder.noteSwipeable.setAlpha(DBStatus.LOCAL_DELETED.equals(note.getStatus()) ? 0.5f : 1.0f); - nvHolder.noteTitle.setText(Html.fromHtml(note.getTitle())); - nvHolder.noteCategory.setVisibility(showCategory && !note.getCategory().isEmpty() ? View.VISIBLE : View.GONE); - nvHolder.noteCategory.setText(Html.fromHtml(note.getCategory())); - nvHolder.noteExcerpt.setText(Html.fromHtml(note.getExcerpt())); - nvHolder.noteStatus.setVisibility(DBStatus.VOID.equals(note.getStatus()) ? View.INVISIBLE : View.VISIBLE); - nvHolder.noteFavorite.setImageResource(note.isFavorite() ? lineageos.platform.R.drawable.ic_star_filled : lineageos.platform.R.drawable.ic_star); - nvHolder.noteFavorite.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - noteClickListener.onNoteFavoriteClick(holder.getAdapterPosition(), view); - } - }); - } - } - - public boolean select(Integer position) { - return !selected.contains(position) && selected.add(position); - } - - public void clearSelection() { - selected.clear(); - } - - @NonNull - public List getSelected() { - return selected; - } - - public boolean deselect(Integer position) { - for (int i = 0; i < selected.size(); i++) { - if (selected.get(i).equals(position)) { - //position was selected and removed - selected.remove(i); - return true; - } - } - // position was not selected - return false; - } - - public Item getItem(int notePosition) { - return itemList.get(notePosition); - } - - public void remove(@NonNull Item item) { - itemList.remove(item); - notifyDataSetChanged(); - } - - public void setShowCategory(boolean showCategory) { - this.showCategory = showCategory; - } - - @Override - public int getItemCount() { - return itemList.size(); - } - - @Override - public int getItemViewType(int position) { - return getItem(position).isSection() ? section_type : note_type; - } - - public interface NoteClickListener { - void onNoteClick(int position, View v); - - void onNoteFavoriteClick(int position, View v); - - boolean onNoteLongClick(int position, View v); - } - - public class NoteViewHolder extends RecyclerView.ViewHolder implements View.OnLongClickListener, View.OnClickListener { - @BindView(R.id.noteSwipeable) - public View noteSwipeable; - View noteSwipeFrame; - ImageView noteFavoriteLeft, noteDeleteRight; - TextView noteTitle; - @BindView(R.id.noteCategory) - TextView noteCategory; - @BindView(R.id.noteExcerpt) - TextView noteExcerpt; - @BindView(R.id.noteStatus) - ImageView noteStatus; - @BindView(R.id.noteFavorite) - ImageView noteFavorite; - - private NoteViewHolder(View v) { - super(v); - this.noteSwipeFrame = v.findViewById(R.id.noteSwipeFrame); - this.noteSwipeable = v.findViewById(R.id.noteSwipeable); - this.noteFavoriteLeft = v.findViewById(R.id.noteFavoriteLeft); - this.noteDeleteRight = v.findViewById(R.id.noteDeleteRight); - this.noteTitle = v.findViewById(R.id.noteTitle); - this.noteCategory = v.findViewById(R.id.noteCategory); - this.noteExcerpt = v.findViewById(R.id.noteExcerpt); - this.noteStatus = v.findViewById(R.id.noteStatus); - this.noteFavorite = v.findViewById(R.id.noteFavorite); - v.setOnClickListener(this); - v.setOnLongClickListener(this); - } - - @Override - public void onClick(View v) { - final int adapterPosition = getAdapterPosition(); - if (adapterPosition != NO_POSITION) { - noteClickListener.onNoteClick(adapterPosition, v); - } - } - - @Override - public boolean onLongClick(View v) { - return noteClickListener.onNoteLongClick(getAdapterPosition(), v); - } - - public void showSwipe(boolean left) { - noteFavoriteLeft.setVisibility(left ? View.VISIBLE : View.INVISIBLE); - noteDeleteRight.setVisibility(left ? View.INVISIBLE : View.VISIBLE); - noteSwipeFrame.setBackgroundResource(left ? R.color.bg_warning : R.color.bg_attention); - } - } - - public static class SectionViewHolder extends RecyclerView.ViewHolder { - @BindView(R.id.sectionTitle) - TextView sectionTitle; - - private SectionViewHolder(View view) { - super(view); - ButterKnife.bind(this, view); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/notes/model/NavigationAdapter.java b/app/src/main/java/foundation/e/notes/model/NavigationAdapter.java deleted file mode 100644 index a652e49470967e6732f1c8c9a78fc42cde47b115..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/model/NavigationAdapter.java +++ /dev/null @@ -1,165 +0,0 @@ -package foundation.e.notes.model; - -import android.graphics.Color; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.ColorUtils; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.List; - -import butterknife.BindView; -import butterknife.ButterKnife; -import foundation.e.notes.R; -import foundation.e.notes.util.NoteUtil; - -public class NavigationAdapter extends RecyclerView.Adapter { - - @DrawableRes - public static final int ICON_FOLDER = lineageos.platform.R.drawable.ic_folder; - @DrawableRes - public static final int ICON_NOFOLDER = R.drawable.ic_folder_open_grey600_24dp; - @DrawableRes - public static final int ICON_SUB_FOLDER = R.drawable.ic_folder_grey600_18dp; - @DrawableRes - public static final int ICON_MULTIPLE = R.drawable.ic_create_new_folder_grey600_24dp; - @DrawableRes - public static final int ICON_MULTIPLE_OPEN = R.drawable.ic_folder_grey600_24dp; - @DrawableRes - public static final int ICON_SUB_MULTIPLE = R.drawable.ic_create_new_folder_grey600_18dp; - - public static class NavigationItem { - @NonNull - public String id; - @NonNull - public String label; - @DrawableRes - public int icon; - @Nullable - public Integer count; - - public NavigationItem(@NonNull String id, @NonNull String label, @Nullable Integer count, @DrawableRes int icon) { - this.id = id; - this.label = label; - this.count = count; - this.icon = icon; - } - } - - class ViewHolder extends RecyclerView.ViewHolder { - @NonNull - private final View view; - - @BindView(R.id.navigationItemLabel) - TextView name; - - @BindView(R.id.navigationItemCount) - TextView count; - - @BindView(R.id.navigationItemIcon) - ImageView icon; - - private NavigationItem currentItem; - - ViewHolder(@NonNull View itemView, @NonNull final ClickListener clickListener) { - super(itemView); - view = itemView; - ButterKnife.bind(this, view); - icon.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - clickListener.onIconClick(currentItem); - } - }); - itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - clickListener.onItemClick(currentItem); - } - }); - } - - void assignItem(@NonNull NavigationItem item) { - currentItem = item; - boolean isSelected = item.id.equals(selectedItem); - name.setText(NoteUtil.extendCategory(item.label)); - count.setVisibility(item.count == null ? View.GONE : View.VISIBLE); - count.setText(String.valueOf(item.count)); - if (item.icon > 0) { - icon.setImageDrawable(ContextCompat.getDrawable(icon.getContext(), item.icon)); - icon.setVisibility(View.VISIBLE); - } else { - icon.setVisibility(View.GONE); - } - view.setBackgroundColor(isSelected ? ColorUtils.setAlphaComponent(view.getResources().getColor(R.color.accent_color), 20) : Color.TRANSPARENT); - int textColor = ContextCompat.getColor(view.getContext(), isSelected ? R.color.accent_color : R.color.color_default_primary_text); - int unSelectedIconColor = ContextCompat.getColor(view.getContext(), isSelected ? R.color.accent_color : R.color.drawer_menu_icon_color); - - name.setTextColor(textColor); - count.setTextColor(textColor); - icon.setColorFilter(isSelected ? textColor : unSelectedIconColor); - } - } - -// private Drawable reSizeIcon(Resources resources, Drawable drawable){ -// Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap(); -// Drawable resizeDrawable = new BitmapDrawable(resources, Bitmap.createScaledBitmap(bitmap, 24, 24, true)); -// return resizeDrawable; -// } - - public interface ClickListener { - void onItemClick(NavigationItem item); - - void onIconClick(NavigationItem item); - } - - @NonNull - private List items = new ArrayList<>(); - private String selectedItem = null; - @NonNull - private ClickListener clickListener; - - public NavigationAdapter(@NonNull ClickListener clickListener) { - this.clickListener = clickListener; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_navigation, parent, false); - return new ViewHolder(v, clickListener); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - holder.assignItem(items.get(position)); - } - - @Override - public int getItemCount() { - return items.size(); - } - - public void setItems(@NonNull List items) { - this.items = items; - notifyDataSetChanged(); - } - - public void setSelectedItem(String id) { - selectedItem = id; - notifyDataSetChanged(); - } - - public String getSelectedItem() { - return selectedItem; - } -} diff --git a/app/src/main/java/foundation/e/notes/model/SectionItem.java b/app/src/main/java/foundation/e/notes/model/SectionItem.java deleted file mode 100644 index 8bab706e6ee224b620b6567385c4e6c80767c5aa..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/model/SectionItem.java +++ /dev/null @@ -1,23 +0,0 @@ -package foundation.e.notes.model; - -public class SectionItem implements Item { - - private String title; - - public SectionItem(String title) { - this.title = title; - } - - public String geTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - @Override - public boolean isSection() { - return true; - } -} diff --git a/app/src/main/java/foundation/e/notes/persistence/LoadNotesListTask.java b/app/src/main/java/foundation/e/notes/persistence/LoadNotesListTask.java deleted file mode 100644 index b0a960892d2e1b65b6342148a49834e88bf94fd3..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/persistence/LoadNotesListTask.java +++ /dev/null @@ -1,172 +0,0 @@ -package foundation.e.notes.persistence; - -import android.content.Context; -import android.os.AsyncTask; -import android.text.Html; -import android.text.SpannableString; -import android.text.TextUtils; -import android.text.format.DateUtils; -import android.text.style.ForegroundColorSpan; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; -import androidx.core.content.ContextCompat; - -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import foundation.e.notes.R; -import foundation.e.notes.model.Category; -import foundation.e.notes.model.DBNote; -import foundation.e.notes.model.Item; -import foundation.e.notes.model.SectionItem; -import foundation.e.notes.util.NoteUtil; - -public class LoadNotesListTask extends AsyncTask> { - - private final Context context; - private final NotesLoadedListener callback; - private final Category category; - private final CharSequence searchQuery; - - public LoadNotesListTask(@NonNull Context context, @NonNull NotesLoadedListener callback, @NonNull Category category, @Nullable CharSequence searchQuery) { - this.context = context; - this.callback = callback; - this.category = category; - this.searchQuery = searchQuery; - } - - @Override - protected List doInBackground(Void... voids) { - List noteList; - NoteSQLiteOpenHelper db = NoteSQLiteOpenHelper.getInstance(context); - noteList = db.searchNotes(searchQuery, category.category, category.favorite); - - if (category.category == null) { - return fillListByTime(noteList); - } else { - return fillListByCategory(noteList); - } - } - - private DBNote colorTheNote(DBNote dbNote) { - if (!TextUtils.isEmpty(searchQuery)) { - SpannableString spannableString = new SpannableString(dbNote.getTitle()); - Matcher matcher = Pattern.compile("(" + searchQuery + ")", Pattern.CASE_INSENSITIVE).matcher(spannableString); - while (matcher.find()) { - spannableString.setSpan(new ForegroundColorSpan(ContextCompat.getColor(context, foundation.e.notes.R.color.light_grey)), - matcher.start(), matcher.end(), 0); - } - - dbNote.setTitle(Html.toHtml(spannableString)); - - spannableString = new SpannableString(dbNote.getExcerpt()); - matcher = Pattern.compile("(" + searchQuery + ")", Pattern.CASE_INSENSITIVE).matcher(spannableString); - while (matcher.find()) { - spannableString.setSpan(new ForegroundColorSpan(ContextCompat.getColor(context, foundation.e.notes.R.color.light_grey)), - matcher.start(), matcher.end(), 0); - } - - dbNote.setExcerptDirectly(Html.toHtml(spannableString)); - } - - return dbNote; - } - - @NonNull - @WorkerThread - private List fillListByCategory(@NonNull List noteList) { - List itemList = new ArrayList<>(); - String currentCategory = category.category; - for (DBNote note : noteList) { - if (currentCategory != null && !currentCategory.equals(note.getCategory())) { - itemList.add(new SectionItem(NoteUtil.extendCategory(note.getCategory()))); - } - - itemList.add(colorTheNote(note)); - currentCategory = note.getCategory(); - } - return itemList; - } - - @NonNull - @WorkerThread - private List fillListByTime(@NonNull List noteList) { - List itemList = new ArrayList<>(); - Timeslotter timeslotter = new Timeslotter(); - String lastTimeslot = null; - for (int i = 0; i < noteList.size(); i++) { - DBNote currentNote = noteList.get(i); - String timeslot = timeslotter.getTimeslot(currentNote); - if(i > 0 && !timeslot.equals(lastTimeslot)) { - itemList.add(new SectionItem(timeslot)); - } - itemList.add(colorTheNote(currentNote)); - lastTimeslot = timeslot; - } - - return itemList; - } - - @Override - protected void onPostExecute(List items) { - callback.onNotesLoaded(items, category.category == null); - } - - public interface NotesLoadedListener { - void onNotesLoaded(List notes, boolean showCategory); - } - - private class Timeslotter { - private final List timeslots = new ArrayList<>(); - private final Calendar lastYear; - - Timeslotter() { - Calendar now = Calendar.getInstance(); - int month = now.get(Calendar.MONTH); - int day = now.get(Calendar.DAY_OF_MONTH); - int offsetWeekStart = (now.get(Calendar.DAY_OF_WEEK) - now.getFirstDayOfWeek() + 7) % 7; - timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_today), month, day)); - timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_yesterday), month,day - 1)); - timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_this_week), month,day - offsetWeekStart)); - timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_last_week), month,day - offsetWeekStart - 7)); - timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_this_month), month,1)); - timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_last_month), month - 1, 1)); - lastYear = Calendar.getInstance(); - lastYear.set(now.get(Calendar.YEAR) - 1, 0, 1, 0, 0, 0); - } - - String getTimeslot(DBNote note) { - if (note.isFavorite()) { - return ""; - } - Calendar modified = note.getModified(); - for (Timeslot timeslot : timeslots) { - if (!modified.before(timeslot.time)) { - return timeslot.label; - } - } - if (!modified.before(this.lastYear)) { - // use YEAR and MONTH in a format based on current locale - return DateUtils.formatDateTime(context, modified.getTimeInMillis(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR | DateUtils.FORMAT_NO_MONTH_DAY); - } else { - return Integer.toString(modified.get(Calendar.YEAR)); - } - } - - private class Timeslot { - final String label; - final Calendar time; - - Timeslot(String label, int month, int day) { - this.label = label; - this.time = Calendar.getInstance(); - this.time.set(this.time.get(Calendar.YEAR), month, day, 0, 0, 0); - } - } - } -} diff --git a/app/src/main/java/foundation/e/notes/persistence/NoteSQLiteOpenHelper.java b/app/src/main/java/foundation/e/notes/persistence/NoteSQLiteOpenHelper.java deleted file mode 100644 index 210705189db47a9fe75eea31b767a7f96532c813..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/persistence/NoteSQLiteOpenHelper.java +++ /dev/null @@ -1,599 +0,0 @@ -package foundation.e.notes.persistence; - -import android.content.ContentValues; -import android.content.Context; -import android.content.Intent; -import android.content.pm.ShortcutManager; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.os.Build; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; - -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import foundation.e.notes.model.CloudNote; -import foundation.e.notes.model.DBNote; -import foundation.e.notes.model.DBStatus; -import foundation.e.notes.model.NavigationAdapter; -import foundation.e.notes.util.ICallback; -import foundation.e.notes.util.NoteUtil; -import foundation.e.notes.android.appwidget.NoteListWidget; -import foundation.e.notes.android.appwidget.SingleNoteWidget; -import foundation.e.notes.R; - -/** - * Helps to add, get, update and delete Notes with the option to trigger a Resync with the Server. - */ -public class NoteSQLiteOpenHelper extends SQLiteOpenHelper { - - private static final int database_version = 8; - private static final String database_name = "OWNCLOUD_NOTES"; - private static final String table_notes = "NOTES"; - private static final String key_id = "ID"; - private static final String key_remote_id = "REMOTEID"; - private static final String key_status = "STATUS"; - private static final String key_title = "TITLE"; - private static final String key_modified = "MODIFIED"; - private static final String key_content = "CONTENT"; - private static final String key_favorite = "FAVORITE"; - private static final String key_category = "CATEGORY"; - private static final String key_etag = "ETAG"; - private static final String[] columns = {key_id, key_remote_id, key_status, key_title, key_modified, key_content, key_favorite, key_category, key_etag}; - private static final String default_order = key_favorite + " DESC, " + key_modified + " DESC"; - - private static NoteSQLiteOpenHelper instance; - - private NoteServerSyncHelper serverSyncHelper; - private Context context; - - private NoteSQLiteOpenHelper(Context context) { - super(context, database_name, null, database_version); - this.context = context.getApplicationContext(); - serverSyncHelper = NoteServerSyncHelper.getInstance(this); - } - - public static NoteSQLiteOpenHelper getInstance(Context context) { - if (instance == null) - return instance = new NoteSQLiteOpenHelper(context.getApplicationContext()); - else - return instance; - } - - public NoteServerSyncHelper getNoteServerSyncHelper() { - return serverSyncHelper; - } - - /** - * Creates initial the Database - * - * @param db Database - */ - @Override - public void onCreate(SQLiteDatabase db) { - createTable(db, table_notes); - } - - private void createTable(SQLiteDatabase db, String tableName) { - db.execSQL("CREATE TABLE " + tableName + " ( " + - key_id + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - key_remote_id + " INTEGER, " + - key_status + " VARCHAR(50), " + - key_title + " TEXT, " + - key_modified + " INTEGER DEFAULT 0, " + - key_content + " TEXT, " + - key_favorite + " INTEGER DEFAULT 0, " + - key_category + " TEXT NOT NULL DEFAULT '', " + - key_etag + " TEXT)"); - createIndexes(db); - } - - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - if (oldVersion < 3) { - recreateDatabase(db); - } - if (oldVersion < 4) { - clearDatabase(db); - } - if (oldVersion < 5) { - db.execSQL("ALTER TABLE " + table_notes + " ADD COLUMN " + key_remote_id + " INTEGER"); - db.execSQL("UPDATE " + table_notes + " SET " + key_remote_id + "=" + key_id + " WHERE (" + key_remote_id + " IS NULL OR " + key_remote_id + "=0) AND " + key_status + "!=?", new String[]{DBStatus.LOCAL_CREATED.getTitle()}); - db.execSQL("UPDATE " + table_notes + " SET " + key_remote_id + "=0, " + key_status + "=? WHERE " + key_status + "=?", new String[]{DBStatus.LOCAL_EDITED.getTitle(), DBStatus.LOCAL_CREATED.getTitle()}); - } - if (oldVersion < 6) { - db.execSQL("ALTER TABLE " + table_notes + " ADD COLUMN " + key_favorite + " INTEGER DEFAULT 0"); - } - if (oldVersion < 7) { - dropIndexes(db); - db.execSQL("ALTER TABLE " + table_notes + " ADD COLUMN " + key_category + " TEXT NOT NULL DEFAULT ''"); - db.execSQL("ALTER TABLE " + table_notes + " ADD COLUMN " + key_etag + " TEXT"); - createIndexes(db); - } - if (oldVersion < 8) { - final String table_temp = "NOTES_TEMP"; - createTable(db, table_temp); - db.execSQL(String.format("INSERT INTO %s(%s,%s,%s,%s,%s,%s,%s,%s,%s) ", table_temp, key_id, key_remote_id, key_status, key_title, key_modified, key_content, key_favorite, key_category, key_etag) - + String.format("SELECT %s,%s,%s,%s,strftime('%%s',%s),%s,%s,%s,%s FROM %s", key_id, key_remote_id, key_status, key_title, key_modified, key_content, key_favorite, key_category, key_etag, table_notes)); - db.execSQL(String.format("DROP TABLE %s", table_notes)); - db.execSQL(String.format("ALTER TABLE %s RENAME TO %s", table_temp, table_notes)); - } - } - - @Override - public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { - recreateDatabase(db); - } - - private void clearDatabase(SQLiteDatabase db) { - db.delete(table_notes, null, null); - } - - private void recreateDatabase(SQLiteDatabase db) { - dropIndexes(db); - db.execSQL("DROP TABLE " + table_notes); - onCreate(db); - } - - private void dropIndexes(SQLiteDatabase db) { - Cursor c = db.query("sqlite_master", new String[]{"name"}, "type=?", new String[]{"index"}, null, null, null); - while (c.moveToNext()) { - db.execSQL("DROP INDEX " + c.getString(0)); - } - c.close(); - } - - private void createIndexes(SQLiteDatabase db) { - createIndex(db, table_notes, key_remote_id); - createIndex(db, table_notes, key_status); - createIndex(db, table_notes, key_favorite); - createIndex(db, table_notes, key_category); - createIndex(db, table_notes, key_modified); - } - - private void createIndex(SQLiteDatabase db, String table, String column) { - String indexName = table + "_" + column + "_idx"; - db.execSQL("CREATE INDEX IF NOT EXISTS " + indexName + " ON " + table + "(" + column + ")"); - } - - public Context getContext() { - return context; - } - - /** - * Creates a new Note in the Database and adds a Synchronization Flag. - * - * @param content String - */ - @SuppressWarnings("UnusedReturnValue") - public long addNoteAndSync(String content, String category, boolean favorite) { - CloudNote note = new CloudNote(0, Calendar.getInstance(), NoteUtil.generateNonEmptyNoteTitle(content, getContext()), content, favorite, category, null); - return addNoteAndSync(note); - } - - /** - * Creates a new Note in the Database and adds a Synchronization Flag. - * - * @param note Note - */ - @SuppressWarnings("UnusedReturnValue") - public long addNoteAndSync(CloudNote note) { - DBNote dbNote = new DBNote(0, 0, note.getModified(), note.getTitle(), note.getContent(), note.isFavorite(), note.getCategory(), note.getEtag(), DBStatus.LOCAL_EDITED); - long id = addNote(dbNote); - notifyNotesChanged(); - getNoteServerSyncHelper().scheduleSync(true); - return id; - } - - /** - * Inserts a note directly into the Database. - * No Synchronisation will be triggered! Use addNoteAndSync()! - * - * @param note Note to be added. Remotely created Notes must be of type CloudNote and locally created Notes must be of Type DBNote (with DBStatus.LOCAL_EDITED)! - */ - long addNote(CloudNote note) { - SQLiteDatabase db = this.getWritableDatabase(); - ContentValues values = new ContentValues(); - if (note instanceof DBNote) { - DBNote dbNote = (DBNote) note; - if (dbNote.getId() > 0) { - values.put(key_id, dbNote.getId()); - } - values.put(key_status, dbNote.getStatus().getTitle()); - } else { - values.put(key_status, DBStatus.VOID.getTitle()); - } - if (note.getRemoteId() > 0) { - values.put(key_remote_id, note.getRemoteId()); - } - values.put(key_title, note.getTitle()); - values.put(key_modified, note.getModified().getTimeInMillis() / 1000); - values.put(key_content, note.getContent()); - values.put(key_favorite, note.isFavorite()); - values.put(key_category, note.getCategory()); - values.put(key_etag, note.getEtag()); - return db.insert(table_notes, null, values); - } - - /** - * Get a single Note by ID - * - * @param id int - ID of the requested Note - * @return requested Note - */ - public DBNote getNote(long id) { - List notes = getNotesCustom(key_id + " = ? AND " + key_status + " != ?", new String[]{String.valueOf(id), DBStatus.LOCAL_DELETED.getTitle()}, null); - return notes.isEmpty() ? null : notes.get(0); - } - - /** - * Query the database with a custom raw query. - * - * @param selection A filter declaring which rows to return, formatted as an SQL WHERE clause (excluding the WHERE itself). - * @param selectionArgs You may include ?s in selection, which will be replaced by the values from selectionArgs, in order that they appear in the selection. The values will be bound as Strings. - * @param orderBy How to order the rows, formatted as an SQL ORDER BY clause (excluding the ORDER BY itself). Passing null will use the default sort order, which may be unordered. - * @return List of Notes - */ - @NonNull - @WorkerThread - private List getNotesCustom(@NonNull String selection, @NonNull String[] selectionArgs, @Nullable String orderBy) { - return this.getNotesCustom(selection, selectionArgs, orderBy, null); - } - - @NonNull - @WorkerThread - private List getNotesCustom(@NonNull String selection, @NonNull String[] selectionArgs, @Nullable String orderBy, @Nullable String limit) { - SQLiteDatabase db = getReadableDatabase(); - if (selectionArgs.length > 2) { - Log.v("Note", selection + " ---- " + selectionArgs[0] + " " + selectionArgs[1] + " " + selectionArgs[2]); - } - Cursor cursor = db.query(table_notes, columns, selection, selectionArgs, null, null, orderBy, limit); - List notes = new ArrayList<>(); - while (cursor.moveToNext()) { - notes.add(getNoteFromCursor(cursor)); - } - cursor.close(); - return notes; - } - - /** - * Creates a DBNote object from the current row of a Cursor. - * - * @param cursor database cursor - * @return DBNote - */ - @NonNull - private DBNote getNoteFromCursor(@NonNull Cursor cursor) { - Calendar modified = Calendar.getInstance(); - modified.setTimeInMillis(cursor.getLong(4) * 1000); - return new DBNote(cursor.getLong(0), cursor.getLong(1), modified, cursor.getString(3), cursor.getString(5), cursor.getInt(6) > 0, cursor.getString(7), cursor.getString(8), DBStatus.parse(cursor.getString(2))); - } - - public void debugPrintFullDB() { - List notes = getNotesCustom("", new String[]{}, default_order); - Log.v(getClass().getSimpleName(), "Full Database (" + notes.size() + " notes):"); - for (DBNote note : notes) { - Log.v(getClass().getSimpleName(), " " + note); - } - } - - @NonNull - @WorkerThread - public Map getIdMap() { - Map result = new HashMap<>(); - SQLiteDatabase db = getReadableDatabase(); - Cursor cursor = db.query(table_notes, new String[]{key_remote_id, key_id}, key_status + " != ?", new String[]{DBStatus.LOCAL_DELETED.getTitle()}, null, null, null); - while (cursor.moveToNext()) { - result.put(cursor.getLong(0), cursor.getLong(1)); - } - cursor.close(); - return result; - } - - /** - * Returns a list of all Notes in the Database - * - * @return List<Note> - */ - @NonNull - @WorkerThread - public List getNotes() { - return getNotesCustom(key_status + " != ?", new String[]{DBStatus.LOCAL_DELETED.getTitle()}, default_order); - } - - @NonNull - @WorkerThread - public List getRecentNotes() { - return getNotesCustom(key_status + " != ?", new String[]{DBStatus.LOCAL_DELETED.getTitle()}, key_modified + " DESC", "4"); - } - - /** - * Returns a list of all Notes in the Database - * - * @return List<Note> - */ - @NonNull - @WorkerThread - public List searchNotes(@Nullable CharSequence query, @Nullable String category, @Nullable Boolean favorite) { - List where = new ArrayList<>(); - List args = new ArrayList<>(); - - where.add(key_status + " != ?"); - args.add(DBStatus.LOCAL_DELETED.getTitle()); - - if (query != null) { - where.add(key_status + " != ?"); - args.add(DBStatus.LOCAL_DELETED.getTitle()); - - where.add("(" + key_title + " LIKE ? OR " + key_content + " LIKE ? OR " + key_category + " LIKE ?" + ")"); - args.add("%" + query + "%"); - args.add("%" + query + "%"); - args.add("%" + query + "%"); - } - - if (category != null) { - where.add("(" + key_category + "=? OR " + key_category + " LIKE ? )"); - args.add(category); - args.add(category + "/%"); - } - - if (favorite != null) { - where.add(key_favorite + "=?"); - args.add(favorite ? "1" : "0"); - } - - String order = category == null ? default_order : key_category + ", " + key_title; - return getNotesCustom(TextUtils.join(" AND ", where), args.toArray(new String[]{}), order); - } - - /** - * Returns a list of all Notes in the Database with were modified locally - * - * @return List<Note> - */ - @NonNull - @WorkerThread - public List getLocalModifiedNotes() { - return getNotesCustom(key_status + " != ?", new String[]{DBStatus.VOID.getTitle()}, null); - } - - @NonNull - @WorkerThread - public Map getFavoritesCount() { - SQLiteDatabase db = getReadableDatabase(); - Cursor cursor = db.query( - table_notes, - new String[]{key_favorite, "COUNT(*)"}, - key_status + " != ?", - new String[]{DBStatus.LOCAL_DELETED.getTitle()}, - key_favorite, - null, - key_favorite); - Map favorites = new HashMap<>(cursor.getCount()); - while (cursor.moveToNext()) { - favorites.put(cursor.getString(0), cursor.getInt(1)); - } - cursor.close(); - return favorites; - } - - @NonNull - @WorkerThread - public List getCategories() { - SQLiteDatabase db = getReadableDatabase(); - Cursor cursor = db.query( - table_notes, - new String[]{key_category, "COUNT(*)"}, - key_status + " != ?", - new String[]{DBStatus.LOCAL_DELETED.getTitle()}, - key_category, - null, - key_category); - List categories = new ArrayList<>(cursor.getCount()); - while (cursor.moveToNext()) { - categories.add(new NavigationAdapter.NavigationItem("category:" + cursor.getString(0), cursor.getString(0), cursor.getInt(1), NavigationAdapter.ICON_FOLDER)); - } - cursor.close(); - return categories; - } - - public void toggleFavorite(@NonNull DBNote note, @Nullable ICallback callback) { - note.setFavorite(!note.isFavorite()); - note.setStatus(DBStatus.LOCAL_EDITED); - SQLiteDatabase db = this.getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(key_status, note.getStatus().getTitle()); - values.put(key_favorite, note.isFavorite() ? "1" : "0"); - db.update(table_notes, values, key_id + " = ?", new String[]{String.valueOf(note.getId())}); - if (callback != null) { - serverSyncHelper.addCallbackPush(callback); - } - serverSyncHelper.scheduleSync(true); - } - - public void setCategory(@NonNull DBNote note, @NonNull String category, @Nullable ICallback callback) { - note.setCategory(category); - note.setStatus(DBStatus.LOCAL_EDITED); - SQLiteDatabase db = this.getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(key_status, note.getStatus().getTitle()); - values.put(key_category, note.getCategory()); - db.update(table_notes, values, key_id + " = ?", new String[]{String.valueOf(note.getId())}); - if (callback != null) { - serverSyncHelper.addCallbackPush(callback); - } - serverSyncHelper.scheduleSync(true); - } - - /** - * Updates a single Note with a new content. - * The title is derived from the new content automatically, and modified date as well as DBStatus are updated, too -- if the content differs to the state in the database. - * - * @param oldNote Note to be changed - * @param newContent New content. If this is null, then oldNote is saved again (useful for undoing changes). - * @param callback When the synchronization is finished, this callback will be invoked (optional). - * @return changed note if differs from database, otherwise the old note. - */ - public DBNote updateNoteAndSync(@NonNull DBNote oldNote, @Nullable String newContent, @Nullable ICallback callback) { - //debugPrintFullDB(); - DBNote newNote; - if (newContent == null) { - newNote = new DBNote(oldNote.getId(), oldNote.getRemoteId(), oldNote.getModified(), oldNote.getTitle(), oldNote.getContent(), oldNote.isFavorite(), oldNote.getCategory(), oldNote.getEtag(), DBStatus.LOCAL_EDITED); - } else { - newNote = new DBNote(oldNote.getId(), oldNote.getRemoteId(), Calendar.getInstance(), NoteUtil.generateNonEmptyNoteTitle(newContent, getContext()), newContent, oldNote.isFavorite(), oldNote.getCategory(), oldNote.getEtag(), DBStatus.LOCAL_EDITED); - } - SQLiteDatabase db = this.getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(key_status, newNote.getStatus().getTitle()); - values.put(key_title, newNote.getTitle()); - values.put(key_category, newNote.getCategory()); - values.put(key_modified, newNote.getModified().getTimeInMillis() / 1000); - values.put(key_content, newNote.getContent()); - int rows = db.update(table_notes, values, key_id + " = ? AND (" + key_content + " != ? OR " + key_category + " != ?)", new String[]{String.valueOf(newNote.getId()), newNote.getContent(), newNote.getCategory()}); - // if data was changed, set new status and schedule sync (with callback); otherwise invoke callback directly. - if (rows > 0) { - notifyNotesChanged(); - if (callback != null) { - serverSyncHelper.addCallbackPush(callback); - } - serverSyncHelper.scheduleSync(true); - return newNote; - } else { - if (callback != null) { - callback.onFinish(); - } - return oldNote; - } - } - - /** - * Updates a single Note with data from the server, (if it was not modified locally). - * Thereby, an optimistic concurrency control is realized in order to prevent conflicts arising due to parallel changes from the UI and synchronization. - * This is used by the synchronization task, hence no Synchronization will be triggered. Use updateNoteAndSync() instead! - * - * @param id local ID of Note - * @param remoteNote Note from the server. - * @param forceUnchangedDBNoteState is not null, then the local note is updated only if it was not modified meanwhile - * @return The number of the Rows affected. - */ - int updateNote(long id, @NonNull CloudNote remoteNote, @Nullable DBNote forceUnchangedDBNoteState) { - SQLiteDatabase db = this.getWritableDatabase(); - - // First, update the remote ID, since this field cannot be changed in parallel, but have to be updated always. - ContentValues values = new ContentValues(); - values.put(key_remote_id, remoteNote.getRemoteId()); - db.update(table_notes, values, key_id + " = ?", new String[]{String.valueOf(id)}); - - // The other columns have to be updated in dependency of forceUnchangedDBNoteState, - // since the Synchronization-Task must not overwrite locales changes! - values.clear(); - values.put(key_status, DBStatus.VOID.getTitle()); - values.put(key_title, remoteNote.getTitle()); - values.put(key_modified, remoteNote.getModified().getTimeInMillis() / 1000); - values.put(key_content, remoteNote.getContent()); - values.put(key_favorite, remoteNote.isFavorite()); - values.put(key_category, remoteNote.getCategory()); - values.put(key_etag, remoteNote.getEtag()); - String whereClause; - String[] whereArgs; - if (forceUnchangedDBNoteState != null) { - // used by: NoteServerSyncHelper.SyncTask.pushLocalChanges() - // update only, if not modified locally during the synchronization - // (i.e. all (!) user changeable columns (content, favorite) should still have the same value), - // uses reference value gathered at start of synchronization - whereClause = key_id + " = ? AND " + key_content + " = ? AND " + key_favorite + " = ? AND " + key_category + " = ?"; - whereArgs = new String[]{String.valueOf(id), forceUnchangedDBNoteState.getContent(), forceUnchangedDBNoteState.isFavorite() ? "1" : "0", forceUnchangedDBNoteState.getCategory()}; - } else { - // used by: NoteServerSyncHelper.SyncTask.pullRemoteChanges() - // update only, if not modified locally (i.e. STATUS="") and if modified remotely (i.e. any (!) column has changed) - whereClause = key_id + " = ? AND " + key_status + " = ? AND (" + key_modified + "!=? OR " + key_title + "!=? OR " + key_favorite + "!=? OR " + key_category + "!=? OR " + (remoteNote.getEtag() != null ? key_etag + " IS NULL OR " : "") + key_etag + "!=? OR " + key_content + "!=?)"; - whereArgs = new String[]{String.valueOf(id), DBStatus.VOID.getTitle(), Long.toString(remoteNote.getModified().getTimeInMillis() / 1000), remoteNote.getTitle(), remoteNote.isFavorite() ? "1" : "0", remoteNote.getCategory(), remoteNote.getEtag(), remoteNote.getContent()}; - } - int i = db.update(table_notes, values, whereClause, whereArgs); - Log.d(getClass().getSimpleName(), "updateNote: " + remoteNote + " || forceUnchangedDBNoteState: " + forceUnchangedDBNoteState + " => " + i + " rows updated"); - return i; - } - - /** - * Marks a Note in the Database as Deleted. In the next Synchronization it will be deleted - * from the Server. - * - * @param id long - ID of the Note that should be deleted - * @return Affected rows - */ - @SuppressWarnings("UnusedReturnValue") - public int deleteNoteAndSync(long id) { - SQLiteDatabase db = this.getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(key_status, DBStatus.LOCAL_DELETED.getTitle()); - int i = db.update(table_notes, - values, - key_id + " = ?", - new String[]{String.valueOf(id)}); - notifyNotesChanged(); - getNoteServerSyncHelper().scheduleSync(true); - - if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class); - shortcutManager.getPinnedShortcuts().forEach((shortcut) -> { - String shortcutId = id + ""; - if (shortcut.getId().equals(shortcutId)) { - Log.v(NoteSQLiteOpenHelper.class.getSimpleName(), "Removing shortcut for " + shortcutId); - shortcutManager.disableShortcuts(Collections.singletonList(shortcutId), context.getResources().getString(R.string.note_has_been_deleted)); - } - }); - } - return i; - } - - /** - * Delete a single Note from the Database, if it has a specific DBStatus. - * Thereby, an optimistic concurrency control is realized in order to prevent conflicts arising due to parallel changes from the UI and synchronization. - * - * @param id long - ID of the Note that should be deleted. - * @param forceDBStatus DBStatus, e.g., if Note was marked as LOCAL_DELETED (for NoteSQLiteOpenHelper.SyncTask.pushLocalChanges()) or is unchanged VOID (for NoteSQLiteOpenHelper.SyncTask.pullRemoteChanges()) - */ - void deleteNote(long id, @NonNull DBStatus forceDBStatus) { - SQLiteDatabase db = this.getWritableDatabase(); - db.delete(table_notes, - key_id + " = ? AND " + key_status + " = ?", - new String[]{String.valueOf(id), forceDBStatus.getTitle()}); - } - - /** - * Notify about changed notes. - */ - void notifyNotesChanged() { - updateSingleNoteWidgets(); - updateNoteListWidgets(); - } - - /** - * Update single note widget, if the note data was changed. - */ - private void updateSingleNoteWidgets() { - Intent intent = new Intent(getContext(), SingleNoteWidget.class); - intent.setAction("android.appwidget.action.APPWIDGET_UPDATE"); - getContext().sendBroadcast(intent); - } - - /** - * Update note list widgets, if the note data was changed. - */ - private void updateNoteListWidgets() { - Intent intent = new Intent(getContext(), NoteListWidget.class); - intent.setAction("android.appwidget.action.APPWIDGET_UPDATE"); - getContext().sendBroadcast(intent); - } -} diff --git a/app/src/main/java/foundation/e/notes/persistence/NoteServerSyncHelper.java b/app/src/main/java/foundation/e/notes/persistence/NoteServerSyncHelper.java deleted file mode 100644 index e66f91a3ffa2a58069c46d3614134b744b42d5e7..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/persistence/NoteServerSyncHelper.java +++ /dev/null @@ -1,499 +0,0 @@ -package foundation.e.notes.persistence; - -import android.accounts.AccountManager; -import android.content.BroadcastReceiver; -import android.content.ComponentName; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.ServiceConnection; -import android.content.SharedPreferences; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.os.AsyncTask; -import android.os.IBinder; -import android.os.RemoteException; -import android.preference.PreferenceManager; -import android.util.Log; -import android.widget.Toast; - -import org.json.JSONException; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import foundation.e.cert4android.CustomCertManager; -import foundation.e.cert4android.CustomCertService; -import foundation.e.cert4android.ICustomCertService; -import foundation.e.cert4android.IOnCertificateDecision; -import foundation.e.notes.android.activity.SettingsActivity; -import foundation.e.notes.model.CloudNote; -import foundation.e.notes.model.DBNote; -import foundation.e.notes.model.DBStatus; -import foundation.e.notes.util.ICallback; -import foundation.e.notes.util.NotesClient; -import foundation.e.notes.util.NotesClientUtil; -import foundation.e.notes.util.ServerResponse; -import foundation.e.notes.util.SupportUtil; -import foundation.e.notes.R; - -/** - * @author Nihar Thakkar - * - * Helps to synchronize the Database to the Server. - */ -public class NoteServerSyncHelper { - - private static NoteServerSyncHelper instance; - - private static final String eelo_account_type = "e.foundation.webdav.eelo"; - private static final String account_email_address_key = "email_address"; - private static final String notes_content_authority = "foundation.e.notes.android.providers.AppContentProvider"; - - /** - * Get (or create) instance from NoteServerSyncHelper. - * This has to be a singleton in order to realize correct registering and unregistering of - * the BroadcastReceiver, which listens on changes of network connectivity. - * - * @param dbHelper NoteSQLiteOpenHelper - * @return NoteServerSyncHelper - */ - public static synchronized NoteServerSyncHelper getInstance(NoteSQLiteOpenHelper dbHelper) { - if (instance == null) { - instance = new NoteServerSyncHelper(dbHelper); - } - return instance; - } - - private final NoteSQLiteOpenHelper dbHelper; - private final Context appContext; - - private CustomCertManager customCertManager; - private ICustomCertService iCustomCertService; - - // Track network connection changes using a BroadcastReceiver - private boolean networkConnected = false; - private String syncOnlyOnWifiKey; - private boolean syncOnlyOnWifi; - - /** - * @see Do not make this a local variable. - */ - private SharedPreferences.OnSharedPreferenceChangeListener onSharedPreferenceChangeListener = (SharedPreferences prefs, String key) -> { - if (syncOnlyOnWifiKey.equals(key)) { - syncOnlyOnWifi = prefs.getBoolean(syncOnlyOnWifiKey, false); - updateNetworkStatus(); - } - }; - - private final BroadcastReceiver networkReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - updateNetworkStatus(); - if (isSyncPossible()) { - scheduleSync(false); - } - } - }; - - private boolean cert4androidReady = false; - private final ServiceConnection certService = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName componentName, IBinder iBinder) { - iCustomCertService = ICustomCertService.Stub.asInterface(iBinder); - cert4androidReady = true; - if (isSyncPossible()) { - scheduleSync(false); - } - } - - @Override - public void onServiceDisconnected(ComponentName componentName) { - cert4androidReady = false; - iCustomCertService = null; - } - }; - - // current state of the synchronization - private boolean syncActive = false; - private boolean syncScheduled = false; - - // list of callbacks for both parts of synchronziation - private List callbacksPush = new ArrayList<>(); - private List callbacksPull = new ArrayList<>(); - - - private NoteServerSyncHelper(NoteSQLiteOpenHelper db) { - this.dbHelper = db; - this.appContext = db.getContext().getApplicationContext(); - this.syncOnlyOnWifiKey = appContext.getResources().getString(R.string.pref_key_wifi_only); - new Thread() { - @Override - public void run() { - customCertManager = SupportUtil.getCertManager(appContext); - } - }.start(); - - // Registers BroadcastReceiver to track network connection changes. - appContext.registerReceiver(networkReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this.appContext); - prefs.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener); - syncOnlyOnWifi = prefs.getBoolean(syncOnlyOnWifiKey, false); - - updateNetworkStatus(); - // bind to certifciate service to block sync attempts if service is not ready - appContext.bindService(new Intent(appContext, CustomCertService.class), certService, Context.BIND_AUTO_CREATE); - } - - @Override - protected void finalize() throws Throwable { - appContext.unregisterReceiver(networkReceiver); - appContext.unbindService(certService); - if (customCertManager != null) { - customCertManager.close(); - } - super.finalize(); - } - - public static boolean isConfigured(Context context) { - return !PreferenceManager.getDefaultSharedPreferences(context).getString(SettingsActivity.SETTINGS_URL, SettingsActivity.DEFAULT_SETTINGS).isEmpty(); - } - - private android.accounts.Account[] getEeloAccountsOnDevice(AccountManager accountManager) { - return accountManager.getAccountsByType( - eelo_account_type); - } - - public boolean isSyncEnabled() { - AccountManager accountManager = AccountManager.get(appContext); - boolean isEeloAccount = false; - - try { - android.accounts.Account[] eeloAccounts = getEeloAccountsOnDevice(accountManager); - - for (android.accounts.Account eeloAccount : eeloAccounts) { - String emailId = accountManager.getUserData(eeloAccount, - account_email_address_key); - if (PreferenceManager.getDefaultSharedPreferences(appContext).getString( - SettingsActivity.SETTINGS_USERNAME, SettingsActivity.DEFAULT_SETTINGS) - .equals(emailId)) { - isEeloAccount = true; - if (ContentResolver.getSyncAutomatically(eeloAccount, - notes_content_authority)) { - return true; - } - } - } - } - catch (SecurityException e) { - e.printStackTrace(); - } - - if (isEeloAccount) { - return false; - } - return true; - } - - /** - * Synchronization is only possible, if there is an active network connection and - * Cert4Android service is available. - * NoteServerSyncHelper observes changes in the network connection. - * The current state can be retrieved with this method. - * - * @return true if sync is possible, otherwise false. - */ - public boolean isSyncPossible() { - return networkConnected && isConfigured(appContext) && cert4androidReady; - } - - public CustomCertManager getCustomCertManager() { - return customCertManager; - } - - public void checkCertificate(byte[] cert, boolean foreground, IOnCertificateDecision callback) throws RemoteException { - iCustomCertService.checkTrusted(cert, true, foreground, callback); - } - - /** - * Adds a callback method to the NoteServerSyncHelper for the synchronization part push local changes to the server. - * All callbacks will be executed once the synchronization operations are done. - * After execution the callback will be deleted, so it has to be added again if it shall be - * executed the next time all synchronize operations are finished. - * - * @param callback Implementation of ICallback, contains one method that shall be executed. - */ - public void addCallbackPush(ICallback callback) { - callbacksPush.add(callback); - } - - /** - * Adds a callback method to the NoteServerSyncHelper for the synchronization part pull remote changes from the server. - * All callbacks will be executed once the synchronization operations are done. - * After execution the callback will be deleted, so it has to be added again if it shall be - * executed the next time all synchronize operations are finished. - * - * @param callback Implementation of ICallback, contains one method that shall be executed. - */ - public void addCallbackPull(ICallback callback) { - callbacksPull.add(callback); - } - - - /** - * Schedules a synchronization and start it directly, if the network is connected and no - * synchronization is currently running. - * - * @param onlyLocalChanges Whether to only push local changes to the server or to also load the whole list of notes from the server. - */ - public void scheduleSync(boolean onlyLocalChanges) { - Log.d(getClass().getSimpleName(), "Sync requested (" + (onlyLocalChanges ? "onlyLocalChanges" : "full") + "; " + (syncActive ? "sync active" : "sync NOT active") + ") ..."); - Log.d(getClass().getSimpleName(), "(network:" + networkConnected + "; conf:" + isConfigured(appContext) + "; cert4android:" + cert4androidReady + ")"); - if (isSyncEnabled()) { - if (isSyncPossible() && (!syncActive || onlyLocalChanges)) { - Log.d(getClass().getSimpleName(), "... starting now"); - SyncTask syncTask = new SyncTask(onlyLocalChanges); - syncTask.addCallbacks(callbacksPush); - callbacksPush = new ArrayList<>(); - if (!onlyLocalChanges) { - syncTask.addCallbacks(callbacksPull); - callbacksPull = new ArrayList<>(); - } - syncTask.execute(); - } else if (!onlyLocalChanges) { - Log.d(getClass().getSimpleName(), "... scheduled"); - syncScheduled = true; - for (ICallback callback : callbacksPush) { - callback.onScheduled(); - } - } else { - Log.d(getClass().getSimpleName(), "... do nothing"); - for (ICallback callback : callbacksPush) { - callback.onScheduled(); - } - } - } - } - - private void updateNetworkStatus() { - ConnectivityManager connMgr = (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo activeInfo = connMgr.getActiveNetworkInfo(); - - if (activeInfo != null && activeInfo.isConnected()) { - networkConnected = - !syncOnlyOnWifi || ((ConnectivityManager) appContext - .getSystemService(Context.CONNECTIVITY_SERVICE)) - .getNetworkInfo(ConnectivityManager.TYPE_WIFI).isConnected(); - - if (networkConnected) { - Log.d(NoteServerSyncHelper.class.getSimpleName(), "Network connection established."); - } else { - Log.d(NoteServerSyncHelper.class.getSimpleName(), "Network connected, but not used because only synced on wifi."); - } - } else { - networkConnected = false; - Log.d(NoteServerSyncHelper.class.getSimpleName(), "No network connection."); - } - } - - /** - * SyncTask is an AsyncTask which performs the synchronization in a background thread. - * Synchronization consists of two parts: pushLocalChanges and pullRemoteChanges. - */ - private class SyncTask extends AsyncTask { - private final boolean onlyLocalChanges; - private final List callbacks = new ArrayList<>(); - private NotesClient client; - private List exceptions = new ArrayList<>(); - - public SyncTask(boolean onlyLocalChanges) { - this.onlyLocalChanges = onlyLocalChanges; - } - - public void addCallbacks(List callbacks) { - this.callbacks.addAll(callbacks); - } - - @Override - protected void onPreExecute() { - super.onPreExecute(); - if (!onlyLocalChanges && syncScheduled) { - syncScheduled = false; - } - syncActive = true; - } - - @Override - protected NotesClientUtil.LoginStatus doInBackground(Void... voids) { - client = createNotesClient(); // recreate NoteClients on every sync in case the connection settings was changed - Log.i(getClass().getSimpleName(), "STARTING SYNCHRONIZATION"); - //dbHelper.debugPrintFullDB(); - NotesClientUtil.LoginStatus status = NotesClientUtil.LoginStatus.OK; - pushLocalChanges(); - if (!onlyLocalChanges) { - status = pullRemoteChanges(); - } - //dbHelper.debugPrintFullDB(); - Log.i(getClass().getSimpleName(), "SYNCHRONIZATION FINISHED"); - return status; - } - - /** - * Push local changes: for each locally created/edited/deleted Note, use NotesClient in order to push the changed to the server. - */ - private void pushLocalChanges() { - Log.d(getClass().getSimpleName(), "pushLocalChanges()"); - List notes = dbHelper.getLocalModifiedNotes(); - for (DBNote note : notes) { - Log.d(getClass().getSimpleName(), " Process Local Note: " + note); - try { - CloudNote remoteNote = null; - switch (note.getStatus()) { - case LOCAL_EDITED: - Log.v(getClass().getSimpleName(), " ...create/edit"); - // if note is not new, try to edit it. - if (note.getRemoteId() > 0) { - Log.v(getClass().getSimpleName(), " ...try to edit"); - try { - remoteNote = client.editNote(customCertManager, note).getNote(); - } catch (FileNotFoundException e) { - // Note does not exists anymore - } - } - // However, the note may be deleted on the server meanwhile; or was never synchronized -> (re)create - // Please note, thas dbHelper.updateNote() realizes an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. - if (remoteNote == null) { - Log.v(getClass().getSimpleName(), " ...Note does not exist on server -> (re)create"); - remoteNote = client.createNote(customCertManager, note).getNote(); - } - dbHelper.updateNote(note.getId(), remoteNote, note); - break; - case LOCAL_DELETED: - if (note.getRemoteId() > 0) { - Log.v(getClass().getSimpleName(), " ...delete (from server and local)"); - try { - client.deleteNote(customCertManager, note.getRemoteId()); - } catch (FileNotFoundException e) { - Log.v(getClass().getSimpleName(), " ...Note does not exist on server (anymore?) -> delete locally"); - } - } else { - Log.v(getClass().getSimpleName(), " ...delete (only local, since it was not synchronized)"); - } - // Please note, thas dbHelper.deleteNote() realizes an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. - dbHelper.deleteNote(note.getId(), DBStatus.LOCAL_DELETED); - break; - default: - throw new IllegalStateException("Unknown State of Note: " + note); - } - } catch (IOException | JSONException e) { - Log.e(getClass().getSimpleName(), "Exception", e); - exceptions.add(e); - } - } - } - - /** - * Pull remote Changes: update or create each remote note (if local pendant has no changes) and remove remotely deleted notes. - */ - private NotesClientUtil.LoginStatus pullRemoteChanges() { - Log.d(getClass().getSimpleName(), "pullRemoteChanges()"); - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(appContext); - String lastETag = preferences.getString(SettingsActivity.SETTINGS_KEY_ETAG, null); - long lastModified = preferences.getLong(SettingsActivity.SETTINGS_KEY_LAST_MODIFIED, 0); - NotesClientUtil.LoginStatus status; - try { - Map idMap = dbHelper.getIdMap(); - ServerResponse.NotesResponse response = client.getNotes(customCertManager, lastModified, lastETag); - List remoteNotes = response.getNotes(); - Set remoteIDs = new HashSet<>(); - // pull remote changes: update or create each remote note - for (CloudNote remoteNote : remoteNotes) { - Log.v(getClass().getSimpleName(), " Process Remote Note: " + remoteNote); - remoteIDs.add(remoteNote.getRemoteId()); - if (remoteNote.getModified() == null) { - Log.v(getClass().getSimpleName(), " ... unchanged"); - } else if (idMap.containsKey(remoteNote.getRemoteId())) { - Log.v(getClass().getSimpleName(), " ... found -> Update"); - dbHelper.updateNote(idMap.get(remoteNote.getRemoteId()), remoteNote, null); - } else { - Log.v(getClass().getSimpleName(), " ... create"); - dbHelper.addNote(remoteNote); - } - } - Log.d(getClass().getSimpleName(), " Remove remotely deleted Notes (only those without local changes)"); - // remove remotely deleted notes (only those without local changes) - for (Map.Entry entry : idMap.entrySet()) { - if (!remoteIDs.contains(entry.getKey())) { - Log.v(getClass().getSimpleName(), " ... remove " + entry.getValue()); - dbHelper.deleteNote(entry.getValue(), DBStatus.VOID); - } - } - status = NotesClientUtil.LoginStatus.OK; - - // update ETag and Last-Modified in order to reduce size of next response - SharedPreferences.Editor editor = preferences.edit(); - String etag = response.getETag(); - if (etag != null && !etag.isEmpty()) { - editor.putString(SettingsActivity.SETTINGS_KEY_ETAG, etag); - } else { - editor.remove(SettingsActivity.SETTINGS_KEY_ETAG); - } - long modified = response.getLastModified(); - if (modified != 0) { - editor.putLong(SettingsActivity.SETTINGS_KEY_LAST_MODIFIED, modified); - } else { - editor.remove(SettingsActivity.SETTINGS_KEY_LAST_MODIFIED); - } - editor.apply(); - } catch (ServerResponse.NotModifiedException e) { - Log.d(getClass().getSimpleName(), "No changes, nothing to do."); - status = NotesClientUtil.LoginStatus.OK; - } catch (IOException e) { - Log.e(getClass().getSimpleName(), "Exception", e); - exceptions.add(e); - status = NotesClientUtil.LoginStatus.CONNECTION_FAILED; - } catch (JSONException e) { - Log.e(getClass().getSimpleName(), "Exception", e); - exceptions.add(e); - status = NotesClientUtil.LoginStatus.JSON_FAILED; - } - return status; - } - - @Override - protected void onPostExecute(NotesClientUtil.LoginStatus status) { - super.onPostExecute(status); - if (status != NotesClientUtil.LoginStatus.OK) { - Toast.makeText(appContext, appContext.getString(foundation.e.notes.R.string.error_sync, appContext.getString(status.str)), Toast.LENGTH_LONG).show(); - for (Throwable e : exceptions) { - Toast.makeText(appContext, e.getClass().getName() + ": " + e.getMessage(), Toast.LENGTH_LONG).show(); - } - } - syncActive = false; - // notify callbacks - for (ICallback callback : callbacks) { - callback.onFinish(); - } - dbHelper.notifyNotesChanged(); - // start next sync if scheduled meanwhile - if (syncScheduled) { - scheduleSync(false); - } - } - } - - private NotesClient createNotesClient() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(appContext.getApplicationContext()); - String url = preferences.getString(SettingsActivity.SETTINGS_URL, SettingsActivity.DEFAULT_SETTINGS); - String username = preferences.getString(SettingsActivity.SETTINGS_USERNAME, SettingsActivity.DEFAULT_SETTINGS); - String password = preferences.getString(SettingsActivity.SETTINGS_PASSWORD, SettingsActivity.DEFAULT_SETTINGS); - return new NotesClient(url, username, password); - } -} diff --git a/app/src/main/java/foundation/e/notes/util/DisplayUtils.java b/app/src/main/java/foundation/e/notes/util/DisplayUtils.java deleted file mode 100644 index 7efc13e22b50aa51b44bc30a3469bbea7920b45a..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/DisplayUtils.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Nextcloud Notes application - * - * @author Mario Danic - * Copyright (C) 2018 Mario Danic - * - * 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. - * - * 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. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package foundation.e.notes.util; - -import android.graphics.Typeface; -import android.text.Spannable; -import android.text.TextUtils; -import android.text.style.CharacterStyle; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import androidx.annotation.ColorInt; - -public class DisplayUtils { - - public static Spannable searchAndColor(String text, Spannable spannable, String searchText, @ColorInt int color) { - - Object spansToRemove[] = spannable.getSpans(0, text.length(), Object.class); - for(Object span: spansToRemove){ - if(span instanceof CharacterStyle) - spannable.removeSpan(span); - } - - if (TextUtils.isEmpty(text) || TextUtils.isEmpty(searchText)) { - return spannable; - } - - Matcher m = Pattern.compile(searchText, Pattern.CASE_INSENSITIVE | Pattern.LITERAL) - .matcher(text); - - - while (m.find()) { - int start = m.start(); - int end = m.end(); - spannable.setSpan(new ForegroundColorSpan(color), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - spannable.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - } - - return spannable; - } - -} diff --git a/app/src/main/java/foundation/e/notes/util/ExceptionHandler.java b/app/src/main/java/foundation/e/notes/util/ExceptionHandler.java deleted file mode 100644 index 830dc7904387037deaca658dcd5de3b5b465db75..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/ExceptionHandler.java +++ /dev/null @@ -1,35 +0,0 @@ -package foundation.e.notes.util; - -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; - -import java.io.Serializable; - -import foundation.e.notes.android.activity.ExceptionActivity; - -import static foundation.e.notes.android.activity.ExceptionActivity.KEY_THROWABLE; - -public class ExceptionHandler implements Thread.UncaughtExceptionHandler { - - private Activity context; - - public ExceptionHandler(Activity context) { - super(); - this.context = context; - } - - @Override - public void uncaughtException(Thread t, Throwable e) { - e.printStackTrace(); - Intent intent = new Intent(context.getApplicationContext(), ExceptionActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - Bundle extras = new Bundle(); - intent.putExtra(KEY_THROWABLE, (Serializable) e); - extras.putSerializable(KEY_THROWABLE, e); - intent.putExtras(extras); - context.getApplicationContext().startActivity(intent); - context.finish(); - Runtime.getRuntime().exit(0); - } -} diff --git a/app/src/main/java/foundation/e/notes/util/ICallback.java b/app/src/main/java/foundation/e/notes/util/ICallback.java deleted file mode 100644 index 8b2f332aa2dada703a61659517c491dca63afc33..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/ICallback.java +++ /dev/null @@ -1,11 +0,0 @@ -package foundation.e.notes.util; - -/** - * Callback - * Created by stefan on 01.10.15. - */ -public interface ICallback { - void onFinish(); - - void onScheduled(); -} diff --git a/app/src/main/java/foundation/e/notes/util/MarkDownUtil.java b/app/src/main/java/foundation/e/notes/util/MarkDownUtil.java deleted file mode 100644 index d603a2b7bb17622118b7e3f4d8eef0ad05a96b23..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/MarkDownUtil.java +++ /dev/null @@ -1,50 +0,0 @@ -package foundation.e.notes.util; - -import android.content.Context; - -import androidx.core.content.ContextCompat; -import androidx.core.content.res.ResourcesCompat; - -import com.yydcdut.rxmarkdown.RxMDConfiguration; -import com.yydcdut.rxmarkdown.RxMDConfiguration.Builder; - -import foundation.e.notes.R; - -/** - * Created by stefan on 07.12.16. - */ - -public class MarkDownUtil { - - /** - * Ensures every instance of RxMD uses the same configuration - * - * @param context Context - * @return RxMDConfiguration - */ - public static Builder getMarkDownConfiguration(Context context) { - return new RxMDConfiguration.Builder(context) - .setUnOrderListColor(ContextCompat.getColor(context, R.color.color_default_secondary_text)) - .setCodeBgColor(ContextCompat.getColor(context, R.color.color_default_primary_text)) - .setHeader2RelativeSize(1.35f) - .setHeader3RelativeSize(1.25f) - .setHeader4RelativeSize(1.15f) - .setHeader5RelativeSize(1.1f) - .setHeader6RelativeSize(1.05f) - .setHorizontalRulesHeight(2) - .setLinkFontColor(ContextCompat.getColor(context, R.color.color_default_primary_text)); - } - - public static Builder getMarkDownConfiguration(Context context, Boolean darkTheme) { - return new RxMDConfiguration.Builder(context) - .setUnOrderListColor(ResourcesCompat.getColor(context.getResources(), - darkTheme ? R.color.widget_fg_dark_theme : R.color.widget_fg_default, null)) - .setHeader2RelativeSize(1.35f) - .setHeader3RelativeSize(1.25f) - .setHeader4RelativeSize(1.15f) - .setHeader5RelativeSize(1.1f) - .setHeader6RelativeSize(1.05f) - .setHorizontalRulesHeight(2) - .setLinkFontColor(ResourcesCompat.getColor(context.getResources(), R.color.color_default_primary_text, null)); - } -} diff --git a/app/src/main/java/foundation/e/notes/util/NoteUtil.java b/app/src/main/java/foundation/e/notes/util/NoteUtil.java deleted file mode 100644 index a4537f0fd2846ecfd81114a57a7a08adaf90e627..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/NoteUtil.java +++ /dev/null @@ -1,136 +0,0 @@ -package foundation.e.notes.util; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.regex.Pattern; - -import foundation.e.notes.R; - -/** - * Provides basic functionality for Note operations. - * Created by stefan on 06.10.15. - */ -public class NoteUtil { - private static final Pattern pLists = Pattern.compile("^\\s*[*+-]\\s+", Pattern.MULTILINE); - private static final Pattern pHeadings = Pattern.compile("^#+\\s+(.*?)\\s*#*$", Pattern.MULTILINE); - private static final Pattern pHeadingLine = Pattern.compile("^(?:=*|-*)$", Pattern.MULTILINE); - private static final Pattern pEmphasis = Pattern.compile("(\\*+|_+)(.*?)\\1", Pattern.MULTILINE); - private static final Pattern pSpace1 = Pattern.compile("^\\s+", Pattern.MULTILINE); - private static final Pattern pSpace2 = Pattern.compile("\\s+$", Pattern.MULTILINE); - - - /** - * Strips all MarkDown from the given String - * - * @param s String - MarkDown - * @return Plain Text-String - */ - @NonNull - public static String removeMarkDown(@Nullable String s) { - if (s == null) - return ""; - s = pLists.matcher(s).replaceAll(""); - s = pHeadings.matcher(s).replaceAll("$1"); - s = pHeadingLine.matcher(s).replaceAll(""); - s = pEmphasis.matcher(s).replaceAll("$2"); - s = pSpace1.matcher(s).replaceAll(""); - s = pSpace2.matcher(s).replaceAll(""); - return s; - } - - /** - * Checks if a line is empty. - *

-     * " "    -> empty
-     * "\n"   -> empty
-     * "\n "  -> empty
-     * " \n"  -> empty
-     * " \n " -> empty
-     * 
- * - * @param line String - a single Line which ends with \n - * @return boolean isEmpty - */ - private static boolean isEmptyLine(@Nullable String line) { - return removeMarkDown(line).trim().length() == 0; - } - - /** - * Truncates a string to a desired maximum length. - * Like String.substring(int,int), but throw no exception if desired length is longer than the string. - * - * @param str String to truncate - * @param len Maximum length of the resulting string - * @return truncated string - */ - @NonNull - private static String truncateString(@NonNull String str, int len) { - return str.substring(0, Math.min(len, str.length())); - } - - /** - * Generates an excerpt of a content String (reads second line which is not empty) - * - * @param content String - * @return excerpt String - */ - @NonNull - public static String generateNoteExcerpt(@NonNull String content) { - if (content.contains("\n")) - return truncateString(removeMarkDown(content.replaceFirst("^.*\n", "")), 200).replace("\n", " "); - else - return ""; - } - - @NonNull - public static String generateNonEmptyNoteTitle(@NonNull String content, Context context) { - String title = generateNoteTitle(content); - if (title.isEmpty()) { - title = context.getString(R.string.action_create); - } - return title; - } - - /** - * Generates a title of a content String (reads fist linew which is not empty) - * - * @param content String - * @return excerpt String - */ - @NonNull - static String generateNoteTitle(@NonNull String content) { - return getLineWithoutMarkDown(content, 0); - } - - /** - * Reads the requested line and strips all MarkDown. If line is empty, it will go ahead to find the next not-empty line. - * - * @param content String - * @param lineNumber int - * @return lineContent String - */ - @NonNull - private static String getLineWithoutMarkDown(@NonNull String content, int lineNumber) { - String line = ""; - if (content.contains("\n")) { - String[] lines = content.split("\n"); - int currentLine = lineNumber; - while (currentLine < lines.length && NoteUtil.isEmptyLine(lines[currentLine])) { - currentLine++; - } - if (currentLine < lines.length) { - line = NoteUtil.removeMarkDown(lines[currentLine]); - } - } else { - line = content; - } - return line; - } - - @NonNull - public static String extendCategory(@NonNull String category) { - return category.replace("/", " / "); - } -} diff --git a/app/src/main/java/foundation/e/notes/util/Notes.java b/app/src/main/java/foundation/e/notes/util/Notes.java deleted file mode 100644 index d96c39a2dfce0c1b8d060c26ca90efa8d5835aff..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/Notes.java +++ /dev/null @@ -1,13 +0,0 @@ -package foundation.e.notes.util; - -import android.app.Application; -import android.content.Context; -import android.content.res.Configuration; - -public class Notes extends Application { - - public static boolean getAppTheme(Context context) { - int nightModeFlags = context.getApplicationContext().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - return nightModeFlags == Configuration.UI_MODE_NIGHT_YES; - } -} diff --git a/app/src/main/java/foundation/e/notes/util/NotesClient.java b/app/src/main/java/foundation/e/notes/util/NotesClient.java deleted file mode 100644 index a588c842883843a701ea3d7c5ca83f751b59688e..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/NotesClient.java +++ /dev/null @@ -1,189 +0,0 @@ -package foundation.e.notes.util; - -import androidx.annotation.WorkerThread; -import android.util.Base64; -import android.util.Log; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.MalformedURLException; - -import foundation.e.cert4android.CustomCertManager; -import foundation.e.notes.model.CloudNote; -import foundation.e.notes.BuildConfig; -import foundation.e.notes.model.CloudNote; -import foundation.e.notes.util.ServerResponse.NoteResponse; -import foundation.e.notes.util.ServerResponse.NotesResponse; - -@WorkerThread -public class NotesClient { - - /** - * This entity class is used to return relevant data of the HTTP reponse. - */ - public static class ResponseData { - private final String content; - private final String etag; - private final long lastModified; - - public ResponseData(String content, String etag, long lastModified) { - this.content = content; - this.etag = etag; - this.lastModified = lastModified; - } - - public String getContent() { - return content; - } - - public String getETag() { - return etag; - } - - public long getLastModified() { - return lastModified; - } - } - - public static final String METHOD_GET = "GET"; - public static final String METHOD_PUT = "PUT"; - public static final String METHOD_POST = "POST"; - public static final String METHOD_DELETE = "DELETE"; - public static final String JSON_ID = "id"; - public static final String JSON_TITLE = "title"; - public static final String JSON_CONTENT = "content"; - public static final String JSON_FAVORITE = "favorite"; - public static final String JSON_CATEGORY = "category"; - public static final String JSON_ETAG = "etag"; - public static final String JSON_MODIFIED = "modified"; - private static final String application_json = "application/json"; - private String url = ""; - private String username = ""; - private String password = ""; - - public NotesClient(String url, String username, String password) { - this.url = url; - this.username = username; - this.password = password; - } - - public NotesResponse getNotes(CustomCertManager ccm, long lastModified, String lastETag) throws JSONException, IOException { - String url = "notes"; - if (lastModified > 0) { - url += "?pruneBefore=" + lastModified; - } - return new NotesResponse(requestServer(ccm, url, METHOD_GET, null, lastETag)); - } - - /** - * Fetches a Note by ID from Server - * - * @param id long - ID of the wanted note - * @return Requested Note - * @throws JSONException - * @throws IOException - */ - @SuppressWarnings("unused") - public NoteResponse getNoteById(CustomCertManager ccm, long id) throws JSONException, IOException { - return new NoteResponse(requestServer(ccm, "notes/" + id, METHOD_GET, null, null)); - } - - private NoteResponse putNote(CustomCertManager ccm, CloudNote note, String path, String method) throws JSONException, IOException { - JSONObject paramObject = new JSONObject(); - paramObject.accumulate(JSON_CONTENT, note.getContent()); - paramObject.accumulate(JSON_MODIFIED, note.getModified().getTimeInMillis() / 1000); - paramObject.accumulate(JSON_FAVORITE, note.isFavorite()); - paramObject.accumulate(JSON_CATEGORY, note.getCategory()); - return new NoteResponse(requestServer(ccm, path, method, paramObject, null)); - } - - - /** - * Creates a Note on the Server - * - * @param note {@link CloudNote} - the new Note - * @return Created Note including generated Title, ID and lastModified-Date - * @throws JSONException - * @throws IOException - */ - public NoteResponse createNote(CustomCertManager ccm, CloudNote note) throws JSONException, IOException { - return putNote(ccm, note, "notes", METHOD_POST); - } - - public NoteResponse editNote(CustomCertManager ccm, CloudNote note) throws JSONException, IOException { - return putNote(ccm, note, "notes/" + note.getRemoteId(), METHOD_PUT); - } - - public void deleteNote(CustomCertManager ccm, long noteId) throws IOException { - this.requestServer(ccm, "notes/" + noteId, METHOD_DELETE, null, null); - } - - /** - * Request-Method for POST, PUT with or without JSON-Object-Parameter - * - * @param target Filepath to the wanted function - * @param method GET, POST, DELETE or PUT - * @param params JSON Object which shall be transferred to the server. - * @return Body of answer - * @throws MalformedURLException - * @throws IOException - */ - private ResponseData requestServer(CustomCertManager ccm, String target, String method, JSONObject params, String lastETag) - throws IOException { - StringBuffer result = new StringBuffer(); - // setup connection - String targetURL = url + "index.php/apps/notes/api/v0.2/" + target; - HttpURLConnection con = SupportUtil.getHttpURLConnection(ccm, targetURL); - con.setRequestMethod(method); - con.setRequestProperty( - "Authorization", - "Basic " + Base64.encodeToString((username + ":" + password).getBytes(), Base64.NO_WRAP)); - // https://github.com/square/retrofit/issues/805#issuecomment-93426183 - con.setRequestProperty( "Connection", "Close"); - con.setRequestProperty("User-Agent", "nextcloud-notes/" + BuildConfig.VERSION_NAME + " (Android)"); - if (lastETag != null && METHOD_GET.equals(method)) { - con.setRequestProperty("If-None-Match", lastETag); - } - con.setConnectTimeout(10 * 1000); // 10 seconds - Log.d(getClass().getSimpleName(), method + " " + targetURL); - // send request data (optional) - byte[] paramData = null; - if (params != null) { - paramData = params.toString().getBytes(); - Log.d(getClass().getSimpleName(), "Params: " + params); - con.setFixedLengthStreamingMode(paramData.length); - con.setRequestProperty("Content-Type", application_json); - con.setDoOutput(true); - OutputStream os = con.getOutputStream(); - os.write(paramData); - os.flush(); - os.close(); - } - // read response data - int responseCode = con.getResponseCode(); - Log.d(getClass().getSimpleName(), "HTTP response code: " + responseCode); - - if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) { - throw new ServerResponse.NotModifiedException(); - } - - BufferedReader rd = new BufferedReader(new InputStreamReader(con.getInputStream())); - String line; - while ((line = rd.readLine()) != null) { - result.append(line); - } - // create response object - String etag = con.getHeaderField("ETag"); - long lastModified = con.getHeaderFieldDate("Last-Modified", 0) / 1000; - Log.i(getClass().getSimpleName(), "Result length: " + result.length() + (paramData == null ? "" : "; Request length: " + paramData.length)); - Log.d(getClass().getSimpleName(), "ETag: " + etag + "; Last-Modified: " + lastModified + " (" + con.getHeaderField("Last-Modified") + ")"); - // return these header fields since they should only be saved after successful processing the result! - return new ResponseData(result.toString(), etag, lastModified); - } -} diff --git a/app/src/main/java/foundation/e/notes/util/NotesClientUtil.java b/app/src/main/java/foundation/e/notes/util/NotesClientUtil.java deleted file mode 100644 index e57ed810406ee6c1a0880473e531be7c94a85daf..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/NotesClientUtil.java +++ /dev/null @@ -1,147 +0,0 @@ -package foundation.e.notes.util; - -import androidx.annotation.StringRes; -import android.util.Base64; -import android.util.Log; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.MalformedURLException; -import java.net.SocketTimeoutException; - -import foundation.e.cert4android.CustomCertManager; -import foundation.e.notes.R; - -/** - * Utils for Validation etc - * Created by stefan on 25.09.15. - */ -public class NotesClientUtil { - - public enum LoginStatus { - OK(0), - AUTH_FAILED(R.string.error_username_password_invalid), - CONNECTION_FAILED(R.string.error_io), - NO_NETWORK(R.string.error_no_network), - JSON_FAILED(R.string.error_json), - SERVER_FAILED(R.string.error_server); - - @StringRes - public final int str; - - LoginStatus(@StringRes int str) { - this.str = str; - } - } - - /** - * Checks if the given url String starts with http:// or https:// - * - * @param url String - * @return true, if the given String is only http - */ - public static boolean isHttp(String url) { - return url != null && url.length() > 4 && url.startsWith("http") && url.charAt(4) != 's'; - } - - /** - * Strips the api part from the path of a given url, handles trailing slash and missing protocol - * - * @param url String - * @return formatted URL - */ - public static String formatURL(String url) { - if (!url.endsWith("/")) { - url += "/"; - } - if (!url.startsWith("http://") && !url.startsWith("https://")) { - url = "https://" + url; - } - String[] replacements = new String[]{"notes/", "v0.2/", "api/", "notes/", "apps/", "index.php/"}; - for (String replacement : replacements) { - if (url.endsWith(replacement)) { - url = url.substring(0, url.length() - replacement.length()); - } - } - return url; - } - - /** - * @param url String - * @param username String - * @param password String - * @return Username and Password are a valid Login-Combination for the given URL. - */ - public static LoginStatus isValidLogin(CustomCertManager ccm, String url, String username, String password) { - try { - String targetURL = url + "index.php/apps/notes/api/v0.2/notes"; - HttpURLConnection con = SupportUtil.getHttpURLConnection(ccm, targetURL); - con.setRequestMethod("GET"); - con.setRequestProperty( - "Authorization", - "Basic " - + new String(Base64.encode((username + ":" - + password).getBytes(), Base64.NO_WRAP))); - con.setConnectTimeout(10 * 1000); // 10 seconds - con.connect(); - - Log.v(NotesClientUtil.class.getSimpleName(), "Establishing connection to server"); - if (con.getResponseCode() == 200) { - Log.v(NotesClientUtil.class.getSimpleName(), "" + con.getResponseMessage()); - StringBuilder result = new StringBuilder(); - BufferedReader rd = new BufferedReader(new InputStreamReader(con.getInputStream())); - String line; - while ((line = rd.readLine()) != null) { - result.append(line); - } - Log.v(NotesClientUtil.class.getSimpleName(), result.toString()); - new JSONArray(result.toString()); - return LoginStatus.OK; - } else if (con.getResponseCode() >= 401 && con.getResponseCode() <= 403) { - return LoginStatus.AUTH_FAILED; - } else { - return LoginStatus.SERVER_FAILED; - } - } catch (MalformedURLException | SocketTimeoutException e) { - Log.e(NotesClientUtil.class.getSimpleName(), "Exception", e); - return LoginStatus.CONNECTION_FAILED; - } catch (IOException e) { - Log.e(NotesClientUtil.class.getSimpleName(), "Exception", e); - return LoginStatus.CONNECTION_FAILED; - } catch (JSONException e) { - Log.e(NotesClientUtil.class.getSimpleName(), "Exception", e); - return LoginStatus.JSON_FAILED; - } - } - - /** - * Pings a server and checks if there is a installed ownCloud instance - * - * @param url String URL to server - * @return true if there is a installed instance, false if not - */ - public static boolean isValidURL(CustomCertManager ccm, String url) { - StringBuilder result = new StringBuilder(); - try { - HttpURLConnection con = SupportUtil.getHttpURLConnection(ccm, url + "status.php"); - con.setRequestMethod(NotesClient.METHOD_GET); - con.setConnectTimeout(10 * 1000); // 10 seconds - BufferedReader rd = new BufferedReader(new InputStreamReader(con.getInputStream())); - String line; - while ((line = rd.readLine()) != null) { - result.append(line); - } - JSONObject response = new JSONObject(result.toString()); - return response.getBoolean("installed"); - } catch (IOException | JSONException | NullPointerException e) { - return false; - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/notes/util/ServerResponse.java b/app/src/main/java/foundation/e/notes/util/ServerResponse.java deleted file mode 100644 index 0c2644a78621960ae9189421046b43b81b29370a..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/ServerResponse.java +++ /dev/null @@ -1,101 +0,0 @@ -package foundation.e.notes.util; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.GregorianCalendar; -import java.util.List; - -import foundation.e.notes.model.CloudNote; -import foundation.e.notes.model.CloudNote; - -/** - * Provides entity classes for handling server responses with a single note ({@link NoteResponse}) or a list of notes ({@link NotesResponse}). - */ -public class ServerResponse { - - public static class NotModifiedException extends IOException { - } - - public static class NoteResponse extends ServerResponse { - public NoteResponse(NotesClient.ResponseData response) { - super(response); - } - - public CloudNote getNote() throws JSONException { - return getNoteFromJSON(new JSONObject(getContent())); - } - } - - public static class NotesResponse extends ServerResponse { - public NotesResponse(NotesClient.ResponseData response) { - super(response); - } - - public List getNotes() throws JSONException { - List notesList = new ArrayList<>(); - JSONArray notes = new JSONArray(getContent()); - for (int i = 0; i < notes.length(); i++) { - JSONObject json = notes.getJSONObject(i); - notesList.add(getNoteFromJSON(json)); - } - return notesList; - } - } - - - private final NotesClient.ResponseData response; - - public ServerResponse(NotesClient.ResponseData response) { - this.response = response; - } - - protected String getContent() { - return response.getContent(); - } - - public String getETag() { - return response.getETag(); - } - - public long getLastModified() { - return response.getLastModified(); - } - - protected CloudNote getNoteFromJSON(JSONObject json) throws JSONException { - long id = 0; - String title = ""; - String content = ""; - Calendar modified = null; - boolean favorite = false; - String category = null; - String etag = null; - if (!json.isNull(NotesClient.JSON_ID)) { - id = json.getLong(NotesClient.JSON_ID); - } - if (!json.isNull(NotesClient.JSON_TITLE)) { - title = json.getString(NotesClient.JSON_TITLE); - } - if (!json.isNull(NotesClient.JSON_CONTENT)) { - content = json.getString(NotesClient.JSON_CONTENT); - } - if (!json.isNull(NotesClient.JSON_MODIFIED)) { - modified = GregorianCalendar.getInstance(); - modified.setTimeInMillis(json.getLong(NotesClient.JSON_MODIFIED) * 1000); - } - if (!json.isNull(NotesClient.JSON_FAVORITE)) { - favorite = json.getBoolean(NotesClient.JSON_FAVORITE); - } - if (!json.isNull(NotesClient.JSON_CATEGORY)) { - category = json.getString(NotesClient.JSON_CATEGORY); - } - if (!json.isNull(NotesClient.JSON_ETAG)) { - etag = json.getString(NotesClient.JSON_ETAG); - } - return new CloudNote(id, modified, title, content, favorite, category, etag); - } -} diff --git a/app/src/main/java/foundation/e/notes/util/StyleCallback.java b/app/src/main/java/foundation/e/notes/util/StyleCallback.java deleted file mode 100644 index f3d5d06d64d2575fe8f518fe0ef91c13979f5518..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/StyleCallback.java +++ /dev/null @@ -1,130 +0,0 @@ -package foundation.e.notes.util; - -import android.graphics.Typeface; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.text.style.StyleSpan; -import android.util.SparseIntArray; -import android.view.ActionMode; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.widget.EditText; - -import foundation.e.notes.R; - -public class StyleCallback implements ActionMode.Callback { - - private EditText editText; - - public StyleCallback(EditText editText) { - this.editText = editText; - } - - @Override - public boolean onCreateActionMode(ActionMode mode, Menu menu) { - MenuInflater inflater = mode.getMenuInflater(); - inflater.inflate(R.menu.style, menu); - menu.removeItem(android.R.id.selectAll); - - SparseIntArray styleFormatMap = new SparseIntArray(); - styleFormatMap.append(R.id.bold, Typeface.BOLD); - styleFormatMap.append(R.id.italic, Typeface.ITALIC); - - MenuItem item; - CharSequence title; - SpannableStringBuilder ssb; - - for (int i = 0; i < styleFormatMap.size(); i++) { - item = menu.findItem(styleFormatMap.keyAt(i)); - title = item.getTitle(); - ssb = new SpannableStringBuilder(title); - ssb.setSpan(new StyleSpan(styleFormatMap.valueAt(i)), 0, title.length(), 0); - item.setTitle(ssb); - } - - return true; - } - - @Override - public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - return false; - } - - @Override - public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - int start = editText.getSelectionStart(); - int end = editText.getSelectionEnd(); - SpannableStringBuilder ssb = new SpannableStringBuilder(editText.getText()); - final String markdown; - - - switch (item.getItemId()) { - case R.id.bold: - markdown = "**"; - if (hasAlreadyMarkdown(start, end, markdown)) { - this.removeMarkdown(ssb, start, end, markdown); - } else { - this.addMarkdown(ssb, start, end, markdown, Typeface.BOLD); - } - editText.setText(ssb); - editText.setSelection(end + markdown.length() * 2); - break; - case R.id.italic: - markdown = "*"; - if (hasAlreadyMarkdown(start, end, markdown)) { - this.removeMarkdown(ssb, start, end, markdown); - } else { - this.addMarkdown(ssb, start, end, markdown, Typeface.ITALIC); - } - editText.setText(ssb); - editText.setSelection(end + markdown.length() * 2); - break; - case R.id.link: - boolean textToFormatIsLink = TextUtils.indexOf(editText.getText().subSequence(start, end), "http") == 0; - if(textToFormatIsLink) { - ssb.insert(end, ")"); - ssb.insert(start, "[]("); - } else { - ssb.insert(end, "]()"); - ssb.insert(start, "["); - } - end++; - ssb.setSpan(new StyleSpan(Typeface.NORMAL), start, end, 1); - editText.setText(ssb); - if(textToFormatIsLink) { - editText.setSelection(start + 1); - } else { - editText.setSelection(end + 2); // after ]( - } - return true; - } - return false; - } - - @Override - public void onDestroyActionMode(ActionMode mode) { - - } - - private boolean hasAlreadyMarkdown(int start, int end, String markdown) { - return start > markdown.length() && markdown.contentEquals(editText.getText().subSequence(start - markdown.length(), start)) && - editText.getText().length() > end + markdown.length() && markdown.contentEquals(editText.getText().subSequence(end, end + markdown.length())); - } - - private void removeMarkdown(SpannableStringBuilder ssb, int start, int end, String markdown) { - // FIXME disabled, because it does not work properly and might cause data loss - // ssb.delete(start - markdown.length(), start); - // ssb.delete(end - markdown.length(), end); - // ssb.setSpan(new StyleSpan(Typeface.NORMAL), start, end, 1); - } - - private void addMarkdown(SpannableStringBuilder ssb, int start, int end, String markdown, int typeface) { - ssb.insert(end, markdown); - ssb.insert(start, markdown); - editText.getText().charAt(start); - editText.getText().charAt(start + 1); - end += markdown.length() * 2; - ssb.setSpan(new StyleSpan(typeface), start, end, 1); - } -} diff --git a/app/src/main/java/foundation/e/notes/util/SupportUtil.java b/app/src/main/java/foundation/e/notes/util/SupportUtil.java deleted file mode 100644 index b8a77976d437ad6efcb5d5e989bcbadcf0a7d0ee..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/notes/util/SupportUtil.java +++ /dev/null @@ -1,99 +0,0 @@ -package foundation.e.notes.util; - -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Build; -import android.preference.PreferenceManager; -import android.text.Html; -import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.util.Log; -import android.widget.TextView; - -import androidx.annotation.WorkerThread; - -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.MalformedURLException; -import java.net.URL; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; - -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; - -import foundation.e.cert4android.CustomCertManager; -import foundation.e.notes.R; - -/** - * Some helper functionality in alike the Android support library. - * Currently, it offers methods for working with HTML string resources. - */ -public class SupportUtil { - - /** - * Creates a {@link Spanned} from a HTML string on all SDK versions. - * - * @param source Source string with HTML markup - * @return Spannable for using in a {@link TextView} - * @see Html#fromHtml(String) - * @see Html#fromHtml(String, int) - */ - public static Spanned fromHtml(String source) { - if (Build.VERSION.SDK_INT >= 24) { - return Html.fromHtml(source, Html.FROM_HTML_MODE_LEGACY); - } else { - return Html.fromHtml(source); - } - } - - /** - * Fills a {@link TextView} with HTML content and activates links in that {@link TextView}. - * - * @param view The {@link TextView} which should be filled. - * @param stringId The string resource containing HTML tags (escaped by <) - * @param formatArgs Arguments for the string resource. - */ - public static void setHtml(TextView view, int stringId, Object... formatArgs) { - view.setText(SupportUtil.fromHtml(view.getResources().getString(stringId, formatArgs))); - view.setMovementMethod(LinkMovementMethod.getInstance()); - } - - /** - * Create a new {@link HttpURLConnection} for strUrl. - * If protocol equals https, then install CustomCertManager in {@link SSLContext}. - * - * @param ccm - * @param strUrl - * @return HttpURLConnection with custom trust manager - * @throws MalformedURLException - * @throws IOException - */ - public static HttpURLConnection getHttpURLConnection(CustomCertManager ccm, String strUrl) throws MalformedURLException, IOException { - URL url = new URL(strUrl); - HttpURLConnection httpCon = (HttpURLConnection) url.openConnection(); - if (ccm != null && url.getProtocol().equals("https")) { - HttpsURLConnection httpsCon = (HttpsURLConnection) httpCon; - httpsCon.setHostnameVerifier(ccm.hostnameVerifier(httpsCon.getHostnameVerifier())); - try { - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, new TrustManager[]{ccm}, null); - httpsCon.setSSLSocketFactory(sslContext.getSocketFactory()); - } catch (NoSuchAlgorithmException e) { - Log.e(SupportUtil.class.getSimpleName(), "Exception", e); - // ignore, use default TrustManager - } catch (KeyManagementException e) { - Log.e(SupportUtil.class.getSimpleName(), "Exception", e); - // ignore, use default TrustManager - } - } - return httpCon; - } - - @WorkerThread - public static CustomCertManager getCertManager(Context ctx) { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(ctx); - return new CustomCertManager(ctx, preferences.getBoolean(ctx.getString(R.string.pref_key_trust_system_certs), true), true, true); - } -} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/AppendToNoteActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/AppendToNoteActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..4f0fb28030e5a179d8a502b53a6f3e6f7cd4b47d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/AppendToNoteActivity.java @@ -0,0 +1,61 @@ +package it.niedermann.owncloud.notes; + +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.lifecycle.LiveData; + +import it.niedermann.owncloud.notes.main.MainActivity; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.util.ShareUtil; + +public class AppendToNoteActivity extends MainActivity { + + private static final String TAG = AppendToNoteActivity.class.getSimpleName(); + + String receivedText = ""; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + receivedText = ShareUtil.extractSharedText(getIntent()); + @Nullable final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + getSupportActionBar().setTitle(R.string.append_to_note); + } else { + Log.e(TAG, "SupportActionBar is null. Expected toolbar to be present to set a title."); + } + binding.activityNotesListView.searchToolbar.setSubtitle(receivedText); + } + + @Override + public void onNoteClick(int position, View v) { + if (!TextUtils.isEmpty(receivedText)) { + final var fullNote$ = mainViewModel.getFullNote$(((Note) adapter.getItem(position)).getId()); + fullNote$.observe(this, (fullNote) -> { + fullNote$.removeObservers(this); + final String oldContent = fullNote.getContent(); + String newContent; + if (!TextUtils.isEmpty(oldContent)) { + newContent = oldContent + "\n\n" + receivedText; + } else { + newContent = receivedText; + } + final var updateLiveData = mainViewModel.updateNoteAndSync(fullNote, newContent, null); + updateLiveData.observe(this, (next) -> { + Toast.makeText(this, getString(R.string.added_content, receivedText), Toast.LENGTH_SHORT).show(); + updateLiveData.removeObservers(this); + }); + }); + } else { + Toast.makeText(this, R.string.shared_text_empty, Toast.LENGTH_SHORT).show(); + } + finish(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/FormattingHelpActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/FormattingHelpActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..91ef3724af42b6dbf808df91eee8ee131c1a6adc --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/FormattingHelpActivity.java @@ -0,0 +1,228 @@ +package it.niedermann.owncloud.notes; + +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.util.TypedValue; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandedActivity; +import it.niedermann.owncloud.notes.databinding.ActivityFormattingHelpBinding; + +import static it.niedermann.owncloud.notes.shared.util.NoteUtil.getFontSizeFromPreferences; + +public class FormattingHelpActivity extends BrandedActivity { + + private ActivityFormattingHelpBinding binding; + + private static final String lineBreak = "\n"; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + binding = ActivityFormattingHelpBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + setSupportActionBar(binding.toolbar); + + binding.contentContextBasedFormatting.setMarkdownString(buildContextBasedFormattingHelp()); + binding.content.setMovementMethod(LinkMovementMethod.getInstance()); + binding.content.setMarkdownString(buildFormattingHelp()); + + final var sp = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + binding.content.setTextSize(TypedValue.COMPLEX_UNIT_PX, getFontSizeFromPreferences(this, sp)); + if (sp.getBoolean(getString(R.string.pref_key_font), false)) { + binding.content.setTypeface(Typeface.MONOSPACE); + } + } + + @NonNull + private String buildContextBasedFormattingHelp() { + return getString(R.string.formatting_help_title, getString(R.string.formatting_help_cbf_title)) + lineBreak + + lineBreak + + getString(R.string.formatting_help_cbf_body_1) + lineBreak + + getString(R.string.formatting_help_cbf_body_2, + getString(R.string.formatting_help_codefence_inline, getString(android.R.string.cut)), + getString(R.string.formatting_help_codefence_inline, getString(android.R.string.copy)), + getString(R.string.formatting_help_codefence_inline, getString(android.R.string.selectAll)), + getString(R.string.formatting_help_codefence_inline, getString(R.string.simple_link)), + getString(R.string.formatting_help_codefence_inline, getString(R.string.simple_checkbox)) + ); + } + + @NonNull + private String buildFormattingHelp() { + final String indention = " "; + final String divider = getString(R.string.formatting_help_divider); + final String codefence = getString(R.string.formatting_help_codefence); + final String outerCodefence = getString(R.string.formatting_help_codefence_outer); + + int numberedListItem = 1; + final String lists = getString(R.string.formatting_help_lists_body_1) + lineBreak + + lineBreak + + getString(R.string.formatting_help_ol, numberedListItem++, getString(R.string.formatting_help_lists_body_2)) + lineBreak + + getString(R.string.formatting_help_ol, numberedListItem++, getString(R.string.formatting_help_lists_body_3)) + lineBreak + + getString(R.string.formatting_help_ol, numberedListItem, getString(R.string.formatting_help_lists_body_4)) + lineBreak + + lineBreak + + getString(R.string.formatting_help_lists_body_5) + lineBreak + + lineBreak + + getString(R.string.formatting_help_ul, getString(R.string.formatting_help_lists_body_6)) + lineBreak + + getString(R.string.formatting_help_ul, getString(R.string.formatting_help_lists_body_7)) + lineBreak + + indention + getString(R.string.formatting_help_ul, getString(R.string.formatting_help_lists_body_8)) + lineBreak + + indention + getString(R.string.formatting_help_ul, getString(R.string.formatting_help_lists_body_9)) + lineBreak; + + final String checkboxes = getString(R.string.formatting_help_checkboxes_body_1) + lineBreak + + lineBreak + + getString(R.string.formatting_help_checkbox_checked, getString(R.string.formatting_help_checkboxes_body_2)) + lineBreak + + getString(R.string.formatting_help_checkbox_unchecked, getString(R.string.formatting_help_checkboxes_body_3)) + lineBreak; + + final String structuredDocuments = getString(R.string.formatting_help_structured_documents_body_1, "`#`", "`##`") + lineBreak + + lineBreak + + getString(R.string.formatting_help_title_level_3, getString(R.string.formatting_help_structured_documents_body_2)) + lineBreak + + lineBreak + + getString(R.string.formatting_help_structured_documents_body_3, "`#`", "`######`") + lineBreak + + lineBreak + + getString(R.string.formatting_help_structured_documents_body_4, getString(R.string.formatting_help_quote_keyword)) + lineBreak + + lineBreak + + getString(R.string.formatting_help_quote, getString(R.string.formatting_help_structured_documents_body_5)) + lineBreak + + getString(R.string.formatting_help_quote, getString(R.string.formatting_help_structured_documents_body_6)) + lineBreak; + + final String javascript = getString(R.string.formatting_help_javascript_1) + lineBreak + + indention + indention + getString(R.string.formatting_help_javascript_2) + lineBreak + + getString(R.string.formatting_help_javascript_3) + lineBreak; + + final int column_count = 3; + final int row_count = 3; + final StringBuilder table = new StringBuilder(); + table.append("|"); + for (int i = 1; i <= column_count; i++) { + table.append(" ").append(getString(R.string.formatting_help_tables_column, i)).append(" |"); + } + table.append("\n"); + table.append("|"); + for (int i = 0; i < column_count; i++) { + table.append(" --- |"); + } + table.append("\n"); + for (int i = 1; i <= row_count; i++) { + table.append("|"); + for (int j = 1; j <= column_count; j++) { + table.append(" ").append(getString(R.string.formatting_help_tables_value, i * j)).append(" |"); + } + table.append("\n"); + } + + return divider + lineBreak + + lineBreak + + getString(R.string.formatting_help_title, getString(R.string.formatting_help_text_title)) + lineBreak + + lineBreak + + getString(R.string.formatting_help_text_body, + getString(R.string.formatting_help_bold), + getString(R.string.formatting_help_italic), + getString(R.string.formatting_help_strike_through) + ) + lineBreak + + lineBreak + + codefence + lineBreak + + getString(R.string.formatting_help_text_body, + getString(R.string.formatting_help_bold), + getString(R.string.formatting_help_italic), + getString(R.string.formatting_help_strike_through) + ) + lineBreak + + codefence + lineBreak + + lineBreak + + divider + lineBreak + + lineBreak + + getString(R.string.formatting_help_title, getString(R.string.formatting_help_lists_title)) + lineBreak + + lineBreak + + lists + + lineBreak + + codefence + lineBreak + + lists + + codefence + lineBreak + + lineBreak + + divider + lineBreak + + lineBreak + + getString(R.string.formatting_help_title, getString(R.string.formatting_help_checkboxes_title)) + lineBreak + + lineBreak + + checkboxes + + lineBreak + + codefence + lineBreak + + checkboxes + + codefence + lineBreak + + lineBreak + + divider + lineBreak + + lineBreak + + getString(R.string.formatting_help_title, getString(R.string.formatting_help_structured_documents_title)) + lineBreak + + lineBreak + + structuredDocuments + + lineBreak + + codefence + lineBreak + + structuredDocuments + + codefence + lineBreak + + lineBreak + + divider + lineBreak + + lineBreak + + getString(R.string.formatting_help_title, getString(R.string.formatting_help_code_title)) + lineBreak + + lineBreak + + getString(R.string.formatting_help_code_body_1) + lineBreak + + lineBreak + + getString(R.string.formatting_help_codefence_inline_escaped, getString(R.string.formatting_help_code_javascript_inline)) + " " + lineBreak + + getString(R.string.formatting_help_codefence_inline, getString(R.string.formatting_help_code_javascript_inline)) + lineBreak + + lineBreak + + getString(R.string.formatting_help_code_body_2) + lineBreak + + lineBreak + + outerCodefence + lineBreak + + codefence + lineBreak + + javascript + + codefence + lineBreak + + outerCodefence + lineBreak + + lineBreak + + codefence + lineBreak + + javascript + + codefence + lineBreak + + lineBreak + + getString(R.string.formatting_help_code_body_3) + lineBreak + + lineBreak + + outerCodefence + lineBreak + + getString(R.string.formatting_help_codefence_javascript) + lineBreak + + javascript + + codefence + lineBreak + + outerCodefence + lineBreak + + lineBreak + + getString(R.string.formatting_help_codefence_javascript) + lineBreak + + javascript + + codefence + lineBreak + + lineBreak + + divider + lineBreak + + lineBreak + + getString(R.string.formatting_help_title, getString(R.string.formatting_help_tables_title)) + lineBreak + + lineBreak + + codefence + lineBreak + + table + + codefence + lineBreak + + lineBreak + + table + + lineBreak + + divider + lineBreak + + lineBreak + + getString(R.string.formatting_help_title, getString(R.string.formatting_help_images_title)) + lineBreak + + lineBreak + + getString(R.string.formatting_help_images_body_1, getString(R.string.formatting_help_codefence_inline, getString(R.string.formatting_help_images_slash))) + lineBreak + + getString(R.string.formatting_help_images_body_2, getString(R.string.formatting_help_codefence_inline, getString(R.string.formatting_help_images_escaped_space))) + lineBreak + + lineBreak + + codefence + lineBreak + + getString(R.string.formatting_help_image, getString(R.string.formatting_help_images_alt), getString(R.string.formatting_help_images_escaped_space)) + lineBreak + + codefence + lineBreak; + } + + @Override + public void applyBrand(int mainColor, int textColor) { + applyBrandToPrimaryToolbar(binding.appBar, binding.toolbar); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/LockedActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/LockedActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..f5695a408c2d680b15807830fc938222329c45f7 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/LockedActivity.java @@ -0,0 +1,112 @@ +package it.niedermann.owncloud.notes; + +import android.app.KeyguardManager; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.WindowManager; + +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; + +import it.niedermann.owncloud.notes.branding.BrandedActivity; +import it.niedermann.owncloud.notes.exception.ExceptionHandler; + +public abstract class LockedActivity extends BrandedActivity { + + private static final String TAG = LockedActivity.class.getSimpleName(); + + private static final int REQUEST_CODE_UNLOCK = 100; + + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler(this)); + + if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean(getString(R.string.pref_key_prevent_screen_capture), false)) { + getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); + } + + if (isTaskRoot()) { + askToUnlock(); + } + } + + @Override + protected void onResume() { + super.onResume(); + + if (!isTaskRoot()) { + askToUnlock(); + } + } + + @Override + protected void onStop() { + super.onStop(); + if (isTaskRoot()) { + NotesApplication.updateLastInteraction(); + } + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + NotesApplication.updateLastInteraction(); + } + + @Override + public void startActivityForResult(Intent intent, int requestCode, @Nullable Bundle options) { + NotesApplication.updateLastInteraction(); + super.startActivityForResult(intent, requestCode, options); + } + + @Override + public void startActivityForResult(Intent intent, int requestCode) { + NotesApplication.updateLastInteraction(); + super.startActivityForResult(intent, requestCode); + } + + @Override + public void startActivity(Intent intent) { + NotesApplication.updateLastInteraction(); + super.startActivity(intent); + } + + @Override + public void startActivity(Intent intent, @Nullable Bundle options) { + NotesApplication.updateLastInteraction(); + super.startActivity(intent, options); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_CODE_UNLOCK) { + if (resultCode == RESULT_OK) { + Log.v(TAG, "Successfully unlocked device"); + NotesApplication.unlock(); + } else { + Log.e(TAG, "Result code of unlocking was " + resultCode); + finish(); + } + } + } + + private void askToUnlock() { + if (NotesApplication.isLocked()) { + final var keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); + if (keyguardManager != null) { + final var intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.unlock_notes), null); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivityForResult(intent, REQUEST_CODE_UNLOCK); + } else { + Log.e(TAG, "Keyguard manager is null"); + } + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/NotesApplication.java b/app/src/main/java/it/niedermann/owncloud/notes/NotesApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..e177f7c7c9ecada189cce84e6aa2f4498fe69d6d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/NotesApplication.java @@ -0,0 +1,92 @@ +package it.niedermann.owncloud.notes; + +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.util.Log; + +import androidx.appcompat.app.AppCompatDelegate; +import androidx.preference.PreferenceManager; + +import it.niedermann.owncloud.notes.preferences.DarkModeSetting; + +import static androidx.preference.PreferenceManager.getDefaultSharedPreferences; + +public class NotesApplication extends Application { + private static final String TAG = NotesApplication.class.getSimpleName(); + + private static final long LOCK_TIME = 30_000; + private static boolean lockedPreference = false; + private static boolean isLocked = true; + private static long lastInteraction = 0; + private static String PREF_KEY_THEME; + private static boolean isGridViewEnabled = false; + + @Override + public void onCreate() { + PREF_KEY_THEME = getString(R.string.pref_key_theme); + setAppTheme(getAppTheme(getApplicationContext())); + final var prefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + lockedPreference = prefs.getBoolean(getString(R.string.pref_key_lock), false); + isGridViewEnabled = getDefaultSharedPreferences(this).getBoolean(getString(R.string.pref_key_gridview), false); + super.onCreate(); + } + + public static void setAppTheme(DarkModeSetting setting) { + AppCompatDelegate.setDefaultNightMode(setting.getModeId()); + } + + public static boolean isGridViewEnabled() { + return isGridViewEnabled; + } + + public static void updateGridViewEnabled(boolean gridView) { + isGridViewEnabled = gridView; + } + + public static DarkModeSetting getAppTheme(Context context) { + final var prefs = PreferenceManager.getDefaultSharedPreferences(context); + String mode; + try { + mode = prefs.getString(PREF_KEY_THEME, DarkModeSetting.SYSTEM_DEFAULT.name()); + } catch (ClassCastException e) { + final boolean darkModeEnabled = prefs.getBoolean(PREF_KEY_THEME, false); + mode = darkModeEnabled ? DarkModeSetting.DARK.name() : DarkModeSetting.LIGHT.name(); + } + return DarkModeSetting.valueOf(mode); + } + + public static boolean isDarkThemeActive(Context context, DarkModeSetting setting) { + if (setting == DarkModeSetting.SYSTEM_DEFAULT) { + return isDarkThemeActive(context); + } else { + return setting == DarkModeSetting.DARK; + } + } + + public static boolean isDarkThemeActive(Context context) { + final int uiMode = context.getResources().getConfiguration().uiMode; + return (uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } + + public static void setLockedPreference(boolean lockedPreference) { + Log.i(TAG, "New locked preference: " + lockedPreference); + NotesApplication.lockedPreference = lockedPreference; + } + + public static boolean isLocked() { + if (!isLocked && System.currentTimeMillis() > (LOCK_TIME + lastInteraction)) { + isLocked = true; + } + return lockedPreference && isLocked; + } + + public static void unlock() { + isLocked = false; + } + + public static void updateLastInteraction() { + lastInteraction = System.currentTimeMillis(); + } +} diff --git a/app/src/main/java/foundation/e/notes/android/activity/SplashscreenActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/SplashscreenActivity.java similarity index 67% rename from app/src/main/java/foundation/e/notes/android/activity/SplashscreenActivity.java rename to app/src/main/java/it/niedermann/owncloud/notes/SplashscreenActivity.java index 3b62f382ba5fd75670565af153f48443a8209c65..8b72b22651f9cb5d1d1e8acee5ab024ea40fab2a 100644 --- a/app/src/main/java/foundation/e/notes/android/activity/SplashscreenActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/SplashscreenActivity.java @@ -1,11 +1,13 @@ -package foundation.e.notes.android.activity; +package it.niedermann.owncloud.notes; import android.content.Intent; import android.os.Bundle; import androidx.appcompat.app.AppCompatActivity; -import foundation.e.notes.util.ExceptionHandler; +import it.niedermann.owncloud.notes.exception.ExceptionHandler; +import it.niedermann.owncloud.notes.main.MainActivity; + /** * Created by stefan on 18.04.17. @@ -17,8 +19,8 @@ public class SplashscreenActivity extends AppCompatActivity { super.onCreate(savedInstanceState); Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler(this)); - Intent intent = new Intent(this, NotesListViewActivity.class); + final var intent = new Intent(this, MainActivity.class); startActivity(intent); finish(); } -} +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/about/AboutActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/about/AboutActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..5c5bf519f4baeada3e4285487fce1cebb9af3ca3 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/about/AboutActivity.java @@ -0,0 +1,95 @@ +package it.niedermann.owncloud.notes.about; + +import android.os.Bundle; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +import com.google.android.material.tabs.TabLayoutMediator; + +import it.niedermann.owncloud.notes.LockedActivity; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.databinding.ActivityAboutBinding; + +public class AboutActivity extends LockedActivity { + + private ActivityAboutBinding binding; + private final static int POS_CREDITS = 0; + private final static int POS_CONTRIB = 1; + private final static int POS_LICENSE = 2; + private final static int TOTAL_COUNT = 3; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + binding = ActivityAboutBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + setSupportActionBar(binding.toolbar); + binding.pager.setAdapter(new TabsStateAdapter(this)); + // generate title based on given position + new TabLayoutMediator(binding.tabs, binding.pager, (tab, position) -> { + switch (position) { + default: // Fall-through to credits tab + case POS_CREDITS: + tab.setText(R.string.about_credits_tab_title); + break; + case POS_CONTRIB: + tab.setText(R.string.about_contribution_tab_title); + break; + case POS_LICENSE: + tab.setText(R.string.about_license_tab_title); + break; + } + }).attach(); + } + + @Override + public void applyBrand(int mainColor, int textColor) { + applyBrandToPrimaryToolbar(binding.appBar, binding.toolbar); + @ColorInt int finalMainColor = BrandingUtil.getSecondaryForegroundColorDependingOnTheme(this, mainColor); + binding.tabs.setSelectedTabIndicatorColor(finalMainColor); + } + + private static class TabsStateAdapter extends FragmentStateAdapter { + + TabsStateAdapter(FragmentActivity fa) { + super(fa); + } + + @Override + public int getItemCount() { + return TOTAL_COUNT; + } + + /** + * return the right fragment for the given position + */ + @NonNull + @Override + public Fragment createFragment(int position) { + switch (position) { + default: // Fall-through to credits tab + case POS_CREDITS: + return new AboutFragmentCreditsTab(); + + case POS_CONTRIB: + return new AboutFragmentContributingTab(); + + case POS_LICENSE: + return new AboutFragmentLicenseTab(); + } + } + } + + @Override + public boolean onSupportNavigateUp() { + finish(); // close this activity as oppose to navigating up + return true; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/about/AboutFragmentContributingTab.java b/app/src/main/java/it/niedermann/owncloud/notes/about/AboutFragmentContributingTab.java new file mode 100644 index 0000000000000000000000000000000000000000..90a9a3bce964ec120d941bb54c176f4965ee421e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/about/AboutFragmentContributingTab.java @@ -0,0 +1,28 @@ +package it.niedermann.owncloud.notes.about; + +import static it.niedermann.owncloud.notes.shared.util.SupportUtil.setTextWithURL; + +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.FragmentAboutContributionTabBinding; +import it.niedermann.owncloud.notes.shared.util.SupportUtil; + +public class AboutFragmentContributingTab extends Fragment { + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + final var binding = FragmentAboutContributionTabBinding.inflate(inflater, container, false); + setTextWithURL(binding.aboutSource, getResources(), R.string.about_source, R.string.url_source, R.string.url_source); + setTextWithURL(binding.aboutIssues, getResources(), R.string.about_issues, R.string.url_issues, R.string.url_issues); + setTextWithURL(binding.aboutTranslate, getResources(), R.string.about_translate, R.string.url_translations, R.string.url_translations); + return binding.getRoot(); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/about/AboutFragmentCreditsTab.java b/app/src/main/java/it/niedermann/owncloud/notes/about/AboutFragmentCreditsTab.java new file mode 100644 index 0000000000000000000000000000000000000000..028bfa4e0a95e6e9534a725163a2f30fdcc18dea --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/about/AboutFragmentCreditsTab.java @@ -0,0 +1,31 @@ +package it.niedermann.owncloud.notes.about; + +import static it.niedermann.owncloud.notes.shared.util.SupportUtil.setTextWithURL; +import static it.niedermann.owncloud.notes.shared.util.SupportUtil.strong; +import static it.niedermann.owncloud.notes.shared.util.SupportUtil.url; + +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import it.niedermann.owncloud.notes.BuildConfig; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.FragmentAboutCreditsTabBinding; + +public class AboutFragmentCreditsTab extends Fragment { + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + final var binding = FragmentAboutCreditsTabBinding.inflate(inflater, container, false); + binding.aboutVersion.setText(getString(R.string.about_version, strong(BuildConfig.VERSION_NAME))); + binding.aboutMaintainer.setText(url(getString(R.string.about_maintainer), getString(R.string.url_maintainer))); + binding.aboutMaintainer.setMovementMethod(new LinkMovementMethod()); + setTextWithURL(binding.aboutTranslators, getResources(), R.string.about_translators_transifex, R.string.about_translators_transifex_label, R.string.url_translations); + return binding.getRoot(); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/about/AboutFragmentLicenseTab.java b/app/src/main/java/it/niedermann/owncloud/notes/about/AboutFragmentLicenseTab.java new file mode 100644 index 0000000000000000000000000000000000000000..10cc02c74afe814326a97375c3cfa94b731a5deb --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/about/AboutFragmentLicenseTab.java @@ -0,0 +1,42 @@ +package it.niedermann.owncloud.notes.about; + +import static it.niedermann.owncloud.notes.shared.util.SupportUtil.setTextWithURL; + +import android.content.Intent; +import android.content.res.ColorStateList; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.core.graphics.drawable.DrawableCompat; + +import it.niedermann.android.util.ColorUtil; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandedFragment; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.databinding.FragmentAboutLicenseTabBinding; + +public class AboutFragmentLicenseTab extends BrandedFragment { + + private FragmentAboutLicenseTabBinding binding; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + binding = FragmentAboutLicenseTabBinding.inflate(inflater, container, false); + setTextWithURL(binding.aboutIconsDisclaimerAppIcon, getResources(), R.string.about_icons_disclaimer_app_icon, R.string.about_app_icon_author_link_label, R.string.url_about_icon_author); + setTextWithURL(binding.aboutIconsDisclaimerMdiIcons, getResources(), R.string.about_icons_disclaimer_mdi_icons, R.string.about_icons_disclaimer_mdi, R.string.url_about_icons_disclaimer_mdi); + binding.aboutAppLicenseButton.setOnClickListener((v) -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.url_license))))); + return binding.getRoot(); + } + + @Override + public void applyBrand(int mainColor, int textColor) { + @ColorInt final int finalMainColor = BrandingUtil.getSecondaryForegroundColorDependingOnTheme(requireContext(), mainColor); + DrawableCompat.setTintList(binding.aboutAppLicenseButton.getBackground(), ColorStateList.valueOf(finalMainColor)); + binding.aboutAppLicenseButton.setTextColor(ColorUtil.INSTANCE.getForegroundColorForBackgroundColor(finalMainColor)); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountpicker/AccountPickerDialogFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/accountpicker/AccountPickerDialogFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..56350be74177b123388165b01c6ca794b74200ab --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountpicker/AccountPickerDialogFragment.java @@ -0,0 +1,117 @@ +package it.niedermann.owncloud.notes.accountpicker; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandedAlertDialogBuilder; +import it.niedermann.owncloud.notes.branding.BrandedDialogFragment; +import it.niedermann.owncloud.notes.databinding.DialogChooseAccountBinding; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.account.AccountChooserAdapter; +import it.niedermann.owncloud.notes.shared.account.AccountChooserViewHolder; + +/** + * A {@link DialogFragment} which provides an {@link Account} chooser that hides the current {@link Account}. + * This can be useful when one wants to pick e. g. a target for move a {@link Note} from one {@link Account} to another.. + */ +public class AccountPickerDialogFragment extends BrandedDialogFragment { + + private static final String PARAM_TARGET_ACCOUNTS = "targetAccounts"; + private static final String PARAM_CURRENT_ACCOUNT_ID = "currentAccountId"; + + private AccountPickerListener accountPickerListener; + + private List targetAccounts; + + /** + * Use newInstance()-Method + */ + public AccountPickerDialogFragment() { + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (context instanceof AccountPickerListener) { + this.accountPickerListener = (AccountPickerListener) context; + } else { + throw new ClassCastException("Caller must implement " + AccountPickerListener.class.getSimpleName()); + } + final var args = requireArguments(); + if (!args.containsKey(PARAM_TARGET_ACCOUNTS)) { + throw new IllegalArgumentException(PARAM_TARGET_ACCOUNTS + " is required."); + } + final var accounts = (Collection) args.getSerializable(PARAM_TARGET_ACCOUNTS); + if (accounts == null) { + throw new IllegalArgumentException(PARAM_TARGET_ACCOUNTS + " is required."); + } + final long currentAccountId = requireArguments().getLong(PARAM_CURRENT_ACCOUNT_ID, -1L); + targetAccounts = accounts + .stream() + .map(a -> (Account) a) + .filter(a -> a.getId() != currentAccountId) + .collect(Collectors.toList()); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final var dialogBuilder = new BrandedAlertDialogBuilder(requireActivity()) + .setTitle(R.string.simple_move) + .setNegativeButton(android.R.string.cancel, null); + + if (targetAccounts.size() > 0) { + final var binding = DialogChooseAccountBinding.inflate(LayoutInflater.from(requireContext())); + final var adapter = new AccountChooserAdapter(targetAccounts, (account -> { + accountPickerListener.onAccountPicked(account); + dismiss(); + })); + binding.accountsList.setAdapter(adapter); + dialogBuilder.setView(binding.getRoot()); + } else { + dialogBuilder.setMessage(getString(R.string.no_other_accounts)); + } + + return dialogBuilder.create(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + Objects.requireNonNull(requireDialog().getWindow()).setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + return super.onCreateView(inflater, container, savedInstanceState); + } + + public static DialogFragment newInstance(@NonNull ArrayList targetAccounts, long currentAccountId) { + final var fragment = new AccountPickerDialogFragment(); + final var args = new Bundle(); + args.putSerializable(PARAM_TARGET_ACCOUNTS, targetAccounts); + args.putLong(PARAM_CURRENT_ACCOUNT_ID, currentAccountId); + fragment.setArguments(args); + return fragment; + } + + @Override + public void applyBrand(int mainColor, int textColor) { + // Nothing to do... + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountpicker/AccountPickerListener.java b/app/src/main/java/it/niedermann/owncloud/notes/accountpicker/AccountPickerListener.java new file mode 100644 index 0000000000000000000000000000000000000000..4b77333a3ebb8eeb6800fcc52d2288f4c47ad868 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountpicker/AccountPickerListener.java @@ -0,0 +1,9 @@ +package it.niedermann.owncloud.notes.accountpicker; + +import androidx.annotation.NonNull; + +import it.niedermann.owncloud.notes.persistence.entity.Account; + +public interface AccountPickerListener { + void onAccountPicked(@NonNull Account account); +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..becc3ac1e85a90595f4c6b4ae7a0b79f43050fb2 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherAdapter.java @@ -0,0 +1,54 @@ +package it.niedermann.owncloud.notes.accountswitcher; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.persistence.entity.Account; + +public class AccountSwitcherAdapter extends RecyclerView.Adapter { + + @NonNull + private final List localAccounts = new ArrayList<>(); + @NonNull + private final Consumer onAccountClick; + + public AccountSwitcherAdapter(@NonNull Consumer onAccountClick) { + this.onAccountClick = onAccountClick; + setHasStableIds(true); + } + + @Override + public long getItemId(int position) { + return localAccounts.get(position).getId(); + } + + @NonNull + @Override + public AccountSwitcherViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new AccountSwitcherViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_account_choose, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull AccountSwitcherViewHolder holder, int position) { + holder.bind(localAccounts.get(position), onAccountClick); + } + + @Override + public int getItemCount() { + return localAccounts.size(); + } + + public void setLocalAccounts(@NonNull List localAccounts) { + this.localAccounts.clear(); + this.localAccounts.addAll(localAccounts); + notifyDataSetChanged(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java new file mode 100644 index 0000000000000000000000000000000000000000..b15f6308c77eb6c054479c3f0cc1659bca1dc694 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java @@ -0,0 +1,126 @@ +package it.niedermann.owncloud.notes.accountswitcher; + +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.LayerDrawable; +import android.net.Uri; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.LiveData; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; + +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandedDialogFragment; +import it.niedermann.owncloud.notes.databinding.DialogAccountSwitcherBinding; +import it.niedermann.owncloud.notes.manageaccounts.ManageAccountsActivity; +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.Account; + +import static it.niedermann.owncloud.notes.branding.BrandingUtil.applyBrandToLayerDrawable; + +/** + * Displays all available {@link Account} entries and provides basic operations for them, like adding or switching + */ +public class AccountSwitcherDialog extends BrandedDialogFragment { + + private static final String KEY_CURRENT_ACCOUNT_ID = "current_account_id"; + + private NotesRepository repo; + private DialogAccountSwitcherBinding binding; + private AccountSwitcherListener accountSwitcherListener; + private long currentAccountId; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (context instanceof AccountSwitcherListener) { + this.accountSwitcherListener = (AccountSwitcherListener) context; + } else { + throw new ClassCastException("Caller must implement " + AccountSwitcherListener.class.getSimpleName()); + } + + final var args = getArguments(); + + if (args == null || !args.containsKey(KEY_CURRENT_ACCOUNT_ID)) { + throw new IllegalArgumentException("Please provide at least " + KEY_CURRENT_ACCOUNT_ID); + } else { + this.currentAccountId = args.getLong(KEY_CURRENT_ACCOUNT_ID); + } + + repo = NotesRepository.getInstance(requireContext()); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + binding = DialogAccountSwitcherBinding.inflate(requireActivity().getLayoutInflater()); + + final var account$ = repo.getAccountById$(currentAccountId); + account$.observe(requireActivity(), (currentLocalAccount) -> { + account$.removeObservers(requireActivity()); + + binding.accountName.setText(currentLocalAccount.getDisplayName()); + binding.accountHost.setText(Uri.parse(currentLocalAccount.getUrl()).getHost()); + Glide.with(requireContext()) + .load(currentLocalAccount.getUrl() + "/index.php/avatar/" + Uri.encode(currentLocalAccount.getUserName()) + "/64") + .error(R.drawable.ic_account_circle_grey_24dp) + .apply(RequestOptions.circleCropTransform()) + .into(binding.currentAccountItemAvatar); + binding.accountLayout.setOnClickListener((v) -> dismiss()); + + final var adapter = new AccountSwitcherAdapter((localAccount -> { + accountSwitcherListener.onAccountChosen(localAccount); + dismiss(); + })); + binding.accountsList.setAdapter(adapter); + final var localAccounts$ = repo.getAccounts$(); + localAccounts$.observe(requireActivity(), (localAccounts) -> { + localAccounts$.removeObservers(requireActivity()); + for (final var localAccount : localAccounts) { + if (localAccount.getId() == currentLocalAccount.getId()) { + localAccounts.remove(localAccount); + break; + } + } + adapter.setLocalAccounts(localAccounts); + }); + }); + + binding.addAccount.setOnClickListener((v) -> { + accountSwitcherListener.addAccount(); + dismiss(); + }); + + binding.manageAccounts.setOnClickListener((v) -> { + requireActivity().startActivity(new Intent(requireContext(), ManageAccountsActivity.class)); + dismiss(); + }); + + return new AlertDialog.Builder(requireContext()) + .setView(binding.getRoot()) + .create(); + } + + public static DialogFragment newInstance(long currentAccountId) { + final var dialog = new AccountSwitcherDialog(); + + final var args = new Bundle(); + args.putLong(KEY_CURRENT_ACCOUNT_ID, currentAccountId); + dialog.setArguments(args); + + return dialog; + } + + @Override + public void applyBrand(int mainColor, int textColor) { + applyBrandToLayerDrawable((LayerDrawable) binding.check.getDrawable(), R.id.area, mainColor); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherListener.java b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherListener.java new file mode 100644 index 0000000000000000000000000000000000000000..87491a163d797658d9f92e35e47b8349bcc9991b --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherListener.java @@ -0,0 +1,11 @@ +package it.niedermann.owncloud.notes.accountswitcher; + +import androidx.annotation.NonNull; + +import it.niedermann.owncloud.notes.persistence.entity.Account; + +public interface AccountSwitcherListener { + void addAccount(); + + void onAccountChosen(@NonNull Account localAccount); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherViewHolder.java new file mode 100644 index 0000000000000000000000000000000000000000..1f096c96e05e38fe378ba07c04437d71200fe615 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherViewHolder.java @@ -0,0 +1,39 @@ +package it.niedermann.owncloud.notes.accountswitcher; + +import android.net.Uri; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; + +import it.niedermann.nextcloud.sso.glide.SingleSignOnUrl; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ItemAccountChooseBinding; +import it.niedermann.owncloud.notes.persistence.entity.Account; + +public class AccountSwitcherViewHolder extends RecyclerView.ViewHolder { + + ItemAccountChooseBinding binding; + + public AccountSwitcherViewHolder(@NonNull View itemView) { + super(itemView); + binding = ItemAccountChooseBinding.bind(itemView); + } + + public void bind(@NonNull Account localAccount, @NonNull Consumer onAccountClick) { + binding.accountName.setText(localAccount.getDisplayName()); + binding.accountHost.setText(Uri.parse(localAccount.getUrl()).getHost()); + Glide.with(itemView.getContext()) + .load(new SingleSignOnUrl(localAccount.getAccountName(), localAccount.getUrl() + "/index.php/avatar/" + Uri.encode(localAccount.getUserName()) + "/64")) + .placeholder(R.drawable.ic_account_circle_grey_24dp) + .error(R.drawable.ic_account_circle_grey_24dp) + .apply(RequestOptions.circleCropTransform()) + .into(binding.accountItemAvatar); + itemView.setOnClickListener((v) -> onAccountClick.accept(localAccount)); + binding.accountContextMenu.setVisibility(View.GONE); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/Branded.java b/app/src/main/java/it/niedermann/owncloud/notes/branding/Branded.java new file mode 100644 index 0000000000000000000000000000000000000000..7ef9138dea8ec8bcfcb8c3cdff6ac80936fc8991 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/Branded.java @@ -0,0 +1,9 @@ +package it.niedermann.owncloud.notes.branding; + +import androidx.annotation.ColorInt; +import androidx.annotation.UiThread; + +public interface Branded { + @UiThread + void applyBrand(@ColorInt int mainColor, @ColorInt int textColor); +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..e7cc6dfd8cf71cfe52a8d13407b0f51f53aec4c6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedActivity.java @@ -0,0 +1,68 @@ +package it.niedermann.owncloud.notes.branding; + +import android.content.res.ColorStateList; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.util.TypedValue; +import android.view.Menu; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; + +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import it.niedermann.owncloud.notes.R; + +import static it.niedermann.owncloud.notes.branding.BrandingUtil.readBrandColors; +import static it.niedermann.owncloud.notes.branding.BrandingUtil.tintMenuIcon; + +public abstract class BrandedActivity extends AppCompatActivity implements Branded { + + @ColorInt + protected int colorAccent; + + public static void applyBrandToFAB(@ColorInt int mainColor, @ColorInt int textColor, @NonNull FloatingActionButton fab) { + fab.setSupportBackgroundTintList(ColorStateList.valueOf(mainColor)); + fab.setColorFilter(textColor); + } + + @Override + protected void onStart() { + super.onStart(); + + final var typedValue = new TypedValue(); + getTheme().resolveAttribute(R.attr.colorAccent, typedValue, true); + colorAccent = typedValue.data; + + readBrandColors(this).observe(this, (pair) -> applyBrand(pair.first, pair.second)); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + for (int i = 0; i < menu.size(); i++) { + tintMenuIcon(menu.getItem(i), colorAccent); + } + return super.onCreateOptionsMenu(menu); + } + + public void applyBrandToPrimaryToolbar(@NonNull AppBarLayout appBarLayout, @NonNull Toolbar toolbar) { + // FIXME Workaround for https://github.com/stefan-niedermann/nextcloud-notes/issues/889 + appBarLayout.setBackgroundColor(ContextCompat.getColor(this, R.color.primary)); + + final var overflowDrawable = toolbar.getOverflowIcon(); + if (overflowDrawable != null) { + overflowDrawable.setColorFilter(colorAccent, PorterDuff.Mode.SRC_ATOP); + toolbar.setOverflowIcon(overflowDrawable); + } + + final var navigationDrawable = toolbar.getNavigationIcon(); + if (navigationDrawable != null) { + navigationDrawable.setColorFilter(colorAccent, PorterDuff.Mode.SRC_ATOP); + toolbar.setNavigationIcon(navigationDrawable); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedAlertDialogBuilder.java b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedAlertDialogBuilder.java new file mode 100644 index 0000000000000000000000000000000000000000..c48e62981a9db4917d08911a2b1cd3fe7d8d2f96 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedAlertDialogBuilder.java @@ -0,0 +1,48 @@ +package it.niedermann.owncloud.notes.branding; + +import android.content.Context; +import android.content.DialogInterface; +import android.widget.Button; + +import androidx.annotation.CallSuper; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import static it.niedermann.owncloud.notes.branding.BrandingUtil.getSecondaryForegroundColorDependingOnTheme; + +public class BrandedAlertDialogBuilder extends AlertDialog.Builder implements Branded { + + protected AlertDialog dialog; + + public BrandedAlertDialogBuilder(Context context) { + super(context); + } + + @NonNull + @Override + public AlertDialog create() { + this.dialog = super.create(); + + @NonNull final var context = getContext(); + @ColorInt final int mainColor = BrandingUtil.readBrandMainColor(context); + @ColorInt final int textColor = BrandingUtil.readBrandTextColor(context); + applyBrand(mainColor, textColor); + dialog.setOnShowListener(dialog -> applyBrand(mainColor, textColor)); + return dialog; + } + + @CallSuper + @Override + public void applyBrand(int mainColor, int textColor) { + final var buttons = new Button[3]; + buttons[0] = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + buttons[1] = dialog.getButton(DialogInterface.BUTTON_NEGATIVE); + buttons[2] = dialog.getButton(DialogInterface.BUTTON_NEUTRAL); + for (final var button : buttons) { + if (button != null) { + button.setTextColor(getSecondaryForegroundColorDependingOnTheme(button.getContext(), mainColor)); + } + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedDeleteAlertDialogBuilder.java b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedDeleteAlertDialogBuilder.java new file mode 100644 index 0000000000000000000000000000000000000000..e50b6fcba26e8e52b68d1229e32ffbc480d4ae72 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedDeleteAlertDialogBuilder.java @@ -0,0 +1,26 @@ +package it.niedermann.owncloud.notes.branding; + +import android.content.Context; +import android.content.DialogInterface; +import android.widget.Button; + +import androidx.annotation.CallSuper; + +import it.niedermann.owncloud.notes.R; + +public class BrandedDeleteAlertDialogBuilder extends BrandedAlertDialogBuilder { + + public BrandedDeleteAlertDialogBuilder(Context context) { + super(context); + } + + @CallSuper + @Override + public void applyBrand(int mainColor, int textColor) { + super.applyBrand(mainColor, textColor); + final var positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + if (positiveButton != null) { + positiveButton.setTextColor(getContext().getResources().getColor(R.color.bg_attention)); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedDialogFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedDialogFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..b930c63a9d0c6e76638f8e07b9517b1063e2a69d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedDialogFragment.java @@ -0,0 +1,20 @@ +package it.niedermann.owncloud.notes.branding; + +import android.content.Context; + +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +public abstract class BrandedDialogFragment extends DialogFragment implements Branded { + + @Override + public void onStart() { + super.onStart(); + + @Nullable final var context = requireContext(); + @ColorInt final int mainColor = BrandingUtil.readBrandMainColor(context); + @ColorInt final int textColor = BrandingUtil.readBrandTextColor(context); + applyBrand(mainColor, textColor); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..993ee37784d8ceb4698a34e38b383e618b7b640e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedFragment.java @@ -0,0 +1,47 @@ +package it.niedermann.owncloud.notes.branding; + +import android.content.Context; +import android.util.TypedValue; +import android.view.Menu; +import android.view.MenuInflater; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import it.niedermann.owncloud.notes.R; + +import static it.niedermann.owncloud.notes.branding.BrandingUtil.tintMenuIcon; + +public abstract class BrandedFragment extends Fragment implements Branded { + + @ColorInt + protected int colorAccent; + @ColorInt + protected int colorPrimary; + + @Override + public void onStart() { + super.onStart(); + + final var context = requireContext(); + final var typedValue = new TypedValue(); + context.getTheme().resolveAttribute(R.attr.colorAccent, typedValue, true); + colorAccent = typedValue.data; + context.getTheme().resolveAttribute(R.attr.colorPrimary, typedValue, true); + colorPrimary = typedValue.data; + + @ColorInt final int mainColor = BrandingUtil.readBrandMainColor(context); + @ColorInt final int textColor = BrandingUtil.readBrandTextColor(context); + applyBrand(mainColor, textColor); + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + for (int i = 0; i < menu.size(); i++) { + tintMenuIcon(menu.getItem(i), colorAccent); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedPreferenceCategory.java b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedPreferenceCategory.java new file mode 100644 index 0000000000000000000000000000000000000000..620ec4b669c614a2698af63d865b5f3ba8021e9d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedPreferenceCategory.java @@ -0,0 +1,44 @@ +package it.niedermann.owncloud.notes.branding; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceViewHolder; + +import static it.niedermann.owncloud.notes.branding.BrandingUtil.getSecondaryForegroundColorDependingOnTheme; + +public class BrandedPreferenceCategory extends PreferenceCategory { + + public BrandedPreferenceCategory(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public BrandedPreferenceCategory(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public BrandedPreferenceCategory(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public BrandedPreferenceCategory(Context context) { + super(context); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + + final var view = holder.itemView.findViewById(android.R.id.title); + @Nullable final var context = getContext(); + if (context != null && view instanceof TextView) { + @ColorInt final int mainColor = getSecondaryForegroundColorDependingOnTheme(context, BrandingUtil.readBrandMainColor(context)); + ((TextView) view).setTextColor(mainColor); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedSnackbar.java b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedSnackbar.java new file mode 100644 index 0000000000000000000000000000000000000000..8e3a4d9fe93548c869137a15ef6905e024b245b1 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedSnackbar.java @@ -0,0 +1,28 @@ +package it.niedermann.owncloud.notes.branding; + +import android.graphics.Color; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import com.google.android.material.snackbar.Snackbar; + +import it.niedermann.android.util.ColorUtil; + +public class BrandedSnackbar { + + @NonNull + public static Snackbar make(@NonNull View view, @NonNull CharSequence text, @Snackbar.Duration int duration) { + final var snackbar = Snackbar.make(view, text, duration); + final int color = BrandingUtil.readBrandMainColor(view.getContext()); + snackbar.setActionTextColor(ColorUtil.INSTANCE.isColorDark(color) ? Color.WHITE : color); + return snackbar; + } + + @NonNull + public static Snackbar make(@NonNull View view, @StringRes int resId, @Snackbar.Duration int duration) { + return make(view, view.getResources().getText(resId), duration); + } + +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedSwitchPreference.java b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedSwitchPreference.java new file mode 100644 index 0000000000000000000000000000000000000000..07eb0c63c632e1988ddf8894eb0ccab30a90a4d5 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedSwitchPreference.java @@ -0,0 +1,111 @@ +package it.niedermann.owncloud.notes.branding; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Switch; + +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.preference.PreferenceViewHolder; +import androidx.preference.SwitchPreference; + +import it.niedermann.owncloud.notes.R; + +import static it.niedermann.owncloud.notes.branding.BrandingUtil.getSecondaryForegroundColorDependingOnTheme; + +public class BrandedSwitchPreference extends SwitchPreference implements Branded { + + @ColorInt + private Integer mainColor = null; + + @ColorInt + private Integer textColor = null; + + @SuppressLint("UseSwitchCompatOrMaterialCode") + @Nullable + private Switch switchView; + + public BrandedSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public BrandedSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public BrandedSwitchPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public BrandedSwitchPreference(Context context) { + super(context); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + + if (holder.itemView instanceof ViewGroup) { + switchView = findSwitchWidget(holder.itemView); + if (mainColor != null && textColor != null) { + applyBrand(); + } + } + } + + @Override + public void applyBrand(@ColorInt int mainColor, @ColorInt int textColor) { + this.mainColor = mainColor; + this.textColor = textColor; + // onBindViewHolder is called after applyBrand, therefore we have to store the given values and apply them later. + applyBrand(); + } + + private void applyBrand() { + if (switchView != null) { + final int finalMainColor = getSecondaryForegroundColorDependingOnTheme(getContext(), mainColor); + // int trackColor = Color.argb(77, Color.red(finalMainColor), Color.green(finalMainColor), Color.blue(finalMainColor)); + DrawableCompat.setTintList(switchView.getThumbDrawable(), new ColorStateList( + new int[][]{new int[]{android.R.attr.state_checked}, new int[]{}}, + new int[]{finalMainColor, getContext().getResources().getColor(R.color.fg_default_low)} + )); + DrawableCompat.setTintList(switchView.getTrackDrawable(), new ColorStateList( + new int[][]{new int[]{android.R.attr.state_checked}, new int[]{}}, + new int[]{finalMainColor, getContext().getResources().getColor(R.color.fg_default_low)} + )); + } + } + + /** + * Recursively go through view tree until we find an android.widget.Switch + * + * @param view Root view to start searching + * @return A Switch class or null + * @see Source + */ + private Switch findSwitchWidget(View view) { + if (view instanceof Switch) { + return (Switch) view; + } + if (view instanceof ViewGroup) { + final var viewGroup = (ViewGroup) view; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + final var child = viewGroup.getChildAt(i); + if (child instanceof ViewGroup) { + @SuppressLint("UseSwitchCompatOrMaterialCode") final var result = findSwitchWidget(child); + if (result != null) return result; + } + if (child instanceof Switch) { + return (Switch) child; + } + } + } + return null; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandingUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandingUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..af2b4a43c61aba218e084fc98300bf2b6fdacb42 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandingUtil.java @@ -0,0 +1,164 @@ +package it.niedermann.owncloud.notes.branding; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.util.Log; +import android.view.MenuItem; +import android.widget.EditText; + +import androidx.annotation.ColorInt; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.core.util.Pair; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; +import androidx.preference.PreferenceManager; + +import it.niedermann.android.sharedpreferences.SharedPreferenceIntLiveData; +import it.niedermann.owncloud.notes.NotesApplication; +import it.niedermann.owncloud.notes.R; + +import static it.niedermann.owncloud.notes.shared.util.NotesColorUtil.contrastRatioIsSufficient; + +public class BrandingUtil { + + private static final String TAG = BrandingUtil.class.getSimpleName(); + private static final String pref_key_branding_main = "branding_main"; + private static final String pref_key_branding_text = "branding_text"; + + private BrandingUtil() { + + } + + public static LiveData> readBrandColors(@NonNull Context context) { + return new BrandingLiveData(context); + } + + private static class BrandingLiveData extends MediatorLiveData> { + @ColorInt + Integer lastMainColor = null; + @ColorInt + Integer lastTextColor = null; + + public BrandingLiveData(@NonNull Context context) { + addSource(readBrandMainColorLiveData(context), (nextMainColor) -> { + lastMainColor = nextMainColor; + if (lastTextColor != null) { + postValue(new Pair<>(lastMainColor, lastTextColor)); + } + }); + addSource(readBrandTextColorLiveData(context), (nextTextColor) -> { + lastTextColor = nextTextColor; + if (lastMainColor != null) { + postValue(new Pair<>(lastMainColor, lastTextColor)); + } + }); + } + } + + public static LiveData readBrandMainColorLiveData(@NonNull Context context) { + final var sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + Log.v(TAG, "--- Read: shared_preference_theme_main"); + return new SharedPreferenceIntLiveData(sharedPreferences, pref_key_branding_main, context.getApplicationContext().getResources().getColor(R.color.defaultBrand)); + } + + public static LiveData readBrandTextColorLiveData(@NonNull Context context) { + final var sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + Log.v(TAG, "--- Read: shared_preference_theme_text"); + return new SharedPreferenceIntLiveData(sharedPreferences, pref_key_branding_text, Color.WHITE); + } + + @ColorInt + public static int readBrandMainColor(@NonNull Context context) { + final var sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + Log.v(TAG, "--- Read: shared_preference_theme_main"); + return sharedPreferences.getInt(pref_key_branding_main, context.getApplicationContext().getResources().getColor(R.color.defaultBrand)); + } + + @ColorInt + public static int readBrandTextColor(@NonNull Context context) { + final var sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + Log.v(TAG, "--- Read: shared_preference_theme_text"); + return sharedPreferences.getInt(pref_key_branding_text, Color.WHITE); + } + + public static void saveBrandColors(@NonNull Context context, @ColorInt int mainColor, @ColorInt int textColor) { + final int previousMainColor = readBrandMainColor(context); + final int previousTextColor = readBrandTextColor(context); + final var editor = PreferenceManager.getDefaultSharedPreferences(context).edit(); + Log.v(TAG, "--- Write: shared_preference_theme_main" + " | " + mainColor); + Log.v(TAG, "--- Write: shared_preference_theme_text" + " | " + textColor); + editor.putInt(pref_key_branding_main, mainColor); + editor.putInt(pref_key_branding_text, textColor); + editor.apply(); + if (context instanceof BrandedActivity) { + if (mainColor != previousMainColor || textColor != previousTextColor) { + final var activity = (BrandedActivity) context; + activity.runOnUiThread(() -> ActivityCompat.recreate(activity)); + } + } + } + + /** + * Since we may collide with dark theme in this area, we have to make sure that the color is visible depending on the background + */ + @ColorInt + public static int getSecondaryForegroundColorDependingOnTheme(@NonNull Context context, @ColorInt int mainColor) { + final int primaryColor = ContextCompat.getColor(context, R.color.primary); + final boolean isDarkTheme = NotesApplication.isDarkThemeActive(context); + if (isDarkTheme && !contrastRatioIsSufficient(mainColor, primaryColor)) { + Log.v(TAG, "Contrast ratio between brand color " + String.format("#%06X", (0xFFFFFF & mainColor)) + " and dark theme is too low. Falling back to WHITE as brand color."); + return Color.WHITE; + } else if (!isDarkTheme && !contrastRatioIsSufficient(mainColor, primaryColor)) { + Log.v(TAG, "Contrast ratio between brand color " + String.format("#%06X", (0xFFFFFF & mainColor)) + " and light theme is too low. Falling back to BLACK as brand color."); + return Color.BLACK; + } else { + return mainColor; + } + } + + public static void applyBrandToEditText(@ColorInt int mainColor, @ColorInt int textColor, @NonNull EditText editText) { + @ColorInt final int finalMainColor = getSecondaryForegroundColorDependingOnTheme(editText.getContext(), mainColor); + DrawableCompat.setTintList(editText.getBackground(), new ColorStateList( + new int[][]{ + new int[]{android.R.attr.state_active}, + new int[]{android.R.attr.state_activated}, + new int[]{android.R.attr.state_focused}, + new int[]{android.R.attr.state_pressed}, + new int[]{} + }, + new int[]{ + finalMainColor, + finalMainColor, + finalMainColor, + finalMainColor, + editText.getContext().getResources().getColor(R.color.fg_default_low) + } + )); + } + + public static void tintMenuIcon(@NonNull MenuItem menuItem, @ColorInt int color) { + var drawable = menuItem.getIcon(); + if (drawable != null) { + drawable = DrawableCompat.wrap(drawable); + DrawableCompat.setTint(drawable, color); + menuItem.setIcon(drawable); + } + } + + public static void applyBrandToLayerDrawable(@NonNull LayerDrawable check, @IdRes int areaToColor, @ColorInt int mainColor) { + final var drawable = check.findDrawableByLayerId(areaToColor); + if (drawable == null) { + Log.e(TAG, "Could not find areaToColor (" + areaToColor + "). Cannot apply brand."); + } else { + DrawableCompat.setTint(drawable, mainColor); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..0695300699889ea609fcd45eb9fd6ce38f02f482 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java @@ -0,0 +1,412 @@ +package it.niedermann.owncloud.notes.edit; + +import static java.lang.Boolean.TRUE; +import static it.niedermann.owncloud.notes.NotesApplication.isDarkThemeActive; +import static it.niedermann.owncloud.notes.branding.BrandingUtil.tintMenuIcon; +import static it.niedermann.owncloud.notes.edit.EditNoteActivity.ACTION_SHORTCUT; +import static it.niedermann.owncloud.notes.shared.util.WidgetUtil.pendingIntentFlagCompat; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.ScrollView; + +import androidx.annotation.CallSuper; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.IconCompat; + +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.android.util.ColorUtil; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.accountpicker.AccountPickerDialogFragment; +import it.niedermann.owncloud.notes.branding.BrandedFragment; +import it.niedermann.owncloud.notes.edit.category.CategoryDialogFragment; +import it.niedermann.owncloud.notes.edit.category.CategoryDialogFragment.CategoryDialogListener; +import it.niedermann.owncloud.notes.edit.title.EditTitleDialogFragment; +import it.niedermann.owncloud.notes.edit.title.EditTitleDialogFragment.EditTitleListener; +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.ApiVersion; +import it.niedermann.owncloud.notes.shared.model.DBStatus; +import it.niedermann.owncloud.notes.shared.model.ISyncCallback; +import it.niedermann.owncloud.notes.shared.util.ApiVersionUtil; +import it.niedermann.owncloud.notes.shared.util.NoteUtil; +import it.niedermann.owncloud.notes.shared.util.NotesColorUtil; +import it.niedermann.owncloud.notes.shared.util.ShareUtil; + +public abstract class BaseNoteFragment extends BrandedFragment implements CategoryDialogListener, EditTitleListener { + + private static final String TAG = BaseNoteFragment.class.getSimpleName(); + protected final ExecutorService executor = Executors.newCachedThreadPool(); + + protected static final int MENU_ID_PIN = -1; + public static final String PARAM_NOTE_ID = "noteId"; + public static final String PARAM_ACCOUNT_ID = "accountId"; + public static final String PARAM_CONTENT = "content"; + public static final String PARAM_NEWNOTE = "newNote"; + private static final String SAVEDKEY_NOTE = "note"; + private static final String SAVEDKEY_ORIGINAL_NOTE = "original_note"; + + private Account localAccount; + + protected Note note; + // TODO do we really need this? The reference to note is currently the same + @Nullable + private Note originalNote; + private int originalScrollY; + protected NotesRepository repo; + private NoteFragmentListener listener; + private boolean titleModified = false; + + protected boolean isNew = true; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + try { + listener = (NoteFragmentListener) context; + } catch (ClassCastException e) { + throw new ClassCastException(context.getClass() + " must implement " + NoteFragmentListener.class); + } + repo = NotesRepository.getInstance(context); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + executor.submit(() -> { + try { + final var ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext().getApplicationContext()); + this.localAccount = repo.getAccountByName(ssoAccount.name); + + if (savedInstanceState == null) { + final long id = requireArguments().getLong(PARAM_NOTE_ID); + if (id > 0) { + final long accountId = requireArguments().getLong(PARAM_ACCOUNT_ID); + if (accountId > 0) { + /* Switch account if account id has been provided */ + this.localAccount = repo.getAccountById(accountId); + SingleAccountHelper.setCurrentAccount(requireContext().getApplicationContext(), localAccount.getAccountName()); + } + isNew = false; + note = originalNote = repo.getNoteById(id); + requireActivity().runOnUiThread(() -> onNoteLoaded(note)); + requireActivity().invalidateOptionsMenu(); + } else { + final var paramNote = (Note) requireArguments().getSerializable(PARAM_NEWNOTE); + final var content = requireArguments().getString(PARAM_CONTENT); + if (paramNote == null) { + if (content == null) { + throw new IllegalArgumentException(PARAM_NOTE_ID + " is not given, argument " + PARAM_NEWNOTE + " is missing and " + PARAM_CONTENT + " is missing."); + } else { + note = new Note(-1, null, Calendar.getInstance(), NoteUtil.generateNoteTitle(content), content, getString(R.string.category_readonly), false, null, DBStatus.VOID, -1, "", 0); + requireActivity().runOnUiThread(() -> onNoteLoaded(note)); + requireActivity().invalidateOptionsMenu(); + } + } else { + paramNote.setStatus(DBStatus.LOCAL_EDITED); + note = repo.addNote(localAccount.getId(), paramNote); + originalNote = null; + requireActivity().runOnUiThread(() -> onNoteLoaded(note)); + requireActivity().invalidateOptionsMenu(); + } + } + } else { + note = (Note) savedInstanceState.getSerializable(SAVEDKEY_NOTE); + originalNote = (Note) savedInstanceState.getSerializable(SAVEDKEY_ORIGINAL_NOTE); + requireActivity().runOnUiThread(() -> onNoteLoaded(note)); + requireActivity().invalidateOptionsMenu(); + } + } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + e.printStackTrace(); + } + }); + setHasOptionsMenu(true); + } + + @Nullable + protected abstract ScrollView getScrollView(); + + protected abstract void scrollToY(int scrollY); + + @Override + public void onResume() { + super.onResume(); + listener.onNoteUpdated(note); + } + + @Override + public void onPause() { + super.onPause(); + saveNote(null); + } + + @Override + public void onDetach() { + super.onDetach(); + listener = null; + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + saveNote(null); + outState.putSerializable(SAVEDKEY_NOTE, note); + outState.putSerializable(SAVEDKEY_ORIGINAL_NOTE, originalNote); + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + inflater.inflate(R.menu.menu_note_fragment, menu); + + if (ShortcutManagerCompat.isRequestPinShortcutSupported(requireContext()) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + menu.add(Menu.NONE, MENU_ID_PIN, 110, R.string.pin_to_homescreen); + } + + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + super.onPrepareOptionsMenu(menu); + if (note != null) { + prepareFavoriteOption(menu.findItem(R.id.menu_favorite)); + + final var preferredApiVersion = ApiVersionUtil.getPreferredApiVersion(localAccount.getApiVersion()); + menu.findItem(R.id.menu_title).setVisible(preferredApiVersion != null && preferredApiVersion.compareTo(ApiVersion.API_VERSION_1_0) >= 0); + menu.findItem(R.id.menu_delete).setVisible(!isNew); + } + } + + private void prepareFavoriteOption(MenuItem item) { + item.setIcon(TRUE.equals(note.getFavorite()) ? R.drawable.ic_star_white_24dp : R.drawable.ic_star_border_white_24dp); + item.setChecked(note.getFavorite()); + tintMenuIcon(item, colorAccent); + } + + /** + * Main-Menu-Handler + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == R.id.menu_cancel) { + executor.submit(() -> { + if (originalNote == null) { + repo.deleteNoteAndSync(localAccount, note.getId()); + } else { + repo.updateNoteAndSync(localAccount, originalNote, null, null, null); + } + }); + listener.close(); + return true; + } else if (itemId == R.id.menu_delete) { + repo.deleteNoteAndSync(localAccount, note.getId()); + listener.close(); + return true; + } else if (itemId == R.id.menu_favorite) { + repo.toggleFavoriteAndSync(localAccount, note.getId()); + listener.onNoteUpdated(note); + prepareFavoriteOption(item); + return true; + } else if (itemId == R.id.menu_category) { + showCategorySelector(); + return true; + } else if (itemId == R.id.menu_title) { + showEditTitleDialog(); + return true; + } else if (itemId == R.id.menu_move) { + executor.submit(() -> AccountPickerDialogFragment + .newInstance(new ArrayList<>(repo.getAccounts()), note.getAccountId()) + .show(requireActivity().getSupportFragmentManager(), BaseNoteFragment.class.getSimpleName())); + return true; + } else if (itemId == R.id.menu_share) { + ShareUtil.openShareDialog(requireContext(), note.getTitle(), note.getContent()); + return false; + } else if (itemId == MENU_ID_PIN) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + final var context = requireContext(); + if (ShortcutManagerCompat.isRequestPinShortcutSupported(context)) { + final var pinShortcutInfo = new ShortcutInfoCompat.Builder(context, String.valueOf(note.getId())) + .setShortLabel(note.getTitle()) + .setIcon(IconCompat.createWithResource(context.getApplicationContext(), TRUE.equals(note.getFavorite()) ? R.drawable.ic_star_yellow_24dp : R.drawable.ic_star_grey_ccc_24dp)) + .setIntent(new Intent(getActivity(), EditNoteActivity.class).putExtra(EditNoteActivity.PARAM_NOTE_ID, note.getId()).setAction(ACTION_SHORTCUT)) + .build(); + + ShortcutManagerCompat.requestPinShortcut(context, pinShortcutInfo, PendingIntent.getBroadcast(context, 0, ShortcutManagerCompat.createShortcutResultIntent(context, pinShortcutInfo), pendingIntentFlagCompat(0)).getIntentSender()); + } else { + Log.i(TAG, "RequestPinShortcut is not supported"); + } + } + + return true; + } + return super.onOptionsItemSelected(item); + } + + @CallSuper + protected void onNoteLoaded(Note note) { + this.originalScrollY = note.getScrollY(); + scrollToY(originalScrollY); + final var scrollView = getScrollView(); + if (scrollView != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + scrollView.setOnScrollChangeListener((View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) -> { + if (scrollY > 0) { + note.setScrollY(scrollY); + } + }); + } + } + } + + public void onCloseNote() { + if (!titleModified && originalNote == null && getContent().isEmpty()) { + repo.deleteNoteAndSync(localAccount, note.getId()); + } + } + + /** + * Save the current state in the database and schedule synchronization if needed. + * + * @param callback Observer which is called after save/synchronization + */ + protected void saveNote(@Nullable ISyncCallback callback) { + Log.d(TAG, "saveData()"); + if (note != null) { + final var newContent = getContent(); + if (note.getContent().equals(newContent)) { + if (note.getScrollY() != originalScrollY) { + Log.v(TAG, "... only saving new scroll state, since content did not change"); + repo.updateScrollY(note.getId(), note.getScrollY()); + } else { + Log.v(TAG, "... not saving, since nothing has changed"); + } + } else { + // FIXME requires database queries on main thread! + note = repo.updateNoteAndSync(localAccount, note, newContent, null, callback); + listener.onNoteUpdated(note); + requireActivity().invalidateOptionsMenu(); + } + } else { + Log.e(TAG, "note is null"); + } + } + + protected abstract String getContent(); + + /** + * Opens a dialog in order to chose a category + */ + private void showCategorySelector() { + final var fragmentId = "fragment_category"; + final var manager = requireActivity().getSupportFragmentManager(); + final var frag = manager.findFragmentByTag(fragmentId); + if (frag != null) { + manager.beginTransaction().remove(frag).commit(); + } + final var categoryFragment = CategoryDialogFragment.newInstance(note.getAccountId(), note.getCategory()); + categoryFragment.setTargetFragment(this, 0); + categoryFragment.show(manager, fragmentId); + } + + /** + * Opens a dialog in order to chose a category + */ + public void showEditTitleDialog() { + saveNote(null); + final var fragmentId = "fragment_edit_title"; + final var manager = requireActivity().getSupportFragmentManager(); + final var frag = manager.findFragmentByTag(fragmentId); + if (frag != null) { + manager.beginTransaction().remove(frag).commit(); + } + final var editTitleFragment = EditTitleDialogFragment.newInstance(note.getTitle()); + editTitleFragment.setTargetFragment(this, 0); + editTitleFragment.show(manager, fragmentId); + } + + @Override + public void onCategoryChosen(String category) { + repo.setCategory(localAccount, note.getId(), category); + note.setCategory(category); + listener.onNoteUpdated(note); + } + + @Override + public void onTitleEdited(String newTitle) { + titleModified = true; + note.setTitle(newTitle); + executor.submit(() -> { + note = repo.updateNoteAndSync(localAccount, note, note.getContent(), newTitle, null); + requireActivity().runOnUiThread(() -> listener.onNoteUpdated(note)); + }); + } + + public void moveNote(Account account) { + final var moveLiveData = repo.moveNoteToAnotherAccount(account, note); + moveLiveData.observe(this, (v) -> moveLiveData.removeObservers(this)); + listener.close(); + } + + @ColorInt + protected static int getTextHighlightBackgroundColor(@NonNull Context context, @ColorInt int mainColor, @ColorInt int colorPrimary, @ColorInt int colorAccent) { + if (isDarkThemeActive(context)) { // Dark background + if (ColorUtil.INSTANCE.isColorDark(mainColor)) { // Dark brand color + if (NotesColorUtil.contrastRatioIsSufficient(mainColor, colorPrimary)) { // But also dark text + return mainColor; + } else { + return ContextCompat.getColor(context, R.color.defaultTextHighlightBackground); + } + } else { // Light brand color + if (NotesColorUtil.contrastRatioIsSufficient(mainColor, colorAccent)) { // But also dark text + return Color.argb(77, Color.red(mainColor), Color.green(mainColor), Color.blue(mainColor)); + } else { + return ContextCompat.getColor(context, R.color.defaultTextHighlightBackground); + } + } + } else { // Light background + if (ColorUtil.INSTANCE.isColorDark(mainColor)) { // Dark brand color + if (NotesColorUtil.contrastRatioIsSufficient(mainColor, colorAccent)) { // But also dark text + return Color.argb(77, Color.red(mainColor), Color.green(mainColor), Color.blue(mainColor)); + } else { + return ContextCompat.getColor(context, R.color.defaultTextHighlightBackground); + } + } else { // Light brand color + if (NotesColorUtil.contrastRatioIsSufficient(mainColor, colorPrimary)) { // But also dark text + return mainColor; + } else { + return ContextCompat.getColor(context, R.color.defaultTextHighlightBackground); + } + } + } + } + + public interface NoteFragmentListener { + void close(); + + void onNoteUpdated(Note note); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..504f333a5bca863102a1e551fc8ddbf13b65485c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java @@ -0,0 +1,298 @@ +package it.niedermann.owncloud.notes.edit; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.preference.PreferenceManager; + +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Calendar; +import java.util.Objects; + +import it.niedermann.owncloud.notes.LockedActivity; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.accountpicker.AccountPickerListener; +import it.niedermann.owncloud.notes.databinding.ActivityEditBinding; +import it.niedermann.owncloud.notes.databinding.ActivityEditBinding; +import it.niedermann.owncloud.notes.edit.category.CategoryViewModel; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.DBStatus; +import it.niedermann.owncloud.notes.shared.model.NavigationCategory; +import it.niedermann.owncloud.notes.shared.util.NoteUtil; +import it.niedermann.owncloud.notes.shared.util.ShareUtil; + +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.FAVORITES; + +public class EditNoteActivity extends LockedActivity implements BaseNoteFragment.NoteFragmentListener, AccountPickerListener { + + private static final String TAG = EditNoteActivity.class.getSimpleName(); + + public static final String ACTION_SHORTCUT = "it.niedermann.owncloud.notes.shortcut"; + private static final String INTENT_GOOGLE_ASSISTANT = "com.google.android.gm.action.AUTO_SEND"; + private static final String MIMETYPE_TEXT_PLAIN = "text/plain"; + public static final String PARAM_NOTE_ID = "noteId"; + public static final String PARAM_ACCOUNT_ID = "accountId"; + public static final String PARAM_CATEGORY = "category"; + public static final String PARAM_CONTENT = "content"; + public static final String PARAM_FAVORITE = "favorite"; + + private CategoryViewModel categoryViewModel; + private ActivityEditBinding binding; + + private BaseNoteFragment fragment; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + try { + if (SingleAccountHelper.getCurrentSingleSignOnAccount(this) == null) { + throw new NoCurrentAccountSelectedException(); + } + } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + Toast.makeText(this, R.string.no_account_configured_yet, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + categoryViewModel = new ViewModelProvider(this).get(CategoryViewModel.class); + binding = ActivityEditBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + + if (savedInstanceState == null) { + launchNoteFragment(); + } else { + fragment = (BaseNoteFragment) getSupportFragmentManager().findFragmentById(R.id.fragment_container_view); + } + + setSupportActionBar(binding.toolbar); + binding.toolbar.setOnClickListener((v) -> fragment.showEditTitleDialog()); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + Log.d(TAG, "onNewIntent: " + intent.getLongExtra(PARAM_NOTE_ID, 0)); + setIntent(intent); + if (fragment != null) { + getSupportFragmentManager().beginTransaction().detach(fragment).commit(); + fragment = null; + } + launchNoteFragment(); + } + + private long getNoteId() { + return getIntent().getLongExtra(PARAM_NOTE_ID, 0); + } + + private long getAccountId() { + return getIntent().getLongExtra(PARAM_ACCOUNT_ID, 0); + } + + /** + * Starts the note fragment for an existing note or a new note. + * The actual behavior is triggered by the activity's intent. + */ + private void launchNoteFragment() { + long noteId = getNoteId(); + if (noteId > 0) { + launchExistingNote(getAccountId(), noteId); + } else { + if (Intent.ACTION_VIEW.equals(getIntent().getAction())) { + launchReadonlyNote(); + } else { + launchNewNote(); + } + } + } + + /** + * Starts a {@link NoteEditFragment} or {@link NotePreviewFragment} for an existing note. + * The type of fragment (view-mode) is chosen based on the user preferences. + * + * @param noteId ID of the existing note. + */ + private void launchExistingNote(long accountId, long noteId) { + final var prefKeyNoteMode = getString(R.string.pref_key_note_mode); + final var prefKeyLastMode = getString(R.string.pref_key_last_note_mode); + final var prefValueEdit = getString(R.string.pref_value_mode_edit); + final var prefValuePreview = getString(R.string.pref_value_mode_preview); + final var prefValueLast = getString(R.string.pref_value_mode_last); + + final var preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + final String mode = preferences.getString(prefKeyNoteMode, prefValueEdit); + final String lastMode = preferences.getString(prefKeyLastMode, prefValueEdit); + boolean editMode = true; + if (prefValuePreview.equals(mode) || (prefValueLast.equals(mode) && prefValuePreview.equals(lastMode))) { + editMode = false; + } + launchExistingNote(accountId, noteId, editMode); + } + + /** + * Starts a {@link NoteEditFragment} or {@link NotePreviewFragment} for an existing note. + * + * @param noteId ID of the existing note. + * @param edit View-mode of the fragment: + * true for {@link NoteEditFragment}, + * false for {@link NotePreviewFragment}. + */ + private void launchExistingNote(long accountId, long noteId, boolean edit) { + // save state of the fragment in order to resume with the same note and originalNote + Fragment.SavedState savedState = null; + if (fragment != null) { + savedState = getSupportFragmentManager().saveFragmentInstanceState(fragment); + } + fragment = edit + ? NoteEditFragment.newInstance(accountId, noteId) + : NotePreviewFragment.newInstance(accountId, noteId); + + if (savedState != null) { + fragment.setInitialSavedState(savedState); + } + getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container_view, fragment).commit(); + } + + /** + * Starts the {@link NoteEditFragment} with a new note. + * Content ("share" functionality), category and favorite attribute can be preset. + */ + private void launchNewNote() { + final var intent = getIntent(); + + String categoryTitle = ""; + boolean favorite = false; + if (intent.hasExtra(PARAM_CATEGORY)) { + final NavigationCategory categoryPreselection = (NavigationCategory) Objects.requireNonNull(intent.getSerializableExtra(PARAM_CATEGORY)); + final String category = categoryPreselection.getCategory(); + if(category != null) { + categoryTitle = category; + } + favorite = categoryPreselection.getType() == FAVORITES; + } + + String content = ""; + if ( + intent.hasExtra(Intent.EXTRA_TEXT) && + MIMETYPE_TEXT_PLAIN.equals(intent.getType()) && + (Intent.ACTION_SEND.equals(intent.getAction()) || + INTENT_GOOGLE_ASSISTANT.equals(intent.getAction())) + ) { + content = ShareUtil.extractSharedText(intent); + } else if (intent.hasExtra(PARAM_CONTENT)) { + content = intent.getStringExtra(PARAM_CONTENT); + } + + if (content == null) { + content = ""; + } + final var newNote = new Note(null, Calendar.getInstance(), NoteUtil.generateNonEmptyNoteTitle(content, this), content, categoryTitle, favorite, null); + fragment = NoteEditFragment.newInstanceWithNewNote(newNote); + getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container_view, fragment).commit(); + } + + private void launchReadonlyNote() { + final var intent = getIntent(); + final var content = new StringBuilder(); + try { + final var inputStream = getContentResolver().openInputStream(Objects.requireNonNull(intent.getData())); + final var bufferedReader = new BufferedReader(new InputStreamReader(Objects.requireNonNull(inputStream))); + String line; + while ((line = bufferedReader.readLine()) != null) { + content.append(line).append('\n'); + } + } catch (IOException e) { + e.printStackTrace(); + } + + fragment = NoteReadonlyFragment.newInstance(content.toString()); + getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container_view, fragment).commit(); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + close(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_note_activity, menu); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + close(); + return true; + } else if (itemId == R.id.menu_preview) { + launchExistingNote(getAccountId(), getNoteId(), false); + return true; + } else if (itemId == R.id.menu_edit) { + launchExistingNote(getAccountId(), getNoteId(), true); + return true; + } + return super.onOptionsItemSelected(item); + } + + + /** + * Send result and closes the Activity + */ + public void close() { + /* TODO enhancement: store last mode in note + * for cross device functionality per note mode should be stored on the server. + */ + final var preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + final String prefKeyLastMode = getString(R.string.pref_key_last_note_mode); + if (fragment instanceof NoteEditFragment) { + preferences.edit().putString(prefKeyLastMode, getString(R.string.pref_value_mode_edit)).apply(); + } else { + preferences.edit().putString(prefKeyLastMode, getString(R.string.pref_value_mode_preview)).apply(); + } + fragment.onCloseNote(); + finish(); + } + + @Override + public void onNoteUpdated(Note note) { + if (note != null) { + binding.toolbar.setTitle(note.getTitle()); + if (TextUtils.isEmpty(note.getCategory())) { + binding.toolbar.setSubtitle(null); + } else { + binding.toolbar.setSubtitle(NoteUtil.extendCategory(note.getCategory())); + } + } + } + + @Override + public void onAccountPicked(@NonNull Account account) { + fragment.moveNote(account); + } + + @Override + public void applyBrand(int mainColor, int textColor) { + applyBrandToPrimaryToolbar(binding.appBar, binding.toolbar); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..6733f4d3a36c0fd9c0ec07c800763094f1590f88 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java @@ -0,0 +1,275 @@ +package it.niedermann.owncloud.notes.edit; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.Layout; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.Log; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.ScrollView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.FragmentNoteEditBinding; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.ISyncCallback; +import it.niedermann.owncloud.notes.shared.util.DisplayUtils; + +import static androidx.core.view.ViewCompat.isAttachedToWindow; +import static it.niedermann.owncloud.notes.shared.util.NoteUtil.getFontSizeFromPreferences; + +public class NoteEditFragment extends SearchableBaseNoteFragment { + + private static final String TAG = NoteEditFragment.class.getSimpleName(); + + private static final String LOG_TAG_AUTOSAVE = "AutoSave"; + + private static final long DELAY = 2000; // Wait for this time after typing before saving + private static final long DELAY_AFTER_SYNC = 5000; // Wait for this time after saving before checking for next save + + private FragmentNoteEditBinding binding; + + private Handler handler; + private boolean saveActive; + private boolean unsavedEdit; + private final Runnable runAutoSave = new Runnable() { + @Override + public void run() { + if (unsavedEdit) { + Log.d(LOG_TAG_AUTOSAVE, "runAutoSave: start AutoSave"); + autoSave(); + } else { + Log.d(LOG_TAG_AUTOSAVE, "runAutoSave: nothing changed"); + } + } + }; + private TextWatcher textWatcher; + private boolean keyboardShown = false; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + handler = new Handler(Looper.getMainLooper()); + } + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.findItem(R.id.menu_edit).setVisible(false); + menu.findItem(R.id.menu_preview).setVisible(true); + } + + @Override + public ScrollView getScrollView() { + return binding.scrollView; + } + + @Override + protected void scrollToY(int y) { + if (binding != null) { + binding.scrollView.post(() -> binding.scrollView.setScrollY(y)); + } + } + + @Override + protected Layout getLayout() { + binding.editContent.onPreDraw(); + return binding.editContent.getLayout(); + } + + @Override + protected FloatingActionButton getSearchNextButton() { + return binding.searchNext; + } + + @Override + protected FloatingActionButton getSearchPrevButton() { + return binding.searchPrev; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { + binding = FragmentNoteEditBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + textWatcher = new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // Nothing to do here... + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // Nothing to do here... + } + + @Override + public void afterTextChanged(final Editable s) { + unsavedEdit = true; + if (!saveActive) { + handler.removeCallbacks(runAutoSave); + handler.postDelayed(runAutoSave, DELAY); + } + } + }; + } + + @Override + public void onResume() { + super.onResume(); + binding.editContent.addTextChangedListener(textWatcher); + + if (keyboardShown) { + openSoftKeyboard(); + } + } + + @Override + protected void onNoteLoaded(Note note) { + super.onNoteLoaded(note); + if (TextUtils.isEmpty(note.getContent())) { + openSoftKeyboard(); + } + + binding.editContent.setMarkdownString(note.getContent()); + binding.editContent.setEnabled(true); + + final var sp = PreferenceManager.getDefaultSharedPreferences(requireContext().getApplicationContext()); + binding.editContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, getFontSizeFromPreferences(requireContext(), sp)); + if (sp.getBoolean(getString(R.string.pref_key_font), false)) { + binding.editContent.setTypeface(Typeface.MONOSPACE); + } + } + + private void openSoftKeyboard() { + binding.editContent.postDelayed(() -> { + binding.editContent.requestFocus(); + + final var imm = (InputMethodManager) requireContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.showSoftInput(binding.editContent, InputMethodManager.SHOW_IMPLICIT); + } else { + Log.e(TAG, InputMethodManager.class.getSimpleName() + " is null."); + } + //Without a small delay the keyboard does not show reliably + }, 100); + } + + @Override + public void onPause() { + super.onPause(); + binding.editContent.removeTextChangedListener(textWatcher); + cancelTimers(); + + final ViewGroup parentView = requireActivity().findViewById(android.R.id.content); + if (parentView != null && parentView.getChildCount() > 0) { + keyboardShown = DisplayUtils.isSoftKeyboardVisible(parentView.getChildAt(0)); + } else { + keyboardShown = false; + } + } + + private void cancelTimers() { + handler.removeCallbacks(runAutoSave); + } + + /** + * Gets the current content of the EditText field in the UI. + * + * @return String of the current content. + */ + @Override + protected String getContent() { + final var editable = binding.editContent.getText(); + return editable == null ? "" : editable.toString(); + } + + @Override + protected void saveNote(@Nullable ISyncCallback callback) { + super.saveNote(callback); + unsavedEdit = false; + } + + /** + * Saves the current changes and show the status in the ActionBar + */ + private void autoSave() { + Log.d(LOG_TAG_AUTOSAVE, "STARTAUTOSAVE"); + saveActive = true; + saveNote(new ISyncCallback() { + @Override + public void onFinish() { + onSaved(); + } + + @Override + public void onScheduled() { + onSaved(); + } + + private void onSaved() { + // AFTER SYNCHRONIZATION + Log.d(LOG_TAG_AUTOSAVE, "FINISHED AUTOSAVE"); + saveActive = false; + + // AFTER "DELAY_AFTER_SYNC" SECONDS: allow next auto-save or start it directly + handler.postDelayed(runAutoSave, DELAY_AFTER_SYNC); + + } + }); + } + + @Override + protected void colorWithText(@NonNull String newText, @Nullable Integer current, int mainColor, int textColor) { + if (binding != null && isAttachedToWindow(binding.editContent)) { + binding.editContent.clearFocus(); + binding.editContent.setSearchText(newText, current); + } + } + + @Override + public void applyBrand(int mainColor, int textColor) { + super.applyBrand(mainColor, textColor); + binding.editContent.setSearchColor(mainColor); + binding.editContent.setHighlightColor(getTextHighlightBackgroundColor(requireContext(), mainColor, colorPrimary, colorAccent)); + } + + public static BaseNoteFragment newInstance(long accountId, long noteId) { + final var fragment = new NoteEditFragment(); + final var args = new Bundle(); + args.putLong(PARAM_NOTE_ID, noteId); + args.putLong(PARAM_ACCOUNT_ID, accountId); + fragment.setArguments(args); + return fragment; + } + + public static BaseNoteFragment newInstanceWithNewNote(Note newNote) { + final var fragment = new NoteEditFragment(); + final var args = new Bundle(); + args.putSerializable(PARAM_NEWNOTE, newNote); + fragment.setArguments(args); + return fragment; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..9d26f50064be677dc5bdb6cab9ae500c65907bab --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java @@ -0,0 +1,195 @@ +package it.niedermann.owncloud.notes.edit; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.os.Bundle; +import android.text.Layout; +import android.text.method.LinkMovementMethod; +import android.util.Log; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ScrollView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.FragmentNotePreviewBinding; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.util.SSOUtil; + +import static androidx.core.view.ViewCompat.isAttachedToWindow; +import static it.niedermann.owncloud.notes.shared.util.NoteUtil.getFontSizeFromPreferences; + +public class NotePreviewFragment extends SearchableBaseNoteFragment implements OnRefreshListener { + + private static final String TAG = NotePreviewFragment.class.getSimpleName(); + + private String changedText; + + protected FragmentNotePreviewBinding binding; + + private boolean noteLoaded = false; + + @Nullable + private Runnable setScrollY; + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.findItem(R.id.menu_edit).setVisible(true); + menu.findItem(R.id.menu_preview).setVisible(false); + } + + @Override + public ScrollView getScrollView() { + return binding.scrollView; + } + + @Override + protected synchronized void scrollToY(int y) { + this.setScrollY = () -> { + if (binding != null) { + Log.v("SCROLL set (preview) to", y + ""); + binding.scrollView.post(() -> binding.scrollView.setScrollY(y)); + } + setScrollY = null; + }; + } + + @Override + protected FloatingActionButton getSearchNextButton() { + return binding.searchNext; + } + + @Override + protected FloatingActionButton getSearchPrevButton() { + return binding.searchPrev; + } + + @Override + protected Layout getLayout() { + binding.singleNoteContent.onPreDraw(); + return binding.singleNoteContent.getLayout(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup + container, @Nullable Bundle savedInstanceState) { + binding = FragmentNotePreviewBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + binding.swiperefreshlayout.setOnRefreshListener(this); + registerInternalNoteLinkHandler(); + binding.singleNoteContent.setMovementMethod(LinkMovementMethod.getInstance()); + + final var sp = PreferenceManager.getDefaultSharedPreferences(requireActivity().getApplicationContext()); + binding.singleNoteContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, getFontSizeFromPreferences(requireContext(), sp)); + if (sp.getBoolean(getString(R.string.pref_key_font), false)) { + binding.singleNoteContent.setTypeface(Typeface.MONOSPACE); + } + } + + @Override + protected void onNoteLoaded(Note note) { + super.onNoteLoaded(note); + noteLoaded = true; + registerInternalNoteLinkHandler(); + changedText = note.getContent(); + binding.singleNoteContent.setMarkdownString(note.getContent(), setScrollY); + binding.singleNoteContent.getMarkdownString().observe(requireActivity(), (newContent) -> { + changedText = newContent.toString(); + saveNote(null); + }); + } + + protected void registerInternalNoteLinkHandler() { + binding.singleNoteContent.registerOnLinkClickCallback((link) -> { + try { + final long noteLocalId = repo.getLocalIdByRemoteId(this.note.getAccountId(), Long.parseLong(link)); + Log.i(TAG, "Found note for remoteId \"" + link + "\" in account \"" + this.note.getAccountId() + "\" with localId + \"" + noteLocalId + "\". Attempt to open " + EditNoteActivity.class.getSimpleName() + " for this note."); + startActivity(new Intent(requireActivity().getApplicationContext(), EditNoteActivity.class).putExtra(EditNoteActivity.PARAM_NOTE_ID, noteLocalId)); + return true; + } catch (NumberFormatException e) { + // Clicked link is not a long and therefore can't be a remote id. + } catch (IllegalArgumentException e) { + Log.i(TAG, "It looks like \"" + link + "\" might be a remote id of a note, but a note with this remote id could not be found in account \"" + note.getAccountId() + "\" .", e); + } + return false; + }); + } + + @Override + protected void colorWithText(@NonNull String newText, @Nullable Integer current, int mainColor, int textColor) { + if (binding != null && isAttachedToWindow(binding.singleNoteContent)) { + binding.singleNoteContent.clearFocus(); + binding.singleNoteContent.setSearchText(newText, current); + } + } + + @Override + protected String getContent() { + return changedText; + } + + @Override + public void onRefresh() { + if (noteLoaded && repo.isSyncPossible() && SSOUtil.isConfigured(getContext())) { + binding.swiperefreshlayout.setRefreshing(true); + executor.submit(() -> { + try { + final var account = repo.getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()).name); + repo.addCallbackPull(account, () -> executor.submit(() -> { + note = repo.getNoteById(note.getId()); + changedText = note.getContent(); + requireActivity().runOnUiThread(() -> { + binding.singleNoteContent.setMarkdownString(note.getContent()); + binding.swiperefreshlayout.setRefreshing(false); + }); + })); + repo.scheduleSync(account, false); + } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + e.printStackTrace(); + } + }); + } else { + binding.swiperefreshlayout.setRefreshing(false); + Toast.makeText(requireContext(), getString(R.string.error_sync, getString(R.string.error_no_network)), Toast.LENGTH_LONG).show(); + } + } + + @Override + public void applyBrand(int mainColor, int textColor) { + super.applyBrand(mainColor, textColor); + binding.singleNoteContent.setSearchColor(mainColor); + binding.singleNoteContent.setHighlightColor(getTextHighlightBackgroundColor(requireContext(), mainColor, colorPrimary, colorAccent)); + } + + public static BaseNoteFragment newInstance(long accountId, long noteId) { + final var fragment = new NotePreviewFragment(); + final var args = new Bundle(); + args.putLong(PARAM_NOTE_ID, noteId); + args.putLong(PARAM_ACCOUNT_ID, accountId); + fragment.setArguments(args); + return fragment; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteReadonlyFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteReadonlyFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..55771c247909e060ce86cd15dd174f6130398a9a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteReadonlyFragment.java @@ -0,0 +1,69 @@ +package it.niedermann.owncloud.notes.edit; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.shared.model.ISyncCallback; + +public class NoteReadonlyFragment extends NotePreviewFragment { + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.findItem(R.id.menu_favorite).setVisible(false); + menu.findItem(R.id.menu_edit).setVisible(false); + menu.findItem(R.id.menu_preview).setVisible(false); + menu.findItem(R.id.menu_cancel).setVisible(false); + menu.findItem(R.id.menu_delete).setVisible(false); + menu.findItem(R.id.menu_share).setVisible(false); + menu.findItem(R.id.menu_move).setVisible(false); + menu.findItem(R.id.menu_category).setVisible(false); + menu.findItem(R.id.menu_title).setVisible(false); + if (menu.findItem(MENU_ID_PIN) != null) + menu.findItem(MENU_ID_PIN).setVisible(false); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + binding.singleNoteContent.setEnabled(false); + binding.swiperefreshlayout.setEnabled(false); + return binding.getRoot(); + } + + @Override + protected void registerInternalNoteLinkHandler() { + // Do nothing + } + + @Override + public void showEditTitleDialog() { + // Do nothing + } + + @Override + public void onCloseNote() { + // Do nothing + } + + @Override + protected void saveNote(@Nullable ISyncCallback callback) { + // Do nothing + } + + public static BaseNoteFragment newInstance(String content) { + final var fragment = new NoteReadonlyFragment(); + final var args = new Bundle(); + args.putString(PARAM_CONTENT, content); + fragment.setArguments(args); + return fragment; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..794bfee71c82a68458400c14ab6b1f9684cac3e1 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java @@ -0,0 +1,301 @@ +package it.niedermann.owncloud.notes.edit; + +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.text.Layout; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.LinearLayout; +import android.widget.ScrollView; + +import androidx.annotation.CallSuper; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SearchView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandedActivity; + +public abstract class SearchableBaseNoteFragment extends BaseNoteFragment { + + private static final String TAG = SearchableBaseNoteFragment.class.getSimpleName(); + private static final String saved_instance_key_searchQuery = "searchQuery"; + private static final String saved_instance_key_currentOccurrence = "currentOccurrence"; + + private int currentOccurrence = 1; + private int occurrenceCount = 0; + private SearchView searchView; + private String searchQuery = null; + private static final int delay = 50; // If the search string does not change after $delay ms, then the search task starts. + + @ColorInt + private int mainColor; + @ColorInt + private int textColor; + + @Override + public void onStart() { + this.mainColor = getResources().getColor(R.color.defaultBrand); + this.textColor = Color.WHITE; + super.onStart(); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState != null) { + searchQuery = savedInstanceState.getString(saved_instance_key_searchQuery, ""); + currentOccurrence = savedInstanceState.getInt(saved_instance_key_currentOccurrence, 1); + } + } + + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + super.onPrepareOptionsMenu(menu); + + final var searchMenuItem = menu.findItem(R.id.search); + searchView = (SearchView) searchMenuItem.getActionView(); + + if (!TextUtils.isEmpty(searchQuery) && isNew) { + searchMenuItem.expandActionView(); + searchView.setQuery(searchQuery, true); + searchView.clearFocus(); + } + + searchMenuItem.collapseActionView(); + + final var searchEditFrame = searchView.findViewById(R.id + .search_edit_frame); + + searchEditFrame.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + int oldVisibility = -1; + + @Override + public void onGlobalLayout() { + final int currentVisibility = searchEditFrame.getVisibility(); + + if (currentVisibility != oldVisibility) { + if (currentVisibility != View.VISIBLE) { + colorWithText("", null, mainColor, textColor); + searchQuery = ""; + hideSearchFabs(); + } else { + jumpToOccurrence(); + colorWithText(searchQuery, null, mainColor, textColor); + occurrenceCount = countOccurrences(getContent(), searchQuery); + showSearchFabs(); + } + + oldVisibility = currentVisibility; + } + } + + }); + + final var next = getSearchNextButton(); + final var prev = getSearchPrevButton(); + + if (next != null) { + next.setOnClickListener(v -> { + currentOccurrence++; + jumpToOccurrence(); + colorWithText(searchView.getQuery().toString(), currentOccurrence, mainColor, textColor); + }); + } + + if (prev != null) { + prev.setOnClickListener(v -> { + occurrenceCount = countOccurrences(getContent(), searchView.getQuery().toString()); + currentOccurrence--; + jumpToOccurrence(); + colorWithText(searchView.getQuery().toString(), currentOccurrence, mainColor, textColor); + }); + } + + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + private DelayQueryRunnable delayQueryTask; + private final Handler handler = new Handler(); + + @Override + public boolean onQueryTextSubmit(@NonNull String query) { + currentOccurrence++; + jumpToOccurrence(); + colorWithText(query, currentOccurrence, mainColor, textColor); + return true; + } + + @Override + public boolean onQueryTextChange(@NonNull String newText) { + queryWithHandler(newText); + return true; + } + + private void queryMatch(@NonNull String newText) { + searchQuery = newText; + occurrenceCount = countOccurrences(getContent(), searchQuery); + if (occurrenceCount > 1) { + showSearchFabs(); + } else { + hideSearchFabs(); + } + currentOccurrence = 1; + jumpToOccurrence(); + colorWithText(searchQuery, currentOccurrence, mainColor, textColor); + } + + private void queryWithHandler(@NonNull String newText) { + if (delayQueryTask != null) { + delayQueryTask.cancel(); + handler.removeCallbacksAndMessages(null); + } + delayQueryTask = new DelayQueryRunnable(newText); + // If there is only one char in the search pattern, we should start the search immediately. + handler.postDelayed(delayQueryTask, newText.length() > 1 ? delay : 0); + } + + class DelayQueryRunnable implements Runnable { + private String text; + private boolean canceled = false; + + public DelayQueryRunnable(String text) { + this.text = text; + } + + @Override + public void run() { + if (canceled) { + return; + } + queryMatch(text); + } + + public void cancel() { + canceled = true; + } + } + }); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + + if (searchView != null && !TextUtils.isEmpty(searchView.getQuery().toString())) { + outState.putString(saved_instance_key_searchQuery, searchView.getQuery().toString()); + outState.putInt(saved_instance_key_currentOccurrence, currentOccurrence); + } + } + + protected abstract void colorWithText(@NonNull String newText, @Nullable Integer current, int mainColor, int textColor); + + protected abstract Layout getLayout(); + + protected abstract FloatingActionButton getSearchNextButton(); + + protected abstract FloatingActionButton getSearchPrevButton(); + + private void showSearchFabs() { + final var next = getSearchNextButton(); + final var prev = getSearchPrevButton(); + if (prev != null) { + prev.show(); + } + if (next != null) { + next.show(); + } + } + + private void hideSearchFabs() { + final var next = getSearchNextButton(); + final var prev = getSearchPrevButton(); + if (prev != null) { + prev.hide(); + } + if (next != null) { + next.hide(); + } + } + + private void jumpToOccurrence() { + final var layout = getLayout(); + if (layout == null) { + Log.w(TAG, "getLayout() is null"); + } else if (getContent() == null || getContent().isEmpty()) { + Log.w(TAG, "getContent is null or empty"); + } else if (currentOccurrence < 1) { + // if currentOccurrence is lower than 1, jump to last occurrence + currentOccurrence = occurrenceCount; + jumpToOccurrence(); + } else if (searchQuery != null && !searchQuery.isEmpty()) { + final String currentContent = getContent().toLowerCase(); + final int indexOfNewText = indexOfNth(currentContent, searchQuery.toLowerCase(), 0, currentOccurrence); + if (indexOfNewText <= 0) { + // Search term is not n times in text + // Go back to first search result + if (currentOccurrence != 1) { + currentOccurrence = 1; + jumpToOccurrence(); + } + return; + } + final String textUntilFirstOccurrence = currentContent.substring(0, indexOfNewText); + final int numberLine = layout.getLineForOffset(textUntilFirstOccurrence.length()); + + if (numberLine >= 0) { + final var scrollView = getScrollView(); + if (scrollView != null) { + scrollView.post(() -> scrollView.smoothScrollTo(0, layout.getLineTop(numberLine))); + } + } + } + } + + private static int indexOfNth(String input, String value, int startIndex, int nth) { + if (nth < 1) + throw new IllegalArgumentException("Param 'nth' must be greater than 0!"); + if (nth == 1) + return input.indexOf(value, startIndex); + final int idx = input.indexOf(value, startIndex); + if (idx == -1) + return -1; + return indexOfNth(input, value, idx + 1, nth - 1); + } + + private static int countOccurrences(String haystack, String needle) { + if (haystack == null || haystack.isEmpty() || needle == null || needle.isEmpty()) { + return 0; + } + // Use regrex which is faster before. + // Such that the main thread will not stop for a long tilme + // And so there will not an ANR problem + final var matcher = Pattern.compile(needle, Pattern.CASE_INSENSITIVE | Pattern.LITERAL) + .matcher(haystack); + + int count = 0; + while (matcher.find()) { + count++; + } + return count; + } + + @CallSuper + @Override + public void applyBrand(int mainColor, int textColor) { + this.mainColor = mainColor; + this.textColor = textColor; + BrandedActivity.applyBrandToFAB(mainColor, textColor, getSearchPrevButton()); + BrandedActivity.applyBrandToFAB(mainColor, textColor, getSearchNextButton()); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..cae09cc02c5cd26c284db9cd1daa9bc44a3dd7ab --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryAdapter.java @@ -0,0 +1,134 @@ +package it.niedermann.owncloud.notes.edit.category; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ItemCategoryBinding; +import it.niedermann.owncloud.notes.main.navigation.NavigationItem; +import it.niedermann.owncloud.notes.shared.util.NoteUtil; + +public class CategoryAdapter extends RecyclerView.Adapter { + + private static final String clearItemId = "clear_item"; + private static final String addItemId = "add_item"; + @NonNull + private final List categories = new ArrayList<>(); + @NonNull + private final CategoryListener listener; + private final Context context; + + CategoryAdapter(@NonNull Context context, @NonNull CategoryListener categoryListener) { + this.context = context; + this.listener = categoryListener; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final var view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_category, parent, false); + return new CategoryViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + final var category = categories.get(position); + final var categoryViewHolder = (CategoryViewHolder) holder; + + switch (category.id) { + case addItemId: + final var wrapDrawable = DrawableCompat.wrap(ContextCompat.getDrawable(context, category.icon)); + DrawableCompat.setTint(wrapDrawable, ContextCompat.getColor(context, R.color.icon_color_default)); + categoryViewHolder.getIcon().setImageDrawable(wrapDrawable); + categoryViewHolder.getCategoryWrapper().setOnClickListener((v) -> listener.onCategoryAdded()); + break; + case clearItemId: + categoryViewHolder.getIcon().setImageDrawable(ContextCompat.getDrawable(context, category.icon)); + categoryViewHolder.getCategoryWrapper().setOnClickListener((v) -> listener.onCategoryCleared()); + break; + default: + categoryViewHolder.getIcon().setImageDrawable(ContextCompat.getDrawable(context, category.icon)); + categoryViewHolder.getCategoryWrapper().setOnClickListener((v) -> listener.onCategoryChosen(category.label)); + break; + } + categoryViewHolder.getCategory().setText(NoteUtil.extendCategory(category.label)); + if (category.count != null && category.count > 0) { + categoryViewHolder.getCount().setText(String.valueOf(category.count)); + } else { + categoryViewHolder.getCount().setVisibility(View.GONE); + } + } + + @Override + public int getItemCount() { + return categories.size(); + } + + static class CategoryViewHolder extends RecyclerView.ViewHolder { + private final ItemCategoryBinding binding; + + private CategoryViewHolder(View view) { + super(view); + binding = ItemCategoryBinding.bind(view); + } + + private View getCategoryWrapper() { + return binding.categoryWrapper; + } + + private AppCompatImageView getIcon() { + return binding.icon; + } + + private TextView getCategory() { + return binding.category; + } + + private TextView getCount() { + return binding.count; + } + } + + void setCategoryList(List categories, @Nullable String currentSearchString) { + this.categories.clear(); + this.categories.addAll(categories); + final NavigationItem clearItem = new NavigationItem(clearItemId, context.getString(R.string.no_category), 0, R.drawable.ic_clear_grey_24dp); + this.categories.add(0, clearItem); + if (currentSearchString != null && currentSearchString.trim().length() > 0) { + boolean currentSearchStringIsInCategories = false; + for (final var category : categories) { + if (currentSearchString.equals(category.label)) { + currentSearchStringIsInCategories = true; + break; + } + } + if (!currentSearchStringIsInCategories) { + final var addItem = new NavigationItem(addItemId, context.getString(R.string.add_category, currentSearchString.trim()), 0, R.drawable.ic_add_blue_24dp); + this.categories.add(addItem); + } + } + notifyDataSetChanged(); + } + + public interface CategoryListener { + void onCategoryChosen(String category); + + void onCategoryAdded(); + + void onCategoryCleared(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryDialogFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryDialogFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..530a55497ca2c8e68662dc1f540606d04557d19c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryDialogFragment.java @@ -0,0 +1,197 @@ +package it.niedermann.owncloud.notes.edit.category; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.Log; +import android.view.View; +import android.view.WindowManager; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModelProvider; + +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandedAlertDialogBuilder; +import it.niedermann.owncloud.notes.branding.BrandedDialogFragment; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.databinding.DialogChangeCategoryBinding; +import it.niedermann.owncloud.notes.main.navigation.NavigationItem; + +/** + * This {@link DialogFragment} allows for the selection of a category. + * It targetFragment is set it must implement the interface {@link CategoryDialogListener}. + * The calling Activity must implement the interface {@link CategoryDialogListener}. + */ +public class CategoryDialogFragment extends BrandedDialogFragment { + + private static final String TAG = CategoryDialogFragment.class.getSimpleName(); + private static final String STATE_CATEGORY = "category"; + + private CategoryViewModel viewModel; + private DialogChangeCategoryBinding binding; + + private CategoryDialogListener listener; + + private CategoryAdapter adapter; + + private EditText editCategory; + + private LiveData> categoryLiveData; + + @Override + public void applyBrand(int mainColor, int textColor) { + BrandingUtil.applyBrandToEditText(mainColor, textColor, binding.search); + } + + /** + * Interface that must be implemented by the calling Activity. + */ + public interface CategoryDialogListener { + /** + * This method is called after the user has chosen a category. + * + * @param category Name of the category which was chosen by the user. + */ + void onCategoryChosen(String category); + } + + public static final String PARAM_ACCOUNT_ID = "account_id"; + public static final String PARAM_CATEGORY = "category"; + + private long accountId; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (getArguments() != null && requireArguments().containsKey(PARAM_ACCOUNT_ID)) { + accountId = requireArguments().getLong(PARAM_ACCOUNT_ID); + } else { + throw new IllegalArgumentException("Provide at least \"" + PARAM_ACCOUNT_ID + "\""); + } + final var target = getTargetFragment(); + if (target instanceof CategoryDialogListener) { + listener = (CategoryDialogListener) target; + } else if (getActivity() instanceof CategoryDialogListener) { + listener = (CategoryDialogListener) getActivity(); + } else { + throw new IllegalArgumentException("Calling activity or target fragment must implement " + CategoryDialogListener.class.getSimpleName()); + } + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + this.viewModel = new ViewModelProvider(requireActivity()).get(CategoryViewModel.class); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final var dialogView = View.inflate(getContext(), R.layout.dialog_change_category, null); + binding = DialogChangeCategoryBinding.bind(dialogView); + this.editCategory = binding.search; + + if (savedInstanceState == null) { + if (requireArguments().containsKey(PARAM_CATEGORY)) { + editCategory.setText(requireArguments().getString(PARAM_CATEGORY)); + } + } else if (savedInstanceState.containsKey(STATE_CATEGORY)) { + editCategory.setText(savedInstanceState.getString(STATE_CATEGORY)); + } + + adapter = new CategoryAdapter(requireContext(), new CategoryAdapter.CategoryListener() { + @Override + public void onCategoryChosen(String category) { + listener.onCategoryChosen(category); + dismiss(); + } + + @Override + public void onCategoryAdded() { + listener.onCategoryChosen(editCategory.getText().toString()); + dismiss(); + } + + @Override + public void onCategoryCleared() { + listener.onCategoryChosen(""); + dismiss(); + } + }); + + binding.recyclerView.setAdapter(adapter); + + categoryLiveData = viewModel.getCategories(accountId); + categoryLiveData.observe(requireActivity(), categories -> adapter.setCategoryList(categories, binding.search.getText().toString())); + + editCategory.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // Nothing to do here... + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // Nothing to do here... + } + + @Override + public void afterTextChanged(Editable s) { + viewModel.postSearchTerm(editCategory.getText().toString()); + } + }); + + return new BrandedAlertDialogBuilder(getActivity()) + .setTitle(R.string.change_category_title) + .setView(dialogView) + .setCancelable(true) + .setPositiveButton(R.string.action_edit_save, (dialog, which) -> listener.onCategoryChosen(editCategory.getText().toString())) + .setNegativeButton(R.string.simple_cancel, null) + .create(); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putString(STATE_CATEGORY, editCategory.getText().toString()); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + if (editCategory.getText() == null || editCategory.getText().length() == 0) { + editCategory.requestFocus(); + if (getDialog() != null && getDialog().getWindow() != null) { + getDialog().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + } else { + Log.w(TAG, "can not set SOFT_INPUT_STATE_ALWAYAS_VISIBLE because getWindow() == null"); + } + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (categoryLiveData != null) { + categoryLiveData.removeObservers(requireActivity()); + } + } + + public static DialogFragment newInstance(long accountId, String category) { + final var categoryFragment = new CategoryDialogFragment(); + final var args = new Bundle(); + args.putString(CategoryDialogFragment.PARAM_CATEGORY, category); + args.putLong(CategoryDialogFragment.PARAM_ACCOUNT_ID, accountId); + categoryFragment.setArguments(args); + return categoryFragment; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryViewModel.java new file mode 100644 index 0000000000000000000000000000000000000000..ea5efd37aae1da727b9c46761d8556e606239ddc --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryViewModel.java @@ -0,0 +1,42 @@ +package it.niedermann.owncloud.notes.edit.category; + +import android.app.Application; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import java.util.List; + +import it.niedermann.owncloud.notes.main.navigation.NavigationItem; +import it.niedermann.owncloud.notes.persistence.NotesRepository; + +import static androidx.lifecycle.Transformations.map; +import static androidx.lifecycle.Transformations.switchMap; +import static it.niedermann.owncloud.notes.shared.util.DisplayUtils.convertToCategoryNavigationItem; + +public class CategoryViewModel extends AndroidViewModel { + + private final NotesRepository repo; + + @NonNull + private final MutableLiveData searchTerm = new MutableLiveData<>(""); + + public CategoryViewModel(@NonNull Application application) { + super(application); + repo = NotesRepository.getInstance(application); + } + + public void postSearchTerm(@NonNull String searchTerm) { + this.searchTerm.postValue(searchTerm); + } + + @NonNull + public LiveData> getCategories(long accountId) { + return switchMap(this.searchTerm, searchTerm -> + map(repo.searchCategories$(accountId, TextUtils.isEmpty(searchTerm) ? "%" : "%" + searchTerm + "%"), + categories -> convertToCategoryNavigationItem(getApplication(), categories))); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/title/EditTitleDialogFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/title/EditTitleDialogFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..d372cdcb7b33b73ad0c37d7e5bf3e66642dab8e5 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/title/EditTitleDialogFragment.java @@ -0,0 +1,96 @@ +package it.niedermann.owncloud.notes.edit.title; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.DialogEditTitleBinding; + +public class EditTitleDialogFragment extends DialogFragment { + + private static final String TAG = EditTitleDialogFragment.class.getSimpleName(); + static final String PARAM_OLD_TITLE = "old_title"; + private DialogEditTitleBinding binding; + + private String oldTitle; + private EditTitleListener listener; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + final var args = getArguments(); + if (args == null) { + throw new IllegalArgumentException("Provide at least " + PARAM_OLD_TITLE); + } + oldTitle = args.getString(PARAM_OLD_TITLE); + + if (getTargetFragment() instanceof EditTitleListener) { + listener = (EditTitleListener) getTargetFragment(); + } else if (getActivity() instanceof EditTitleListener) { + listener = (EditTitleListener) getActivity(); + } else { + throw new IllegalArgumentException("Calling activity or target fragment must implement " + EditTitleListener.class.getSimpleName()); + } + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + final var dialogView = View.inflate(getContext(), R.layout.dialog_edit_title, null); + binding = DialogEditTitleBinding.bind(dialogView); + + if (savedInstanceState == null) { + binding.title.setText(oldTitle); + } + + return new AlertDialog.Builder(getActivity()) + .setTitle(R.string.change_note_title) + .setView(dialogView) + .setCancelable(true) + .setPositiveButton(R.string.action_edit_save, (dialog, which) -> listener.onTitleEdited(binding.title.getText().toString())) + .setNegativeButton(R.string.simple_cancel, null) + .create(); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + binding.title.requestFocus(); + final var window = requireDialog().getWindow(); + if (window != null) { + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + } else { + Log.w(TAG, "can not enable soft keyboard because " + Window.class.getSimpleName() + " is null."); + } + } + + public static DialogFragment newInstance(String title) { + final var fragment = new EditTitleDialogFragment(); + final var args = new Bundle(); + args.putString(PARAM_OLD_TITLE, title); + fragment.setArguments(args); + return fragment; + } + + /** + * Interface that must be implemented by the calling Activity. + */ + public interface EditTitleListener { + /** + * This method is called after the user has changed the title of a note manually. + * + * @param newTitle the new title that a user submitted + */ + void onTitleEdited(String newTitle); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/exception/ExceptionActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/exception/ExceptionActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..9664dfaf153255624f54a4ef1073a7e2d13fe7e5 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/exception/ExceptionActivity.java @@ -0,0 +1,62 @@ +package it.niedermann.owncloud.notes.exception; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import java.util.Collections; + +import it.niedermann.android.util.ClipboardUtil; +import it.niedermann.nextcloud.exception.ExceptionUtil; +import it.niedermann.owncloud.notes.BuildConfig; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ActivityExceptionBinding; +import it.niedermann.owncloud.notes.exception.tips.TipsAdapter; + + +public class ExceptionActivity extends AppCompatActivity { + + private static final String KEY_THROWABLE = "throwable"; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final var binding = ActivityExceptionBinding.inflate(getLayoutInflater()); + + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + + var throwable = ((Throwable) getIntent().getSerializableExtra(KEY_THROWABLE)); + + if (throwable == null) { + throwable = new Exception("Could not get exception"); + } + + final var adapter = new TipsAdapter(this::startActivity); + final String debugInfos = ExceptionUtil.INSTANCE.getDebugInfos(this, throwable); + + binding.tips.setAdapter(adapter); + binding.tips.setNestedScrollingEnabled(false); + binding.toolbar.setTitle(getString(R.string.simple_error)); + binding.message.setText(throwable.getMessage()); + binding.stacktrace.setText(debugInfos); + binding.copy.setOnClickListener((v) -> ClipboardUtil.INSTANCE.copyToClipboard(this, getString(R.string.simple_exception), "```\n" + debugInfos + "\n```")); + binding.close.setOnClickListener((v) -> finish()); + + adapter.setThrowables(Collections.singletonList(throwable)); + } + + @NonNull + public static Intent createIntent(@NonNull Context context, Throwable throwable) { + final var args = new Bundle(); + args.putSerializable(KEY_THROWABLE, throwable); + return new Intent(context, ExceptionActivity.class) + .putExtras(args) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/exception/ExceptionDialogFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/exception/ExceptionDialogFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..b38f3495e35b3b752f20ccd8c6a9515234015a56 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/exception/ExceptionDialogFragment.java @@ -0,0 +1,90 @@ +package it.niedermann.owncloud.notes.exception; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatDialogFragment; +import androidx.fragment.app.DialogFragment; + +import java.util.ArrayList; + +import it.niedermann.android.util.ClipboardUtil; +import it.niedermann.nextcloud.exception.ExceptionUtil; +import it.niedermann.owncloud.notes.BuildConfig; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.DialogExceptionBinding; +import it.niedermann.owncloud.notes.exception.tips.TipsAdapter; + +public class ExceptionDialogFragment extends AppCompatDialogFragment { + + private static final String KEY_THROWABLES = "throwables"; + public static final String INTENT_EXTRA_BUTTON_TEXT = "button_text"; + + @NonNull + private final ArrayList throwables = new ArrayList<>(); + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + final var args = getArguments(); + if (args != null) { + final var throwablesArgument = args.getSerializable(KEY_THROWABLES); + if (throwablesArgument instanceof Iterable) { + for (final var arg : (Iterable) throwablesArgument) { + if (arg instanceof Throwable) { + throwables.add((Throwable) arg); + } else { + throw new IllegalArgumentException("Expected all " + KEY_THROWABLES + " to be instance of " + Throwable.class.getSimpleName()); + } + } + } else { + throw new IllegalArgumentException(KEY_THROWABLES + " needs to be an " + Iterable.class.getSimpleName() + "<" + Throwable.class.getSimpleName() + ">"); + } + } + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final var view = View.inflate(getContext(), R.layout.dialog_exception, null); + final var binding = DialogExceptionBinding.bind(view); + + final var adapter = new TipsAdapter((actionIntent) -> requireActivity().startActivity(actionIntent)); + + final String debugInfos = ExceptionUtil.INSTANCE.getDebugInfos(requireContext(), throwables); + + binding.tips.setAdapter(adapter); + binding.stacktrace.setText(debugInfos); + + adapter.setThrowables(throwables); + + return new AlertDialog.Builder(requireActivity()) + .setView(binding.getRoot()) + .setTitle(R.string.error_dialog_title) + .setPositiveButton(android.R.string.copy, (a, b) -> ClipboardUtil.INSTANCE.copyToClipboard(requireContext(), getString(R.string.simple_exception), "```\n" + debugInfos + "\n```")) + .setNegativeButton(R.string.simple_close, null) + .create(); + } + + public static DialogFragment newInstance(ArrayList exceptions) { + final var args = new Bundle(); + args.putSerializable(KEY_THROWABLES, exceptions); + final var fragment = new ExceptionDialogFragment(); + fragment.setArguments(args); + return fragment; + } + + public static DialogFragment newInstance(Throwable exception) { + final var args = new Bundle(); + final var list = new ArrayList(1); + list.add(exception); + args.putSerializable(KEY_THROWABLES, list); + final var fragment = new ExceptionDialogFragment(); + fragment.setArguments(args); + return fragment; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/exception/ExceptionHandler.java b/app/src/main/java/it/niedermann/owncloud/notes/exception/ExceptionHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..7e90bd72469da88e05c0452b3b4ed140e9e0d522 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/exception/ExceptionHandler.java @@ -0,0 +1,27 @@ +package it.niedermann.owncloud.notes.exception; + +import android.app.Activity; +import android.util.Log; + +import androidx.annotation.NonNull; + + +public class ExceptionHandler implements Thread.UncaughtExceptionHandler { + + private static final String TAG = ExceptionHandler.class.getSimpleName(); + + @NonNull + private final Activity activity; + + public ExceptionHandler(@NonNull Activity activity) { + this.activity = activity; + } + + @Override + public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) { + Log.e(TAG, e.getMessage(), e); + activity.getApplicationContext().startActivity(ExceptionActivity.createIntent(activity.getApplicationContext(), e)); + activity.finish(); + Runtime.getRuntime().exit(0); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/exception/IntendedOfflineException.java b/app/src/main/java/it/niedermann/owncloud/notes/exception/IntendedOfflineException.java new file mode 100644 index 0000000000000000000000000000000000000000..1cd1f206fbbb43b64bd30c9f6733665cc27b06f5 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/exception/IntendedOfflineException.java @@ -0,0 +1,14 @@ +package it.niedermann.owncloud.notes.exception; + +import androidx.annotation.NonNull; + +/** + * This type of {@link Exception} occurs, when a user has an active internet connection but decided by intention not to use it. + * Example: "Sync only on Wi-Fi" is set to true, Wi-Fi is not connected, mobile data is available + */ +public class IntendedOfflineException extends Exception { + + public IntendedOfflineException(@NonNull String message) { + super(message); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/exception/tips/TipsAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/exception/tips/TipsAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..f82b612dbbad966b6a20e35567426511a562143c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/exception/tips/TipsAdapter.java @@ -0,0 +1,126 @@ +package it.niedermann.owncloud.notes.exception.tips; + +import static android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS; +import static it.niedermann.owncloud.notes.exception.ExceptionDialogFragment.INTENT_EXTRA_BUTTON_TEXT; + +import android.content.Intent; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.Settings; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import com.nextcloud.android.sso.exceptions.NextcloudApiNotRespondingException; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppNotSupportedException; +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; +import com.nextcloud.android.sso.exceptions.TokenMismatchException; +import com.nextcloud.android.sso.exceptions.UnknownErrorException; + +import org.json.JSONException; + +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.util.LinkedList; +import java.util.List; + +import it.niedermann.owncloud.notes.BuildConfig; +import it.niedermann.owncloud.notes.R; + +public class TipsAdapter extends RecyclerView.Adapter { + + @NonNull + private final Consumer actionButtonClickedListener; + @NonNull + private final List tips = new LinkedList<>(); + + public TipsAdapter(@NonNull Consumer actionButtonClickedListener) { + this.actionButtonClickedListener = actionButtonClickedListener; + } + + @NonNull + @Override + public TipsViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final var view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_tip, parent, false); + return new TipsViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull TipsViewHolder holder, int position) { + holder.bind(tips.get(position), actionButtonClickedListener); + } + + @Override + public int getItemCount() { + return tips.size(); + } + + public void setThrowables(@NonNull List throwables) { + for (final var throwable : throwables) { + if (throwable instanceof TokenMismatchException) { + add(R.string.error_dialog_tip_token_mismatch_retry); + add(R.string.error_dialog_tip_token_mismatch_clear_storage); + final var intent = new Intent(ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.parse("package:" + BuildConfig.APPLICATION_ID)) + .putExtra(INTENT_EXTRA_BUTTON_TEXT, R.string.error_action_open_deck_info); + add(R.string.error_dialog_tip_clear_storage, intent); + } else if (throwable instanceof NextcloudFilesAppNotSupportedException) { + add(R.string.error_dialog_tip_files_outdated); + } else if (throwable instanceof NextcloudApiNotRespondingException) { + if (VERSION.SDK_INT >= VERSION_CODES.M) { + add(R.string.error_dialog_tip_disable_battery_optimizations, new Intent().setAction(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).putExtra(INTENT_EXTRA_BUTTON_TEXT, R.string.error_action_open_battery_settings)); + } else { + add(R.string.error_dialog_tip_disable_battery_optimizations); + } + add(R.string.error_dialog_tip_files_force_stop); + add(R.string.error_dialog_tip_files_delete_storage); + final var intent = new Intent(ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.parse("package:" + BuildConfig.APPLICATION_ID)) + .putExtra(INTENT_EXTRA_BUTTON_TEXT, R.string.error_action_open_deck_info); + add(R.string.error_dialog_tip_clear_storage, intent); + } else if (throwable instanceof SocketTimeoutException || throwable instanceof ConnectException) { + add(R.string.error_dialog_timeout_instance); + add(R.string.error_dialog_timeout_toggle, new Intent(Settings.ACTION_WIFI_SETTINGS).putExtra(INTENT_EXTRA_BUTTON_TEXT, R.string.error_action_open_network)); + } else if (throwable instanceof JSONException || throwable instanceof NullPointerException) { + add(R.string.error_dialog_check_server); + } else if (throwable instanceof NextcloudHttpRequestFailedException) { + final int statusCode = ((NextcloudHttpRequestFailedException) throwable).getStatusCode(); + switch (statusCode) { + case 302: + add(R.string.error_dialog_server_app_enabled); + add(R.string.error_dialog_redirect); + break; + case 500: + add(R.string.error_dialog_check_server_logs); + break; + case 503: + add(R.string.error_dialog_check_maintenance); + break; + case 507: + add(R.string.error_dialog_insufficient_storage); + break; + } + } else if (throwable instanceof UnknownErrorException) { + if ("com.nextcloud.android.sso.QueryParam".equals(throwable.getMessage())) { + add(R.string.error_dialog_min_version, new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.nextcloud.client")) + .putExtra(INTENT_EXTRA_BUTTON_TEXT, R.string.error_action_update_files_app)); + } + } + } + notifyDataSetChanged(); + } + + private void add(@StringRes int text) { + add(text, null); + } + + private void add(@StringRes int text, @Nullable Intent primaryAction) { + tips.add(new TipsModel(text, primaryAction)); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/exception/tips/TipsModel.java b/app/src/main/java/it/niedermann/owncloud/notes/exception/tips/TipsModel.java new file mode 100644 index 0000000000000000000000000000000000000000..1197898da380adeabb49aaea2010be79205cf51d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/exception/tips/TipsModel.java @@ -0,0 +1,29 @@ +package it.niedermann.owncloud.notes.exception.tips; + +import android.content.Intent; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +@SuppressWarnings("WeakerAccess") +public class TipsModel { + @StringRes + private final int text; + @Nullable + private final Intent actionIntent; + + TipsModel(@StringRes int text, @Nullable Intent actionIntent) { + this.text = text; + this.actionIntent = actionIntent; + } + + @StringRes + public int getText() { + return this.text; + } + + @Nullable + public Intent getActionIntent() { + return this.actionIntent; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/exception/tips/TipsViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/exception/tips/TipsViewHolder.java new file mode 100644 index 0000000000000000000000000000000000000000..e0c2a410df4d3f405ea220e4d18e4d6d39ab09ef --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/exception/tips/TipsViewHolder.java @@ -0,0 +1,35 @@ +package it.niedermann.owncloud.notes.exception.tips; + +import android.content.Intent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import it.niedermann.owncloud.notes.databinding.ItemTipBinding; + +import static it.niedermann.owncloud.notes.exception.ExceptionDialogFragment.INTENT_EXTRA_BUTTON_TEXT; + + +public class TipsViewHolder extends RecyclerView.ViewHolder { + private final ItemTipBinding binding; + + @SuppressWarnings("WeakerAccess") + public TipsViewHolder(@NonNull View itemView) { + super(itemView); + binding = ItemTipBinding.bind(itemView); + } + + public void bind(TipsModel tip, Consumer actionButtonClickedListener) { + binding.tip.setText(tip.getText()); + final var intent = tip.getActionIntent(); + if (intent != null && intent.hasExtra(INTENT_EXTRA_BUTTON_TEXT)) { + binding.actionButton.setVisibility(View.VISIBLE); + binding.actionButton.setText(intent.getIntExtra(INTENT_EXTRA_BUTTON_TEXT, 0)); + binding.actionButton.setOnClickListener((v) -> actionButtonClickedListener.accept(intent)); + } else { + binding.actionButton.setVisibility(View.GONE); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..54e92baf9ab125cb419a53fe499efa3e146c7325 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountActivity.java @@ -0,0 +1,178 @@ +package it.niedermann.owncloud.notes.importaccount; + +import android.accounts.NetworkErrorException; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.ViewModelProvider; +import androidx.preference.PreferenceManager; + +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.exceptions.AccountImportCancelledException; +import com.nextcloud.android.sso.exceptions.AndroidGetAccountsPermissionNotGranted; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppNotInstalledException; +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; +import com.nextcloud.android.sso.exceptions.UnknownErrorException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; +import com.nextcloud.android.sso.ui.UiExceptionManager; + +import java.net.HttpURLConnection; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.databinding.ActivityImportAccountBinding; +import it.niedermann.owncloud.notes.exception.ExceptionDialogFragment; +import it.niedermann.owncloud.notes.exception.ExceptionHandler; +import it.niedermann.owncloud.notes.persistence.ApiProvider; +import it.niedermann.owncloud.notes.persistence.CapabilitiesClient; +import it.niedermann.owncloud.notes.persistence.SyncWorker; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; + +public class ImportAccountActivity extends AppCompatActivity { + + private static final String TAG = ImportAccountActivity.class.getSimpleName(); + public static final int REQUEST_CODE_IMPORT_ACCOUNT = 1; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + private ImportAccountViewModel importAccountViewModel; + private ActivityImportAccountBinding binding; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler(this)); + + binding = ActivityImportAccountBinding.inflate(getLayoutInflater()); + importAccountViewModel = new ViewModelProvider(this).get(ImportAccountViewModel.class); + + setContentView(binding.getRoot()); + + binding.welcomeText.setText(getString(R.string.welcome_text, getString(R.string.app_name))); + binding.addButton.setOnClickListener((v) -> { + binding.addButton.setEnabled(false); + binding.status.setVisibility(View.GONE); + try { + AccountImporter.pickNewAccount(this); + } catch (NextcloudFilesAppNotInstalledException e) { + UiExceptionManager.showDialogForException(this, e); + Log.w(TAG, "============================================================="); + Log.w(TAG, "Nextcloud app is not installed. Cannot choose account"); + e.printStackTrace(); + } catch (AndroidGetAccountsPermissionNotGranted e) { + binding.addButton.setEnabled(true); + AccountImporter.requestAndroidAccountPermissionsAndPickAccount(this); + } + }); + } + + @Override + public boolean onSupportNavigateUp() { + super.onSupportNavigateUp(); + setResult(RESULT_CANCELED); + return true; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + try { + AccountImporter.onActivityResult(requestCode, resultCode, data, ImportAccountActivity.this, ssoAccount -> { + runOnUiThread(() -> binding.progressCircular.setVisibility(View.VISIBLE)); + + SingleAccountHelper.setCurrentAccount(getApplicationContext(), ssoAccount.name); + executor.submit(() -> { + Log.i(TAG, "Added account: " + "name:" + ssoAccount.name + ", " + ssoAccount.url + ", userId" + ssoAccount.userId); + try { + Log.i(TAG, "Loading capabilities for " + ssoAccount.name); + final var capabilities = CapabilitiesClient.getCapabilities(getApplicationContext(), ssoAccount, null, ApiProvider.getInstance()); + final String displayName = CapabilitiesClient.getDisplayName(getApplicationContext(), ssoAccount, ApiProvider.getInstance()); + final var status$ = importAccountViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities, displayName, new IResponseCallback<>() { + + /** + * Update syncing when adding account + * https://github.com/stefan-niedermann/nextcloud-deck/issues/531 + * @param account the account to add + */ + @Override + public void onSuccess(Account account) { + runOnUiThread(() -> { + Log.i(TAG, capabilities.toString()); + BrandingUtil.saveBrandColors(ImportAccountActivity.this, capabilities.getColor(), capabilities.getTextColor()); + setResult(RESULT_OK); + finish(); + }); + SyncWorker.update(ImportAccountActivity.this, PreferenceManager.getDefaultSharedPreferences(ImportAccountActivity.this) + .getBoolean(getString(R.string.pref_key_background_sync), true)); + } + + @Override + public void onError(@NonNull Throwable t) { + runOnUiThread(() -> { + binding.addButton.setEnabled(true); + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + }); + } + }); + runOnUiThread(() -> status$.observe(ImportAccountActivity.this, (status) -> { + binding.progressText.setVisibility(View.VISIBLE); + Log.v(TAG, "Status: " + status.count + " of " + status.total); + if(status.count > 0) { + binding.progressCircular.setIndeterminate(false); + } + binding.progressText.setText(getString(R.string.progress_import, status.count + 1, status.total)); + binding.progressCircular.setProgress(status.count); + binding.progressCircular.setMax(status.total); + })); + } catch (Throwable t) { + t.printStackTrace(); + ApiProvider.getInstance().invalidateAPICache(ssoAccount); + SingleAccountHelper.setCurrentAccount(this, null); + runOnUiThread(() -> { + restoreCleanState(); + if (t instanceof NextcloudHttpRequestFailedException && ((NextcloudHttpRequestFailedException) t).getStatusCode() == HttpURLConnection.HTTP_UNAVAILABLE) { + binding.status.setText(R.string.error_maintenance_mode); + binding.status.setVisibility(View.VISIBLE); + } else if (t instanceof NetworkErrorException) { + binding.status.setText(getString(R.string.error_sync, getString(R.string.error_no_network))); + binding.status.setVisibility(View.VISIBLE); + } else if (t instanceof UnknownErrorException && t.getMessage() != null && t.getMessage().contains("No address associated with hostname")) { + // https://github.com/stefan-niedermann/nextcloud-notes/issues/1014 + binding.status.setText(R.string.you_have_to_be_connected_to_the_internet_in_order_to_add_an_account); + binding.status.setVisibility(View.VISIBLE); + } else { + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + }); + } + }); + }); + } catch (AccountImportCancelledException e) { + restoreCleanState(); + Log.i(TAG, "Account import has been canceled."); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + AccountImporter.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + private void restoreCleanState() { + runOnUiThread(() -> { + binding.addButton.setEnabled(true); + binding.progressCircular.setVisibility(View.GONE); + binding.progressText.setVisibility(View.GONE); + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountViewModel.java new file mode 100644 index 0000000000000000000000000000000000000000..1d60a0434ca61c42b2ce1881b7b078139f8451e8 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountViewModel.java @@ -0,0 +1,29 @@ +package it.niedermann.owncloud.notes.importaccount; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; + +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; +import it.niedermann.owncloud.notes.shared.model.ImportStatus; + +public class ImportAccountViewModel extends AndroidViewModel { + + @NonNull + private final NotesRepository repo; + + public ImportAccountViewModel(@NonNull Application application) { + super(application); + this.repo = NotesRepository.getInstance(application); + } + + public LiveData addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback callback) { + return repo.addAccount(url, username, accountName, capabilities, displayName, callback); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..3eb32044d1eb570be1d3c0a496109916eaddfbe8 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java @@ -0,0 +1,806 @@ +package it.niedermann.owncloud.notes.main; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.O; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static it.niedermann.owncloud.notes.NotesApplication.isDarkThemeActive; +import static it.niedermann.owncloud.notes.NotesApplication.isGridViewEnabled; +import static it.niedermann.owncloud.notes.branding.BrandingUtil.getSecondaryForegroundColorDependingOnTheme; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.DEFAULT_CATEGORY; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.FAVORITES; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.RECENT; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.UNCATEGORIZED; +import static it.niedermann.owncloud.notes.shared.util.NotesColorUtil.contrastRatioIsSufficient; +import static it.niedermann.owncloud.notes.shared.util.SSOUtil.askForNewAccount; + +import android.accounts.NetworkErrorException; +import android.animation.AnimatorInflater; +import android.app.SearchManager; +import android.content.Intent; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBarDrawerToggle; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.view.ActionMode; +import androidx.appcompat.widget.SearchView; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.core.view.GravityCompat; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.selection.SelectionTracker; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.snackbar.Snackbar; +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.exceptions.AccountImportCancelledException; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.exceptions.TokenMismatchException; +import com.nextcloud.android.sso.exceptions.UnknownErrorException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; + +import java.net.HttpURLConnection; +import java.util.LinkedList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import it.niedermann.owncloud.notes.LockedActivity; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.accountpicker.AccountPickerListener; +import it.niedermann.owncloud.notes.accountswitcher.AccountSwitcherDialog; +import it.niedermann.owncloud.notes.accountswitcher.AccountSwitcherListener; +import it.niedermann.owncloud.notes.branding.BrandedSnackbar; +import it.niedermann.owncloud.notes.databinding.ActivityNotesListViewBinding; +import it.niedermann.owncloud.notes.databinding.DrawerLayoutBinding; +import it.niedermann.owncloud.notes.edit.EditNoteActivity; +import it.niedermann.owncloud.notes.edit.category.CategoryDialogFragment; +import it.niedermann.owncloud.notes.edit.category.CategoryViewModel; +import it.niedermann.owncloud.notes.exception.ExceptionDialogFragment; +import it.niedermann.owncloud.notes.exception.IntendedOfflineException; +import it.niedermann.owncloud.notes.importaccount.ImportAccountActivity; +import it.niedermann.owncloud.notes.main.items.ItemAdapter; +import it.niedermann.owncloud.notes.main.items.grid.GridItemDecoration; +import it.niedermann.owncloud.notes.main.items.list.NotesListViewItemTouchHelper; +import it.niedermann.owncloud.notes.main.items.section.SectionItemDecoration; +import it.niedermann.owncloud.notes.main.items.selection.ItemSelectionTracker; +import it.niedermann.owncloud.notes.main.menu.MenuAdapter; +import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; +import it.niedermann.owncloud.notes.main.navigation.NavigationClickListener; +import it.niedermann.owncloud.notes.main.navigation.NavigationItem; +import it.niedermann.owncloud.notes.persistence.ApiProvider; +import it.niedermann.owncloud.notes.persistence.CapabilitiesClient; +import it.niedermann.owncloud.notes.persistence.CapabilitiesWorker; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; +import it.niedermann.owncloud.notes.shared.model.NavigationCategory; +import it.niedermann.owncloud.notes.shared.model.NoteClickListener; +import it.niedermann.owncloud.notes.shared.util.CustomAppGlideModule; +import it.niedermann.owncloud.notes.shared.util.NoteUtil; +import it.niedermann.owncloud.notes.shared.util.ShareUtil; + +public class MainActivity extends LockedActivity implements NoteClickListener, AccountPickerListener, AccountSwitcherListener, CategoryDialogFragment.CategoryDialogListener { + + private static final String TAG = MainActivity.class.getSimpleName(); + + protected final ExecutorService executor = Executors.newCachedThreadPool(); + + protected MainViewModel mainViewModel; + private CategoryViewModel categoryViewModel; + + private boolean gridView = true; + + public static final String ADAPTER_KEY_RECENT = "recent"; + public static final String ADAPTER_KEY_STARRED = "starred"; + public static final String ADAPTER_KEY_UNCATEGORIZED = "uncategorized"; + + private static final int REQUEST_CODE_CREATE_NOTE = 0; + private static final int REQUEST_CODE_SERVER_SETTINGS = 1; + + protected ItemAdapter adapter; + private NavigationAdapter adapterCategories; + private MenuAdapter menuAdapter; + + private SelectionTracker tracker; + private NotesListViewItemTouchHelper itemTouchHelper; + + protected DrawerLayoutBinding binding; + protected ActivityNotesListViewBinding activityBinding; + protected FloatingActionButton fabCreate; + private CoordinatorLayout coordinatorLayout; + private SwipeRefreshLayout swipeRefreshLayout; + private RecyclerView listView; + private ActionMode mActionMode; + + boolean canMoveNoteToAnotherAccounts = false; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mainViewModel = new ViewModelProvider(this).get(MainViewModel.class); + categoryViewModel = new ViewModelProvider(this).get(CategoryViewModel.class); + CapabilitiesWorker.update(this); + binding = DrawerLayoutBinding.inflate(getLayoutInflater()); + activityBinding = ActivityNotesListViewBinding.bind(binding.activityNotesListView.getRoot()); + + setContentView(binding.getRoot()); + + this.coordinatorLayout = binding.activityNotesListView.activityNotesListView; + this.swipeRefreshLayout = binding.activityNotesListView.swiperefreshlayout; + this.fabCreate = binding.activityNotesListView.fabCreate; + this.listView = binding.activityNotesListView.recyclerView; + + gridView = isGridViewEnabled(); + + if (!gridView || isDarkThemeActive(this)) { + activityBinding.activityNotesListView.setBackgroundColor(ContextCompat.getColor(this, R.color.primary)); + } + + setupToolbars(); + setupNavigationList(); + setupNotesList(); + + mainViewModel.getAccountsCount().observe(this, (count) -> { + if (count == 0) { + startActivityForResult(new Intent(this, ImportAccountActivity.class), ImportAccountActivity.REQUEST_CODE_IMPORT_ACCOUNT); + } else { + executor.submit(() -> { + try { + final var account = mainViewModel.getLocalAccountByAccountName(SingleAccountHelper.getCurrentSingleSignOnAccount(getApplicationContext()).name); + runOnUiThread(() -> mainViewModel.postCurrentAccount(account)); + } catch (NextcloudFilesAppAccountNotFoundException e) { + // Verbose log output for https://github.com/stefan-niedermann/nextcloud-notes/issues/1256 + runOnUiThread(() -> new AlertDialog.Builder(this) + .setTitle(NextcloudFilesAppAccountNotFoundException.class.getSimpleName()) + .setMessage(R.string.backup) + .setPositiveButton(R.string.simple_backup, (a, b) -> executor.submit(() -> { + final var modifiedNotes = new LinkedList(); + for (final var account : mainViewModel.getAccounts()) { + modifiedNotes.addAll(mainViewModel.getLocalModifiedNotes(account.getId())); + } + if (modifiedNotes.size() == 1) { + final var note = modifiedNotes.get(0); + ShareUtil.openShareDialog(this, note.getTitle(), note.getContent()); + } else { + ShareUtil.openShareDialog(this, + getResources().getQuantityString(R.plurals.share_multiple, modifiedNotes.size(), modifiedNotes.size()), + mainViewModel.collectNoteContents(modifiedNotes.stream().map(Note::getId).collect(Collectors.toList()))); + } + })) + .setNegativeButton(R.string.simple_error, (a, b) -> { + final var ssoPreferences = AccountImporter.getSharedPreferences(getApplicationContext()); + final var ssoPreferencesString = new StringBuilder() + .append("Current SSO account: ").append(ssoPreferences.getString("PREF_CURRENT_ACCOUNT_STRING", null)).append("\n") + .append("\n") + .append("SSO SharedPreferences: ").append("\n"); + for (final var entry : ssoPreferences.getAll().entrySet()) { + ssoPreferencesString.append(entry.getKey()).append(": ").append(entry.getValue()).append("\n"); + } + ssoPreferencesString.append("\n") + .append("Available accounts in DB: ").append(TextUtils.join(", ", mainViewModel.getAccounts().stream().map(Account::getAccountName).collect(Collectors.toList()))); + runOnUiThread(() -> ExceptionDialogFragment.newInstance(new RuntimeException(e.getMessage(), new RuntimeException(ssoPreferencesString.toString(), e))).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())); + }) + .show()); + } catch (NoCurrentAccountSelectedException e) { + runOnUiThread(() -> ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())); + } + }); + } + }); + + mainViewModel.hasMultipleAccountsConfigured().observe(this, hasMultipleAccountsConfigured -> canMoveNoteToAnotherAccounts = hasMultipleAccountsConfigured); + mainViewModel.getSyncStatus().observe(this, syncStatus -> swipeRefreshLayout.setRefreshing(syncStatus)); + mainViewModel.getSyncErrors().observe(this, exceptions -> { + if (mainViewModel.containsNonInfrastructureRelatedItems(exceptions)) { + BrandedSnackbar.make(coordinatorLayout, R.string.error_synchronization, Snackbar.LENGTH_LONG) + .setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(exceptions) + .show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())) + .show(); + } + }); + mainViewModel.getSelectedCategory().observe(this, (selectedCategory) -> { + binding.activityNotesListView.emptyContentView.getRoot().setVisibility(GONE); + adapter.setShowCategory(selectedCategory.getType() == RECENT || selectedCategory.getType() == FAVORITES); + fabCreate.show(); + + switch (selectedCategory.getType()) { + case RECENT: { + activityBinding.searchText.setText(getString(R.string.search_in_all)); + break; + } + case FAVORITES: { + activityBinding.searchText.setText(getString(R.string.search_in_category, getString(R.string.label_favorites))); + break; + } + case UNCATEGORIZED: { + activityBinding.searchText.setText(getString(R.string.search_in_category, getString(R.string.action_uncategorized))); + break; + } + case DEFAULT_CATEGORY: + default: { + final String category = selectedCategory.getCategory(); + if (category == null) { + throw new IllegalStateException(NavigationCategory.class.getSimpleName() + " type is " + DEFAULT_CATEGORY + ", but category is null."); + } + activityBinding.searchText.setText(getString(R.string.search_in_category, NoteUtil.extendCategory(category))); + break; + } + } + + fabCreate.setOnClickListener((View view) -> { + final var createIntent = new Intent(getApplicationContext(), EditNoteActivity.class); + createIntent.putExtra(EditNoteActivity.PARAM_CATEGORY, selectedCategory); + if (activityBinding.searchView.getQuery().length() > 0) { + createIntent.putExtra(EditNoteActivity.PARAM_CONTENT, activityBinding.searchView.getQuery().toString()); + invalidateOptionsMenu(); + } + startActivityForResult(createIntent, REQUEST_CODE_CREATE_NOTE); + }); + }); + mainViewModel.getNotesListLiveData().observe(this, notes -> { + // https://stackoverflow.com/a/37342327 + itemTouchHelper.attachToRecyclerView(null); + itemTouchHelper.attachToRecyclerView(listView); + adapter.setItemList(notes); + binding.activityNotesListView.progressCircular.setVisibility(GONE); + binding.activityNotesListView.emptyContentView.getRoot().setVisibility(notes.size() > 0 ? GONE : VISIBLE); + // Remove deleted notes from the selection + if (tracker.hasSelection()) { + final var deletedNotes = new LinkedList(); + for (final var id : tracker.getSelection()) { + if (notes + .stream() + .filter(item -> !item.isSection()) + .map(item -> (Note) item) + .noneMatch(item -> item.getId() == id)) { + deletedNotes.add(id); + } + } + for (final var id : deletedNotes) { + tracker.deselect(id); + } + } + }); + mainViewModel.getSearchTerm().observe(this, adapter::setHighlightSearchQuery); + mainViewModel.getCategorySortingMethodOfSelectedCategory().observe(this, methodOfCategory -> { + updateSortMethodIcon(methodOfCategory.second); + activityBinding.sortingMethod.setOnClickListener((v) -> { + if (methodOfCategory.first != null) { + var newMethod = methodOfCategory.second; + if (newMethod == CategorySortingMethod.SORT_LEXICOGRAPHICAL_ASC) { + newMethod = CategorySortingMethod.SORT_MODIFIED_DESC; + } else { + newMethod = CategorySortingMethod.SORT_LEXICOGRAPHICAL_ASC; + } + final var modifyLiveData = mainViewModel.modifyCategoryOrder(methodOfCategory.first, newMethod); + modifyLiveData.observe(this, (next) -> modifyLiveData.removeObservers(this)); + } + }); + }); + mainViewModel.getNavigationCategories().observe(this, navigationItems -> this.adapterCategories.setItems(navigationItems)); + mainViewModel.getCurrentAccount().observe(this, (nextAccount) -> { + fabCreate.hide(); + Glide + .with(this) + .load(nextAccount.getUrl() + "/index.php/avatar/" + Uri.encode(nextAccount.getUserName()) + "/64") + .placeholder(R.drawable.ic_account_circle_grey_24dp) + .error(R.drawable.ic_account_circle_grey_24dp) + .apply(RequestOptions.circleCropTransform()) + .into(activityBinding.launchAccountSwitcher); + + mainViewModel.synchronizeNotes(nextAccount, new IResponseCallback<>() { + @Override + public void onSuccess(Void v) { + Log.d(TAG, "Successfully synchronized notes for " + nextAccount.getAccountName()); + } + + @Override + public void onError(@NonNull Throwable t) { + runOnUiThread(() -> { + if (t instanceof IntendedOfflineException) { + Log.i(TAG, "Capabilities and notes not updated because " + nextAccount.getAccountName() + " is offline by intention."); + } else if (t instanceof NetworkErrorException) { + BrandedSnackbar.make(coordinatorLayout, getString(R.string.error_sync, getString(R.string.error_no_network)), Snackbar.LENGTH_LONG).show(); + } else { + BrandedSnackbar.make(coordinatorLayout, R.string.error_synchronization, Snackbar.LENGTH_LONG) + .setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(t) + .show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())) + .show(); + } + }); + } + }); + fabCreate.show(); + activityBinding.launchAccountSwitcher.setOnClickListener((v) -> AccountSwitcherDialog.newInstance(nextAccount.getId()).show(getSupportFragmentManager(), AccountSwitcherDialog.class.getSimpleName())); + + if (menuAdapter == null) { + menuAdapter = new MenuAdapter(getApplicationContext(), nextAccount, REQUEST_CODE_SERVER_SETTINGS, (menuItem) -> { + @Nullable Integer resultCode = menuItem.getResultCode(); + if (resultCode == null) { + startActivity(menuItem.getIntent()); + } else { + startActivityForResult(menuItem.getIntent(), resultCode); + } + }); + + binding.navigationMenu.setAdapter(menuAdapter); + } else { + menuAdapter.updateAccount(this, nextAccount); + } + }); + } + + @Override + protected void onResume() { + final var accountLiveData = mainViewModel.getCurrentAccount(); + accountLiveData.observe(this, (currentAccount) -> { + accountLiveData.removeObservers(this); + try { + // It is possible that after the deletion of the last account, this onResponse gets called before the ImportAccountActivity gets started. + if (SingleAccountHelper.getCurrentSingleSignOnAccount(this) != null) { + mainViewModel.synchronizeNotes(currentAccount, new IResponseCallback() { + @Override + public void onSuccess(Void v) { + Log.d(TAG, "Successfully synchronized notes for " + currentAccount.getAccountName()); + } + + @Override + public void onError(@NonNull Throwable t) { + t.printStackTrace(); + } + }); + } + } catch (NextcloudFilesAppAccountNotFoundException e) { + ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } catch (NoCurrentAccountSelectedException e) { + Log.i(TAG, "No current account is selected - maybe the last account has been deleted?"); + } + }); + super.onResume(); + } + + @Override + protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + mainViewModel.restoreInstanceState(); + } + + private void setupToolbars() { + setSupportActionBar(binding.activityNotesListView.searchToolbar); + activityBinding.homeToolbar.setOnClickListener((v) -> { + if (activityBinding.searchToolbar.getVisibility() == GONE) { + updateToolbars(true); + } + }); + + final var toggle = new ActionBarDrawerToggle(this, binding.drawerLayout, activityBinding.homeToolbar, 0, 0); + binding.drawerLayout.addDrawerListener(toggle); + toggle.syncState(); + + activityBinding.searchView.setOnCloseListener(() -> { + if (activityBinding.searchToolbar.getVisibility() == VISIBLE && TextUtils.isEmpty(activityBinding.searchView.getQuery())) { + updateToolbars(false); + return true; + } + return false; + }); + activityBinding.searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + return false; + } + + @Override + public boolean onQueryTextChange(String newText) { + mainViewModel.postSearchTerm(newText); + return true; + } + }); + } + + private void setupNotesList() { + adapter = new ItemAdapter(this, gridView); + listView.setAdapter(adapter); + listView.setItemAnimator(null); + if (gridView) { + final int spanCount = getResources().getInteger(R.integer.grid_view_span_count); + final var gridLayoutManager = new StaggeredGridLayoutManager(spanCount, StaggeredGridLayoutManager.VERTICAL); + listView.setLayoutManager(gridLayoutManager); + listView.addItemDecoration(new GridItemDecoration(adapter, spanCount, + getResources().getDimensionPixelSize(R.dimen.spacer_3x), + getResources().getDimensionPixelSize(R.dimen.spacer_5x), + getResources().getDimensionPixelSize(R.dimen.spacer_3x), + getResources().getDimensionPixelSize(R.dimen.spacer_1x), + getResources().getDimensionPixelSize(R.dimen.spacer_activity_sides) + getResources().getDimensionPixelSize(R.dimen.spacer_1x) + )); + } else { + final var layoutManager = new LinearLayoutManager(this); + listView.setLayoutManager(layoutManager); + listView.addItemDecoration(new SectionItemDecoration(adapter, + getResources().getDimensionPixelSize(R.dimen.spacer_activity_sides) + getResources().getDimensionPixelSize(R.dimen.spacer_1x) + getResources().getDimensionPixelSize(R.dimen.spacer_3x) + getResources().getDimensionPixelSize(R.dimen.spacer_2x), + getResources().getDimensionPixelSize(R.dimen.spacer_5x), + getResources().getDimensionPixelSize(R.dimen.spacer_1x), + 0 + )); + } + + listView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + if (dy > 0) + fabCreate.hide(); + else if (dy < 0) + fabCreate.show(); + } + }); + + swipeRefreshLayout.setOnRefreshListener(() -> { + CustomAppGlideModule.clearCache(this); + final var syncLiveData = mainViewModel.getCurrentAccount(); + final Observer syncObserver = currentAccount -> { + syncLiveData.removeObservers(this); + mainViewModel.synchronizeCapabilitiesAndNotes(currentAccount, new IResponseCallback<>() { + @Override + public void onSuccess(Void v) { + Log.d(TAG, "Successfully synchronized capabilities and notes for " + currentAccount.getAccountName()); + } + + @Override + public void onError(@NonNull Throwable t) { + runOnUiThread(() -> { + swipeRefreshLayout.setRefreshing(false); + if (t instanceof IntendedOfflineException) { + Log.i(TAG, "Capabilities and notes not updated because " + currentAccount.getAccountName() + " is offline by intention."); + } else if (t instanceof NextcloudHttpRequestFailedException && ((NextcloudHttpRequestFailedException) t).getStatusCode() == HttpURLConnection.HTTP_UNAVAILABLE) { + BrandedSnackbar.make(coordinatorLayout, R.string.error_maintenance_mode, Snackbar.LENGTH_LONG).show(); + } else if (t instanceof NetworkErrorException) { + BrandedSnackbar.make(coordinatorLayout, getString(R.string.error_sync, getString(R.string.error_no_network)), Snackbar.LENGTH_LONG).show(); + } else { + BrandedSnackbar.make(coordinatorLayout, R.string.error_synchronization, Snackbar.LENGTH_LONG) + .setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(t) + .show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())) + .show(); + } + }); + } + }); + }; + syncLiveData.observe(this, syncObserver); + }); + + tracker = ItemSelectionTracker.build(listView, adapter); + adapter.setTracker(tracker); + tracker.addObserver(new SelectionTracker.SelectionObserver() { + @Override + public void onSelectionChanged() { + super.onSelectionChanged(); + if (tracker.hasSelection() && mActionMode == null) { + mActionMode = startSupportActionMode(new MultiSelectedActionModeCallback(MainActivity.this, coordinatorLayout, mainViewModel, MainActivity.this, canMoveNoteToAnotherAccounts, tracker, getSupportFragmentManager())); + } + if (mActionMode != null) { + if (tracker.hasSelection()) { + int selected = tracker.getSelection().size(); + mActionMode.setTitle(getResources().getQuantityString(R.plurals.ab_selected, selected, selected)); + } else { + mActionMode.finish(); + mActionMode = null; + } + } + } + } + ); + + itemTouchHelper = new NotesListViewItemTouchHelper(this, mainViewModel, this, tracker, adapter, swipeRefreshLayout, coordinatorLayout, gridView); + itemTouchHelper.attachToRecyclerView(listView); + } + + private void setupNavigationList() { + adapterCategories = new NavigationAdapter(this, new NavigationClickListener() { + @Override + public void onItemClick(NavigationItem item) { + selectItem(item, true); + } + + private void selectItem(NavigationItem item, boolean closeNavigation) { + adapterCategories.setSelectedItem(item.id); + // update current selection + if (item.type != null) { + switch (item.type) { + case RECENT: { + mainViewModel.postSelectedCategory(new NavigationCategory(RECENT)); + break; + } + case FAVORITES: { + mainViewModel.postSelectedCategory(new NavigationCategory(FAVORITES)); + break; + } + case UNCATEGORIZED: { + mainViewModel.postSelectedCategory(new NavigationCategory(UNCATEGORIZED)); + break; + } + default: { + if (item.getClass() == NavigationItem.CategoryNavigationItem.class) { + mainViewModel.postSelectedCategory(new NavigationCategory(((NavigationItem.CategoryNavigationItem) item).accountId, ((NavigationItem.CategoryNavigationItem) item).category)); + } else { + throw new IllegalStateException(NavigationItem.class.getSimpleName() + " type is " + DEFAULT_CATEGORY + ", but item is not of type " + NavigationItem.CategoryNavigationItem.class.getSimpleName() + "."); + } + } + } + } else { + Log.e(TAG, "Unknown item navigation type. Fallback to show " + RECENT); + mainViewModel.postSelectedCategory(new NavigationCategory(RECENT)); + } + + if (closeNavigation) { + binding.drawerLayout.closeDrawer(GravityCompat.START); + } + } + + @Override + public void onIconClick(NavigationItem item) { + final var expandedCategoryLiveData = mainViewModel.getExpandedCategory(); + expandedCategoryLiveData.observe(MainActivity.this, expandedCategory -> { + if (item.icon == NavigationAdapter.ICON_MULTIPLE && !item.label.equals(expandedCategory)) { + mainViewModel.postExpandedCategory(item.label); + selectItem(item, false); + } else if (item.icon == NavigationAdapter.ICON_MULTIPLE || item.icon == NavigationAdapter.ICON_MULTIPLE_OPEN && item.label.equals(expandedCategory)) { + mainViewModel.postExpandedCategory(null); + } else { + onItemClick(item); + } + expandedCategoryLiveData.removeObservers(MainActivity.this); + }); + } + }); + adapterCategories.setSelectedItem(ADAPTER_KEY_RECENT); + binding.navigationList.setAdapter(adapterCategories); + } + + @Override + public void applyBrand(int mainColor, int textColor) { + applyBrandToPrimaryToolbar(activityBinding.appBar, activityBinding.searchToolbar); + applyBrandToFAB(mainColor, textColor, activityBinding.fabCreate); + + binding.headerView.setBackgroundColor(mainColor); + binding.appName.setTextColor(textColor); + activityBinding.progressCircular.getIndeterminateDrawable().setColorFilter(getSecondaryForegroundColorDependingOnTheme(this, mainColor), PorterDuff.Mode.SRC_IN); + + // TODO We assume, that the background of the spinner is always white + activityBinding.swiperefreshlayout.setColorSchemeColors(contrastRatioIsSufficient(Color.WHITE, mainColor) ? mainColor : Color.BLACK); + binding.appName.setTextColor(textColor); + DrawableCompat.setTint(binding.logo.getDrawable(), textColor); + + adapter.applyBrand(mainColor, textColor); + adapterCategories.applyBrand(mainColor, textColor); + invalidateOptionsMenu(); + } + + @Override + public boolean onSupportNavigateUp() { + if (activityBinding.searchToolbar.getVisibility() == VISIBLE) { + updateToolbars(false); + return true; + } else { + return super.onSupportNavigateUp(); + } + } + + /** + * Updates sorting method icon. + */ + private void updateSortMethodIcon(CategorySortingMethod method) { + if (method == CategorySortingMethod.SORT_LEXICOGRAPHICAL_ASC) { + activityBinding.sortingMethod.setImageResource(R.drawable.alphabetical_asc); + activityBinding.sortingMethod.setContentDescription(getString(R.string.sort_last_modified)); + if (SDK_INT >= O) { + activityBinding.sortingMethod.setTooltipText(getString(R.string.sort_last_modified)); + } + } else { + activityBinding.sortingMethod.setImageResource(R.drawable.modification_desc); + activityBinding.sortingMethod.setContentDescription(getString(R.string.sort_alphabetically)); + if (SDK_INT >= O) { + activityBinding.sortingMethod.setTooltipText(getString(R.string.sort_alphabetically)); + } + } + } + + @Override + protected void onNewIntent(Intent intent) { + if (Intent.ACTION_SEARCH.equals(intent.getAction())) { + activityBinding.searchView.setQuery(intent.getStringExtra(SearchManager.QUERY), true); + } + super.onNewIntent(intent); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + AccountImporter.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + /** + * Handles the Results of started Sub Activities (Created Note, Edited Note) + * + * @param requestCode int to distinguish between the different Sub Activities + * @param resultCode int Return Code + * @param data Intent + */ + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + switch (requestCode) { + case REQUEST_CODE_CREATE_NOTE: { + listView.scrollToPosition(0); + break; + } + case REQUEST_CODE_SERVER_SETTINGS: { + // Recreate activity completely, because theme switching makes problems when only invalidating the views. + // @see https://github.com/stefan-niedermann/nextcloud-notes/issues/529 + if (RESULT_OK == resultCode) { + ActivityCompat.recreate(this); + return; + } + break; + } + default: { + try { + AccountImporter.onActivityResult(requestCode, resultCode, data, this, (ssoAccount) -> { + CapabilitiesWorker.update(this); + executor.submit(() -> { + final var importSnackbar = BrandedSnackbar.make(coordinatorLayout, R.string.progress_import_indeterminate, Snackbar.LENGTH_INDEFINITE); + Log.i(TAG, "Added account: " + "name:" + ssoAccount.name + ", " + ssoAccount.url + ", userId" + ssoAccount.userId); + try { + Log.i(TAG, "Refreshing capabilities for " + ssoAccount.name); + final var capabilities = CapabilitiesClient.getCapabilities(getApplicationContext(), ssoAccount, null, ApiProvider.getInstance()); + final String displayName = CapabilitiesClient.getDisplayName(getApplicationContext(), ssoAccount, ApiProvider.getInstance()); + final var status$ = mainViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities, displayName, new IResponseCallback() { + @Override + public void onSuccess(Account result) { + executor.submit(() -> { + runOnUiThread(() -> { + importSnackbar.setText(R.string.account_imported); + importSnackbar.setAction(R.string.simple_switch, (v) -> mainViewModel.postCurrentAccount(mainViewModel.getLocalAccountByAccountName(ssoAccount.name))); + }); + Log.i(TAG, capabilities.toString()); + }); + } + + @Override + public void onError(@NonNull Throwable t) { + runOnUiThread(() -> { + importSnackbar.dismiss(); + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + }); + } + }); + runOnUiThread(() -> status$.observe(this, (status) -> { + importSnackbar.show(); + Log.v(TAG, "Status: " + status.count + " of " + status.total); + if (status.count > 0) { + importSnackbar.setText(getString(R.string.progress_import, status.count + 1, status.total)); + } + })); + } catch (Throwable e) { + importSnackbar.dismiss(); + ApiProvider.getInstance().invalidateAPICache(ssoAccount); + // Happens when importing an already existing account the second time + if (e instanceof TokenMismatchException && mainViewModel.getLocalAccountByAccountName(ssoAccount.name) != null) { + Log.w(TAG, "Received " + TokenMismatchException.class.getSimpleName() + " and the given ssoAccount.name (" + ssoAccount.name + ") does already exist in the database. Assume that this account has already been imported."); + runOnUiThread(() -> { + mainViewModel.postCurrentAccount(mainViewModel.getLocalAccountByAccountName(ssoAccount.name)); + // TODO there is already a sync in progress and results in displaying a TokenMissMatchException snackbar which conflicts with this one + coordinatorLayout.post(() -> BrandedSnackbar.make(coordinatorLayout, R.string.account_already_imported, Snackbar.LENGTH_LONG).show()); + }); + } else if (e instanceof UnknownErrorException && e.getMessage() != null && e.getMessage().contains("No address associated with hostname")) { + // https://github.com/stefan-niedermann/nextcloud-notes/issues/1014 + runOnUiThread(() -> Snackbar.make(coordinatorLayout, R.string.you_have_to_be_connected_to_the_internet_in_order_to_add_an_account, Snackbar.LENGTH_LONG).show()); + } else { + e.printStackTrace(); + runOnUiThread(() -> { + binding.activityNotesListView.progressCircular.setVisibility(GONE); + ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + }); + } + } + }); + }); + } catch (AccountImportCancelledException e) { + Log.i(TAG, "AccountImport has been cancelled."); + } + } + } + } + + @Override + public void onNoteClick(int position, View v) { + final boolean hasCheckedItems = tracker.getSelection().size() > 0; + if (!hasCheckedItems) { + final var note = (Note) adapter.getItem(position); + startActivity(new Intent(getApplicationContext(), EditNoteActivity.class) + .putExtra(EditNoteActivity.PARAM_NOTE_ID, note.getId())); + } + } + + @Override + public void onNoteFavoriteClick(int position, View view) { + final var toggleLiveData = mainViewModel.toggleFavoriteAndSync(((Note) adapter.getItem(position)).getId()); + toggleLiveData.observe(this, (next) -> toggleLiveData.removeObservers(this)); + } + + @Override + public void onBackPressed() { + if (activityBinding.searchToolbar.getVisibility() == VISIBLE) { + updateToolbars(false); + } else if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { + binding.drawerLayout.closeDrawer(GravityCompat.START); + } else { + super.onBackPressed(); + } + } + + private void updateToolbars(boolean enableSearch) { + activityBinding.homeToolbar.setVisibility(enableSearch ? GONE : VISIBLE); + activityBinding.searchToolbar.setVisibility(enableSearch ? VISIBLE : GONE); + activityBinding.appBar.setStateListAnimator(AnimatorInflater.loadStateListAnimator(activityBinding.appBar.getContext(), enableSearch + ? R.animator.appbar_elevation_on + : R.animator.appbar_elevation_off)); + if (enableSearch) { + activityBinding.searchView.setIconified(false); + fabCreate.show(); + } else { + activityBinding.searchView.setQuery(null, true); + } + } + + @Override + public void addAccount() { + askForNewAccount(this); + } + + @Override + public void onAccountChosen(@NonNull Account localAccount) { + binding.drawerLayout.closeDrawer(GravityCompat.START); + mainViewModel.postCurrentAccount(localAccount); + } + + @Override + public void onAccountPicked(@NonNull Account account) { + for (final var noteId : tracker.getSelection()) { + final var moveLiveData = mainViewModel.moveNoteToAnotherAccount(account, noteId); + moveLiveData.observe(this, (v) -> { + tracker.deselect(noteId); + moveLiveData.removeObservers(this); + }); + } + } + + @Override + public void onCategoryChosen(String category) { + final var categoryLiveData = mainViewModel.setCategory(tracker.getSelection(), category); + categoryLiveData.observe(this, (next) -> categoryLiveData.removeObservers(this)); + tracker.clearSelection(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java new file mode 100644 index 0000000000000000000000000000000000000000..4de6ebeced78dbff5d1edcefcf1f82f45fc69d10 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java @@ -0,0 +1,666 @@ +package it.niedermann.owncloud.notes.main; + +import static androidx.lifecycle.Transformations.distinctUntilChanged; +import static androidx.lifecycle.Transformations.map; +import static androidx.lifecycle.Transformations.switchMap; +import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; +import static it.niedermann.owncloud.notes.main.MainActivity.ADAPTER_KEY_RECENT; +import static it.niedermann.owncloud.notes.main.MainActivity.ADAPTER_KEY_STARRED; +import static it.niedermann.owncloud.notes.main.slots.SlotterUtil.fillListByCategory; +import static it.niedermann.owncloud.notes.main.slots.SlotterUtil.fillListByInitials; +import static it.niedermann.owncloud.notes.main.slots.SlotterUtil.fillListByTime; +import static it.niedermann.owncloud.notes.shared.model.CategorySortingMethod.SORT_MODIFIED_DESC; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.DEFAULT_CATEGORY; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.FAVORITES; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.RECENT; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.UNCATEGORIZED; +import static it.niedermann.owncloud.notes.shared.util.DisplayUtils.convertToCategoryNavigationItem; + +import android.accounts.NetworkErrorException; +import android.app.Application; +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; +import android.util.Pair; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.SavedStateHandle; + +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; +import com.nextcloud.android.sso.exceptions.UnknownErrorException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import it.niedermann.owncloud.notes.BuildConfig; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.exception.IntendedOfflineException; +import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; +import it.niedermann.owncloud.notes.main.navigation.NavigationItem; +import it.niedermann.owncloud.notes.persistence.ApiProvider; +import it.niedermann.owncloud.notes.persistence.CapabilitiesClient; +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; +import it.niedermann.owncloud.notes.shared.model.ImportStatus; +import it.niedermann.owncloud.notes.shared.model.Item; +import it.niedermann.owncloud.notes.shared.model.NavigationCategory; + +public class MainViewModel extends AndroidViewModel { + + private static final String TAG = MainViewModel.class.getSimpleName(); + + private final ExecutorService executor = Executors.newCachedThreadPool(); + + private final SavedStateHandle state; + + private static final String KEY_CURRENT_ACCOUNT = "currentAccount"; + private static final String KEY_SEARCH_TERM = "searchTerm"; + private static final String KEY_SELECTED_CATEGORY = "selectedCategory"; + private static final String KEY_EXPANDED_CATEGORY = "expandedCategory"; + + @NonNull + private final NotesRepository repo; + + @NonNull + private final MutableLiveData currentAccount = new MutableLiveData<>(); + @NonNull + private final MutableLiveData searchTerm = new MutableLiveData<>(null); + @NonNull + private final MutableLiveData selectedCategory = new MutableLiveData<>(new NavigationCategory(RECENT)); + @NonNull + private final MutableLiveData expandedCategory = new MutableLiveData<>(null); + + public MainViewModel(@NonNull Application application, @NonNull SavedStateHandle savedStateHandle) { + super(application); + this.repo = NotesRepository.getInstance(application); + this.state = savedStateHandle; + } + + public void restoreInstanceState() { + Log.v(TAG, "[restoreInstanceState]"); + final Account account = state.get(KEY_CURRENT_ACCOUNT); + if (account != null) { + postCurrentAccount(account); + } + postSearchTerm(state.get(KEY_SEARCH_TERM)); + final NavigationCategory selectedCategory = state.get(KEY_SELECTED_CATEGORY); + if (selectedCategory != null) { + postSelectedCategory(selectedCategory); + Log.v(TAG, "[restoreInstanceState] - selectedCategory: " + selectedCategory); + } + postExpandedCategory(state.get(KEY_EXPANDED_CATEGORY)); + } + + @NonNull + public LiveData getCurrentAccount() { + return distinctUntilChanged(currentAccount); + } + + public void postCurrentAccount(@NonNull Account account) { + state.set(KEY_CURRENT_ACCOUNT, account); + BrandingUtil.saveBrandColors(getApplication(), account.getColor(), account.getTextColor()); + SingleAccountHelper.setCurrentAccount(getApplication(), account.getAccountName()); + + final var currentAccount = this.currentAccount.getValue(); + // If only ETag or colors change, we must not reset the navigation + // TODO in the long term we should store the last NavigationCategory for each Account + if (currentAccount == null || currentAccount.getId() != account.getId()) { + this.currentAccount.setValue(account); + this.searchTerm.setValue(""); + this.selectedCategory.setValue(new NavigationCategory(RECENT)); + } + } + + @NonNull + public LiveData getSearchTerm() { + return distinctUntilChanged(searchTerm); + } + + public void postSearchTerm(String searchTerm) { + state.set(KEY_SEARCH_TERM, searchTerm); + this.searchTerm.postValue(searchTerm); + } + + @NonNull + public LiveData getSelectedCategory() { + return distinctUntilChanged(selectedCategory); + } + + public void postSelectedCategory(@NonNull NavigationCategory selectedCategory) { + state.set(KEY_SELECTED_CATEGORY, selectedCategory); + Log.v(TAG, "[postSelectedCategory] - selectedCategory: " + selectedCategory); + this.selectedCategory.postValue(selectedCategory); + + // Close sub categories + switch (selectedCategory.getType()) { + case RECENT: + case FAVORITES: + case UNCATEGORIZED: { + postExpandedCategory(null); + break; + } + case DEFAULT_CATEGORY: + default: { + final String category = selectedCategory.getCategory(); + if (category == null) { + postExpandedCategory(null); + Log.e(TAG, "navigation selection is a " + DEFAULT_CATEGORY + ", but the contained category is null."); + } else { + int slashIndex = category.indexOf('/'); + final String rootCategory = slashIndex < 0 ? category : category.substring(0, slashIndex); + final String expandedCategory = getExpandedCategory().getValue(); + if (expandedCategory != null && !expandedCategory.equals(rootCategory)) { + postExpandedCategory(null); + } + } + break; + } + } + } + + @NonNull + @MainThread + public LiveData> getCategorySortingMethodOfSelectedCategory() { + return switchMap(getSelectedCategory(), selectedCategory -> map(repo.getCategoryOrder(selectedCategory), sortingMethod -> new Pair<>(selectedCategory, sortingMethod))); + } + + public LiveData modifyCategoryOrder(@NonNull NavigationCategory selectedCategory, @NonNull CategorySortingMethod sortingMethod) { + return switchMap(getCurrentAccount(), currentAccount -> { + if (currentAccount == null) { + return new MutableLiveData<>(null); + } else { + Log.v(TAG, "[modifyCategoryOrder] - currentAccount: " + currentAccount.getAccountName()); + repo.modifyCategoryOrder(currentAccount.getId(), selectedCategory, sortingMethod); + return new MutableLiveData<>(null); + } + }); + } + + public void postExpandedCategory(@Nullable String expandedCategory) { + state.set(KEY_EXPANDED_CATEGORY, expandedCategory); + this.expandedCategory.postValue(expandedCategory); + } + + @NonNull + public LiveData getExpandedCategory() { + return distinctUntilChanged(expandedCategory); + } + + @NonNull + @MainThread + public LiveData> getNotesListLiveData() { + final var insufficientInformation = new MutableLiveData>(); + return distinctUntilChanged(switchMap(getCurrentAccount(), currentAccount -> { + Log.v(TAG, "[getNotesListLiveData] - currentAccount: " + currentAccount); + if (currentAccount == null) { + return insufficientInformation; + } else { + return switchMap(getSelectedCategory(), selectedCategory -> { + if (selectedCategory == null) { + return insufficientInformation; + } else { + Log.v(TAG, "[getNotesListLiveData] - selectedCategory: " + selectedCategory); + return switchMap(getSearchTerm(), searchTerm -> { + Log.v(TAG, "[getNotesListLiveData] - searchTerm: " + (BuildConfig.DEBUG ? "******" : searchTerm)); + return switchMap(getCategorySortingMethodOfSelectedCategory(), sortingMethod -> { + final long accountId = currentAccount.getId(); + final String searchQueryOrWildcard = searchTerm == null ? "%" : "%" + searchTerm.trim() + "%"; + Log.v(TAG, "[getNotesListLiveData] - sortMethod: " + sortingMethod.second); + final LiveData> fromDatabase; + switch (selectedCategory.getType()) { + case RECENT: { + Log.v(TAG, "[getNotesListLiveData] - category: " + RECENT); + fromDatabase = sortingMethod.second == SORT_MODIFIED_DESC + ? repo.searchRecentByModified$(accountId, searchQueryOrWildcard) + : repo.searchRecentLexicographically$(accountId, searchQueryOrWildcard); + break; + } + case FAVORITES: { + Log.v(TAG, "[getNotesListLiveData] - category: " + FAVORITES); + fromDatabase = sortingMethod.second == SORT_MODIFIED_DESC + ? repo.searchFavoritesByModified$(accountId, searchQueryOrWildcard) + : repo.searchFavoritesLexicographically$(accountId, searchQueryOrWildcard); + break; + } + case UNCATEGORIZED: { + Log.v(TAG, "[getNotesListLiveData] - category: " + UNCATEGORIZED); + fromDatabase = sortingMethod.second == SORT_MODIFIED_DESC + ? repo.searchUncategorizedByModified$(accountId, searchQueryOrWildcard) + : repo.searchUncategorizedLexicographically$(accountId, searchQueryOrWildcard); + break; + } + case DEFAULT_CATEGORY: + default: { + final String category = selectedCategory.getCategory(); + if (category == null) { + throw new IllegalStateException(NavigationCategory.class.getSimpleName() + " type is " + DEFAULT_CATEGORY + ", but category is null."); + } + Log.v(TAG, "[getNotesListLiveData] - category: " + category); + fromDatabase = sortingMethod.second == SORT_MODIFIED_DESC + ? repo.searchCategoryByModified$(accountId, searchQueryOrWildcard, category) + : repo.searchCategoryLexicographically$(accountId, searchQueryOrWildcard, category); + break; + } + } + + Log.v(TAG, "[getNotesListLiveData] - -------------------------------------"); + return distinctUntilChanged(map(fromDatabase, noteList -> fromNotes(noteList, selectedCategory, sortingMethod.second))); + }); + }); + } + }); + } + })); + } + + private List fromNotes(List noteList, @NonNull NavigationCategory selectedCategory, @Nullable CategorySortingMethod sortingMethod) { + if (selectedCategory.getType() == DEFAULT_CATEGORY) { + final String category = selectedCategory.getCategory(); + if (category != null) { + return fillListByCategory(noteList, category); + } else { + throw new IllegalStateException(NavigationCategory.class.getSimpleName() + " type is " + DEFAULT_CATEGORY + ", but category is null."); + } + } + if (sortingMethod == SORT_MODIFIED_DESC) { + return fillListByTime(getApplication(), noteList); + } else { + return fillListByInitials(getApplication(), noteList); + } + } + + @NonNull + @MainThread + public LiveData> getNavigationCategories() { + final var insufficientInformation = new MutableLiveData>(); + return switchMap(getCurrentAccount(), currentAccount -> { + if (currentAccount == null) { + return insufficientInformation; + } else { + Log.v(TAG, "[getNavigationCategories] - currentAccount: " + currentAccount.getAccountName()); + return switchMap(getExpandedCategory(), expandedCategory -> { + Log.v(TAG, "[getNavigationCategories] - expandedCategory: " + expandedCategory); + return switchMap(repo.count$(currentAccount.getId()), (count) -> { + Log.v(TAG, "[getNavigationCategories] - count: " + count); + return switchMap(repo.countFavorites$(currentAccount.getId()), (favoritesCount) -> { + Log.v(TAG, "[getNavigationCategories] - favoritesCount: " + favoritesCount); + return distinctUntilChanged(map(repo.getCategories$(currentAccount.getId()), fromDatabase -> + fromCategoriesWithNotesCount(getApplication(), expandedCategory, fromDatabase, count, favoritesCount) + )); + }); + }); + }); + } + }); + } + + private static List fromCategoriesWithNotesCount(@NonNull Context context, @Nullable String expandedCategory, @NonNull List fromDatabase, int count, int favoritesCount) { + final var categories = convertToCategoryNavigationItem(context, fromDatabase); + final var itemRecent = new NavigationItem(ADAPTER_KEY_RECENT, context.getString(R.string.label_all_notes), count, R.drawable.ic_access_time_grey600_24dp, RECENT); + final var itemFavorites = new NavigationItem(ADAPTER_KEY_STARRED, context.getString(R.string.label_favorites), favoritesCount, R.drawable.ic_star_yellow_24dp, FAVORITES); + + final var items = new ArrayList(fromDatabase.size() + 3); + items.add(itemRecent); + items.add(itemFavorites); + NavigationItem lastPrimaryCategory = null; + NavigationItem lastSecondaryCategory = null; + for (final var item : categories) { + final int slashIndex = item.label.indexOf('/'); + final String currentPrimaryCategory = slashIndex < 0 ? item.label : item.label.substring(0, slashIndex); + final boolean isCategoryOpen = currentPrimaryCategory.equals(expandedCategory); + String currentSecondaryCategory = null; + + if (isCategoryOpen && !currentPrimaryCategory.equals(item.label)) { + final String currentCategorySuffix = item.label.substring(expandedCategory.length() + 1); + final int subSlashIndex = currentCategorySuffix.indexOf('/'); + currentSecondaryCategory = subSlashIndex < 0 ? currentCategorySuffix : currentCategorySuffix.substring(0, subSlashIndex); + } + + boolean belongsToLastPrimaryCategory = lastPrimaryCategory != null && currentPrimaryCategory.equals(lastPrimaryCategory.label); + final boolean belongsToLastSecondaryCategory = belongsToLastPrimaryCategory && lastSecondaryCategory != null && lastSecondaryCategory.label.equals(currentSecondaryCategory); + + if (isCategoryOpen && !belongsToLastPrimaryCategory && currentSecondaryCategory != null) { + lastPrimaryCategory = new NavigationItem("category:" + currentPrimaryCategory, currentPrimaryCategory, 0, NavigationAdapter.ICON_MULTIPLE_OPEN); + items.add(lastPrimaryCategory); + belongsToLastPrimaryCategory = true; + } + + if (belongsToLastPrimaryCategory && belongsToLastSecondaryCategory) { + lastSecondaryCategory.count += item.count; + lastSecondaryCategory.icon = NavigationAdapter.ICON_SUB_MULTIPLE; + } else if (belongsToLastPrimaryCategory) { + if (isCategoryOpen) { + if (currentSecondaryCategory == null) { + throw new IllegalStateException("Current secondary category is null. Last primary category: " + lastPrimaryCategory); + } + item.label = currentSecondaryCategory; + item.id = "category:" + item.label; + item.icon = NavigationAdapter.ICON_SUB_FOLDER; + items.add(item); + lastSecondaryCategory = item; + } else { + lastPrimaryCategory.count += item.count; + lastPrimaryCategory.icon = NavigationAdapter.ICON_MULTIPLE; + lastSecondaryCategory = null; + } + } else { + if (isCategoryOpen) { + item.icon = NavigationAdapter.ICON_MULTIPLE_OPEN; + } else { + item.label = currentPrimaryCategory; + item.id = "category:" + item.label; + } + items.add(item); + lastPrimaryCategory = item; + lastSecondaryCategory = null; + } + } + return items; + } + + public void synchronizeCapabilitiesAndNotes(@NonNull Account localAccount, @NonNull IResponseCallback callback) { + Log.i(TAG, "[synchronizeCapabilitiesAndNotes] Synchronize capabilities for " + localAccount.getAccountName()); + synchronizeCapabilities(localAccount, new IResponseCallback() { + @Override + public void onSuccess(Void v) { + Log.i(TAG, "[synchronizeCapabilitiesAndNotes] Synchronize notes for " + localAccount.getAccountName()); + synchronizeNotes(localAccount, callback); + } + + @Override + public void onError(@NonNull Throwable t) { + callback.onError(t); + } + }); + } + + /** + * Updates the network status if necessary and pulls the latest {@link Capabilities} of the given {@param localAccount} + */ + public void synchronizeCapabilities(@NonNull Account localAccount, @NonNull IResponseCallback callback) { + executor.submit(() -> { + if (!repo.isSyncPossible()) { + repo.updateNetworkStatus(); + } + if (repo.isSyncPossible()) { + try { + final var ssoAccount = AccountImporter.getSingleSignOnAccount(getApplication(), localAccount.getAccountName()); + try { + final var capabilities = CapabilitiesClient.getCapabilities(getApplication(), ssoAccount, localAccount.getCapabilitiesETag(), ApiProvider.getInstance()); + repo.updateCapabilitiesETag(localAccount.getId(), capabilities.getETag()); + repo.updateBrand(localAccount.getId(), capabilities.getColor(), capabilities.getTextColor()); + localAccount.setColor(capabilities.getColor()); + localAccount.setTextColor(capabilities.getTextColor()); + BrandingUtil.saveBrandColors(getApplication(), localAccount.getColor(), localAccount.getTextColor()); + repo.updateApiVersion(localAccount.getId(), capabilities.getApiVersion()); + callback.onSuccess(null); + } catch (Throwable t) { + if (t.getClass() == NextcloudHttpRequestFailedException.class || t instanceof NextcloudHttpRequestFailedException) { + if (((NextcloudHttpRequestFailedException) t).getStatusCode() == HTTP_NOT_MODIFIED) { + Log.d(TAG, "Server returned HTTP Status Code " + ((NextcloudHttpRequestFailedException) t).getStatusCode() + " - Capabilities not modified."); + callback.onSuccess(null); + return; + } + } + callback.onError(t); + } + } catch (NextcloudFilesAppAccountNotFoundException e) { + repo.deleteAccount(localAccount); + callback.onError(e); + } + } else { + if (repo.isNetworkConnected() && repo.isSyncOnlyOnWifi()) { + callback.onError(new IntendedOfflineException("Network is connected, but sync is not possible.")); + } else { + callback.onError(new NetworkErrorException("Sync is not possible, because network is not connected.")); + } + } + }, "SYNC_CAPABILITIES"); + } + + /** + * Updates the network status if necessary and pulls the latest notes of the given {@param localAccount} + */ + public void synchronizeNotes(@NonNull Account currentAccount, @NonNull IResponseCallback callback) { + executor.submit(() -> { + Log.v(TAG, "[synchronize] - currentAccount: " + currentAccount.getAccountName()); + if (!repo.isSyncPossible()) { + repo.updateNetworkStatus(); + } + if (repo.isSyncPossible()) { + repo.scheduleSync(currentAccount, false); + callback.onSuccess(null); + } else { // Sync is not possible + if (repo.isNetworkConnected() && repo.isSyncOnlyOnWifi()) { + callback.onError(new IntendedOfflineException("Network is connected, but sync is not possible.")); + } else { + callback.onError(new NetworkErrorException("Sync is not possible, because network is not connected.")); + } + } + }, "SYNC_NOTES"); + } + + public LiveData getSyncStatus() { + return repo.getSyncStatus(); + } + + public LiveData> getSyncErrors() { + return repo.getSyncErrors(); + } + + public LiveData hasMultipleAccountsConfigured() { + return map(repo.countAccounts$(), (counter) -> counter != null && counter > 1); + } + + @WorkerThread + public Account getLocalAccountByAccountName(String accountName) { + return repo.getAccountByName(accountName); + } + + @WorkerThread + public List getAccounts() { + return repo.getAccounts(); + } + + public LiveData setCategory(Iterable noteIds, @NonNull String category) { + return switchMap(getCurrentAccount(), currentAccount -> { + if (currentAccount == null) { + return new MutableLiveData<>(null); + } else { + Log.v(TAG, "[setCategory] - currentAccount: " + currentAccount.getAccountName()); + for (Long noteId : noteIds) { + repo.setCategory(currentAccount, noteId, category); + } + return new MutableLiveData<>(null); + } + }); + } + + public LiveData moveNoteToAnotherAccount(Account account, long noteId) { + return switchMap(repo.getNoteById$(noteId), (note) -> { + Log.v(TAG, "[moveNoteToAnotherAccount] - note: " + (BuildConfig.DEBUG ? note : note.getTitle())); + return repo.moveNoteToAnotherAccount(account, note); + }); + } + + public LiveData toggleFavoriteAndSync(long noteId) { + return switchMap(getCurrentAccount(), currentAccount -> { + if (currentAccount == null) { + return new MutableLiveData<>(null); + } else { + Log.v(TAG, "[toggleFavoriteAndSync] - currentAccount: " + currentAccount.getAccountName()); + repo.toggleFavoriteAndSync(currentAccount, noteId); + return new MutableLiveData<>(null); + } + }); + } + + public LiveData deleteNoteAndSync(long id) { + return switchMap(getCurrentAccount(), currentAccount -> { + if (currentAccount == null) { + return new MutableLiveData<>(null); + } else { + Log.v(TAG, "[deleteNoteAndSync] - currentAccount: " + currentAccount.getAccountName()); + repo.deleteNoteAndSync(currentAccount, id); + return new MutableLiveData<>(null); + } + }); + } + + public LiveData deleteNotesAndSync(@NonNull Collection ids) { + return switchMap(getCurrentAccount(), currentAccount -> { + if (currentAccount == null) { + return new MutableLiveData<>(null); + } else { + Log.v(TAG, "[deleteNotesAndSync] - currentAccount: " + currentAccount.getAccountName()); + for (final var id : ids) { + repo.deleteNoteAndSync(currentAccount, id); + } + return new MutableLiveData<>(null); + } + }); + } + + public LiveData addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback callback) { + return repo.addAccount(url, username, accountName, capabilities, displayName, callback); + } + + public LiveData getFullNote$(long id) { + return map(getFullNotesWithCategory(Collections.singleton(id)), input -> input.get(0)); + } + + @WorkerThread + public Note getFullNote(long id) { + return repo.getNoteById(id); + } + + public LiveData> getFullNotesWithCategory(@NonNull Collection ids) { + return switchMap(getCurrentAccount(), currentAccount -> { + if (currentAccount == null) { + return new MutableLiveData<>(); + } else { + Log.v(TAG, "[getNote] - currentAccount: " + currentAccount.getAccountName()); + final var notes = new MutableLiveData>(); + executor.submit(() -> notes.postValue( + ids + .stream() + .map(repo::getNoteById) + .collect(Collectors.toList()) + )); + return notes; + } + }); + } + + public LiveData addNoteAndSync(Note note) { + return switchMap(getCurrentAccount(), currentAccount -> { + if (currentAccount == null) { + return new MutableLiveData<>(); + } else { + Log.v(TAG, "[addNoteAndSync] - currentAccount: " + currentAccount.getAccountName()); + return repo.addNoteAndSync(currentAccount, note); + } + }); + } + + public LiveData updateNoteAndSync(@NonNull Note oldNote, @Nullable String newContent, @Nullable String newTitle) { + return switchMap(getCurrentAccount(), currentAccount -> { + if (currentAccount != null) { + Log.v(TAG, "[updateNoteAndSync] - currentAccount: " + currentAccount.getAccountName()); + repo.updateNoteAndSync(currentAccount, oldNote, newContent, newTitle, null); + } + return new MutableLiveData<>(null); + }); + } + + public void createOrUpdateSingleNoteWidgetData(SingleNoteWidgetData data) { + repo.createOrUpdateSingleNoteWidgetData(data); + } + + public List getLocalModifiedNotes(long accountId) { + return repo.getLocalModifiedNotes(accountId); + } + + public LiveData getAccountsCount() { + return repo.countAccounts$(); + } + + @WorkerThread + public String collectNoteContents(@NonNull List noteIds) { + final var noteContents = new StringBuilder(); + for (final var noteId : noteIds) { + final var fullNote = repo.getNoteById(noteId); + final String tempFullNote = fullNote.getContent(); + if (!TextUtils.isEmpty(tempFullNote)) { + if (noteContents.length() > 0) { + noteContents.append("\n\n"); + } + noteContents.append(tempFullNote); + } + } + return noteContents.toString(); + } + + /** + * @return true if {@param exceptions} contains at least one exception which is not caused by flaky infrastructure. + * @see Issue #1303 + */ + public boolean containsNonInfrastructureRelatedItems(@Nullable Collection exceptions) { + if (exceptions == null || exceptions.isEmpty()) { + return false; + } + + return exceptions.stream().anyMatch(e -> !exceptionIsInfrastructureRelated(e)); + } + + private boolean exceptionIsInfrastructureRelated(@Nullable Throwable e) { + if (e == null) { + return false; + } + + if (e instanceof RuntimeException || e instanceof UnknownErrorException) { + if (isSoftwareCausedConnectionAbort(e.getMessage()) || isNetworkUnreachable(e.getMessage())) { + return true; + } + } + + return exceptionIsInfrastructureRelated(e.getCause()); + } + + private boolean isSoftwareCausedConnectionAbort(@Nullable String input) { + if (input == null) { + return false; + } + return input.toLowerCase(Locale.ROOT).contains("software caused connection abort"); + } + + private boolean isNetworkUnreachable(@Nullable String input) { + if (input == null) { + return false; + } + final var lower = input.toLowerCase(Locale.ROOT); + return lower.contains("failed to connect") && lower.contains("network is unreachable"); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MultiSelectedActionModeCallback.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MultiSelectedActionModeCallback.java new file mode 100644 index 0000000000000000000000000000000000000000..a76b20684417c86d06e3f2970169d25170afc422 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MultiSelectedActionModeCallback.java @@ -0,0 +1,174 @@ +package it.niedermann.owncloud.notes.main; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.TypedValue; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.appcompat.view.ActionMode; +import androidx.appcompat.view.ActionMode.Callback; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.recyclerview.selection.SelectionTracker; + +import com.google.android.material.snackbar.Snackbar; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.accountpicker.AccountPickerDialogFragment; +import it.niedermann.owncloud.notes.branding.BrandedSnackbar; +import it.niedermann.owncloud.notes.edit.category.CategoryDialogFragment; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.util.ShareUtil; + +public class MultiSelectedActionModeCallback implements Callback { + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + @ColorInt + private final int colorAccent; + @NonNull + private final Context context; + @NonNull + private final View view; + @NonNull + private final MainViewModel mainViewModel; + @NonNull + private final LifecycleOwner lifecycleOwner; + private final boolean canMoveNoteToAnotherAccounts; + @NonNull + private final SelectionTracker tracker; + @NonNull + private final FragmentManager fragmentManager; + + public MultiSelectedActionModeCallback( + @NonNull Context context, @NonNull View view, @NonNull MainViewModel mainViewModel, @NonNull LifecycleOwner lifecycleOwner, boolean canMoveNoteToAnotherAccounts, @NonNull SelectionTracker tracker, @NonNull FragmentManager fragmentManager) { + this.context = context; + this.view = view; + this.mainViewModel = mainViewModel; + this.lifecycleOwner = lifecycleOwner; + this.canMoveNoteToAnotherAccounts = canMoveNoteToAnotherAccounts; + this.tracker = tracker; + this.fragmentManager = fragmentManager; + + final TypedValue typedValue = new TypedValue(); + context.getTheme().resolveAttribute(R.attr.colorAccent, typedValue, true); + colorAccent = typedValue.data; + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + // inflate contextual menu + mode.getMenuInflater().inflate(R.menu.menu_list_context_multiple, menu); + menu.findItem(R.id.menu_move).setVisible(canMoveNoteToAnotherAccounts); + for (int i = 0; i < menu.size(); i++) { + var drawable = menu.getItem(i).getIcon(); + if (drawable != null) { + drawable = DrawableCompat.wrap(drawable); + DrawableCompat.setTint(drawable, colorAccent); + menu.getItem(i).setIcon(drawable); + } + } + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + /** + * @param mode ActionMode - used to close the Action Bar after all work is done. + * @param item MenuItem - the item in the List that contains the Node + * @return boolean + */ + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + int itemId = item.getItemId(); + if (itemId == R.id.menu_delete) { + final var selection = new ArrayList(tracker.getSelection().size()); + for (final var sel : tracker.getSelection()) { + selection.add(sel); + } + final var fullNotes$ = mainViewModel.getFullNotesWithCategory(selection); + fullNotes$.observe(lifecycleOwner, (fullNotes) -> { + fullNotes$.removeObservers(lifecycleOwner); + tracker.clearSelection(); + final var deleteLiveData = mainViewModel.deleteNotesAndSync(selection); + deleteLiveData.observe(lifecycleOwner, (next) -> deleteLiveData.removeObservers(lifecycleOwner)); + final String deletedSnackbarTitle = fullNotes.size() == 1 + ? context.getString(R.string.action_note_deleted, fullNotes.get(0).getTitle()) + : context.getResources().getQuantityString(R.plurals.bulk_notes_deleted, fullNotes.size(), fullNotes.size()); + BrandedSnackbar.make(view, deletedSnackbarTitle, Snackbar.LENGTH_LONG) + .setAction(R.string.action_undo, (View v) -> { + for (final var deletedNote : fullNotes) { + final var undoLiveData = mainViewModel.addNoteAndSync(deletedNote); + undoLiveData.observe(lifecycleOwner, (o) -> undoLiveData.removeObservers(lifecycleOwner)); + } + String restoreSnackbarTitle = fullNotes.size() == 1 + ? context.getString(R.string.action_note_restored, fullNotes.get(0).getTitle()) + : context.getResources().getQuantityString(R.plurals.bulk_notes_restored, fullNotes.size(), fullNotes.size()); + BrandedSnackbar.make(view, restoreSnackbarTitle, Snackbar.LENGTH_SHORT) + .show(); + }) + .show(); + }); + return true; + } else if (itemId == R.id.menu_move) { + final var currentAccount$ = mainViewModel.getCurrentAccount(); + currentAccount$.observe(lifecycleOwner, account -> { + currentAccount$.removeObservers(lifecycleOwner); + executor.submit(() -> AccountPickerDialogFragment + .newInstance(new ArrayList<>(mainViewModel.getAccounts()), account.getId()) + .show(fragmentManager, AccountPickerDialogFragment.class.getSimpleName())); + }); + return true; + } else if (itemId == R.id.menu_share) { + final var selection = new ArrayList(tracker.getSelection().size()); + for (final var sel : tracker.getSelection()) { + selection.add(sel); + } + tracker.clearSelection(); + + executor.submit(() -> { + if (selection.size() == 1) { + final var note = mainViewModel.getFullNote(selection.get(0)); + ShareUtil.openShareDialog(context, note.getTitle(), note.getContent()); + } else { + ShareUtil.openShareDialog(context, + context.getResources().getQuantityString(R.plurals.share_multiple, selection.size(), selection.size()), + mainViewModel.collectNoteContents(selection)); + } + }); + return true; + } else if (itemId == R.id.menu_category) {// TODO detect whether all selected notes do have the same category - in this case preselect it + final var accountLiveData = mainViewModel.getCurrentAccount(); + accountLiveData.observe(lifecycleOwner, account -> { + accountLiveData.removeObservers(lifecycleOwner); + CategoryDialogFragment + .newInstance(account.getId(), "") + .show(fragmentManager, CategoryDialogFragment.class.getSimpleName()); + }); + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + if (mode != null) { + mode.finish(); + } + tracker.clearSelection(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/ItemAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/ItemAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..7470fa2579b70efdab5574db3b33c8899c5143db --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/ItemAdapter.java @@ -0,0 +1,242 @@ +package it.niedermann.owncloud.notes.main.items; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.ColorInt; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.selection.SelectionTracker; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.Branded; +import it.niedermann.owncloud.notes.databinding.ItemNotesListNoteItemGridBinding; +import it.niedermann.owncloud.notes.databinding.ItemNotesListNoteItemGridOnlyTitleBinding; +import it.niedermann.owncloud.notes.databinding.ItemNotesListNoteItemWithExcerptBinding; +import it.niedermann.owncloud.notes.databinding.ItemNotesListNoteItemWithoutExcerptBinding; +import it.niedermann.owncloud.notes.databinding.ItemNotesListSectionItemBinding; +import it.niedermann.owncloud.notes.main.items.grid.NoteViewGridHolder; +import it.niedermann.owncloud.notes.main.items.grid.NoteViewGridHolderOnlyTitle; +import it.niedermann.owncloud.notes.main.items.list.NoteViewHolderWithExcerpt; +import it.niedermann.owncloud.notes.main.items.list.NoteViewHolderWithoutExcerpt; +import it.niedermann.owncloud.notes.main.items.section.SectionItem; +import it.niedermann.owncloud.notes.main.items.section.SectionViewHolder; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.Item; +import it.niedermann.owncloud.notes.shared.model.NoteClickListener; + +import static it.niedermann.owncloud.notes.shared.util.NoteUtil.getFontSizeFromPreferences; + +public class ItemAdapter extends RecyclerView.Adapter implements Branded { + + private static final String TAG = ItemAdapter.class.getSimpleName(); + + public static final int TYPE_SECTION = 0; + public static final int TYPE_NOTE_WITH_EXCERPT = 1; + public static final int TYPE_NOTE_WITHOUT_EXCERPT = 2; + public static final int TYPE_NOTE_ONLY_TITLE = 3; + + private final NoteClickListener noteClickListener; + private final boolean gridView; + @NonNull + private final List itemList = new ArrayList<>(); + private boolean showCategory = true; + private CharSequence searchQuery; + private SelectionTracker tracker = null; + @Px + private final float fontSize; + private final boolean monospace; + @ColorInt + private int mainColor; + @ColorInt + private int textColor; + @Nullable + private Integer swipedPosition; + + public ItemAdapter(@NonNull T context, boolean gridView) { + this.noteClickListener = context; + this.gridView = gridView; + this.mainColor = ContextCompat.getColor(context, R.color.defaultBrand); + this.textColor = Color.WHITE; + final var sp = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + this.fontSize = getFontSizeFromPreferences(context, sp); + this.monospace = sp.getBoolean(context.getString(R.string.pref_key_font), false); + setHasStableIds(true); + } + + + // FIXME this causes {@link it.niedermann.owncloud.notes.noteslist.items.list.NotesListViewItemTouchHelper} to not call clearView anymore → After marking a note as favorite, it stays yellow. + @Override + public long getItemId(int position) { + return getItemViewType(position) == TYPE_SECTION + ? ((SectionItem) getItem(position)).getTitle().hashCode() * -1 + : ((Note) getItem(position)).getId(); + } + + /** + * Updates the item list and notifies respective view to update. + * + * @param itemList List of items to be set + */ + public void setItemList(@NonNull List itemList) { + this.itemList.clear(); + this.itemList.addAll(itemList); + this.swipedPosition = null; + notifyDataSetChanged(); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + if (gridView) { + switch (viewType) { + case TYPE_SECTION: { + return new SectionViewHolder(ItemNotesListSectionItemBinding.inflate(inflater)); + } + case TYPE_NOTE_ONLY_TITLE: { + return new NoteViewGridHolderOnlyTitle(ItemNotesListNoteItemGridOnlyTitleBinding.inflate(inflater, parent, false), noteClickListener, monospace, fontSize); + } + case TYPE_NOTE_WITH_EXCERPT: + case TYPE_NOTE_WITHOUT_EXCERPT: { + return new NoteViewGridHolder(ItemNotesListNoteItemGridBinding.inflate(inflater, parent, false), noteClickListener, monospace, fontSize); + } + default: { + throw new IllegalArgumentException("Not supported viewType: " + viewType); + } + } + } else { + switch (viewType) { + case TYPE_SECTION: { + return new SectionViewHolder(ItemNotesListSectionItemBinding.inflate(inflater)); + } + case TYPE_NOTE_WITH_EXCERPT: { + return new NoteViewHolderWithExcerpt(ItemNotesListNoteItemWithExcerptBinding.inflate(inflater, parent, false), noteClickListener); + } + case TYPE_NOTE_ONLY_TITLE: + case TYPE_NOTE_WITHOUT_EXCERPT: { + return new NoteViewHolderWithoutExcerpt(ItemNotesListNoteItemWithoutExcerptBinding.inflate(inflater, parent, false), noteClickListener); + } + default: { + throw new IllegalArgumentException("Not supported viewType: " + viewType); + } + } + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) { + boolean isSelected = false; + if (tracker != null) { + final Long itemId = getItemId(position); + if (tracker.isSelected(itemId)) { + tracker.select(itemId); + isSelected = true; + } else { + tracker.deselect(itemId); + } + } + switch (getItemViewType(position)) { + case TYPE_SECTION: { + ((SectionViewHolder) holder).bind((SectionItem) itemList.get(position)); + break; + } + case TYPE_NOTE_WITH_EXCERPT: + case TYPE_NOTE_WITHOUT_EXCERPT: + case TYPE_NOTE_ONLY_TITLE: { + ((NoteViewHolder) holder).bind(isSelected, (Note) itemList.get(position), showCategory, mainColor, textColor, searchQuery); + break; + } + } + } + + public void setTracker(SelectionTracker tracker) { + this.tracker = tracker; + } + + public Item getItem(int notePosition) { + return itemList.get(notePosition); + } + + public boolean hasItemPosition(int notePosition) { + return notePosition >= 0 && notePosition < itemList.size(); + } + + public void remove(@NonNull Item item) { + itemList.remove(item); + notifyDataSetChanged(); + } + + public void setShowCategory(boolean showCategory) { + this.showCategory = showCategory; + } + + @Override + public int getItemCount() { + return itemList.size(); + } + + @IntRange(from = 0, to = 3) + @Override + public int getItemViewType(int position) { + final var item = getItem(position); + if (item == null) { + throw new IllegalArgumentException("Item at position " + position + " must not be null"); + } + if (getItem(position).isSection()) return TYPE_SECTION; + final var note = (Note) getItem(position); + if (TextUtils.isEmpty(note.getExcerpt())) { + if (TextUtils.isEmpty(note.getCategory())) { + return TYPE_NOTE_ONLY_TITLE; + } else { + return TYPE_NOTE_WITHOUT_EXCERPT; + } + } + return TYPE_NOTE_WITH_EXCERPT; + } + + @Override + public void applyBrand(int mainColor, int textColor) { + this.mainColor = mainColor; + this.textColor = textColor; + notifyDataSetChanged(); + } + + public void setHighlightSearchQuery(CharSequence searchQuery) { + this.searchQuery = searchQuery; + notifyDataSetChanged(); + } + + /** + * @return the position of the first {@link Item} which matches the given viewtype, -1 if not available + */ + public int getFirstPositionOfViewType(@IntRange(from = 0, to = 3) int viewType) { + for (int i = 0; i < itemList.size(); i++) { + if (getItemViewType(i) == viewType) { + return i; + } + } + return -1; + } + + @Nullable + public Integer getSwipedPosition() { + return swipedPosition; + } + + public void setSwipedPosition(@Nullable Integer swipedPosition) { + this.swipedPosition = swipedPosition; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/NoteViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/NoteViewHolder.java new file mode 100644 index 0000000000000000000000000000000000000000..609d1aeff4a9191cf3c64018c1bdb41d224ee413 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/NoteViewHolder.java @@ -0,0 +1,153 @@ +package it.niedermann.owncloud.notes.main.items; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.CallSuper; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.recyclerview.selection.ItemDetailsLookup; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.chip.Chip; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import it.niedermann.android.util.ColorUtil; +import it.niedermann.owncloud.notes.NotesApplication; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.DBStatus; +import it.niedermann.owncloud.notes.shared.model.NoteClickListener; + +import static android.view.View.INVISIBLE; +import static android.view.View.VISIBLE; +import static it.niedermann.owncloud.notes.shared.util.NotesColorUtil.contrastRatioIsSufficient; + +public abstract class NoteViewHolder extends RecyclerView.ViewHolder { + @NonNull + private final NoteClickListener noteClickListener; + + public NoteViewHolder(@NonNull View v, @NonNull NoteClickListener noteClickListener) { + super(v); + this.noteClickListener = noteClickListener; + this.setIsRecyclable(false); + } + + @CallSuper + public void bind(boolean isSelected, @NonNull Note note, boolean showCategory, int mainColor, int textColor, @Nullable CharSequence searchQuery) { + itemView.setSelected(isSelected); + itemView.setOnClickListener((view) -> noteClickListener.onNoteClick(getLayoutPosition(), view)); + } + + protected void bindStatus(AppCompatImageView noteStatus, DBStatus status, int mainColor) { + noteStatus.setVisibility(DBStatus.VOID.equals(status) ? INVISIBLE : VISIBLE); + DrawableCompat.setTint(noteStatus.getDrawable(), BrandingUtil.getSecondaryForegroundColorDependingOnTheme(noteStatus.getContext(), mainColor)); + } + + protected void bindCategory(@NonNull Context context, @NonNull TextView noteCategory, boolean showCategory, @NonNull String category, int mainColor) { + final boolean isDarkThemeActive = NotesApplication.isDarkThemeActive(context); + noteCategory.setVisibility(showCategory && !category.isEmpty() ? View.VISIBLE : View.GONE); + noteCategory.setText(category); + + @ColorInt final int categoryForeground; + @ColorInt final int categoryBackground; + + if (isDarkThemeActive) { + if (ColorUtil.INSTANCE.isColorDark(mainColor)) { + if (contrastRatioIsSufficient(mainColor, Color.BLACK)) { + categoryBackground = mainColor; + categoryForeground = Color.WHITE; + } else { + categoryBackground = Color.WHITE; + categoryForeground = mainColor; + } + } else { + categoryBackground = mainColor; + categoryForeground = Color.BLACK; + } + } else { + categoryForeground = Color.BLACK; + if (ColorUtil.INSTANCE.isColorDark(mainColor) || contrastRatioIsSufficient(mainColor, Color.WHITE)) { + categoryBackground = mainColor; + } else { + categoryBackground = Color.BLACK; + } + } + + noteCategory.setTextColor(categoryForeground); + if (noteCategory instanceof Chip) { + final Chip chip = (Chip) noteCategory; + chip.setChipStrokeColor(ColorStateList.valueOf(categoryBackground)); + if(isDarkThemeActive) { + chip.setChipBackgroundColor(ColorStateList.valueOf(categoryBackground)); + } else { + chip.setChipBackgroundColorResource(R.color.grid_item_background_selector); + } + } else { + DrawableCompat.setTint(noteCategory.getBackground(), categoryBackground); + } + } + + protected void bindFavorite(@NonNull ImageView noteFavorite, boolean isFavorite) { + noteFavorite.setImageResource(isFavorite ? R.drawable.ic_star_yellow_24dp : R.drawable.ic_star_grey_ccc_24dp); + noteFavorite.setOnClickListener(view -> noteClickListener.onNoteFavoriteClick(getLayoutPosition(), view)); + } + + protected void bindSearchableContent(@NonNull Context context, @NonNull TextView textView, @Nullable CharSequence searchQuery, @NonNull String content, int mainColor) { + CharSequence processedContent = content; + if (!TextUtils.isEmpty(searchQuery)) { + @ColorInt final int searchBackground = ContextCompat.getColor(context, R.color.bg_highlighted); + @ColorInt final int searchForeground = BrandingUtil.getSecondaryForegroundColorDependingOnTheme(context, mainColor); + + // The Pattern.quote method will add \Q to the very beginning of the string and \E to the end of the string + // It implies that the string between \Q and \E is a literal string and thus the reserved keyword in such string will be ignored. + // See https://stackoverflow.com/questions/15409296/what-is-the-use-of-pattern-quote-method + //noinspection ConstantConditions + final Pattern pattern = Pattern.compile("(" + Pattern.quote(searchQuery.toString()) + ")", Pattern.CASE_INSENSITIVE); + SpannableString spannableString = new SpannableString(content); + Matcher matcher = pattern.matcher(spannableString); + + while (matcher.find()) { + spannableString.setSpan(new ForegroundColorSpan(searchForeground), matcher.start(), matcher.end(), 0); + spannableString.setSpan(new BackgroundColorSpan(searchBackground), matcher.start(), matcher.end(), 0); + } + + processedContent = spannableString; + } + textView.setText(processedContent); + } + + public abstract void showSwipe(boolean left); + + @Nullable + public abstract View getNoteSwipeable(); + + public ItemDetailsLookup.ItemDetails getItemDetails() { + return new ItemDetailsLookup.ItemDetails() { + @Override + public int getPosition() { + return getAdapterPosition(); + } + + @Override + public Long getSelectionKey() { + return getItemId(); + } + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/GridItemDecoration.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/GridItemDecoration.java new file mode 100644 index 0000000000000000000000000000000000000000..1f50202052cce75bd90f13ae3b4139237dee1ae2 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/GridItemDecoration.java @@ -0,0 +1,60 @@ +package it.niedermann.owncloud.notes.main.items.grid; + +import android.graphics.Rect; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Px; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; + +import it.niedermann.owncloud.notes.main.items.ItemAdapter; +import it.niedermann.owncloud.notes.main.items.section.SectionItemDecoration; + +public class GridItemDecoration extends SectionItemDecoration { + + @NonNull + private final ItemAdapter adapter; + private final int spanCount; + private final int gutter; + + public GridItemDecoration(@NonNull ItemAdapter adapter, int spanCount, @Px int sectionLeft, @Px int sectionTop, @Px int sectionRight, @Px int sectionBottom, @Px int gutter) { + super(adapter, sectionLeft, sectionTop, sectionRight, sectionBottom); + if(spanCount < 1) { + throw new IllegalArgumentException("Requires at least one span"); + } + this.spanCount = spanCount; + this.adapter = adapter; + this.gutter = gutter; + } + + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + super.getItemOffsets(outRect, view, parent, state); + final int position = parent.getChildAdapterPosition(view); + if (position >= 0) { + final var lp = (StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams(); + + if (adapter.getItemViewType(position) == ItemAdapter.TYPE_SECTION) { + lp.setFullSpan(true); + } else { + final int spanIndex = lp.getSpanIndex(); + + // First row gets some spacing at the top + final int firstSectionPosition = adapter.getFirstPositionOfViewType(ItemAdapter.TYPE_SECTION); + if (position < spanCount && (firstSectionPosition < 0 || position < firstSectionPosition)) { + outRect.top = gutter; + } + + // First column gets some spacing at the left and the right side + if (spanIndex == 0) { + outRect.left = gutter; + } + + // All columns get some spacing at the bottom and at the right side + outRect.right = gutter; + outRect.bottom = gutter; + } + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolder.java new file mode 100644 index 0000000000000000000000000000000000000000..7c4cecfe9b411eb59ad225c9a123e3bf52b7ce2b --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolder.java @@ -0,0 +1,57 @@ +package it.niedermann.owncloud.notes.main.items.grid; + +import android.content.Context; +import android.graphics.Typeface; +import android.text.TextUtils; +import android.util.TypedValue; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; + +import it.niedermann.owncloud.notes.databinding.ItemNotesListNoteItemGridBinding; +import it.niedermann.owncloud.notes.main.items.NoteViewHolder; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.NoteClickListener; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static it.niedermann.owncloud.notes.shared.util.NoteUtil.EXCERPT_LINE_SEPARATOR; + +public class NoteViewGridHolder extends NoteViewHolder { + @NonNull + private final ItemNotesListNoteItemGridBinding binding; + + public NoteViewGridHolder(@NonNull ItemNotesListNoteItemGridBinding binding, @NonNull NoteClickListener noteClickListener, boolean monospace, @Px float fontSize) { + super(binding.getRoot(), noteClickListener); + this.binding = binding; + + binding.noteTitle.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 1.1f); + binding.noteExcerpt.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * .8f); + if (monospace) { + binding.noteTitle.setTypeface(Typeface.MONOSPACE); + binding.noteExcerpt.setTypeface(Typeface.MONOSPACE); + } + } + + public void showSwipe(boolean left) { + throw new UnsupportedOperationException(NoteViewGridHolder.class.getSimpleName() + " does not support swiping"); + } + + public void bind(boolean isSelected, @NonNull Note note, boolean showCategory, int mainColor, int textColor, @Nullable CharSequence searchQuery) { + super.bind(isSelected, note, showCategory, mainColor, textColor, searchQuery); + @NonNull final Context context = itemView.getContext(); + bindCategory(context, binding.noteCategory, showCategory, note.getCategory(), mainColor); + bindStatus(binding.noteStatus, note.getStatus(), mainColor); + bindFavorite(binding.noteFavorite, note.getFavorite()); + bindSearchableContent(context, binding.noteTitle, searchQuery, note.getTitle(), mainColor); + bindSearchableContent(context, binding.noteExcerpt, searchQuery, note.getExcerpt().replace(EXCERPT_LINE_SEPARATOR, "\n"), mainColor); + binding.noteExcerpt.setVisibility(TextUtils.isEmpty(note.getExcerpt()) ? GONE : VISIBLE); + } + + @Nullable + public View getNoteSwipeable() { + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolderOnlyTitle.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolderOnlyTitle.java new file mode 100644 index 0000000000000000000000000000000000000000..416d99d2566f1a70e002757a24f472947409095c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolderOnlyTitle.java @@ -0,0 +1,47 @@ +package it.niedermann.owncloud.notes.main.items.grid; + +import android.content.Context; +import android.graphics.Typeface; +import android.util.TypedValue; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; + +import it.niedermann.owncloud.notes.databinding.ItemNotesListNoteItemGridOnlyTitleBinding; +import it.niedermann.owncloud.notes.main.items.NoteViewHolder; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.NoteClickListener; + +public class NoteViewGridHolderOnlyTitle extends NoteViewHolder { + @NonNull + private final ItemNotesListNoteItemGridOnlyTitleBinding binding; + + public NoteViewGridHolderOnlyTitle(@NonNull ItemNotesListNoteItemGridOnlyTitleBinding binding, @NonNull NoteClickListener noteClickListener, boolean monospace, @Px float fontSize) { + super(binding.getRoot(), noteClickListener); + this.binding = binding; + + binding.noteTitle.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 1.1f); + if (monospace) { + binding.noteTitle.setTypeface(Typeface.MONOSPACE); + } + } + + public void showSwipe(boolean left) { + throw new UnsupportedOperationException(NoteViewGridHolderOnlyTitle.class.getSimpleName() + " does not support swiping"); + } + + public void bind(boolean isSelected, @NonNull Note note, boolean showCategory, int mainColor, int textColor, @Nullable CharSequence searchQuery) { + super.bind(isSelected, note, showCategory, mainColor, textColor, searchQuery); + @NonNull final Context context = itemView.getContext(); + bindStatus(binding.noteStatus, note.getStatus(), mainColor); + bindFavorite(binding.noteFavorite, note.getFavorite()); + bindSearchableContent(context, binding.noteTitle, searchQuery, note.getTitle(), mainColor); + } + + @Nullable + public View getNoteSwipeable() { + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NoteViewHolderWithExcerpt.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NoteViewHolderWithExcerpt.java new file mode 100644 index 0000000000000000000000000000000000000000..c171a236a1272dc7a66ae31cd7e2719385e35c7e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NoteViewHolderWithExcerpt.java @@ -0,0 +1,47 @@ +package it.niedermann.owncloud.notes.main.items.list; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ItemNotesListNoteItemWithExcerptBinding; +import it.niedermann.owncloud.notes.main.items.NoteViewHolder; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.DBStatus; +import it.niedermann.owncloud.notes.shared.model.NoteClickListener; + +public class NoteViewHolderWithExcerpt extends NoteViewHolder { + @NonNull + private final ItemNotesListNoteItemWithExcerptBinding binding; + + public NoteViewHolderWithExcerpt(@NonNull ItemNotesListNoteItemWithExcerptBinding binding, @NonNull NoteClickListener noteClickListener) { + super(binding.getRoot(), noteClickListener); + this.binding = binding; + } + + public void showSwipe(boolean left) { + binding.noteFavoriteLeft.setVisibility(left ? View.VISIBLE : View.INVISIBLE); + binding.noteDeleteRight.setVisibility(left ? View.INVISIBLE : View.VISIBLE); + binding.noteSwipeFrame.setBackgroundResource(left ? R.color.bg_warning : R.color.bg_attention); + } + + public void bind(boolean isSelected, @NonNull Note note, boolean showCategory, int mainColor, int textColor, @Nullable CharSequence searchQuery) { + super.bind(isSelected, note, showCategory, mainColor, textColor, searchQuery); + @NonNull final var context = itemView.getContext(); + binding.noteSwipeable.setAlpha(DBStatus.LOCAL_DELETED.equals(note.getStatus()) ? 0.5f : 1.0f); + bindCategory(context, binding.noteCategory, showCategory, note.getCategory(), mainColor); + bindStatus(binding.noteStatus, note.getStatus(), mainColor); + bindFavorite(binding.noteFavorite, note.getFavorite()); + + bindSearchableContent(context, binding.noteTitle, searchQuery, note.getTitle(), mainColor); + bindSearchableContent(context, binding.noteExcerpt, searchQuery, note.getExcerpt(), mainColor); + } + + @NonNull + public View getNoteSwipeable() { + return binding.noteSwipeable; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NoteViewHolderWithoutExcerpt.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NoteViewHolderWithoutExcerpt.java new file mode 100644 index 0000000000000000000000000000000000000000..3ca136e49c5c8b9ab33cb48addefa3270f9620fc --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NoteViewHolderWithoutExcerpt.java @@ -0,0 +1,45 @@ +package it.niedermann.owncloud.notes.main.items.list; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ItemNotesListNoteItemWithoutExcerptBinding; +import it.niedermann.owncloud.notes.main.items.NoteViewHolder; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.DBStatus; +import it.niedermann.owncloud.notes.shared.model.NoteClickListener; + +public class NoteViewHolderWithoutExcerpt extends NoteViewHolder { + @NonNull + private final ItemNotesListNoteItemWithoutExcerptBinding binding; + + public NoteViewHolderWithoutExcerpt(@NonNull ItemNotesListNoteItemWithoutExcerptBinding binding, @NonNull NoteClickListener noteClickListener) { + super(binding.getRoot(), noteClickListener); + this.binding = binding; + } + + public void showSwipe(boolean left) { + binding.noteFavoriteLeft.setVisibility(left ? View.VISIBLE : View.INVISIBLE); + binding.noteDeleteRight.setVisibility(left ? View.INVISIBLE : View.VISIBLE); + binding.noteSwipeFrame.setBackgroundResource(left ? R.color.bg_warning : R.color.bg_attention); + } + + public void bind(boolean isSelected, @NonNull Note note, boolean showCategory, int mainColor, int textColor, @Nullable CharSequence searchQuery) { + super.bind(isSelected, note, showCategory, mainColor, textColor, searchQuery); + @NonNull final Context context = itemView.getContext(); + binding.noteSwipeable.setAlpha(DBStatus.LOCAL_DELETED.equals(note.getStatus()) ? 0.5f : 1.0f); + bindCategory(context, binding.noteCategory, showCategory, note.getCategory(), mainColor); + bindStatus(binding.noteStatus, note.getStatus(), mainColor); + bindFavorite(binding.noteFavorite, note.getFavorite()); + bindSearchableContent(context, binding.noteTitle, searchQuery, note.getTitle(), mainColor); + } + + @NonNull + public View getNoteSwipeable() { + return binding.noteSwipeable; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NotesListViewItemTouchHelper.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NotesListViewItemTouchHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..d31bb2f30fe496c077506fce5bbefa5c5c91fde9 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NotesListViewItemTouchHelper.java @@ -0,0 +1,140 @@ +package it.niedermann.owncloud.notes.main.items.list; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Canvas; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.recyclerview.selection.SelectionTracker; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.snackbar.Snackbar; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandedSnackbar; +import it.niedermann.owncloud.notes.main.MainViewModel; +import it.niedermann.owncloud.notes.main.items.ItemAdapter; +import it.niedermann.owncloud.notes.main.items.NoteViewHolder; +import it.niedermann.owncloud.notes.main.items.section.SectionViewHolder; +import it.niedermann.owncloud.notes.persistence.entity.Note; + +public class NotesListViewItemTouchHelper extends ItemTouchHelper { + + private static final String TAG = NotesListViewItemTouchHelper.class.getSimpleName(); + private static final int UNDO_DURATION = 12_000; + + public NotesListViewItemTouchHelper( + @NonNull Context context, + @NonNull MainViewModel mainViewModel, + @NonNull LifecycleOwner lifecycleOwner, + @NonNull SelectionTracker tracker, + @NonNull ItemAdapter adapter, + @NonNull SwipeRefreshLayout swipeRefreshLayout, + @NonNull View view, + boolean gridView) { + super(new SimpleCallback(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) { + private boolean swipeRefreshLayoutEnabled; + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { + return false; + } + + /** + * Disable swipe on sections and if grid view is enabled + * + * @param recyclerView RecyclerView + * @param viewHolder RecyclerView.ViewHolder + * @return 0 if viewHolder is section or grid view is enabled, otherwise super() + */ + @Override + public int getSwipeDirs(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + if (gridView || viewHolder instanceof SectionViewHolder) return 0; + return super.getSwipeDirs(recyclerView, viewHolder); + } + + /** + * Delete note if note is swiped to left or right + * + * @param viewHolder RecyclerView.ViewHoler + * @param direction int + */ + @SuppressLint("WrongConstant") + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + switch (direction) { + case ItemTouchHelper.LEFT: + viewHolder.setIsRecyclable(false); + final var dbNoteWithoutContent = (Note) adapter.getItem(viewHolder.getLayoutPosition()); + final var dbNoteLiveData = mainViewModel.getFullNote$(dbNoteWithoutContent.getId()); + dbNoteLiveData.observe(lifecycleOwner, (dbNote) -> { + dbNoteLiveData.removeObservers(lifecycleOwner); + tracker.deselect(dbNote.getId()); + final var deleteLiveData = mainViewModel.deleteNoteAndSync(dbNote.getId()); + deleteLiveData.observe(lifecycleOwner, (next) -> deleteLiveData.removeObservers(lifecycleOwner)); + Log.v(TAG, "Item deleted through swipe ----------------------------------------------"); + BrandedSnackbar.make(view, context.getString(R.string.action_note_deleted, dbNote.getTitle()), UNDO_DURATION) + .setAction(R.string.action_undo, (View v) -> { + final var undoLiveData = mainViewModel.addNoteAndSync(dbNote); + undoLiveData.observe(lifecycleOwner, (o) -> undoLiveData.removeObservers(lifecycleOwner)); + BrandedSnackbar.make(view, context.getString(R.string.action_note_restored, dbNote.getTitle()), Snackbar.LENGTH_SHORT) + .show(); + }) + .show(); + }); + break; + case ItemTouchHelper.RIGHT: + viewHolder.setIsRecyclable(false); + final var adapterNote = (Note) adapter.getItem(viewHolder.getLayoutPosition()); + final var toggleLiveData = mainViewModel.toggleFavoriteAndSync(adapterNote.getId()); + toggleLiveData.observe(lifecycleOwner, (next) -> toggleLiveData.removeObservers(lifecycleOwner)); + break; + default: + //NoOp + } + } + + @Override + public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { + final var noteViewHolder = (NoteViewHolder) viewHolder; + // show swipe icon on the side + noteViewHolder.showSwipe(dX > 0); + // move only swipeable part of item (not leave-behind) + getDefaultUIUtil().onDraw(c, recyclerView, noteViewHolder.getNoteSwipeable(), dX, dY, actionState, isCurrentlyActive); + } + + @Override + public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState) { + if (actionState == ACTION_STATE_SWIPE) { + Log.i(TAG, "Start swiping, disable swipeRefreshLayout"); + swipeRefreshLayoutEnabled = swipeRefreshLayout.isEnabled(); + swipeRefreshLayout.setEnabled(false); + if (viewHolder != null) { + adapter.setSwipedPosition(viewHolder.getLayoutPosition()); + } + } + super.onSelectedChanged(viewHolder, actionState); + } + + @Override + public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + Log.i(TAG, "End swiping, resetting swipeRefreshLayout state"); + swipeRefreshLayout.setEnabled(swipeRefreshLayoutEnabled); + getDefaultUIUtil().clearView(((NoteViewHolder) viewHolder).getNoteSwipeable()); + adapter.setSwipedPosition(null); + } + + @Override + public float getSwipeEscapeVelocity(float defaultValue) { + return defaultValue * 3; + } + }); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/section/SectionItem.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/section/SectionItem.java new file mode 100644 index 0000000000000000000000000000000000000000..6f7ca1c79583fb0302bfe40bdbd0d3eea83d5b6f --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/section/SectionItem.java @@ -0,0 +1,50 @@ +package it.niedermann.owncloud.notes.main.items.section; + +import androidx.annotation.NonNull; + +import it.niedermann.owncloud.notes.shared.model.Item; + +public class SectionItem implements Item { + + private String title; + + public SectionItem(String title) { + this.title = title; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + @Override + public boolean isSection() { + return true; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SectionItem)) return false; + + SectionItem that = (SectionItem) o; + + return title != null ? title.equals(that.title) : that.title == null; + } + + @Override + public int hashCode() { + return title != null ? title.hashCode() : 0; + } + + @NonNull + @Override + public String toString() { + return "SectionItem{" + + "title='" + title + '\'' + + '}'; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/section/SectionItemDecoration.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/section/SectionItemDecoration.java new file mode 100644 index 0000000000000000000000000000000000000000..bde8f1551626f6d1a193dce02e29d13e96bee68c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/section/SectionItemDecoration.java @@ -0,0 +1,40 @@ +package it.niedermann.owncloud.notes.main.items.section; + +import android.graphics.Rect; +import android.view.View; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Px; +import androidx.recyclerview.widget.RecyclerView; + +import it.niedermann.owncloud.notes.main.items.ItemAdapter; + +public class SectionItemDecoration extends RecyclerView.ItemDecoration { + + @NonNull + private final ItemAdapter adapter; + private final int sectionLeft; + private final int sectionTop; + private final int sectionRight; + private final int sectionBottom; + + public SectionItemDecoration(@NonNull ItemAdapter adapter, @Px int sectionLeft, @Px int sectionTop, @Px int sectionRight, @Px int sectionBottom) { + this.adapter = adapter; + this.sectionLeft = sectionLeft; + this.sectionTop = sectionTop; + this.sectionRight = sectionRight; + this.sectionBottom = sectionBottom; + } + + @CallSuper + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + final int position = parent.getChildAdapterPosition(view); + if (position >= 0 && adapter.getItemViewType(position) == ItemAdapter.TYPE_SECTION) { + outRect.left = sectionLeft; + outRect.top = sectionTop; + outRect.right = sectionRight; + outRect.bottom = sectionBottom; + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/section/SectionViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/section/SectionViewHolder.java new file mode 100644 index 0000000000000000000000000000000000000000..b1ce4c45dfa261241b9caaef63822456352d308e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/section/SectionViewHolder.java @@ -0,0 +1,18 @@ +package it.niedermann.owncloud.notes.main.items.section; + +import androidx.recyclerview.widget.RecyclerView; + +import it.niedermann.owncloud.notes.databinding.ItemNotesListSectionItemBinding; + +public class SectionViewHolder extends RecyclerView.ViewHolder { + private final ItemNotesListSectionItemBinding binding; + + public SectionViewHolder(ItemNotesListSectionItemBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(SectionItem item) { + binding.sectionTitle.setText(item.getTitle()); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/selection/ItemIdKeyProvider.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/selection/ItemIdKeyProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..03fda02025f8c169e5fa3fcdbcb10e58a5664584 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/selection/ItemIdKeyProvider.java @@ -0,0 +1,33 @@ +package it.niedermann.owncloud.notes.main.items.selection; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.selection.ItemKeyProvider; +import androidx.recyclerview.widget.RecyclerView; + +import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; + +public class ItemIdKeyProvider extends ItemKeyProvider { + private final RecyclerView recyclerView; + + public ItemIdKeyProvider(RecyclerView recyclerView) { + super(SCOPE_MAPPED); + this.recyclerView = recyclerView; + } + + @Nullable + @Override + public Long getKey(int position) { + final var adapter = recyclerView.getAdapter(); + if (adapter == null) { + throw new IllegalStateException("RecyclerView adapter is not set!"); + } + return adapter.getItemId(position); + } + + @Override + public int getPosition(@NonNull Long key) { + final var viewHolder = recyclerView.findViewHolderForItemId(key); + return viewHolder == null ? NO_POSITION : viewHolder.getLayoutPosition(); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/selection/ItemLookup.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/selection/ItemLookup.java new file mode 100644 index 0000000000000000000000000000000000000000..e3ad40ddcc4aed659672cbb3be1e7152fc938318 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/selection/ItemLookup.java @@ -0,0 +1,37 @@ +package it.niedermann.owncloud.notes.main.items.selection; + +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.selection.ItemDetailsLookup; +import androidx.recyclerview.widget.RecyclerView; + +import it.niedermann.owncloud.notes.main.items.NoteViewHolder; + +public class ItemLookup extends ItemDetailsLookup { + + @NonNull + private final RecyclerView recyclerView; + + public ItemLookup(@NonNull RecyclerView recyclerView) { + this.recyclerView = recyclerView; + } + + @Nullable + @Override + public ItemDetails getItemDetails(@NonNull MotionEvent e) { + final var view = recyclerView.findChildViewUnder(e.getX(), e.getY()); + if (view != null) { + final RecyclerView.ViewHolder viewHolder = recyclerView.getChildViewHolder(view); + if (viewHolder instanceof NoteViewHolder) { + return ((NoteViewHolder) recyclerView.getChildViewHolder(view)) + .getItemDetails(); + } else { + return null; + } + } + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/selection/ItemSelectionTracker.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/selection/ItemSelectionTracker.java new file mode 100644 index 0000000000000000000000000000000000000000..bce834e2dca3290139a00cbe8a6f44e10b8583ba --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/selection/ItemSelectionTracker.java @@ -0,0 +1,46 @@ +package it.niedermann.owncloud.notes.main.items.selection; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.selection.SelectionTracker; +import androidx.recyclerview.selection.StorageStrategy; +import androidx.recyclerview.widget.RecyclerView; + +import it.niedermann.owncloud.notes.main.items.ItemAdapter; + +public class ItemSelectionTracker { + + private ItemSelectionTracker() { + // Use build() method + } + + public static SelectionTracker build(@NonNull RecyclerView recyclerView, @NonNull ItemAdapter adapter) { + return new SelectionTracker.Builder<>( + ItemSelectionTracker.class.getSimpleName(), + recyclerView, + new ItemIdKeyProvider(recyclerView), + new ItemLookup(recyclerView), + StorageStrategy.createLongStorage() + ).withSelectionPredicate( + new SelectionTracker.SelectionPredicate<>() { + @Override + public boolean canSetStateForKey(@NonNull Long key, boolean nextState) { + return true; + } + + @Override + public boolean canSetStateAtPosition(int position, boolean nextState) { + @Nullable Integer swipedPosition = adapter.getSwipedPosition(); + if (!adapter.hasItemPosition(position)) { + return false; + } + return (swipedPosition == null || swipedPosition != position) && !adapter.getItem(position).isSection(); + } + + @Override + public boolean canSelectMultiple() { + return true; + } + }).build(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/menu/MenuAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/main/menu/MenuAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..8112f7ec35d025c1ef25a9ccbd2aae917e2574c2 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/menu/MenuAdapter.java @@ -0,0 +1,102 @@ +package it.niedermann.owncloud.notes.main.menu; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import com.nextcloud.android.sso.Constants; +import com.nextcloud.android.sso.helper.VersionCheckHelper; + +import it.niedermann.owncloud.notes.FormattingHelpActivity; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.about.AboutActivity; +import it.niedermann.owncloud.notes.databinding.ItemNavigationBinding; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.preferences.PreferencesActivity; + +public class MenuAdapter extends RecyclerView.Adapter { + + @NonNull + private final MenuItem[] menuItems; + @NonNull + private final Consumer onClick; + + public MenuAdapter(@NonNull Context context, @NonNull Account account, int settingsRequestCode, @NonNull Consumer onClick) { + this.menuItems = new MenuItem[]{ + new MenuItem(new Intent(context, FormattingHelpActivity.class), R.string.action_formatting_help, R.drawable.ic_baseline_help_outline_24), + new MenuItem(generateTrashbinIntent(context, account), R.string.action_trashbin, R.drawable.ic_delete_grey600_24dp), + new MenuItem(new Intent(context, PreferencesActivity.class), settingsRequestCode, R.string.action_settings, R.drawable.ic_settings_grey600_24dp), + new MenuItem(new Intent(context, AboutActivity.class), R.string.simple_about, R.drawable.ic_info_outline_grey600_24dp) + }; + this.onClick = onClick; + setHasStableIds(true); + } + + @Override + public long getItemId(int position) { + return position; + } + + @NonNull + @Override + public MenuViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new MenuViewHolder(ItemNavigationBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull MenuViewHolder holder, int position) { + holder.bind(menuItems[position], onClick); + } + + public void updateAccount(@NonNull Context context, @NonNull Account account) { + menuItems[1].setIntent(new Intent(generateTrashbinIntent(context, account))); + } + + @Override + public int getItemCount() { + return menuItems.length; + } + + @NonNull + private static Intent generateTrashbinIntent(@NonNull Context context, @NonNull Account account) { + // https://github.com/nextcloud/android/pull/8405#issuecomment-852966877 + final int minVersionCode = 30170090; + try { + if (VersionCheckHelper.getNextcloudFilesVersionCode(context, true) > minVersionCode) { + return generateTrashbinAppIntent(context, account, true); + } else if (VersionCheckHelper.getNextcloudFilesVersionCode(context, false) > minVersionCode) { + return generateTrashbinAppIntent(context, account, false); + } else { + // Files app is too old to be able to switch the account when launching the TrashbinActivity + return generateTrashbinWebIntent(account); + } + } catch (PackageManager.NameNotFoundException | SecurityException e) { + e.printStackTrace(); + return generateTrashbinWebIntent(account); + } + } + + private static Intent generateTrashbinAppIntent(@NonNull Context context, @NonNull Account account, boolean prod) throws PackageManager.NameNotFoundException { + final var packageManager = context.getPackageManager(); + final String packageName = prod ? Constants.PACKAGE_NAME_PROD : Constants.PACKAGE_NAME_DEV; + final var intent = new Intent(); + intent.setClassName(packageName, "com.owncloud.android.ui.trashbin.TrashbinActivity"); + if (packageManager.resolveActivity(intent, 0) != null) { + return intent + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(Intent.EXTRA_USER, account.getAccountName()); + } + throw new PackageManager.NameNotFoundException("Could not resolve target activity."); + } + + private static Intent generateTrashbinWebIntent(@NonNull Account account) { + return new Intent(Intent.ACTION_VIEW, Uri.parse(account.getUrl() + "/index.php/apps/files/?dir=/&view=trashbin")); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/menu/MenuItem.java b/app/src/main/java/it/niedermann/owncloud/notes/main/menu/MenuItem.java new file mode 100644 index 0000000000000000000000000000000000000000..5503860ca835cabec8381f363045a85938582cfb --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/menu/MenuItem.java @@ -0,0 +1,56 @@ +package it.niedermann.owncloud.notes.main.menu; + +import android.content.Intent; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +public class MenuItem { + + @NonNull + private Intent intent; + @StringRes + private final int labelResource; + @DrawableRes + private final int drawableResource; + + @Nullable + private Integer resultCode; + + public MenuItem(@NonNull Intent intent, int labelResource, int drawableResource) { + this.intent = intent; + this.labelResource = labelResource; + this.drawableResource = drawableResource; + } + + public MenuItem(@NonNull Intent intent, int resultCode, int labelResource, int drawableResource) { + this.intent = intent; + this.resultCode = resultCode; + this.labelResource = labelResource; + this.drawableResource = drawableResource; + } + + @NonNull + public Intent getIntent() { + return intent; + } + + public void setIntent(@NonNull Intent intent) { + this.intent = intent; + } + + public int getLabelResource() { + return labelResource; + } + + public int getDrawableResource() { + return drawableResource; + } + + @Nullable + public Integer getResultCode() { + return resultCode; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/menu/MenuViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/main/menu/MenuViewHolder.java new file mode 100644 index 0000000000000000000000000000000000000000..d5a2f60887ff54b5cdbeedc8bb7ecd00e7ffc9d1 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/menu/MenuViewHolder.java @@ -0,0 +1,32 @@ +package it.niedermann.owncloud.notes.main.menu; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ItemNavigationBinding; + +import static android.view.View.GONE; + +public class MenuViewHolder extends RecyclerView.ViewHolder { + + private final ItemNavigationBinding binding; + + public MenuViewHolder(@NonNull ItemNavigationBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(@NonNull MenuItem menuItem, @NonNull Consumer onClick) { + @NonNull Context context = itemView.getContext(); + binding.navigationItemLabel.setText(context.getString(menuItem.getLabelResource())); + binding.navigationItemLabel.setTextColor(binding.getRoot().getResources().getColor(R.color.fg_default)); + binding.navigationItemIcon.setImageDrawable(ContextCompat.getDrawable(context, menuItem.getDrawableResource())); + binding.navigationItemCount.setVisibility(GONE); + binding.getRoot().setOnClickListener((v) -> onClick.accept(menuItem)); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..78ac616d2852c74d6d420a1ab1f8e310e16a2f47 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationAdapter.java @@ -0,0 +1,94 @@ +package it.niedermann.owncloud.notes.main.navigation; + +import android.content.Context; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.main.MainActivity; + +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.UNCATEGORIZED; + +public class NavigationAdapter extends RecyclerView.Adapter { + + @NonNull + private final Context context; + @ColorInt + private int mainColor; + @DrawableRes + public static final int ICON_FOLDER = R.drawable.ic_folder_grey600_24dp; + @DrawableRes + public static final int ICON_NOFOLDER = R.drawable.ic_folder_open_grey600_24dp; + @DrawableRes + public static final int ICON_SUB_FOLDER = R.drawable.ic_folder_grey600_18dp; + @DrawableRes + public static final int ICON_MULTIPLE = R.drawable.ic_create_new_folder_grey600_24dp; + @DrawableRes + public static final int ICON_MULTIPLE_OPEN = R.drawable.ic_folder_grey600_24dp; + @DrawableRes + public static final int ICON_SUB_MULTIPLE = R.drawable.ic_create_new_folder_grey600_18dp; + + public void applyBrand(int mainColor, int textColor) { + this.mainColor = BrandingUtil.getSecondaryForegroundColorDependingOnTheme(context, mainColor); + notifyDataSetChanged(); + } + + @NonNull + private List items = new ArrayList<>(); + private String selectedItem = null; + @NonNull + private final NavigationClickListener navigationClickListener; + + public NavigationAdapter(@NonNull Context context, @NonNull NavigationClickListener navigationClickListener) { + this.context = context; + this.mainColor = BrandingUtil.getSecondaryForegroundColorDependingOnTheme(context, BrandingUtil.readBrandMainColor(context)); + this.navigationClickListener = navigationClickListener; + } + + @NonNull + @Override + public NavigationViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new NavigationViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_navigation, parent, false), navigationClickListener); + } + + @Override + public void onBindViewHolder(@NonNull NavigationViewHolder holder, int position) { + holder.bind(items.get(position), mainColor, selectedItem); + } + + @Override + public int getItemCount() { + return items.size(); + } + + public void setItems(@NonNull List items) { + for (final var item : items) { + if (TextUtils.isEmpty(item.label)) { + item.id = MainActivity.ADAPTER_KEY_UNCATEGORIZED; + item.label = context.getString(R.string.action_uncategorized); + item.icon = NavigationAdapter.ICON_NOFOLDER; + item.type = UNCATEGORIZED; + break; + } + } + this.items = items; + notifyDataSetChanged(); + } + + public void setSelectedItem(String id) { + selectedItem = id; + notifyDataSetChanged(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationClickListener.java b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationClickListener.java new file mode 100644 index 0000000000000000000000000000000000000000..0e21d85e47da7bd55c20c8d0dcbdfbfe241fbf21 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationClickListener.java @@ -0,0 +1,7 @@ +package it.niedermann.owncloud.notes.main.navigation; + +public interface NavigationClickListener { + void onItemClick(NavigationItem item); + + void onIconClick(NavigationItem item); +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationItem.java b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationItem.java new file mode 100644 index 0000000000000000000000000000000000000000..c92f04945347f7db5e904c9edab245735c08be69 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationItem.java @@ -0,0 +1,108 @@ +package it.niedermann.owncloud.notes.main.navigation; + +import android.text.TextUtils; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType; + +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.UNCATEGORIZED; + +public class NavigationItem { + @NonNull + public String id; + @NonNull + public String label; + @DrawableRes + public int icon; + @Nullable + public Integer count; + @Nullable + public ENavigationCategoryType type; + + public NavigationItem(@NonNull String id, @NonNull String label, @Nullable Integer count, @DrawableRes int icon) { + this.id = id; + this.label = label; + this.type = TextUtils.isEmpty(label) ? UNCATEGORIZED : null; + this.count = count; + this.icon = icon; + } + + public NavigationItem(@NonNull String id, @NonNull String label, @Nullable Integer count, @DrawableRes int icon, @NonNull ENavigationCategoryType type) { + this.id = id; + this.label = label; + this.type = type; + this.count = count; + this.icon = icon; + } + + public static class CategoryNavigationItem extends NavigationItem { + public long accountId; + @NonNull + public String category; + + public CategoryNavigationItem(@NonNull String id, @NonNull String label, @Nullable Integer count, @DrawableRes int icon, long accountId, @NonNull String category) { + super(id, label, count, icon, ENavigationCategoryType.DEFAULT_CATEGORY); + this.accountId = accountId; + this.category = category; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CategoryNavigationItem)) return false; + if (!super.equals(o)) return false; + + CategoryNavigationItem that = (CategoryNavigationItem) o; + + if (accountId != that.accountId) return false; + return category.equals(that.category); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + (int) (accountId ^ (accountId >>> 32)); + result = 31 * result + category.hashCode(); + return result; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof NavigationItem)) return false; + + final var that = (NavigationItem) o; + + if (icon != that.icon) return false; + if (!id.equals(that.id)) return false; + if (!label.equals(that.label)) return false; + if (count != null ? !count.equals(that.count) : that.count != null) return false; + return type == that.type; + } + + @Override + public int hashCode() { + int result = id.hashCode(); + result = 31 * result + label.hashCode(); + result = 31 * result + icon; + result = 31 * result + (count != null ? count.hashCode() : 0); + result = 31 * result + (type != null ? type.hashCode() : 0); + return result; + } + + @Override + @NonNull + public String toString() { + return "NavigationItem{" + + "id='" + id + '\'' + + ", label='" + label + '\'' + + ", icon=" + icon + + ", count=" + count + + ", type=" + type + + '}'; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationViewHolder.java new file mode 100644 index 0000000000000000000000000000000000000000..8fb03dfa4399492aa17109763c880211f85efbc8 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationViewHolder.java @@ -0,0 +1,70 @@ +package it.niedermann.owncloud.notes.main.navigation; + +import static java.util.Objects.requireNonNull; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.recyclerview.widget.RecyclerView; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ItemNavigationBinding; +import it.niedermann.owncloud.notes.shared.util.NoteUtil; + +class NavigationViewHolder extends RecyclerView.ViewHolder { + @NonNull + private final View view; + + @NonNull + private final TextView name; + @NonNull + private final TextView count; + @NonNull + private final ImageView icon; + + private NavigationItem currentItem; + + NavigationViewHolder(@NonNull View itemView, @NonNull final NavigationClickListener navigationClickListener) { + super(itemView); + view = itemView; + final var binding = ItemNavigationBinding.bind(view); + this.name = binding.navigationItemLabel; + this.count = binding.navigationItemCount; + this.icon = binding.navigationItemIcon; + icon.setOnClickListener(view -> navigationClickListener.onIconClick(currentItem)); + itemView.setOnClickListener(view -> navigationClickListener.onItemClick(currentItem)); + } + + public void bind(@NonNull NavigationItem item, @ColorInt int mainColor, String selectedItem) { + currentItem = item; + final boolean isSelected = item.id.equals(selectedItem); + name.setText(NoteUtil.extendCategory(item.label)); + count.setVisibility(item.count == null ? View.GONE : View.VISIBLE); + count.setText(String.valueOf(item.count)); + if (item.icon > 0) { + icon.setImageDrawable(DrawableCompat.wrap(requireNonNull(ContextCompat.getDrawable(icon.getContext(), item.icon)))); + icon.setVisibility(View.VISIBLE); + } else { + icon.setVisibility(View.GONE); + } + final int textColor = isSelected ? mainColor : view.getResources().getColor(R.color.fg_default); + + name.setTextColor(textColor); + count.setTextColor(textColor); + icon.setColorFilter(isSelected ? textColor : 0); + + view.setSelected(isSelected); + + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + params.leftMargin = item.icon == NavigationAdapter.ICON_SUB_FOLDER || item.icon == NavigationAdapter.ICON_SUB_MULTIPLE + ? view.getResources().getDimensionPixelSize(R.dimen.spacer_3x) + : 0; + view.requestLayout(); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/slots/SlotterUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/main/slots/SlotterUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..d2ff009fc8bb517cd2d2d8d9bce841df661559f6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/slots/SlotterUtil.java @@ -0,0 +1,80 @@ +package it.niedermann.owncloud.notes.main.slots; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.main.items.section.SectionItem; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.Item; +import it.niedermann.owncloud.notes.shared.util.NoteUtil; + +public class SlotterUtil { + + private SlotterUtil() { + // Util class + } + + @NonNull + public static List fillListByCategory(@NonNull List noteList, @Nullable String currentCategory) { + final var itemList = new ArrayList(); + for (final var note : noteList) { + if (currentCategory != null && !currentCategory.equals(note.getCategory())) { + itemList.add(new SectionItem(NoteUtil.extendCategory(note.getCategory()))); + } + + itemList.add(note); + currentCategory = note.getCategory(); + } + return itemList; + } + + @NonNull + public static List fillListByTime(@NonNull Context context, @NonNull List noteList) { + final var itemList = new ArrayList(); + final var timeslotter = new Timeslotter(context); + String lastTimeslot = null; + for (int i = 0; i < noteList.size(); i++) { + final var currentNote = noteList.get(i); + String timeslot = timeslotter.getTimeslot(currentNote); + if (i > 0 && !timeslot.equals(lastTimeslot)) { + itemList.add(new SectionItem(timeslot)); + } + itemList.add(currentNote); + lastTimeslot = timeslot; + } + + return itemList; + } + + @NonNull + public static List fillListByInitials(@NonNull Context context, @NonNull List noteList) { + final var itemList = new ArrayList(); + String lastInitials = null; + for (int i = 0; i < noteList.size(); i++) { + final var currentNote = noteList.get(i); + final var title = currentNote.getTitle(); + String initials = ""; + if(!TextUtils.isEmpty(title)) { + initials = title.substring(0, 1).toUpperCase(); + if (!initials.matches("[A-Z\\u00C0-\\u00DF]")) { + initials = initials.matches("[\\u0250-\\uFFFF]") ? context.getString(R.string.simple_other) : "#"; + } + } + if (i > 0 && !initials.equals(lastInitials)) { + itemList.add(new SectionItem(initials)); + } + itemList.add(currentNote); + lastInitials = initials; + } + + return itemList; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/slots/Timeslot.java b/app/src/main/java/it/niedermann/owncloud/notes/main/slots/Timeslot.java new file mode 100644 index 0000000000000000000000000000000000000000..562a2985dacbdede079ced72a3559f73e76339bc --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/slots/Timeslot.java @@ -0,0 +1,22 @@ +package it.niedermann.owncloud.notes.main.slots; + +import java.util.Calendar; + +public class Timeslot { + private final String label; + private final Calendar time; + + Timeslot(String label, int month, int day) { + this.label = label; + this.time = Calendar.getInstance(); + this.time.set(this.time.get(Calendar.YEAR), month, day, 0, 0, 0); + } + + public String getLabel() { + return label; + } + + public Calendar getTime() { + return time; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/slots/Timeslotter.java b/app/src/main/java/it/niedermann/owncloud/notes/main/slots/Timeslotter.java new file mode 100644 index 0000000000000000000000000000000000000000..bc271dac8a888ad47f7a3ac06898f895e3a053cb --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/slots/Timeslotter.java @@ -0,0 +1,53 @@ +package it.niedermann.owncloud.notes.main.slots; + +import android.content.Context; +import android.text.format.DateUtils; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.persistence.entity.Note; + +public class Timeslotter { + private final List timeslots = new ArrayList<>(); + private final Calendar lastYear; + private final Context context; + + public Timeslotter(@NonNull Context context) { + this.context = context; + Calendar now = Calendar.getInstance(); + int month = now.get(Calendar.MONTH); + int day = now.get(Calendar.DAY_OF_MONTH); + int offsetWeekStart = (now.get(Calendar.DAY_OF_WEEK) - now.getFirstDayOfWeek() + 7) % 7; + timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_today), month, day)); + timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_yesterday), month, day - 1)); + timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_this_week), month, day - offsetWeekStart)); + timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_last_week), month, day - offsetWeekStart - 7)); + timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_this_month), month, 1)); + timeslots.add(new Timeslot(context.getResources().getString(R.string.listview_updated_last_month), month - 1, 1)); + lastYear = Calendar.getInstance(); + lastYear.set(now.get(Calendar.YEAR) - 1, 0, 1, 0, 0, 0); + } + + public String getTimeslot(Note note) { + if (note.getFavorite()) { + return ""; + } + final var modified = note.getModified(); + for (final var timeslot : timeslots) { + if (!modified.before(timeslot.getTime())) { + return timeslot.getLabel(); + } + } + if (!modified.before(this.lastYear)) { + // use YEAR and MONTH in a format based on current locale + return DateUtils.formatDateTime(context, modified.getTimeInMillis(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR | DateUtils.FORMAT_NO_MONTH_DAY); + } else { + return Integer.toString(modified.get(Calendar.YEAR)); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..19adee12ac7e956d71d33d77de7c5afbb29dcc77 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountAdapter.java @@ -0,0 +1,78 @@ +package it.niedermann.owncloud.notes.manageaccounts; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.persistence.entity.Account; + +public class ManageAccountAdapter extends RecyclerView.Adapter { + + @Nullable + private Account currentLocalAccount = null; + @NonNull + private final List localAccounts = new ArrayList<>(); + @NonNull + private final Consumer onAccountClick; + @NonNull + private final Consumer onAccountDelete; + @NonNull + Consumer onChangeNotesPath; + @NonNull + Consumer onChangeFileSuffix; + + public ManageAccountAdapter(@NonNull Consumer onAccountClick, + @NonNull Consumer onAccountDelete, + @NonNull Consumer onChangeNotesPath, + @NonNull Consumer onChangeFileSuffix) { + this.onAccountClick = onAccountClick; + this.onAccountDelete = onAccountDelete; + this.onChangeNotesPath = onChangeNotesPath; + this.onChangeFileSuffix = onChangeFileSuffix; + setHasStableIds(true); + } + + @Override + public long getItemId(int position) { + return localAccounts.get(position).getId(); + } + + @NonNull + @Override + public ManageAccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ManageAccountViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_account_choose, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ManageAccountViewHolder holder, int position) { + final var localAccount = localAccounts.get(position); + holder.bind(localAccount, (localAccountClicked) -> { + setCurrentLocalAccount(localAccountClicked); + onAccountClick.accept(localAccountClicked); + }, onAccountDelete, onChangeNotesPath, onChangeFileSuffix, currentLocalAccount != null && currentLocalAccount.getId() == localAccount.getId()); + } + + @Override + public int getItemCount() { + return localAccounts.size(); + } + + public void setLocalAccounts(@NonNull List localAccounts) { + this.localAccounts.clear(); + this.localAccounts.addAll(localAccounts); + notifyDataSetChanged(); + } + + public void setCurrentLocalAccount(@Nullable Account currentLocalAccount) { + this.currentLocalAccount = currentLocalAccount; + notifyDataSetChanged(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountViewHolder.java new file mode 100644 index 0000000000000000000000000000000000000000..3b61851aa8ee0d9c6c69cd845e2a61233204e825 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountViewHolder.java @@ -0,0 +1,88 @@ +package it.niedermann.owncloud.notes.manageaccounts; + +import android.graphics.drawable.LayerDrawable; +import android.net.Uri; +import android.view.Menu; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.PopupMenu; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; + +import java.util.stream.Stream; + +import it.niedermann.nextcloud.sso.glide.SingleSignOnUrl; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ItemAccountChooseBinding; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.shared.model.ApiVersion; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static it.niedermann.owncloud.notes.branding.BrandingUtil.applyBrandToLayerDrawable; +import static it.niedermann.owncloud.notes.shared.util.ApiVersionUtil.getPreferredApiVersion; + +public class ManageAccountViewHolder extends RecyclerView.ViewHolder { + + private final ItemAccountChooseBinding binding; + + public ManageAccountViewHolder(@NonNull View itemView) { + super(itemView); + binding = ItemAccountChooseBinding.bind(itemView); + } + + public void bind( + @NonNull Account localAccount, + @NonNull Consumer onAccountClick, + @NonNull Consumer onAccountDelete, + @NonNull Consumer onChangeNotesPath, + @NonNull Consumer onChangeFileSuffix, + boolean isCurrentAccount + ) { + binding.accountName.setText(localAccount.getUserName()); + binding.accountHost.setText(Uri.parse(localAccount.getUrl()).getHost()); + Glide.with(itemView.getContext()) + .load(new SingleSignOnUrl(localAccount.getAccountName(), localAccount.getUrl() + "/index.php/avatar/" + Uri.encode(localAccount.getUserName()) + "/64")) + .error(R.drawable.ic_account_circle_grey_24dp) + .apply(RequestOptions.circleCropTransform()) + .into(binding.accountItemAvatar); + itemView.setOnClickListener((v) -> onAccountClick.accept(localAccount)); + binding.accountContextMenu.setVisibility(VISIBLE); + binding.accountContextMenu.setOnClickListener((v) -> { + final var popup = new PopupMenu(itemView.getContext(), v); + popup.inflate(R.menu.menu_account); + final var preferredApiVersion = getPreferredApiVersion(localAccount.getApiVersion()); + if (preferredApiVersion != null && !preferredApiVersion.supportsSettings()) { + final var menu = popup.getMenu(); + Stream.of( + R.id.notes_path, + R.id.file_suffix + ).forEach((i) -> menu.removeItem(menu.findItem(i).getItemId())); + } + popup.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.notes_path) { + onChangeNotesPath.accept(localAccount); + return true; + } else if (item.getItemId() == R.id.file_suffix) { + onChangeFileSuffix.accept(localAccount); + return true; + } else if (item.getItemId() == R.id.delete) { + onAccountDelete.accept(localAccount); + return true; + } + return false; + }); + popup.show(); + }); + if (isCurrentAccount) { + binding.currentAccountIndicator.setVisibility(VISIBLE); + applyBrandToLayerDrawable((LayerDrawable) binding.currentAccountIndicator.getDrawable(), R.id.area, localAccount.getColor()); + } else { + binding.currentAccountIndicator.setVisibility(GONE); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..b6341f614a8bb007b00dd128858d69d740f86417 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsActivity.java @@ -0,0 +1,283 @@ +package it.niedermann.owncloud.notes.manageaccounts; + +import android.accounts.NetworkErrorException; +import android.os.Bundle; +import android.util.TypedValue; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.ProgressBar; +import android.widget.Spinner; +import android.widget.Toast; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.appcompat.app.AlertDialog; +import androidx.lifecycle.ViewModelProvider; + +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; + +import it.niedermann.owncloud.notes.LockedActivity; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandedAlertDialogBuilder; +import it.niedermann.owncloud.notes.branding.BrandedDeleteAlertDialogBuilder; +import it.niedermann.owncloud.notes.databinding.ActivityManageAccountsBinding; +import it.niedermann.owncloud.notes.exception.ExceptionDialogFragment; +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; +import it.niedermann.owncloud.notes.shared.model.NotesSettings; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1; +import static it.niedermann.owncloud.notes.shared.util.ApiVersionUtil.getPreferredApiVersion; + +public class ManageAccountsActivity extends LockedActivity { + + private ActivityManageAccountsBinding binding; + private ManageAccountsViewModel viewModel; + private ManageAccountAdapter adapter; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + binding = ActivityManageAccountsBinding.inflate(getLayoutInflater()); + viewModel = new ViewModelProvider(this).get(ManageAccountsViewModel.class); + + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + + adapter = new ManageAccountAdapter( + this::selectAccount, + this::deleteAccount, + this::onChangeNotesPath, + this::onChangeFileSuffix + ); + binding.accounts.setAdapter(adapter); + + viewModel.getAccounts$().observe(this, (accounts) -> { + if (accounts == null || accounts.size() < 1) { + finish(); + return; + } + this.adapter.setLocalAccounts(accounts); + viewModel.getCurrentAccount(this, new IResponseCallback() { + @Override + public void onSuccess(Account result) { + runOnUiThread(() -> adapter.setCurrentLocalAccount(result)); + } + + @Override + public void onError(@NonNull Throwable t) { + runOnUiThread(() -> adapter.setCurrentLocalAccount(null)); + t.printStackTrace(); + } + }); + }); + } + + private void selectAccount(@NonNull Account accountToSelect) { + viewModel.selectAccount(accountToSelect, this); + } + + private void deleteAccount(@NonNull Account accountToDelete) { + viewModel.countUnsynchronizedNotes(accountToDelete.getId(), new IResponseCallback() { + @Override + public void onSuccess(Long unsynchronizedChangesCount) { + runOnUiThread(() -> { + if (unsynchronizedChangesCount > 0) { + new BrandedDeleteAlertDialogBuilder(ManageAccountsActivity.this) + .setTitle(getString(R.string.remove_account, accountToDelete.getUserName())) + .setMessage(getResources().getQuantityString(R.plurals.remove_account_message, (int) unsynchronizedChangesCount.longValue(), accountToDelete.getAccountName(), unsynchronizedChangesCount)) + .setNeutralButton(android.R.string.cancel, null) + .setPositiveButton(R.string.simple_remove, (d, l) -> viewModel.deleteAccount(accountToDelete, ManageAccountsActivity.this)) + .show(); + } else { + viewModel.deleteAccount(accountToDelete, ManageAccountsActivity.this); + } + }); + } + + @Override + public void onError(@NonNull Throwable t) { + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + }); + } + + private void onChangeNotesPath(@NonNull Account localAccount) { + final var repository = NotesRepository.getInstance(getApplicationContext()); + final var editText = new EditText(this); + final var wrapper = createDialogViewWrapper(); + final var dialog = new BrandedAlertDialogBuilder(this) + .setTitle(R.string.settings_notes_path) + .setMessage(R.string.settings_notes_path_description) + .setView(wrapper) + .setNeutralButton(android.R.string.cancel, null) + .setPositiveButton(R.string.action_edit_save, (v, d) -> new Thread(() -> { + try { + final var putSettingsCall = repository.putServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), new NotesSettings(editText.getText().toString(), null), getPreferredApiVersion(localAccount.getApiVersion())); + putSettingsCall.enqueue(new Callback<>() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + final var body = response.body(); + if (response.isSuccessful() && body != null) { + runOnUiThread(() -> Toast.makeText(ManageAccountsActivity.this, getString(R.string.settings_notes_path_success, body.getNotesPath()), Toast.LENGTH_LONG).show()); + } else { + runOnUiThread(() -> Toast.makeText(ManageAccountsActivity.this, getString(R.string.http_status_code, response.code()), Toast.LENGTH_LONG).show()); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + runOnUiThread(() -> ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())); + } + }); + } catch (NextcloudFilesAppAccountNotFoundException e) { + ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + }).start()) + .show(); + try { + repository.getServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), getPreferredApiVersion(localAccount.getApiVersion())) + .enqueue(new Callback<>() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + runOnUiThread(() -> { + final var body = response.body(); + if (response.isSuccessful() && body != null) { + wrapper.removeAllViews(); + final var editText = new EditText(ManageAccountsActivity.this); + editText.setText(body.getNotesPath()); + wrapper.addView(editText); + } else { + dialog.dismiss(); + ExceptionDialogFragment.newInstance(new NetworkErrorException(getString(R.string.http_status_code, response.code()))).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + }); + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + runOnUiThread(() -> { + dialog.dismiss(); + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + }); + } + }); + } catch (NextcloudFilesAppAccountNotFoundException e) { + dialog.dismiss(); + ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + } + + private void onChangeFileSuffix(@NonNull Account localAccount) { + final var repository = NotesRepository.getInstance(getApplicationContext()); + final var spinner = new Spinner(this); + final var wrapper = createDialogViewWrapper(); + final var adapter = ArrayAdapter.createFromResource(this, R.array.settings_file_suffixes, android.R.layout.simple_spinner_item); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + final var dialog = new BrandedAlertDialogBuilder(this) + .setTitle(R.string.settings_file_suffix) + .setMessage(R.string.settings_file_suffix_description) + .setView(wrapper) + .setNeutralButton(android.R.string.cancel, null) + .setPositiveButton(R.string.action_edit_save, (v, d) -> new Thread(() -> { + try { + final Call putSettingsCall = repository.putServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), new NotesSettings(null, spinner.getSelectedItem().toString()), getPreferredApiVersion(localAccount.getApiVersion())); + putSettingsCall.enqueue(new Callback<>() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + final var body = response.body(); + if (response.isSuccessful() && body != null) { + runOnUiThread(() -> Toast.makeText(ManageAccountsActivity.this, getString(R.string.settings_file_suffix_success, body.getNotesPath()), Toast.LENGTH_LONG).show()); + } else { + runOnUiThread(() -> Toast.makeText(ManageAccountsActivity.this, getString(R.string.http_status_code, response.code()), Toast.LENGTH_LONG).show()); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + runOnUiThread(() -> ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())); + } + }); + } catch (NextcloudFilesAppAccountNotFoundException e) { + runOnUiThread(() -> ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())); + } + }).start()) + .show(); + try { + repository.getServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), getPreferredApiVersion(localAccount.getApiVersion())) + .enqueue(new Callback<>() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + final NotesSettings body = response.body(); + runOnUiThread(() -> { + if (response.isSuccessful() && body != null) { + for (int i = 0; i < adapter.getCount(); i++) { + if (adapter.getItem(i).equals(body.getFileSuffix())) { + spinner.setSelection(i); + break; + } + } + wrapper.removeAllViews(); + wrapper.addView(spinner); + } else { + dialog.dismiss(); + ExceptionDialogFragment.newInstance(new Exception(getString(R.string.http_status_code, response.code()))).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + }); + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + runOnUiThread(() -> { + dialog.dismiss(); + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + }); + } + }); + } catch (NextcloudFilesAppAccountNotFoundException e) { + dialog.dismiss(); + ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + } + + @NonNull + private ViewGroup createDialogViewWrapper() { + final var progressBar = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal); + progressBar.setIndeterminate(true); + final var wrapper = new FrameLayout(this); + final int paddingVertical = getResources().getDimensionPixelSize(R.dimen.spacer_1x); + final int paddingHorizontal = SDK_INT >= LOLLIPOP_MR1 + ? getDimensionFromAttribute(android.R.attr.dialogPreferredPadding) + : getResources().getDimensionPixelSize(R.dimen.spacer_2x); + wrapper.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical); + wrapper.addView(progressBar); + return wrapper; + } + + @Px + private int getDimensionFromAttribute(@SuppressWarnings("SameParameterValue") @AttrRes int attr) { + final var typedValue = new TypedValue(); + if (getTheme().resolveAttribute(attr, typedValue, true)) + return TypedValue.complexToDimensionPixelSize(typedValue.data, getResources().getDisplayMetrics()); + else { + return 0; + } + } + + @Override + public void applyBrand(int mainColor, int textColor) { + applyBrandToPrimaryToolbar(binding.appBar, binding.toolbar); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsViewModel.java new file mode 100644 index 0000000000000000000000000000000000000000..b625b0767e52492cffe10b5febf2343929a70cd3 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsViewModel.java @@ -0,0 +1,75 @@ +package it.niedermann.owncloud.notes.manageaccounts; + +import android.app.Application; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; + +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; + +import static androidx.lifecycle.Transformations.distinctUntilChanged; + +public class ManageAccountsViewModel extends AndroidViewModel { + + private final ExecutorService executor = Executors.newCachedThreadPool(); + + @NonNull + private final NotesRepository repo; + + public ManageAccountsViewModel(@NonNull Application application) { + super(application); + this.repo = NotesRepository.getInstance(application); + } + + public void getCurrentAccount(@NonNull Context context, @NonNull IResponseCallback callback) { + try { + callback.onSuccess(repo.getAccountByName((SingleAccountHelper.getCurrentSingleSignOnAccount(context).name))); + } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + callback.onError(e); + } + } + + public LiveData> getAccounts$() { + return distinctUntilChanged(repo.getAccounts$()); + } + + public void deleteAccount(@NonNull Account account, @NonNull Context context) { + executor.submit(() -> { + final var accounts = repo.getAccounts(); + for (int i = 0; i < accounts.size(); i++) { + if (accounts.get(i).getId() == account.getId()) { + if (i > 0) { + selectAccount(accounts.get(i - 1), context); + } else if (accounts.size() > 1) { + selectAccount(accounts.get(i + 1), context); + } else { + selectAccount(null, context); + } + repo.deleteAccount(accounts.get(i)); + break; + } + } + }); + } + + public void selectAccount(@Nullable Account account, @NonNull Context context) { + SingleAccountHelper.setCurrentAccount(context, (account == null) ? null : account.getAccountName()); + } + + public void countUnsynchronizedNotes(long accountId, @NonNull IResponseCallback callback) { + executor.submit(() -> callback.onSuccess(repo.countUnsynchronizedNotes(accountId))); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..ce609607571894c73b2be990df3f4b47185e4c85 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java @@ -0,0 +1,139 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializer; +import com.nextcloud.android.sso.api.NextcloudAPI; +import com.nextcloud.android.sso.model.SingleSignOnAccount; + +import java.util.Calendar; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import it.niedermann.owncloud.notes.persistence.sync.CapabilitiesDeserializer; +import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; +import it.niedermann.owncloud.notes.persistence.sync.OcsAPI; +import it.niedermann.owncloud.notes.shared.model.ApiVersion; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import retrofit2.NextcloudRetrofitApiBuilder; +import retrofit2.Retrofit; + +/** + * Since creating APIs via {@link Retrofit} uses reflection and {@link NextcloudAPI} is supposed to stay alive as long as possible, those artifacts are going to be cached. + * They can be invalidated by using either {@link #invalidateAPICache()} for all or {@link #invalidateAPICache(SingleSignOnAccount)} for a specific {@link SingleSignOnAccount} and will be recreated when they are queried the next time. + */ +@WorkerThread +public class ApiProvider { + + private static final String TAG = ApiProvider.class.getSimpleName(); + + private static final ApiProvider INSTANCE = new ApiProvider(); + + private static final String API_ENDPOINT_OCS = "/ocs/v2.php/cloud/"; + + private static final Map API_CACHE = new ConcurrentHashMap<>(); + + private static final Map API_CACHE_OCS = new ConcurrentHashMap<>(); + private static final Map API_CACHE_NOTES = new ConcurrentHashMap<>(); + + public static ApiProvider getInstance() { + return INSTANCE; + } + + private ApiProvider() { + // Singleton + } + + /** + * An {@link OcsAPI} currently shares the {@link Gson} configuration with the {@link NotesAPI} and therefore divides all {@link Calendar} milliseconds by 1000 while serializing and multiplies values by 1000 during deserialization. + */ + public synchronized OcsAPI getOcsAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { + if (API_CACHE_OCS.containsKey(ssoAccount.name)) { + return API_CACHE_OCS.get(ssoAccount.name); + } + final var ocsAPI = new NextcloudRetrofitApiBuilder(getNextcloudAPI(context, ssoAccount), API_ENDPOINT_OCS).create(OcsAPI.class); + API_CACHE_OCS.put(ssoAccount.name, ocsAPI); + return ocsAPI; + } + + /** + * In case the {@param preferredApiVersion} changes, call {@link #invalidateAPICache(SingleSignOnAccount)} or {@link #invalidateAPICache()} to make sure that this call returns a {@link NotesAPI} that uses the correct compatibility layer. + */ + public synchronized NotesAPI getNotesAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount, @Nullable ApiVersion preferredApiVersion) { + if (API_CACHE_NOTES.containsKey(ssoAccount.name)) { + return API_CACHE_NOTES.get(ssoAccount.name); + } + final var notesAPI = new NotesAPI(getNextcloudAPI(context, ssoAccount), preferredApiVersion); + API_CACHE_NOTES.put(ssoAccount.name, notesAPI); + return notesAPI; + } + + private synchronized NextcloudAPI getNextcloudAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { + if (API_CACHE.containsKey(ssoAccount.name)) { + return API_CACHE.get(ssoAccount.name); + } else { + Log.v(TAG, "NextcloudRequest account: " + ssoAccount.name); + final var nextcloudAPI = new NextcloudAPI(context.getApplicationContext(), ssoAccount, + new GsonBuilder() + .excludeFieldsWithoutExposeAnnotation() + .registerTypeHierarchyAdapter(Calendar.class, (JsonSerializer) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.getTimeInMillis() / 1_000)) + .registerTypeHierarchyAdapter(Calendar.class, (JsonDeserializer) (src, typeOfSrc, ctx) -> { + final var calendar = Calendar.getInstance(); + calendar.setTimeInMillis(src.getAsLong() * 1_000); + return calendar; + }) + .registerTypeAdapter(Capabilities.class, new CapabilitiesDeserializer()) + .create(), (e) -> { + invalidateAPICache(ssoAccount); + e.printStackTrace(); + }); + API_CACHE.put(ssoAccount.name, nextcloudAPI); + return nextcloudAPI; + } + } + + /** + * Invalidates the API cache for the given {@param ssoAccount} + * + * @param ssoAccount the ssoAccount for which the API cache should be cleared. + */ + public synchronized void invalidateAPICache(@NonNull SingleSignOnAccount ssoAccount) { + Log.v(TAG, "Invalidating API cache for " + ssoAccount.name); + if (API_CACHE.containsKey(ssoAccount.name)) { + final var nextcloudAPI = API_CACHE.get(ssoAccount.name); + if (nextcloudAPI != null) { + nextcloudAPI.stop(); + } + API_CACHE.remove(ssoAccount.name); + } + API_CACHE_NOTES.remove(ssoAccount.name); + API_CACHE_OCS.remove(ssoAccount.name); + } + + /** + * Invalidates the whole API cache for all accounts + */ + public synchronized void invalidateAPICache() { + for (final String key : API_CACHE.keySet()) { + Log.v(TAG, "Invalidating API cache for " + key); + if (API_CACHE.containsKey(key)) { + final var nextcloudAPI = API_CACHE.get(key); + if (nextcloudAPI != null) { + nextcloudAPI.stop(); + } + API_CACHE.remove(key); + } + } + API_CACHE_NOTES.clear(); + API_CACHE_OCS.clear(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java new file mode 100644 index 0000000000000000000000000000000000000000..bc53af45d704aeb9b1b523bb049ee878211c3833 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java @@ -0,0 +1,72 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.nextcloud.android.sso.api.ParsedResponse; +import com.nextcloud.android.sso.model.SingleSignOnAccount; + +import java.util.Map; + +import it.niedermann.owncloud.notes.persistence.sync.OcsAPI; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import it.niedermann.owncloud.notes.shared.model.OcsResponse; +import it.niedermann.owncloud.notes.shared.model.OcsUser; +import retrofit2.Response; + +@WorkerThread +public class CapabilitiesClient { + + private static final String TAG = CapabilitiesClient.class.getSimpleName(); + + private static final String HEADER_KEY_ETAG = "ETag"; + + @WorkerThread + public static Capabilities getCapabilities(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount, @Nullable String lastETag, @NonNull ApiProvider apiProvider) throws Throwable { + final var ocsAPI = apiProvider.getOcsAPI(context, ssoAccount); + try { + final var response = ocsAPI.getCapabilities(lastETag).blockingSingle(); + final var capabilities = response.getResponse().ocs.data; + final var headers = response.getHeaders(); + if (headers != null) { + capabilities.setETag(headers.get(HEADER_KEY_ETAG)); + } else { + Log.w(TAG, "Response headers of capabilities are null"); + } + return capabilities; + } catch (RuntimeException e) { + final var cause = e.getCause(); + if (cause != null) { + throw cause; + } else { + throw e; + } + } + } + + @WorkerThread + @Nullable + public static String getDisplayName(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount, @NonNull ApiProvider apiProvider) { + final var ocsAPI = apiProvider.getOcsAPI(context, ssoAccount); + try { + final var userResponse = ocsAPI.getUser(ssoAccount.userId).execute(); + if (userResponse.isSuccessful()) { + final var ocsResponse = userResponse.body(); + if (ocsResponse != null) { + return ocsResponse.ocs.data.displayName; + } else { + Log.w(TAG, "ocsResponse is null"); + } + } else { + Log.w(TAG, "Fetching user was not successful."); + } + } catch (Throwable t) { + t.printStackTrace(); + } + return null; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java new file mode 100644 index 0000000000000000000000000000000000000000..b593f86dacb0fe709f349cc7312fa4ec7acf1755 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java @@ -0,0 +1,83 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Constraints; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; +import com.nextcloud.android.sso.model.SingleSignOnAccount; + +import java.net.HttpURLConnection; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.shared.model.Capabilities; + +public class CapabilitiesWorker extends Worker { + + private static final String TAG = Objects.requireNonNull(CapabilitiesWorker.class.getSimpleName()); + private static final String WORKER_TAG = "capabilities"; + + private static final Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + + private static final PeriodicWorkRequest work = new PeriodicWorkRequest.Builder(CapabilitiesWorker.class, 24, TimeUnit.HOURS) + .setConstraints(constraints).build(); + + public CapabilitiesWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + } + + @NonNull + @Override + public Result doWork() { + final var repo = NotesRepository.getInstance(getApplicationContext()); + for (final var account : repo.getAccounts()) { + try { + final var ssoAccount = AccountImporter.getSingleSignOnAccount(getApplicationContext(), account.getAccountName()); + Log.i(TAG, "Refreshing capabilities for " + ssoAccount.name); + final var capabilities = CapabilitiesClient.getCapabilities(getApplicationContext(), ssoAccount, account.getCapabilitiesETag(), ApiProvider.getInstance()); + repo.updateCapabilitiesETag(account.getId(), capabilities.getETag()); + repo.updateBrand(account.getId(), capabilities.getColor(), capabilities.getTextColor()); + repo.updateApiVersion(account.getId(), capabilities.getApiVersion()); + Log.i(TAG, capabilities.toString()); + repo.updateDisplayName(account.getId(), CapabilitiesClient.getDisplayName(getApplicationContext(), ssoAccount, ApiProvider.getInstance())); + } catch (Throwable e) { + if (e instanceof NextcloudHttpRequestFailedException) { + if (((NextcloudHttpRequestFailedException) e).getStatusCode() == HttpURLConnection.HTTP_NOT_MODIFIED) { + Log.i(TAG, "Capabilities not modified."); + return Result.success(); + } else if (((NextcloudHttpRequestFailedException) e).getStatusCode() == HttpURLConnection.HTTP_UNAVAILABLE) { + Log.i(TAG, "Server is in maintenance mode."); + return Result.success(); + } + } + e.printStackTrace(); + return Result.failure(); + } + } + return Result.success(); + } + + public static void update(@NonNull Context context) { + deregister(context); + Log.i(TAG, "Registering capabilities worker running each 24 hours."); + WorkManager.getInstance(context.getApplicationContext()).enqueueUniquePeriodicWork(WORKER_TAG, ExistingPeriodicWorkPolicy.REPLACE, work); + } + + private static void deregister(@NonNull Context context) { + Log.i(TAG, "Deregistering all workers with tag \"" + WORKER_TAG + "\""); + WorkManager.getInstance(context.getApplicationContext()).cancelUniqueWork(WORKER_TAG); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java new file mode 100644 index 0000000000000000000000000000000000000000..5cc50641e7d9e3c4680d50ec9923d51232c8541b --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java @@ -0,0 +1,108 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.room.Database; +import androidx.room.Room; +import androidx.room.RoomDatabase; +import androidx.room.TypeConverters; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import it.niedermann.owncloud.notes.persistence.dao.AccountDao; +import it.niedermann.owncloud.notes.persistence.dao.CategoryOptionsDao; +import it.niedermann.owncloud.notes.persistence.dao.NoteDao; +import it.niedermann.owncloud.notes.persistence.dao.WidgetNotesListDao; +import it.niedermann.owncloud.notes.persistence.dao.WidgetSingleNoteDao; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.CategoryOptions; +import it.niedermann.owncloud.notes.persistence.entity.Converters; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; +import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; +import it.niedermann.owncloud.notes.persistence.migration.Migration_10_11; +import it.niedermann.owncloud.notes.persistence.migration.Migration_11_12; +import it.niedermann.owncloud.notes.persistence.migration.Migration_12_13; +import it.niedermann.owncloud.notes.persistence.migration.Migration_13_14; +import it.niedermann.owncloud.notes.persistence.migration.Migration_14_15; +import it.niedermann.owncloud.notes.persistence.migration.Migration_15_16; +import it.niedermann.owncloud.notes.persistence.migration.Migration_16_17; +import it.niedermann.owncloud.notes.persistence.migration.Migration_17_18; +import it.niedermann.owncloud.notes.persistence.migration.Migration_18_19; +import it.niedermann.owncloud.notes.persistence.migration.Migration_19_20; +import it.niedermann.owncloud.notes.persistence.migration.Migration_20_21; +import it.niedermann.owncloud.notes.persistence.migration.Migration_21_22; +import it.niedermann.owncloud.notes.persistence.migration.Migration_22_23; +import it.niedermann.owncloud.notes.persistence.migration.Migration_9_10; + +@Database( + entities = { + Account.class, + Note.class, + CategoryOptions.class, + SingleNoteWidgetData.class, + NotesListWidgetData.class + }, version = 23 +) +@TypeConverters({Converters.class}) +public abstract class NotesDatabase extends RoomDatabase { + + private static final String TAG = NotesDatabase.class.getSimpleName(); + private static final String NOTES_DB_NAME = "OWNCLOUD_NOTES"; + private static volatile NotesDatabase instance; + + public static NotesDatabase getInstance(@NonNull Context context) { + if (instance == null) { + instance = create(context.getApplicationContext()); + } + return instance; + } + + private static NotesDatabase create(final Context context) { + return Room.databaseBuilder( + context, + NotesDatabase.class, + NOTES_DB_NAME) + .addMigrations( + new Migration_9_10(), // v2.0.0 + new Migration_10_11(context), + new Migration_11_12(context), + new Migration_12_13(context), + new Migration_13_14(context), + new Migration_14_15(), + new Migration_15_16(context), + new Migration_16_17(), + new Migration_17_18(), + new Migration_18_19(context), + new Migration_19_20(context), + new Migration_20_21(), + new Migration_21_22(context), + new Migration_22_23() + ) + .fallbackToDestructiveMigrationOnDowngrade() + .fallbackToDestructiveMigration() + .addCallback(new RoomDatabase.Callback() { + @Override + public void onCreate(@NonNull SupportSQLiteDatabase db) { + super.onCreate(db); + final String cleanUpStatement = "DELETE FROM CategoryOptions WHERE CategoryOptions.category NOT IN (SELECT Note.category FROM Note WHERE Note.accountId = CategoryOptions.accountId);"; + db.execSQL("CREATE TRIGGER TRG_CLEANUP_CATEGORIES_DEL AFTER DELETE ON Note BEGIN " + cleanUpStatement + " END;"); + db.execSQL("CREATE TRIGGER TRG_CLEANUP_CATEGORIES_UPD AFTER UPDATE ON Note BEGIN " + cleanUpStatement + " END;"); + Log.v(TAG, NotesDatabase.class.getSimpleName() + " created."); + } + }) + .allowMainThreadQueries() // FIXME Needed in BaseNoteFragment#saveNote() + .build(); + } + + public abstract AccountDao getAccountDao(); + + public abstract CategoryOptionsDao getCategoryOptionsDao(); + + public abstract NoteDao getNoteDao(); + + public abstract WidgetSingleNoteDao getWidgetSingleNoteDao(); + + public abstract WidgetNotesListDao getWidgetNotesListDao(); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesImportTask.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesImportTask.java new file mode 100644 index 0000000000000000000000000000000000000000..acdf1441009549deb556d6dfa14d6927a59be2bf --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesImportTask.java @@ -0,0 +1,84 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; +import it.niedermann.owncloud.notes.shared.model.ImportStatus; +import it.niedermann.owncloud.notes.shared.util.ApiVersionUtil; + + +public class NotesImportTask { + + private static final String TAG = NotesImportTask.class.getSimpleName(); + + private final NotesAPI notesAPI; + @NonNull + private final NotesRepository repo; + @NonNull + private final Account localAccount; + @NonNull + private final ExecutorService executor; + @NonNull + private final ExecutorService fetchExecutor; + + NotesImportTask(@NonNull Context context, @NonNull NotesRepository repo, @NonNull Account localAccount, @NonNull ExecutorService executor, @NonNull ApiProvider apiProvider) throws NextcloudFilesAppAccountNotFoundException { + this(context, repo, localAccount, executor, Executors.newFixedThreadPool(20), apiProvider); + } + + private NotesImportTask(@NonNull Context context, @NonNull NotesRepository repo, @NonNull Account localAccount, @NonNull ExecutorService executor, @NonNull ExecutorService fetchExecutor, @NonNull ApiProvider apiProvider) throws NextcloudFilesAppAccountNotFoundException { + this.repo = repo; + this.localAccount = localAccount; + this.executor = executor; + this.fetchExecutor = fetchExecutor; + this.notesAPI = apiProvider.getNotesAPI(context, AccountImporter.getSingleSignOnAccount(context, localAccount.getAccountName()), ApiVersionUtil.getPreferredApiVersion(localAccount.getApiVersion())); + } + + public LiveData importNotes(@NonNull IResponseCallback callback) { + final var status$ = new MutableLiveData(); + Log.i(TAG, "STARTING IMPORT"); + executor.submit(() -> { + Log.i(TAG, "… Fetching notes IDs"); + final var status = new ImportStatus(); + final var remoteIds = notesAPI.getNotesIDs().blockingSingle(); + status.total = remoteIds.size(); + status$.postValue(status); + Log.i(TAG, "… Total count: " + remoteIds.size()); + final var latch = new CountDownLatch(remoteIds.size()); + for (long id : remoteIds) { + fetchExecutor.submit(() -> { + try { + repo.addNote(localAccount.getId(), notesAPI.getNote(id).blockingSingle().getResponse()); + } catch (Throwable t) { + Log.w(TAG, "Could not import note with remoteId " + id + ": " + t.getMessage()); + status.warnings.add(t); + } + status.count++; + status$.postValue(status); + latch.countDown(); + }); + } + try { + latch.await(); + Log.i(TAG, "IMPORT FINISHED"); + callback.onSuccess(null); + } catch (InterruptedException e) { + callback.onError(e); + } + }); + return status$; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..3f5ba31e3ad2c14b71189829a2138ea14e79e5c6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java @@ -0,0 +1,947 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.accounts.NetworkErrorException; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.drawable.Icon; +import android.net.ConnectivityManager; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.AnyThread; +import androidx.annotation.ColorInt; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.preference.PreferenceManager; + +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; +import com.nextcloud.android.sso.model.SingleSignOnAccount; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.android.sharedpreferences.SharedPreferenceIntLiveData; +import it.niedermann.owncloud.notes.BuildConfig; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.edit.EditNoteActivity; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.CategoryOptions; +import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; +import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; +import it.niedermann.owncloud.notes.shared.model.ApiVersion; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; +import it.niedermann.owncloud.notes.shared.model.DBStatus; +import it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; +import it.niedermann.owncloud.notes.shared.model.ISyncCallback; +import it.niedermann.owncloud.notes.shared.model.ImportStatus; +import it.niedermann.owncloud.notes.shared.model.NavigationCategory; +import it.niedermann.owncloud.notes.shared.model.NotesSettings; +import it.niedermann.owncloud.notes.shared.model.SyncResultStatus; +import it.niedermann.owncloud.notes.shared.util.ApiVersionUtil; +import it.niedermann.owncloud.notes.shared.util.NoteUtil; +import it.niedermann.owncloud.notes.shared.util.SSOUtil; +import retrofit2.Call; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.O; +import static androidx.lifecycle.Transformations.distinctUntilChanged; +import static androidx.lifecycle.Transformations.map; +import static it.niedermann.owncloud.notes.edit.EditNoteActivity.ACTION_SHORTCUT; +import static it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt; +import static it.niedermann.owncloud.notes.widget.notelist.NoteListWidget.updateNoteListWidgets; +import static it.niedermann.owncloud.notes.widget.singlenote.SingleNoteWidget.updateSingleNoteWidgets; +import static java.util.stream.Collectors.toMap; + +@SuppressWarnings("UnusedReturnValue") +public class NotesRepository { + + private static final String TAG = NotesRepository.class.getSimpleName(); + + private static NotesRepository instance; + + private final ApiProvider apiProvider; + private final ExecutorService executor; + private final ExecutorService syncExecutor; + private final ExecutorService importExecutor; + private final Context context; + private final NotesDatabase db; + private final String defaultNonEmptyTitle; + + /** + * Track network connection changes using a {@link BroadcastReceiver} + */ + private boolean isSyncPossible = false; + private boolean networkConnected = false; + private String syncOnlyOnWifiKey; + private boolean syncOnlyOnWifi; + private final MutableLiveData syncStatus = new MutableLiveData<>(false); + private final MutableLiveData> syncErrors = new MutableLiveData<>(); + + /** + * @see Do not make this a local variable. + */ + @SuppressWarnings("FieldCanBeLocal") + private final SharedPreferences.OnSharedPreferenceChangeListener onSharedPreferenceChangeListener = (SharedPreferences prefs, String key) -> { + if (syncOnlyOnWifiKey.equals(key)) { + syncOnlyOnWifi = prefs.getBoolean(syncOnlyOnWifiKey, false); + updateNetworkStatus(); + } + }; + + private final BroadcastReceiver networkReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + updateNetworkStatus(); + if (isSyncPossible() && SSOUtil.isConfigured(context)) { + executor.submit(() -> { + try { + scheduleSync(getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(context).name), false); + } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + Log.v(TAG, "Can not select current SingleSignOn account after network changed, do not sync."); + } + }); + } + } + }; + + // current state of the synchronization + private final Map syncActive = new ConcurrentHashMap<>(); + private final Map syncScheduled = new ConcurrentHashMap<>(); + + // list of callbacks for both parts of synchronization + private final Map> callbacksPush = new ConcurrentHashMap<>(); + private final Map> callbacksPull = new ConcurrentHashMap<>(); + + + public static synchronized NotesRepository getInstance(@NonNull Context context) { + if (instance == null) { + instance = new NotesRepository(context, NotesDatabase.getInstance(context.getApplicationContext()), Executors.newCachedThreadPool(), Executors.newSingleThreadExecutor(), Executors.newSingleThreadExecutor(), ApiProvider.getInstance()); + } + return instance; + } + + private NotesRepository(@NonNull final Context context, @NonNull final NotesDatabase db, @NonNull final ExecutorService executor, @NonNull final ExecutorService syncExecutor, @NonNull final ExecutorService importExecutor, @NonNull ApiProvider apiProvider) { + this.context = context.getApplicationContext(); + this.db = db; + this.executor = executor; + this.syncExecutor = syncExecutor; + this.importExecutor = importExecutor; + this.apiProvider = apiProvider; + this.defaultNonEmptyTitle = NoteUtil.generateNonEmptyNoteTitle("", this.context); + this.syncOnlyOnWifiKey = context.getApplicationContext().getResources().getString(R.string.pref_key_wifi_only); + + // Registers BroadcastReceiver to track network connection changes. + this.context.registerReceiver(networkReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + + final var prefs = PreferenceManager.getDefaultSharedPreferences(this.context); + prefs.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener); + syncOnlyOnWifi = prefs.getBoolean(syncOnlyOnWifiKey, false); + + updateNetworkStatus(); + } + + + // Accounts + + @AnyThread + public LiveData addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback callback) { + final var account = db.getAccountDao().getAccountById(db.getAccountDao().insert(new Account(url, username, accountName, displayName, capabilities))); + if (account == null) { + callback.onError(new Exception("Could not read created account.")); + } else { + if (isSyncPossible()) { + syncActive.put(account.getId(), true); + try { + Log.d(TAG, "… starting now"); + final NotesImportTask importTask = new NotesImportTask(context, this, account, importExecutor, apiProvider); + return importTask.importNotes(new IResponseCallback<>() { + @Override + public void onSuccess(Void result) { + callback.onSuccess(account); + } + + @Override + public void onError(@NonNull Throwable t) { + callback.onError(t); + } + }); + } catch (NextcloudFilesAppAccountNotFoundException e) { + Log.e(TAG, "… Could not find " + SingleSignOnAccount.class.getSimpleName() + " for account name " + account.getAccountName()); + callback.onError(e); + } + } else { + callback.onError(new NetworkErrorException()); + } + } + return new MutableLiveData<>(new ImportStatus()); + } + + @WorkerThread + public List getAccounts() { + return db.getAccountDao().getAccounts(); + } + + @WorkerThread + public void deleteAccount(@NonNull Account account) { + try { + apiProvider.invalidateAPICache(AccountImporter.getSingleSignOnAccount(context, account.getAccountName())); + } catch (NextcloudFilesAppAccountNotFoundException e) { + e.printStackTrace(); + apiProvider.invalidateAPICache(); + } + + db.getAccountDao().deleteAccount(account); + } + + public Account getAccountByName(String accountName) { + return db.getAccountDao().getAccountByName(accountName); + } + + public Account getAccountById(long accountId) { + return db.getAccountDao().getAccountById(accountId); + } + + public LiveData> getAccounts$() { + return db.getAccountDao().getAccounts$(); + } + + public LiveData getAccountById$(long accountId) { + return db.getAccountDao().getAccountById$(accountId); + } + + public LiveData countAccounts$() { + return db.getAccountDao().countAccounts$(); + } + + public void updateBrand(long id, @ColorInt Integer color, @ColorInt Integer textColor) { + db.getAccountDao().updateBrand(id, color, textColor); + } + + public void updateETag(long id, String eTag) { + db.getAccountDao().updateETag(id, eTag); + } + + public void updateCapabilitiesETag(long id, String capabilitiesETag) { + db.getAccountDao().updateCapabilitiesETag(id, capabilitiesETag); + } + + public void updateModified(long id, long modified) { + db.getAccountDao().updateModified(id, modified); + } + + + // Notes + + public LiveData getNoteById$(long id) { + return db.getNoteDao().getNoteById$(id); + } + + public Note getNoteById(long id) { + return db.getNoteDao().getNoteById(id); + } + + public LiveData count$(long accountId) { + return db.getNoteDao().count$(accountId); + } + + public LiveData countFavorites$(long accountId) { + return db.getNoteDao().countFavorites$(accountId); + } + + public void updateScrollY(long id, int scrollY) { + db.getNoteDao().updateScrollY(id, scrollY); + } + + public LiveData> searchCategories$(Long accountId, String searchTerm) { + return db.getNoteDao().searchCategories$(accountId, searchTerm); + } + + public LiveData> searchRecentByModified$(long accountId, String query) { + return db.getNoteDao().searchRecentByModified$(accountId, query); + } + + public List searchRecentByModified(long accountId, String query) { + return db.getNoteDao().searchRecentByModified(accountId, query); + } + + public LiveData> searchRecentLexicographically$(long accountId, String query) { + return db.getNoteDao().searchRecentLexicographically$(accountId, query); + } + + public LiveData> searchFavoritesByModified$(long accountId, String query) { + return db.getNoteDao().searchFavoritesByModified$(accountId, query); + } + + public List searchFavoritesByModified(long accountId, String query) { + return db.getNoteDao().searchFavoritesByModified(accountId, query); + } + + public LiveData> searchFavoritesLexicographically$(long accountId, String query) { + return db.getNoteDao().searchFavoritesLexicographically$(accountId, query); + } + + public LiveData> searchUncategorizedByModified$(long accountId, String query) { + return db.getNoteDao().searchUncategorizedByModified$(accountId, query); + } + + public List searchUncategorizedByModified(long accountId, String query) { + return db.getNoteDao().searchUncategorizedByModified(accountId, query); + } + + public LiveData> searchUncategorizedLexicographically$(long accountId, String query) { + return db.getNoteDao().searchUncategorizedLexicographically$(accountId, query); + } + + public LiveData> searchCategoryByModified$(long accountId, String query, String category) { + return db.getNoteDao().searchCategoryByModified$(accountId, query, category); + } + + public List searchCategoryByModified(long accountId, String query, String category) { + return db.getNoteDao().searchCategoryByModified(accountId, query, category); + } + + public LiveData> searchCategoryLexicographically$(long accountId, String query, String category) { + return db.getNoteDao().searchCategoryLexicographically$(accountId, query, category); + } + + public LiveData> getCategories$(Long accountId) { + return db.getNoteDao().getCategories$(accountId); + } + + public void updateRemoteId(long id, Long remoteId) { + db.getNoteDao().updateRemoteId(id, remoteId); + } + + public Long getLocalIdByRemoteId(long accountId, long remoteId) { + return db.getNoteDao().getLocalIdByRemoteId(accountId, remoteId); + } + + public List getLocalModifiedNotes(long accountId) { + return db.getNoteDao().getLocalModifiedNotes(accountId); + } + + public void deleteByNoteId(long id, DBStatus forceDBStatus) { + db.getNoteDao().deleteByNoteId(id, forceDBStatus); + } + + /** + * Please note, that db.updateNote() realized an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. + */ + public int updateIfNotModifiedLocallyDuringSync(long noteId, Long targetModified, String targetTitle, boolean targetFavorite, String targetETag, String targetContent, String targetExcerpt, String contentBeforeSyncStart, String categoryBeforeSyncStart, boolean favoriteBeforeSyncStart) { + return db.getNoteDao().updateIfNotModifiedLocallyDuringSync(noteId, targetModified, targetTitle, targetFavorite, targetETag, targetContent, targetExcerpt, contentBeforeSyncStart, categoryBeforeSyncStart, favoriteBeforeSyncStart); + } + + public int updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged(long id, Long modified, String title, boolean favorite, String category, String eTag, String content, String excerpt) { + return db.getNoteDao().updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged(id, modified, title, favorite, category, eTag, content, excerpt); + } + + public long countUnsynchronizedNotes(long accountId) { + final Long unsynchronizedNotesCount = db.getNoteDao().countUnsynchronizedNotes(accountId); + return unsynchronizedNotesCount == null ? 0 : unsynchronizedNotesCount; + } + + + // SingleNoteWidget + + public void createOrUpdateSingleNoteWidgetData(SingleNoteWidgetData data) { + db.getWidgetSingleNoteDao().createOrUpdateSingleNoteWidgetData(data); + } + + public void removeSingleNoteWidget(int id) { + db.getWidgetSingleNoteDao().removeSingleNoteWidget(id); + } + + public SingleNoteWidgetData getSingleNoteWidgetData(int id) { + return db.getWidgetSingleNoteDao().getSingleNoteWidgetData(id); + } + + + // ListWidget + + public void createOrUpdateNoteListWidgetData(NotesListWidgetData data) { + db.getWidgetNotesListDao().createOrUpdateNoteListWidgetData(data); + } + + public void removeNoteListWidget(int appWidgetId) { + db.getWidgetNotesListDao().removeNoteListWidget(appWidgetId); + } + + public NotesListWidgetData getNoteListWidgetData(int appWidgetId) { + return db.getWidgetNotesListDao().getNoteListWidgetData(appWidgetId); + } + + /** + * Creates a new Note in the Database and adds a Synchronization Flag. + * + * @param note Note + */ + @NonNull + @MainThread + public LiveData addNoteAndSync(Account account, Note note) { + final var entity = new Note(0, null, note.getModified(), note.getTitle(), note.getContent(), note.getCategory(), note.getFavorite(), note.getETag(), DBStatus.LOCAL_EDITED, account.getId(), generateNoteExcerpt(note.getContent(), note.getTitle()), 0); + final var ret = new MutableLiveData(); + executor.submit(() -> ret.postValue(addNote(account.getId(), entity))); + return map(ret, newNote -> { + notifyWidgets(); + scheduleSync(account, true); + return newNote; + }); + } + + /** + * Inserts a note directly into the Database. + * Excerpt will be generated, {@link DBStatus#LOCAL_EDITED} will be applied in case the note has + * already has a local ID, otherwise {@link DBStatus#VOID} will be applied. + * No Synchronisation will be triggered! Use {@link #addNoteAndSync(Account, Note)}! + * + * @param note {@link Note} to be added. + */ + @NonNull + @WorkerThread + public Note addNote(long accountId, @NonNull Note note) { + note.setAccountId(accountId); + note.setExcerpt(generateNoteExcerpt(note.getContent(), note.getTitle())); + return db.getNoteDao().getNoteById(db.getNoteDao().addNote(note)); + } + + @MainThread + public LiveData moveNoteToAnotherAccount(Account account, @NonNull Note note) { + final var fullNote = new Note(null, note.getModified(), note.getTitle(), note.getContent(), note.getCategory(), note.getFavorite(), null); + fullNote.setStatus(DBStatus.LOCAL_EDITED); + deleteNoteAndSync(account, note.getId()); + return addNoteAndSync(account, fullNote); + } + + /** + * @return a {@link Map} of remote IDs as keys and local IDs as values of all {@link Note}s of + * the given {@param accountId} which are not {@link DBStatus#LOCAL_DELETED} + */ + @NonNull + @WorkerThread + public Map getIdMap(long accountId) { + return db.getNoteDao() + .getRemoteIdAndId(accountId) + .stream() + .collect(toMap(Note::getRemoteId, Note::getId)); + } + + @AnyThread + public void toggleFavoriteAndSync(Account account, long noteId) { + executor.submit(() -> { + db.getNoteDao().toggleFavorite(noteId); + scheduleSync(account, true); + }); + } + + /** + * Set the category for a given note. + * This method will search in the database to find out the category id in the db. + * If there is no such category existing, this method will create it and search again. + * + * @param account The single sign on account + * @param noteId The note which will be updated + * @param category The category title which should be used to find the category id. + */ + @AnyThread + public void setCategory(@NonNull Account account, long noteId, @NonNull String category) { + executor.submit(() -> { + db.getNoteDao().updateStatus(noteId, DBStatus.LOCAL_EDITED); + db.getNoteDao().updateCategory(noteId, category); + scheduleSync(account, true); + }); + } + + /** + * Updates a single Note with a new content. + * The title is derived from the new content automatically, and modified date as well as DBStatus are updated, too -- if the content differs to the state in the database. + * + * @param oldNote Note to be changed + * @param newContent New content. If this is null, then oldNote is saved again (useful for undoing changes). + * @param newTitle New title. If this is null, then either the old title is reused (in case the note has been synced before) or a title is generated (in case it is a new note) + * @param callback When the synchronization is finished, this callback will be invoked (optional). + * @return changed {@link Note} if differs from database, otherwise the old {@link Note}. + */ + @WorkerThread + public Note updateNoteAndSync(@NonNull Account localAccount, @NonNull Note oldNote, @Nullable String newContent, @Nullable String newTitle, @Nullable ISyncCallback callback) { + final Note newNote; + // Re-read the up to date remoteId from the database because the UI might not have the state after synchronization yet + // https://github.com/stefan-niedermann/nextcloud-notes/issues/1198 + @Nullable final Long remoteId = db.getNoteDao().getRemoteId(oldNote.getId()); + if (newContent == null) { + newNote = new Note(oldNote.getId(), remoteId, oldNote.getModified(), oldNote.getTitle(), oldNote.getContent(), oldNote.getCategory(), oldNote.getFavorite(), oldNote.getETag(), DBStatus.LOCAL_EDITED, localAccount.getId(), oldNote.getExcerpt(), oldNote.getScrollY()); + } else { + final String title; + if (newTitle != null) { + title = newTitle; + } else { + final ApiVersion preferredApiVersion = ApiVersionUtil.getPreferredApiVersion(localAccount.getApiVersion()); + if ((remoteId == null || preferredApiVersion == null || preferredApiVersion.compareTo(ApiVersion.API_VERSION_1_0) < 0) && + (defaultNonEmptyTitle.equals(oldNote.getTitle()))) { + title = NoteUtil.generateNonEmptyNoteTitle(newContent, context); + } else { + title = oldNote.getTitle(); + } + } + newNote = new Note(oldNote.getId(), remoteId, Calendar.getInstance(), title, newContent, oldNote.getCategory(), oldNote.getFavorite(), oldNote.getETag(), DBStatus.LOCAL_EDITED, localAccount.getId(), generateNoteExcerpt(newContent, title), oldNote.getScrollY()); + } + int rows = db.getNoteDao().updateNote(newNote); + // if data was changed, set new status and schedule sync (with callback); otherwise invoke callback directly. + if (rows > 0) { + notifyWidgets(); + if (callback != null) { + addCallbackPush(localAccount, callback); + } + scheduleSync(localAccount, true); + return newNote; + } else { + if (callback != null) { + callback.onFinish(); + } + return oldNote; + } + } + + /** + * Marks a Note in the Database as Deleted. In the next Synchronization it will be deleted + * from the Server. + * + * @param id long - ID of the Note that should be deleted + */ + @AnyThread + public void deleteNoteAndSync(Account account, long id) { + executor.submit(() -> { + db.getNoteDao().updateStatus(id, DBStatus.LOCAL_DELETED); + notifyWidgets(); + scheduleSync(account, true); + + if (SDK_INT >= O) { + final var shortcutManager = context.getSystemService(ShortcutManager.class); + if (shortcutManager != null) { + shortcutManager.getPinnedShortcuts().forEach((shortcut) -> { + final String shortcutId = String.valueOf(id); + if (shortcut.getId().equals(shortcutId)) { + Log.v(TAG, "Removing shortcut for " + shortcutId); + shortcutManager.disableShortcuts(Collections.singletonList(shortcutId), context.getResources().getString(R.string.note_has_been_deleted)); + } + }); + } else { + Log.e(TAG, ShortcutManager.class.getSimpleName() + "is null."); + } + } + }); + } + + /** + * Notify about changed notes. + */ + @AnyThread + private void notifyWidgets() { + executor.submit(() -> { + updateSingleNoteWidgets(context); + updateNoteListWidgets(context); + }); + } + + @AnyThread + private void updateDynamicShortcuts(long accountId) { + executor.submit(() -> { + if (SDK_INT >= android.os.Build.VERSION_CODES.N_MR1) { + final var shortcutManager = this.context.getSystemService(ShortcutManager.class); + if (shortcutManager != null) { + if (!shortcutManager.isRateLimitingActive()) { + var newShortcuts = new ArrayList(); + + for (final var note : db.getNoteDao().getRecentNotes(accountId)) { + if (!TextUtils.isEmpty(note.getTitle())) { + final var intent = new Intent(this.context, EditNoteActivity.class); + intent.putExtra(EditNoteActivity.PARAM_NOTE_ID, note.getId()); + intent.setAction(ACTION_SHORTCUT); + + newShortcuts.add(new ShortcutInfo.Builder(this.context, note.getId() + "") + .setShortLabel(note.getTitle() + "") + .setIcon(Icon.createWithResource(this.context, note.getFavorite() ? R.drawable.ic_star_yellow_24dp : R.drawable.ic_star_grey_ccc_24dp)) + .setIntent(intent) + .build()); + } else { + // Prevent crash https://github.com/stefan-niedermann/nextcloud-notes/issues/613 + Log.e(TAG, "shortLabel cannot be empty " + (BuildConfig.DEBUG ? note : note.getTitle())); + } + } + Log.d(TAG, "Update dynamic shortcuts"); + shortcutManager.removeAllDynamicShortcuts(); + shortcutManager.addDynamicShortcuts(newShortcuts); + } + } + } + }); + } + + /** + * @param raw has to be a JSON array as a string ["0.2", "1.0", ...] + */ + public void updateApiVersion(long accountId, @Nullable String raw) { + final var apiVersions = ApiVersionUtil.parse(raw); + if (apiVersions.size() > 0) { + final int updatedRows = db.getAccountDao().updateApiVersion(accountId, ApiVersionUtil.serialize(apiVersions)); + if (updatedRows == 0) { + Log.d(TAG, "ApiVersion not updated, because it did not change"); + } else if (updatedRows == 1) { + Log.i(TAG, "Updated apiVersion to \"" + raw + "\" for accountId = " + accountId); + apiProvider.invalidateAPICache(); + } else { + Log.w(TAG, "Updated " + updatedRows + " but expected only 1 for accountId = " + accountId + " and apiVersion = \"" + raw + "\""); + } + } else { + Log.v(TAG, "Could not extract any version from the given String: " + raw); + } + } + + /** + * Modifies the sorting method for one category, the category can be normal category or + * one of "All notes", "Favorite", and "Uncategorized". + * If category is one of these three, sorting method will be modified in android.content.SharedPreference. + * The user can determine use which sorting method to show the notes for a category. + * When the user changes the sorting method, this method should be called. + * + * @param accountId The user accountID + * @param selectedCategory The category to be modified + * @param sortingMethod The sorting method in {@link CategorySortingMethod} enum format + */ + @AnyThread + public void modifyCategoryOrder(long accountId, @NonNull NavigationCategory selectedCategory, @NonNull CategorySortingMethod sortingMethod) { + executor.submit(() -> { + final var ctx = context.getApplicationContext(); + final var sp = PreferenceManager.getDefaultSharedPreferences(ctx).edit(); + int orderIndex = sortingMethod.getId(); + + switch (selectedCategory.getType()) { + case FAVORITES: { + sp.putInt(ctx.getString(R.string.action_sorting_method) + ' ' + ctx.getString(R.string.label_favorites), orderIndex); + break; + } + case UNCATEGORIZED: { + sp.putInt(ctx.getString(R.string.action_sorting_method) + ' ' + ctx.getString(R.string.action_uncategorized), orderIndex); + break; + } + case RECENT: { + sp.putInt(ctx.getString(R.string.action_sorting_method) + ' ' + ctx.getString(R.string.label_all_notes), orderIndex); + break; + } + case DEFAULT_CATEGORY: + default: { + final String category = selectedCategory.getCategory(); + if (category != null) { + if (db.getCategoryOptionsDao().modifyCategoryOrder(accountId, category, sortingMethod) == 0) { + // Nothing updated means we didn't have this yet + final var categoryOptions = new CategoryOptions(); + categoryOptions.setAccountId(accountId); + categoryOptions.setCategory(category); + categoryOptions.setSortingMethod(sortingMethod); + db.getCategoryOptionsDao().addCategoryOptions(categoryOptions); + } + } else { + throw new IllegalStateException("Tried to modify category order for " + ENavigationCategoryType.DEFAULT_CATEGORY + "but category is null."); + } + break; + } + } + sp.apply(); + }); + } + + /** + * Gets the sorting method of a {@link NavigationCategory}, the category can be normal + * {@link CategoryOptions} or one of {@link ENavigationCategoryType}. + * If the category no normal {@link CategoryOptions}, sorting method will be got from + * {@link SharedPreferences}. + *

+ * The sorting method of the category can be used to decide to use which sorting method to show + * the notes for each categories. + * + * @param selectedCategory The category + * @return The sorting method in CategorySortingMethod enum format + */ + @NonNull + @MainThread + public LiveData getCategoryOrder(@NonNull NavigationCategory selectedCategory) { + final var sp = PreferenceManager.getDefaultSharedPreferences(context); + String prefKey; + + switch (selectedCategory.getType()) { + // TODO make this account specific + case RECENT: { + prefKey = context.getString(R.string.action_sorting_method) + ' ' + context.getString(R.string.label_all_notes); + break; + } + case FAVORITES: { + prefKey = context.getString(R.string.action_sorting_method) + ' ' + context.getString(R.string.label_favorites); + break; + } + case UNCATEGORIZED: { + prefKey = context.getString(R.string.action_sorting_method) + ' ' + context.getString(R.string.action_uncategorized); + break; + } + case DEFAULT_CATEGORY: + default: { + final String category = selectedCategory.getCategory(); + if (category != null) { + return db.getCategoryOptionsDao().getCategoryOrder(selectedCategory.getAccountId(), category); + } else { + Log.e(TAG, "Cannot read " + CategorySortingMethod.class.getSimpleName() + " for " + ENavigationCategoryType.DEFAULT_CATEGORY + "."); + return new MutableLiveData<>(CategorySortingMethod.SORT_MODIFIED_DESC); + } + } + } + + return map(new SharedPreferenceIntLiveData(sp, prefKey, CategorySortingMethod.SORT_MODIFIED_DESC.getId()), CategorySortingMethod::findById); + } + + @Override + protected void finalize() throws Throwable { + this.context.unregisterReceiver(networkReceiver); + super.finalize(); + } + + /** + * Synchronization is only possible, if there is an active network connection. + *

+ * This method respects the user preference "Sync on Wi-Fi only". + *

+ * NoteServerSyncHelper observes changes in the network connection. + * The current state can be retrieved with this method. + * + * @return true if sync is possible, otherwise false. + */ + public boolean isSyncPossible() { + return isSyncPossible; + } + + public boolean isNetworkConnected() { + return networkConnected; + } + + public boolean isSyncOnlyOnWifi() { + return syncOnlyOnWifi; + } + + /** + * Adds a callback method to the NoteServerSyncHelper for the synchronization part push local changes to the server. + * All callbacks will be executed once the synchronization operations are done. + * After execution the callback will be deleted, so it has to be added again if it shall be + * executed the next time all synchronize operations are finished. + * + * @param callback Implementation of ISyncCallback, contains one method that shall be executed. + */ + private void addCallbackPush(Account account, ISyncCallback callback) { + if (account == null) { + Log.i(TAG, "ssoAccount is null. Is this a local account?"); + callback.onScheduled(); + callback.onFinish(); + } else { + if (!callbacksPush.containsKey(account.getId())) { + callbacksPush.put(account.getId(), new ArrayList<>()); + } + Objects.requireNonNull(callbacksPush.get(account.getId())).add(callback); + } + } + + /** + * Adds a callback method to the NoteServerSyncHelper for the synchronization part pull remote changes from the server. + * All callbacks will be executed once the synchronization operations are done. + * After execution the callback will be deleted, so it has to be added again if it shall be + * executed the next time all synchronize operations are finished. + * + * @param callback Implementation of ISyncCallback, contains one method that shall be executed. + */ + public void addCallbackPull(Account account, ISyncCallback callback) { + if (account == null) { + Log.i(TAG, "ssoAccount is null. Is this a local account?"); + callback.onScheduled(); + callback.onFinish(); + } else { + if (!callbacksPull.containsKey(account.getId())) { + callbacksPull.put(account.getId(), new ArrayList<>()); + } + Objects.requireNonNull(callbacksPull.get(account.getId())).add(callback); + } + } + + /** + * Schedules a synchronization and start it directly, if the network is connected and no + * synchronization is currently running. + * + * @param onlyLocalChanges Whether to only push local changes to the server or to also load the whole list of notes from the server. + */ + public synchronized void scheduleSync(@Nullable Account account, boolean onlyLocalChanges) { + if (account == null) { + Log.i(TAG, SingleSignOnAccount.class.getSimpleName() + " is null. Is this a local account?"); + } else { + if (syncActive.get(account.getId()) == null) { + syncActive.put(account.getId(), false); + } + Log.d(TAG, "Sync requested (" + (onlyLocalChanges ? "onlyLocalChanges" : "full") + "; " + (Boolean.TRUE.equals(syncActive.get(account.getId())) ? "sync active" : "sync NOT active") + ") ..."); + if (isSyncPossible() && (!Boolean.TRUE.equals(syncActive.get(account.getId())) || onlyLocalChanges)) { + syncActive.put(account.getId(), true); + try { + Log.d(TAG, "... starting now"); + final NotesServerSyncTask syncTask = new NotesServerSyncTask(context, this, account, onlyLocalChanges, apiProvider) { + @Override + void onPreExecute() { + syncStatus.postValue(true); + if (!syncScheduled.containsKey(localAccount.getId()) || syncScheduled.get(localAccount.getId()) == null) { + syncScheduled.put(localAccount.getId(), false); + } + if (!onlyLocalChanges && Boolean.TRUE.equals(syncScheduled.get(localAccount.getId()))) { + syncScheduled.put(localAccount.getId(), false); + } + } + + @Override + void onPostExecute(SyncResultStatus status) { + for (Throwable e : exceptions) { + Log.e(TAG, e.getMessage(), e); + } + if (!status.pullSuccessful || !status.pushSuccessful) { + syncErrors.postValue(exceptions); + } + syncActive.put(localAccount.getId(), false); + // notify callbacks + if (callbacks.containsKey(localAccount.getId()) && callbacks.get(localAccount.getId()) != null) { + for (ISyncCallback callback : Objects.requireNonNull(callbacks.get(localAccount.getId()))) { + callback.onFinish(); + } + } + notifyWidgets(); + updateDynamicShortcuts(localAccount.getId()); + // start next sync if scheduled meanwhile + if (syncScheduled.containsKey(localAccount.getId()) && syncScheduled.get(localAccount.getId()) != null && Boolean.TRUE.equals(syncScheduled.get(localAccount.getId()))) { + scheduleSync(localAccount, false); + } + syncStatus.postValue(false); + } + }; + syncTask.addCallbacks(account, callbacksPush.get(account.getId())); + callbacksPush.put(account.getId(), new ArrayList<>()); + if (!onlyLocalChanges) { + syncTask.addCallbacks(account, callbacksPull.get(account.getId())); + callbacksPull.put(account.getId(), new ArrayList<>()); + } + syncExecutor.submit(syncTask); + } catch (NextcloudFilesAppAccountNotFoundException e) { + Log.e(TAG, "... Could not find " + SingleSignOnAccount.class.getSimpleName() + " for account name " + account.getAccountName()); + e.printStackTrace(); + } + } else if (!onlyLocalChanges) { + Log.d(TAG, "... scheduled"); + syncScheduled.put(account.getId(), true); + if (callbacksPush.containsKey(account.getId()) && callbacksPush.get(account.getId()) != null) { + final var callbacks = callbacksPush.get(account.getId()); + if (callbacks != null) { + for (final var callback : callbacks) { + callback.onScheduled(); + } + } else { + Log.w(TAG, "List of push-callbacks was set for account \"" + account.getAccountName() + "\" but it was null"); + } + } + } else { + Log.d(TAG, "... do nothing"); + if (callbacksPush.containsKey(account.getId()) && callbacksPush.get(account.getId()) != null) { + final var callbacks = callbacksPush.get(account.getId()); + if (callbacks != null) { + for (final var callback : callbacks) { + callback.onScheduled(); + } + } else { + Log.w(TAG, "List of push-callbacks was set for account \"" + account.getAccountName() + "\" but it was null"); + } + } + } + } + } + + public void updateNetworkStatus() { + try { + final var connMgr = (ConnectivityManager) this.context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connMgr == null) { + throw new NetworkErrorException("ConnectivityManager is null"); + } + + final var activeInfo = connMgr.getActiveNetworkInfo(); + if (activeInfo == null) { + throw new NetworkErrorException("NetworkInfo is null"); + } + + if (activeInfo.isConnected()) { + networkConnected = true; + + final var networkInfo = connMgr.getNetworkInfo((ConnectivityManager.TYPE_WIFI)); + if (networkInfo == null) { + throw new NetworkErrorException("connMgr.getNetworkInfo(ConnectivityManager.TYPE_WIFI) is null"); + } + + isSyncPossible = !syncOnlyOnWifi || networkInfo.isConnected(); + + if (isSyncPossible) { + Log.d(TAG, "Network connection established."); + } else { + Log.d(TAG, "Network connected, but not used because only synced on wifi."); + } + } else { + networkConnected = false; + isSyncPossible = false; + Log.d(TAG, "No network connection."); + } + } catch (NetworkErrorException e) { + Log.i(TAG, e.getMessage()); + networkConnected = false; + isSyncPossible = false; + } + } + + @NonNull + public LiveData getSyncStatus() { + return distinctUntilChanged(this.syncStatus); + } + + @NonNull + public LiveData> getSyncErrors() { + return this.syncErrors; + } + + public Call getServerSettings(@NonNull SingleSignOnAccount ssoAccount, @Nullable ApiVersion preferredApiVersion) { + return ApiProvider.getInstance().getNotesAPI(context, ssoAccount, preferredApiVersion).getSettings(); + } + + public Call putServerSettings(@NonNull SingleSignOnAccount ssoAccount, @NonNull NotesSettings settings, @Nullable ApiVersion preferredApiVersion) { + return ApiProvider.getInstance().getNotesAPI(context, ssoAccount, preferredApiVersion).putSettings(settings); + } + + public void updateDisplayName(long id, @Nullable String displayName) { + db.getAccountDao().updateDisplayName(id, displayName); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java new file mode 100644 index 0000000000000000000000000000000000000000..f50dd21ef09f6e9e10b0e9adedb9de361f00b158 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java @@ -0,0 +1,291 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.api.ParsedResponse; +import com.nextcloud.android.sso.exceptions.NextcloudApiNotRespondingException; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; +import com.nextcloud.android.sso.exceptions.TokenMismatchException; +import com.nextcloud.android.sso.model.SingleSignOnAccount; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import it.niedermann.owncloud.notes.BuildConfig; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; +import it.niedermann.owncloud.notes.shared.model.DBStatus; +import it.niedermann.owncloud.notes.shared.model.ISyncCallback; +import it.niedermann.owncloud.notes.shared.model.SyncResultStatus; +import it.niedermann.owncloud.notes.shared.util.ApiVersionUtil; +import retrofit2.Response; + +import static it.niedermann.owncloud.notes.shared.model.DBStatus.LOCAL_DELETED; +import static it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; +import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; + + +/** + * {@link NotesServerSyncTask} is a {@link Thread} which performs the synchronization in a background thread. + * Synchronization consists of two parts: {@link #pushLocalChanges()} and {@link #pullRemoteChanges}. + */ +abstract class NotesServerSyncTask extends Thread { + + private static final String TAG = NotesServerSyncTask.class.getSimpleName(); + + private static final String HEADER_KEY_X_NOTES_API_VERSIONS = "X-Notes-API-Versions"; + private static final String HEADER_KEY_ETAG = "ETag"; + private static final String HEADER_KEY_LAST_MODIFIED = "Last-Modified"; + + private NotesAPI notesAPI; + @NonNull + private final ApiProvider apiProvider; + @NonNull + private final Context context; + @NonNull + private final NotesRepository repo; + @NonNull + protected final Account localAccount; + @NonNull + private final SingleSignOnAccount ssoAccount; + private final boolean onlyLocalChanges; + @NonNull + protected final Map> callbacks = new HashMap<>(); + @NonNull + protected final ArrayList exceptions = new ArrayList<>(); + + NotesServerSyncTask(@NonNull Context context, @NonNull NotesRepository repo, @NonNull Account localAccount, boolean onlyLocalChanges, @NonNull ApiProvider apiProvider) throws NextcloudFilesAppAccountNotFoundException { + super(TAG); + this.context = context; + this.repo = repo; + this.localAccount = localAccount; + this.ssoAccount = AccountImporter.getSingleSignOnAccount(context, localAccount.getAccountName()); + this.onlyLocalChanges = onlyLocalChanges; + this.apiProvider = apiProvider; + } + + void addCallbacks(Account account, List callbacks) { + this.callbacks.put(account.getId(), callbacks); + } + + @Override + public void run() { + onPreExecute(); + + notesAPI = apiProvider.getNotesAPI(context, ssoAccount, ApiVersionUtil.getPreferredApiVersion(localAccount.getApiVersion())); + + Log.i(TAG, "STARTING SYNCHRONIZATION"); + + final var status = new SyncResultStatus(); + status.pushSuccessful = pushLocalChanges(); + if (!onlyLocalChanges) { + status.pullSuccessful = pullRemoteChanges(); + } + + Log.i(TAG, "SYNCHRONIZATION FINISHED"); + + onPostExecute(status); + } + + abstract void onPreExecute(); + + abstract void onPostExecute(SyncResultStatus status); + + /** + * Push local changes: for each locally created/edited/deleted Note, use NotesClient in order to push the changed to the server. + */ + private boolean pushLocalChanges() { + Log.d(TAG, "pushLocalChanges()"); + + boolean success = true; + final var notes = repo.getLocalModifiedNotes(localAccount.getId()); + for (Note note : notes) { + Log.d(TAG, " Process Local Note: " + (BuildConfig.DEBUG ? note : note.getTitle())); + try { + Note remoteNote; + switch (note.getStatus()) { + case LOCAL_EDITED: + Log.v(TAG, " ...create/edit"); + if (note.getRemoteId() != null) { + Log.v(TAG, " ...Note has remoteId → try to edit"); + final var editResponse = notesAPI.editNote(note).execute(); + if (editResponse.isSuccessful()) { + remoteNote = editResponse.body(); + if (remoteNote == null) { + Log.e(TAG, " ...Tried to edit \"" + note.getTitle() + "\" (#" + note.getId() + ") but the server response was null."); + throw new Exception("Server returned null after editing \"" + note.getTitle() + "\" (#" + note.getId() + ")"); + } + } else if (editResponse.code() == HTTP_NOT_FOUND) { + Log.v(TAG, " ...Note does no longer exist on server → recreate"); + final var createResponse = notesAPI.createNote(note).execute(); + if (createResponse.isSuccessful()) { + remoteNote = createResponse.body(); + if (remoteNote == null) { + Log.e(TAG, " ...Tried to recreate \"" + note.getTitle() + "\" (#" + note.getId() + ") but the server response was null."); + throw new Exception("Server returned null after recreating \"" + note.getTitle() + "\" (#" + note.getId() + ")"); + } + } else { + throw new Exception(createResponse.message()); + } + } else { + throw new Exception(editResponse.message()); + } + } else { + Log.v(TAG, " ...Note does not have a remoteId yet → create"); + final var createResponse = notesAPI.createNote(note).execute(); + if (createResponse.isSuccessful()) { + remoteNote = createResponse.body(); + if (remoteNote == null) { + Log.e(TAG, " ...Tried to create \"" + note.getTitle() + "\" (#" + note.getId() + ") but the server response was null."); + throw new Exception("Server returned null after creating \"" + note.getTitle() + "\" (#" + note.getId() + ")"); + } + repo.updateRemoteId(note.getId(), remoteNote.getRemoteId()); + } else { + throw new Exception(createResponse.message()); + } + } + // Please note, that db.updateNote() realized an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. + repo.updateIfNotModifiedLocallyDuringSync(note.getId(), remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle()), note.getContent(), note.getCategory(), note.getFavorite()); + break; + case LOCAL_DELETED: + if (note.getRemoteId() == null) { + Log.v(TAG, " ...delete (only local, since it has never been synchronized)"); + } else { + Log.v(TAG, " ...delete (from server and local)"); + final var deleteResponse = notesAPI.deleteNote(note.getRemoteId()).execute(); + if (!deleteResponse.isSuccessful()) { + if (deleteResponse.code() == HTTP_NOT_FOUND) { + Log.v(TAG, " ...delete (note has already been deleted remotely)"); + } else { + throw new Exception(deleteResponse.message()); + } + } + } + // Please note, that db.deleteNote() realizes an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. + repo.deleteByNoteId(note.getId(), LOCAL_DELETED); + break; + default: + throw new IllegalStateException("Unknown State of Note " + note + ": " + note.getStatus()); + } + } catch (NextcloudHttpRequestFailedException e) { + if (e.getStatusCode() == HTTP_NOT_MODIFIED) { + Log.d(TAG, "Server returned HTTP Status Code 304 - Not Modified"); + } else { + exceptions.add(e); + success = false; + } + } catch (Exception e) { + if (e instanceof TokenMismatchException) { + apiProvider.invalidateAPICache(ssoAccount); + } + exceptions.add(e); + success = false; + } + } + return success; + } + + /** + * Pull remote Changes: update or create each remote note (if local pendant has no changes) and remove remotely deleted notes. + */ + private boolean pullRemoteChanges() { + Log.d(TAG, "pullRemoteChanges() for account " + localAccount.getAccountName()); + try { + final var idMap = repo.getIdMap(localAccount.getId()); + + // FIXME re-reading the localAccount is only a workaround for a not-up-to-date eTag in localAccount. + final var accountFromDatabase = repo.getAccountById(localAccount.getId()); + if (accountFromDatabase == null) { + callbacks.remove(localAccount.getId()); + return true; + } + localAccount.setModified(accountFromDatabase.getModified()); + localAccount.setETag(accountFromDatabase.getETag()); + + final var fetchResponse = notesAPI.getNotes(localAccount.getModified(), localAccount.getETag()).blockingSingle(); + final var remoteNotes = fetchResponse.getResponse(); + final var remoteIDs = new HashSet(); + // pull remote changes: update or create each remote note + for (final var remoteNote : remoteNotes) { + Log.v(TAG, " Process Remote Note: " + (BuildConfig.DEBUG ? remoteNote : remoteNote.getTitle())); + remoteIDs.add(remoteNote.getRemoteId()); + if (remoteNote.getModified() == null) { + Log.v(TAG, " ... unchanged"); + } else if (idMap.containsKey(remoteNote.getRemoteId())) { + Log.v(TAG, " ... found → Update"); + final Long localId = idMap.get(remoteNote.getRemoteId()); + if (localId != null) { + repo.updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged( + localId, remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getCategory(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle())); + } else { + Log.e(TAG, "Tried to update note from server, but local id of note is null. " + (BuildConfig.DEBUG ? remoteNote : remoteNote.getTitle())); + } + } else { + Log.v(TAG, " ... create"); + repo.addNote(localAccount.getId(), remoteNote); + } + } + Log.d(TAG, " Remove remotely deleted Notes (only those without local changes)"); + // remove remotely deleted notes (only those without local changes) + for (final var entry : idMap.entrySet()) { + if (!remoteIDs.contains(entry.getKey())) { + Log.v(TAG, " ... remove " + entry.getValue()); + repo.deleteByNoteId(entry.getValue(), DBStatus.VOID); + } + } + + // update ETag and Last-Modified in order to reduce size of next response + localAccount.setETag(fetchResponse.getHeaders().get(HEADER_KEY_ETAG)); + + final var lastModified = Calendar.getInstance(); + lastModified.setTimeInMillis(0); + final String lastModifiedHeader = fetchResponse.getHeaders().get(HEADER_KEY_LAST_MODIFIED); + if (lastModifiedHeader != null) + lastModified.setTimeInMillis(Date.parse(lastModifiedHeader)); + Log.d(TAG, "ETag: " + fetchResponse.getHeaders().get(HEADER_KEY_ETAG) + "; Last-Modified: " + lastModified + " (" + lastModified + ")"); + + localAccount.setModified(lastModified); + + repo.updateETag(localAccount.getId(), localAccount.getETag()); + repo.updateModified(localAccount.getId(), localAccount.getModified().getTimeInMillis()); + + final String newApiVersion = ApiVersionUtil.sanitize(fetchResponse.getHeaders().get(HEADER_KEY_X_NOTES_API_VERSIONS)); + localAccount.setApiVersion(newApiVersion); + repo.updateApiVersion(localAccount.getId(), newApiVersion); + Log.d(TAG, "ApiVersion: " + newApiVersion); + return true; + } catch (Throwable t) { + final Throwable cause = t.getCause(); + if (t.getClass() == RuntimeException.class && cause != null) { + if (cause.getClass() == NextcloudHttpRequestFailedException.class || cause instanceof NextcloudHttpRequestFailedException) { + final NextcloudHttpRequestFailedException httpException = (NextcloudHttpRequestFailedException) cause; + if (httpException.getStatusCode() == HTTP_NOT_MODIFIED) { + Log.d(TAG, "Server returned HTTP Status Code " + httpException.getStatusCode() + " - Notes not modified."); + return true; + } else if (httpException.getStatusCode() == HTTP_UNAVAILABLE) { + Log.d(TAG, "Server returned HTTP Status Code " + httpException.getStatusCode() + " - Server is in maintenance mode."); + return true; + } + } else if (cause.getClass() == NextcloudApiNotRespondingException.class || cause instanceof NextcloudApiNotRespondingException) { + apiProvider.invalidateAPICache(ssoAccount); + } + } + exceptions.add(t); + return false; + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncWorker.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncWorker.java new file mode 100644 index 0000000000000000000000000000000000000000..3816f6b8fec43203612c8941149c70f78fc86228 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncWorker.java @@ -0,0 +1,78 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Constraints; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public class SyncWorker extends Worker { + + private static final String TAG = Objects.requireNonNull(SyncWorker.class.getSimpleName()); + private static final String WORKER_TAG = "background_synchronization"; + + private static final Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + + public SyncWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + } + + @NonNull + @Override + public Result doWork() { + final var repo = NotesRepository.getInstance(getApplicationContext()); + final var accounts = repo.getAccounts(); + final var latch = new CountDownLatch(accounts.size()); + + for (final var account : accounts) { + Log.v(TAG, "Starting background synchronization for " + account.getAccountName()); + repo.addCallbackPull(account, () -> { + Log.v(TAG, "Finished background synchronization for " + account.getAccountName()); + latch.countDown(); + }); + repo.scheduleSync(account, false); + } + + try { + latch.await(); + return Result.success(); + } catch (InterruptedException e) { + return Result.failure(); + } + } + + /** + * Set up sync work to enabled every 15 minutes or just disabled + * https://github.com/stefan-niedermann/nextcloud-notes/issues/1168 + * + * @param context the application + * @param backgroundSync the toggle result backgroundSync + */ + + public static void update(@NonNull Context context, boolean backgroundSync) { + deregister(context); + if (backgroundSync) { + final var work = new PeriodicWorkRequest.Builder(SyncWorker.class, 15, TimeUnit.MINUTES) + .setConstraints(constraints).build(); + WorkManager.getInstance(context.getApplicationContext()).enqueueUniquePeriodicWork(WORKER_TAG, ExistingPeriodicWorkPolicy.REPLACE, work); + Log.i(TAG, "Registering worker running each " + 15 + " " + TimeUnit.MINUTES); + } + } + + private static void deregister(@NonNull Context context) { + Log.i(TAG, "Deregistering all workers with tag \"" + WORKER_TAG + "\""); + WorkManager.getInstance(context.getApplicationContext()).cancelUniqueWork(WORKER_TAG); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java new file mode 100644 index 0000000000000000000000000000000000000000..7723e1f0dfbab01d59627e30c49583dd2a9cb88a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java @@ -0,0 +1,62 @@ +package it.niedermann.owncloud.notes.persistence.dao; + +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.room.Dao; +import androidx.room.Delete; +import androidx.room.Insert; +import androidx.room.Query; + +import java.util.List; + +import it.niedermann.owncloud.notes.persistence.entity.Account; + +@Dao +public interface AccountDao { + + @Insert + long insert(Account localAccount); + + @Delete + void deleteAccount(Account localAccount); + + String getAccounts = "SELECT id, url, userName, accountName, eTag, modified, apiVersion, color, textColor, capabilitiesEtag, COALESCE(displayName, userName) as displayName FROM Account"; + String getAccountById = "SELECT id, url, userName, accountName, eTag, modified, apiVersion, color, textColor, capabilitiesEtag, COALESCE(displayName, userName) as displayName FROM Account WHERE ID = :accountId"; + + @Query(getAccounts) + LiveData> getAccounts$(); + + @Query(getAccounts) + List getAccounts(); + + @Query(getAccountById) + LiveData getAccountById$(long accountId); + + @Query(getAccountById) + Account getAccountById(long accountId); + + @Query("SELECT id, url, userName, accountName, eTag, modified, apiVersion, color, textColor, capabilitiesEtag, COALESCE(displayName, userName) as displayName FROM Account WHERE ACCOUNTNAME = :accountName") + Account getAccountByName(String accountName); + + @Query("SELECT COUNT(*) FROM Account") + LiveData countAccounts$(); + + @Query("UPDATE Account SET COLOR = :color, TEXTCOLOR = :textColor WHERE id = :id") + void updateBrand(long id, @ColorInt Integer color, @ColorInt Integer textColor); + + @Query("UPDATE Account SET ETAG = :eTag WHERE ID = :id") + void updateETag(long id, String eTag); + + @Query("UPDATE Account SET CAPABILITIESETAG = :capabilitiesETag WHERE id = :id") + void updateCapabilitiesETag(long id, String capabilitiesETag); + + @Query("UPDATE Account SET MODIFIED = :modified WHERE id = :id") + void updateModified(long id, long modified); + + @Query("UPDATE Account SET APIVERSION = :apiVersion WHERE id = :id AND ((APIVERSION IS NULL AND :apiVersion IS NOT NULL) OR (APIVERSION IS NOT NULL AND :apiVersion IS NULL) OR APIVERSION <> :apiVersion)") + int updateApiVersion(Long id, String apiVersion); + + @Query("UPDATE Account SET DISPLAYNAME = :displayName WHERE id = :id") + void updateDisplayName(long id, @Nullable String displayName); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/CategoryOptionsDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/CategoryOptionsDao.java new file mode 100644 index 0000000000000000000000000000000000000000..46fdb0f53e74eaf5b4f0c48096af50265ef03d48 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/CategoryOptionsDao.java @@ -0,0 +1,42 @@ +package it.niedermann.owncloud.notes.persistence.dao; + +import androidx.lifecycle.LiveData; +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; + +import java.util.List; + +import it.niedermann.owncloud.notes.persistence.entity.CategoryOptions; +import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; +import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; + +@Dao +public interface CategoryOptionsDao { + + @Insert + void addCategoryOptions(CategoryOptions entity); + + /** + * This method is used to modify the sorting method for one category by title. + * The user can determine use which sorting method to show the notes for a category. + * When the user changes the sorting method, this method should be called. + * + * @param accountId The user accountID + * @param category The category + * @param sortingMethod The sorting method in {@link CategorySortingMethod} enum format + */ + @Query("UPDATE CategoryOptions SET sortingMethod = :sortingMethod WHERE category = :category AND accountId = :accountId") + int modifyCategoryOrder(long accountId, String category, CategorySortingMethod sortingMethod); + + /** + * This function is used to get the sorting method of a category by title. + * The sorting method of the category can be used to decide + * to use which sorting method to show the notes for each categories. + * + * @param category The category + * @return The sorting method in {@link CategorySortingMethod} enum format + */ + @Query("SELECT sortingMethod FROM CategoryOptions WHERE accountId = :accountId AND category = :category") + LiveData getCategoryOrder(long accountId, String category); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java new file mode 100644 index 0000000000000000000000000000000000000000..89a2a9000cb1922bf623742fe35949c9adbf2c8e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java @@ -0,0 +1,196 @@ +package it.niedermann.owncloud.notes.persistence.dao; + +import androidx.lifecycle.LiveData; +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Update; + +import java.util.List; +import java.util.Set; + +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.DBStatus; + +/** + * Each method starting with search will return only a partial {@link Note} without any + * {@link Note#eTag}, {@link Note#status}, {@link Note#content} or {@link Note#scrollY} for performance reasons. + */ +@SuppressWarnings("JavadocReference") +@Dao +public interface NoteDao { + + @Insert + long addNote(Note note); + + @Update(onConflict = OnConflictStrategy.REPLACE) + int updateNote(Note newNote); + + String getNoteById = "SELECT * FROM NOTE WHERE id = :id"; + String count = "SELECT COUNT(*) FROM NOTE WHERE status != 'LOCAL_DELETED' AND accountId = :accountId"; + String countFavorites = "SELECT COUNT(*) FROM NOTE WHERE status != 'LOCAL_DELETED' AND accountId = :accountId AND favorite = 1"; + String searchRecentByModified = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) ORDER BY favorite DESC, modified DESC"; + String searchRecentLexicographically = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) ORDER BY favorite DESC, title COLLATE NOCASE ASC"; + String searchFavoritesByModified = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND favorite = 1 ORDER BY modified DESC"; + String searchFavoritesLexicographically = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND favorite = 1 ORDER BY title COLLATE NOCASE ASC"; + String searchUncategorizedByModified = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND category = '' ORDER BY favorite DESC, modified DESC"; + String searchUncategorizedLexicographically = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND category = '' ORDER BY favorite DESC, title COLLATE NOCASE ASC"; + String searchCategoryByModified = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND (category = :category OR category LIKE :category || '/%') ORDER BY category, favorite DESC, modified DESC"; + String searchCategoryLexicographically = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND (category = :category OR category LIKE :category || '/%') ORDER BY category, favorite DESC, title COLLATE NOCASE ASC"; + + @Query(getNoteById) + LiveData getNoteById$(long id); + + @Query(getNoteById) + Note getNoteById(long id); + + @Query("SELECT remoteId FROM NOTE WHERE id = :id") + Long getRemoteId(long id); + + @Query(count) + LiveData count$(long accountId); + + @Query(count) + Integer count(long accountId); + + @Query(countFavorites) + LiveData countFavorites$(long accountId); + + @Query(countFavorites) + Integer countFavorites(long accountId); + + @Query(searchRecentByModified) + LiveData> searchRecentByModified$(long accountId, String query); + + @Query(searchRecentByModified) + List searchRecentByModified(long accountId, String query); + + @Query(searchRecentLexicographically) + LiveData> searchRecentLexicographically$(long accountId, String query); + + @Query(searchRecentLexicographically) + List searchRecentLexicographically(long accountId, String query); + + @Query(searchFavoritesByModified) + LiveData> searchFavoritesByModified$(long accountId, String query); + + @Query(searchFavoritesByModified) + List searchFavoritesByModified(long accountId, String query); + + @Query(searchFavoritesLexicographically) + LiveData> searchFavoritesLexicographically$(long accountId, String query); + + @Query(searchFavoritesLexicographically) + List searchFavoritesLexicographically(long accountId, String query); + + @Query(searchUncategorizedByModified) + LiveData> searchUncategorizedByModified$(long accountId, String query); + + @Query(searchUncategorizedByModified) + List searchUncategorizedByModified(long accountId, String query); + + @Query(searchUncategorizedLexicographically) + LiveData> searchUncategorizedLexicographically$(long accountId, String query); + + @Query(searchUncategorizedLexicographically) + List searchUncategorizedLexicographically(long accountId, String query); + + @Query(searchCategoryByModified) + LiveData> searchCategoryByModified$(long accountId, String query, String category); + + @Query(searchCategoryByModified) + List searchCategoryByModified(long accountId, String query, String category); + + @Query(searchCategoryLexicographically) + LiveData> searchCategoryLexicographically$(long accountId, String query, String category); + + @Query(searchCategoryLexicographically) + List searchCategoryLexicographically(long accountId, String query, String category); + + @Query("DELETE FROM NOTE WHERE id = :id AND status = :forceDBStatus") + void deleteByNoteId(long id, DBStatus forceDBStatus); + + @Query("UPDATE NOTE SET scrollY = :scrollY WHERE id = :id") + void updateScrollY(long id, int scrollY); + + @Query("UPDATE NOTE SET status = :status WHERE id = :id") + void updateStatus(long id, DBStatus status); + + @Query("UPDATE NOTE SET category = :category WHERE id = :id") + void updateCategory(long id, String category); + + /** + * Gets all the {@link Note#remoteId}s of all not deleted {@link Note}s of an {@link Account} + * + * @param accountId get the {@link Note#remoteId} from all {@link Note}s of this {@link Account} + * @return {@link Set} {@link Note#remoteId}s from all {@link Note}s + */ + @Query("SELECT DISTINCT remoteId FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED'") + List getRemoteIds(long accountId); + + /** + * Gets a list of {@link Note} objects with filled {@link Note#id} and {@link Note#remoteId}, + * where {@link Note#remoteId} is not null + */ + @Query("SELECT id, remoteId, 0 as accountId, '' as title, 0 as favorite, '' as excerpt, 0 as modified, '' as eTag, 0 as status, '' as category, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND remoteId IS NOT NULL") + List getRemoteIdAndId(long accountId); + + /** + * Get a single {@link Note} by {@link Note#remoteId} (aka. Nextcloud file id) + * + * @param remoteId int - {@link Note#remoteId} of the requested {@link Note} + * @return {@link Note#id} + */ + @Query("SELECT id FROM NOTE WHERE accountId = :accountId AND remoteId = :remoteId AND status != 'LOCAL_DELETED'") + Long getLocalIdByRemoteId(long accountId, long remoteId); + + /** + * Returns a list of all {@link Note}s in the Database which were modified locally + * + * @return {@link List} + */ + @Query("SELECT * FROM NOTE WHERE status != '' AND accountId = :accountId") + List getLocalModifiedNotes(long accountId); + + @Query("SELECT * FROM NOTE WHERE status != 'LOCAL_DELETED' AND accountId = :accountId ORDER BY modified DESC LIMIT 4") + List getRecentNotes(long accountId); + + @Query("UPDATE NOTE SET status = 'LOCAL_EDITED', favorite = ((favorite | 1) - (favorite & 1)) WHERE id = :id") + void toggleFavorite(long id); + + @Query("UPDATE NOTE SET remoteId = :remoteId WHERE id = :id") + void updateRemoteId(long id, Long remoteId); + + /** + * used by: {@link it.niedermann.owncloud.notes.persistence.NotesServerSyncTask#pushLocalChanges()} update only, if not modified locally during the synchronization + * (i.e. all (!) user changeable columns (content, favorite, category) must still have the same value), uses reference value gathered at start of synchronization + */ + @Query("UPDATE NOTE SET title = :targetTitle, modified = :targetModified, favorite = :targetFavorite, etag = :targetETag, content = :targetContent, status = '', excerpt = :targetExcerpt " + + "WHERE id = :noteId AND content = :contentBeforeSyncStart AND favorite = :favoriteBeforeSyncStart AND category = :categoryBeforeSyncStart") + int updateIfNotModifiedLocallyDuringSync(long noteId, Long targetModified, String targetTitle, boolean targetFavorite, String targetETag, String targetContent, String targetExcerpt, String contentBeforeSyncStart, String categoryBeforeSyncStart, boolean favoriteBeforeSyncStart); + + /** + * used by: {@link it.niedermann.owncloud.notes.persistence.NotesServerSyncTask#pullRemoteChanges()} update only, if not modified locally (i.e. STATUS="") and if modified remotely (i.e. any (!) column has changed) + */ + @Query("UPDATE NOTE SET title = :title, modified = :modified, favorite = :favorite, etag = :eTag, content = :content, status = '', excerpt = :excerpt, category = :category " + + "WHERE id = :id AND status = '' AND (title != :title OR modified != :modified OR favorite != :favorite OR category != :category OR (eTag IS NULL OR eTag != :eTag) OR content != :content)") + int updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged(long id, Long modified, String title, boolean favorite, String category, String eTag, String content, String excerpt); + + /** + * This method return all of the categories with given {@param accountId} + * + * @param accountId The user account Id + * @return All of the categories with given accountId + */ + @Query("SELECT accountId, category, COUNT(*) as 'totalNotes' FROM NOTE WHERE STATUS != 'LOCAL_DELETED' AND accountId = :accountId GROUP BY category") + LiveData> getCategories$(Long accountId); + + @Query("SELECT accountId, category, COUNT(*) as 'totalNotes' FROM NOTE WHERE STATUS != 'LOCAL_DELETED' AND accountId = :accountId AND category != '' AND category LIKE :searchTerm GROUP BY category") + LiveData> searchCategories$(Long accountId, String searchTerm); + + @Query("SELECT COUNT(*) FROM NOTE WHERE STATUS != '' AND accountId = :accountId") + Long countUnsynchronizedNotes(long accountId); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetNotesListDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetNotesListDao.java new file mode 100644 index 0000000000000000000000000000000000000000..c198ab2f0b9eb6a49d5e49e5ad367f4afab4769d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetNotesListDao.java @@ -0,0 +1,20 @@ +package it.niedermann.owncloud.notes.persistence.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; + +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; + +@Dao +public interface WidgetNotesListDao { + + @Insert + void createOrUpdateNoteListWidgetData(NotesListWidgetData data); + + @Query("DELETE FROM NOTESLISTWIDGETDATA WHERE id = :appWidgetId") + void removeNoteListWidget(int appWidgetId); + + @Query("SELECT * FROM NOTESLISTWIDGETDATA WHERE id = :appWidgetId") + NotesListWidgetData getNoteListWidgetData(int appWidgetId); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetSingleNoteDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetSingleNoteDao.java new file mode 100644 index 0000000000000000000000000000000000000000..dd0d1a19493ace40e38caa005af122e605704433 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetSingleNoteDao.java @@ -0,0 +1,21 @@ +package it.niedermann.owncloud.notes.persistence.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; + +import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; + +@Dao +public interface WidgetSingleNoteDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + void createOrUpdateSingleNoteWidgetData(SingleNoteWidgetData data); + + @Query("DELETE FROM SINGLENOTEWIDGETDATA WHERE id = :id") + void removeSingleNoteWidget(int id); + + @Query("SELECT * FROM SINGLENOTEWIDGETDATA WHERE id = :id") + SingleNoteWidgetData getSingleNoteWidgetData(int id); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java new file mode 100644 index 0000000000000000000000000000000000000000..016b2fd0c1e8f7f6884a5b11e9cbd332313a5640 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java @@ -0,0 +1,233 @@ +package it.niedermann.owncloud.notes.persistence.entity; + +import android.graphics.Color; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Index; +import androidx.room.PrimaryKey; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.io.Serializable; +import java.util.Calendar; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.NoSuchElementException; + +import it.niedermann.owncloud.notes.shared.model.ApiVersion; +import it.niedermann.owncloud.notes.shared.model.Capabilities; + +@Entity( + indices = { + @Index(name = "IDX_ACCOUNT_MODIFIED", value = "modified"), + @Index(name = "IDX_ACCOUNT_URL", value = "url"), + @Index(name = "IDX_ACCOUNT_USERNAME", value = "userName"), + @Index(name = "IDX_ACCOUNT_ACCOUNTNAME", value = "accountName"), + @Index(name = "IDX_ACCOUNT_ETAG", value = "eTag") + } +) +public class Account implements Serializable { + @PrimaryKey(autoGenerate = true) + private long id; + @NonNull + @ColumnInfo(defaultValue = "") + private String url = ""; + @NonNull + @ColumnInfo(defaultValue = "") + private String userName = ""; + @NonNull + @ColumnInfo(defaultValue = "") + private String accountName = ""; + @Nullable + private String eTag; + @Nullable + private Calendar modified; + @Nullable + private String apiVersion; + @ColorInt + @ColumnInfo(defaultValue = "-16743735") + private int color = Color.parseColor("#0082C9"); + @ColorInt + @ColumnInfo(defaultValue = "-16777216") + private int textColor = Color.WHITE; + @Nullable + private String capabilitiesETag; + @Nullable + private String displayName; + + public Account() { + // Default constructor + } + + public Account(@NonNull String url, @NonNull String username, @NonNull String accountName, @Nullable String displayName, @NonNull Capabilities capabilities) { + setUrl(url); + setUserName(username); + setAccountName(accountName); + setDisplayName(displayName); + setCapabilities(capabilities); + } + + public void setCapabilities(@NonNull Capabilities capabilities) { + capabilitiesETag = capabilities.getETag(); + apiVersion = capabilities.getApiVersion(); + setColor(capabilities.getColor()); + setTextColor(capabilities.getTextColor()); + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + @NonNull + public String getUrl() { + return url; + } + + public void setUrl(@NonNull String url) { + this.url = url; + } + + @NonNull + public String getUserName() { + return userName; + } + + public void setUserName(@NonNull String userName) { + this.userName = userName; + } + + @NonNull + public String getAccountName() { + return accountName; + } + + public void setAccountName(@NonNull String accountName) { + this.accountName = accountName; + } + + @Nullable + public String getETag() { + return eTag; + } + + public void setETag(@Nullable String eTag) { + this.eTag = eTag; + } + + @Nullable + public Calendar getModified() { + return modified; + } + + public void setModified(@Nullable Calendar modified) { + this.modified = modified; + } + + @Nullable + public String getApiVersion() { + return apiVersion; + } + + public void setApiVersion(@Nullable String apiVersion) { + this.apiVersion = apiVersion; + } + + public int getColor() { + return color; + } + + public void setColor(int color) { + this.color = color; + } + + public int getTextColor() { + return textColor; + } + + public void setTextColor(int textColor) { + this.textColor = textColor; + } + + @Nullable + public String getCapabilitiesETag() { + return capabilitiesETag; + } + + public void setCapabilitiesETag(@Nullable String capabilitiesETag) { + this.capabilitiesETag = capabilitiesETag; + } + + @Nullable + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(@Nullable String displayName) { + this.displayName = displayName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Account)) return false; + + Account account = (Account) o; + + if (id != account.id) return false; + if (color != account.color) return false; + if (textColor != account.textColor) return false; + if (!url.equals(account.url)) return false; + if (!userName.equals(account.userName)) return false; + if (!accountName.equals(account.accountName)) return false; + if (eTag != null ? !eTag.equals(account.eTag) : account.eTag != null) return false; + if (modified != null ? !modified.equals(account.modified) : account.modified != null) + return false; + if (apiVersion != null ? !apiVersion.equals(account.apiVersion) : account.apiVersion != null) + return false; + if (capabilitiesETag != null ? !capabilitiesETag.equals(account.capabilitiesETag) : account.capabilitiesETag != null) + return false; + return true; + } + + @Override + public int hashCode() { + int result = (int) (id ^ (id >>> 32)); + result = 31 * result + url.hashCode(); + result = 31 * result + userName.hashCode(); + result = 31 * result + accountName.hashCode(); + result = 31 * result + (eTag != null ? eTag.hashCode() : 0); + result = 31 * result + (modified != null ? modified.hashCode() : 0); + result = 31 * result + (apiVersion != null ? apiVersion.hashCode() : 0); + result = 31 * result + color; + result = 31 * result + textColor; + result = 31 * result + (capabilitiesETag != null ? capabilitiesETag.hashCode() : 0); + return result; + } + + @NonNull + @Override + public String toString() { + return "Account{" + + "id=" + id + + ", url='" + url + '\'' + + ", userName='" + userName + '\'' + + ", accountName='" + accountName + '\'' + + ", eTag='" + eTag + '\'' + + ", modified=" + modified + + ", apiVersion='" + apiVersion + '\'' + + ", color=" + color + + ", textColor=" + textColor + + ", capabilitiesETag='" + capabilitiesETag + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/CategoryOptions.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/CategoryOptions.java new file mode 100644 index 0000000000000000000000000000000000000000..da37ee3117c4cbeed950786411e2470815838449 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/CategoryOptions.java @@ -0,0 +1,93 @@ +package it.niedermann.owncloud.notes.persistence.entity; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.Entity; +import androidx.room.ForeignKey; +import androidx.room.Ignore; +import androidx.room.Index; + +import java.io.Serializable; + +import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; + +@Entity( + primaryKeys = { + "accountId", + "category" + }, + foreignKeys = { + @ForeignKey( + entity = Account.class, + parentColumns = "id", + childColumns = "accountId", + onDelete = ForeignKey.CASCADE + ), +// Not possible with SQLite because parent column is not unique +// @ForeignKey( +// entity = Note.class, +// parentColumns = {"accountId", "category"}, +// childColumns = {"accountId", "category"}, +// onDelete = ForeignKey.CASCADE +// ) + }, + indices = { + @Index(name = "IDX_CATEGORIYOPTIONS_ACCOUNTID", value = "accountId"), + @Index(name = "IDX_CATEGORIYOPTIONS_CATEGORY", value = "category"), + @Index(name = "IDX_CATEGORIYOPTIONS_SORTING_METHOD", value = "sortingMethod"), + @Index(name = "IDX_UNIQUE_CATEGORYOPTIONS_ACCOUNT_CATEGORY", value = {"accountId", "category"}, unique = true) + } +) +public class CategoryOptions implements Serializable { + private long accountId; + @NonNull + private String category = ""; + @Nullable + private CategorySortingMethod sortingMethod; + + public long getAccountId() { + return accountId; + } + + public void setAccountId(long accountId) { + this.accountId = accountId; + } + + @NonNull + public String getCategory() { + return category; + } + + public void setCategory(@NonNull String category) { + this.category = category; + } + + @Nullable + public CategorySortingMethod getSortingMethod() { + return sortingMethod; + } + + public void setSortingMethod(@Nullable CategorySortingMethod sortingMethod) { + this.sortingMethod = sortingMethod; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CategoryOptions)) return false; + + CategoryOptions that = (CategoryOptions) o; + + if (accountId != that.accountId) return false; + if (!category.equals(that.category)) return false; + return sortingMethod == that.sortingMethod; + } + + @Override + public int hashCode() { + int result = (int) (accountId ^ (accountId >>> 32)); + result = 31 * result + category.hashCode(); + result = 31 * result + (sortingMethod != null ? sortingMethod.hashCode() : 0); + return result; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/CategoryWithNotesCount.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/CategoryWithNotesCount.java new file mode 100644 index 0000000000000000000000000000000000000000..2eb71eb6e200bc8a3ad6cbbe52745a966967092c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/CategoryWithNotesCount.java @@ -0,0 +1,66 @@ +package it.niedermann.owncloud.notes.persistence.entity; + +import androidx.room.Ignore; + +public class CategoryWithNotesCount { + + private long accountId; + private String category; + private Integer totalNotes; + + public CategoryWithNotesCount() { + // Default constructor for Room + } + + @Ignore + public CategoryWithNotesCount(long accountId, String category, Integer totalNotes) { + this.accountId = accountId; + this.category = category; + this.totalNotes = totalNotes; + } + + public Integer getTotalNotes() { + return totalNotes; + } + + public void setTotalNotes(Integer totalNotes) { + this.totalNotes = totalNotes; + } + + public long getAccountId() { + return accountId; + } + + public void setAccountId(long accountId) { + this.accountId = accountId; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CategoryWithNotesCount)) return false; + + CategoryWithNotesCount that = (CategoryWithNotesCount) o; + + if (accountId != that.accountId) return false; + if (category != null ? !category.equals(that.category) : that.category != null) + return false; + return totalNotes != null ? totalNotes.equals(that.totalNotes) : that.totalNotes == null; + } + + @Override + public int hashCode() { + int result = (int) (accountId ^ (accountId >>> 32)); + result = 31 * result + (category != null ? category.hashCode() : 0); + result = 31 * result + (totalNotes != null ? totalNotes.hashCode() : 0); + return result; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Converters.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Converters.java new file mode 100644 index 0000000000000000000000000000000000000000..099fec4e46ce21d98f64464a48dbd4c933fa4707 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Converters.java @@ -0,0 +1,57 @@ +package it.niedermann.owncloud.notes.persistence.entity; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.TypeConverter; + +import java.util.Calendar; + +import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; +import it.niedermann.owncloud.notes.shared.model.DBStatus; + +public class Converters { + + @TypeConverter + public static DBStatus fromString(@Nullable String value) { + for (DBStatus status : DBStatus.values()) { + if (status.getTitle().equals(value)) { + return status; + } + } + return DBStatus.VOID; + } + + @TypeConverter + public static String dbStatusToString(@Nullable DBStatus status) { + return status == null ? null : status.getTitle(); + } + + @TypeConverter + @NonNull + public static CategorySortingMethod categorySortingMethodFromString(@Nullable Integer value) { + return value == null ? CategorySortingMethod.SORT_MODIFIED_DESC : CategorySortingMethod.findById(value); + } + + @TypeConverter + @Nullable + public static Integer dbStatusToString(@Nullable CategorySortingMethod categorySortingMethod) { + return categorySortingMethod == null ? null : categorySortingMethod.getId(); + } + + @TypeConverter + public static Calendar calendarFromLong(Long value) { + Calendar calendar = Calendar.getInstance(); + if (value == null) { + calendar.setTimeInMillis(0); + } else { + calendar.setTimeInMillis(value); + } + return calendar; + } + + @TypeConverter + public static Long calendarToLong(Calendar calendar) { + return calendar == null ? 0 : calendar.getTimeInMillis(); + } + +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java new file mode 100644 index 0000000000000000000000000000000000000000..e0d0325c6943dc9bb8ddb4e7ffbc0474659c8be8 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java @@ -0,0 +1,276 @@ +package it.niedermann.owncloud.notes.persistence.entity; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.ForeignKey; +import androidx.room.Ignore; +import androidx.room.Index; +import androidx.room.PrimaryKey; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +import java.io.Serializable; +import java.util.Calendar; + +import it.niedermann.owncloud.notes.shared.model.DBStatus; +import it.niedermann.owncloud.notes.shared.model.Item; + +@Entity( + foreignKeys = { + @ForeignKey( + entity = Account.class, + parentColumns = "id", + childColumns = "accountId", + onDelete = ForeignKey.CASCADE + ) + }, + indices = { + @Index(name = "IDX_NOTE_ACCOUNTID", value = "accountId"), + @Index(name = "IDX_NOTE_CATEGORY", value = "category"), + @Index(name = "IDX_NOTE_FAVORITE", value = "favorite"), + @Index(name = "IDX_NOTE_MODIFIED", value = "modified"), + @Index(name = "IDX_NOTE_REMOTEID", value = "remoteId"), + @Index(name = "IDX_NOTE_STATUS", value = "status") + } +) +public class Note implements Serializable, Item { + @SerializedName("localId") + @PrimaryKey(autoGenerate = true) + private long id; + + @Nullable + @Expose + @SerializedName("id") + private Long remoteId; + + private long accountId; + + @NonNull + private DBStatus status = DBStatus.VOID; + + @NonNull + @ColumnInfo(defaultValue = "") + @Expose + private String title = ""; + + @NonNull + @Expose + @ColumnInfo(defaultValue = "") + private String category = ""; + + @Expose + @Nullable + private Calendar modified; + + @NonNull + @ColumnInfo(defaultValue = "") + @Expose + private String content = ""; + + @Expose + @ColumnInfo(defaultValue = "0") + private boolean favorite = false; + + @Expose + @Nullable + @SerializedName("etag") + private String eTag; + + @NonNull + @ColumnInfo(defaultValue = "") + private String excerpt = ""; + + @ColumnInfo(defaultValue = "0") + private int scrollY = 0; + + public Note() { + super(); + } + + @Ignore + public Note(@Nullable Long remoteId, @Nullable Calendar modified, @NonNull String title, @NonNull String content, @NonNull String category, boolean favorite, @Nullable String eTag) { + this.remoteId = remoteId; + this.title = title; + this.modified = modified; + this.content = content; + this.favorite = favorite; + this.category = category; + this.eTag = eTag; + } + + @Ignore + public Note(long id, @Nullable Long remoteId, @Nullable Calendar modified, @NonNull String title, @NonNull String content, @NonNull String category, boolean favorite, @Nullable String etag, @NonNull DBStatus status, long accountId, @NonNull String excerpt, int scrollY) { + this(remoteId, modified, title, content, category, favorite, etag); + this.id = id; + this.status = status; + this.accountId = accountId; + this.excerpt = excerpt; + this.scrollY = scrollY; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + @NonNull + public String getCategory() { + return category; + } + + public void setCategory(@NonNull String category) { + this.category = category; + } + + @Nullable + public Long getRemoteId() { + return remoteId; + } + + public void setRemoteId(@Nullable Long remoteId) { + this.remoteId = remoteId; + } + + public long getAccountId() { + return accountId; + } + + public void setAccountId(long accountId) { + this.accountId = accountId; + } + + @NonNull + public DBStatus getStatus() { + return status; + } + + public void setStatus(@NonNull DBStatus status) { + this.status = status; + } + + @NonNull + public String getTitle() { + return title; + } + + public void setTitle(@NonNull String title) { + this.title = title; + } + + @Nullable + public Calendar getModified() { + return modified; + } + + public void setModified(@Nullable Calendar modified) { + this.modified = modified; + } + + @NonNull + public String getContent() { + return content; + } + + public void setContent(@NonNull String content) { + this.content = content; + } + + public boolean getFavorite() { + return favorite; + } + + public void setFavorite(boolean favorite) { + this.favorite = favorite; + } + + @Nullable + public String getETag() { + return eTag; + } + + public void setETag(@Nullable String eTag) { + this.eTag = eTag; + } + + @NonNull + public String getExcerpt() { + return excerpt; + } + + public void setExcerpt(@NonNull String excerpt) { + this.excerpt = excerpt; + } + + public int getScrollY() { + return scrollY; + } + + public void setScrollY(int scrollY) { + this.scrollY = scrollY; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Note)) return false; + + Note note = (Note) o; + + if (id != note.id) return false; + if (accountId != note.accountId) return false; + if (favorite != note.favorite) return false; + if (scrollY != note.scrollY) return false; + if (remoteId != null ? !remoteId.equals(note.remoteId) : note.remoteId != null) + return false; + if (status != note.status) return false; + if (!title.equals(note.title)) return false; + if (!category.equals(note.category)) return false; + if (modified != null ? !modified.equals(note.modified) : note.modified != null) + return false; + if (!content.equals(note.content)) return false; + if (eTag != null ? !eTag.equals(note.eTag) : note.eTag != null) return false; + return excerpt.equals(note.excerpt); + } + + @Override + public int hashCode() { + int result = (int) (id ^ (id >>> 32)); + result = 31 * result + (remoteId != null ? remoteId.hashCode() : 0); + result = 31 * result + (int) (accountId ^ (accountId >>> 32)); + result = 31 * result + status.hashCode(); + result = 31 * result + title.hashCode(); + result = 31 * result + category.hashCode(); + result = 31 * result + (modified != null ? modified.hashCode() : 0); + result = 31 * result + content.hashCode(); + result = 31 * result + (favorite ? 1 : 0); + result = 31 * result + (eTag != null ? eTag.hashCode() : 0); + result = 31 * result + excerpt.hashCode(); + result = 31 * result + scrollY; + return result; + } + + @NonNull + @Override + public String toString() { + return "Note{" + + "id=" + id + + ", remoteId=" + remoteId + + ", accountId=" + accountId + + ", status=" + status + + ", title='" + title + '\'' + + ", category='" + category + '\'' + + ", modified=" + modified + + ", content='" + content + '\'' + + ", favorite=" + favorite + + ", eTag='" + eTag + '\'' + + ", excerpt='" + excerpt + '\'' + + ", scrollY=" + scrollY + + '}'; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/NotesListWidgetData.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/NotesListWidgetData.java new file mode 100644 index 0000000000000000000000000000000000000000..da784d1dcfecba2fc5b2ca8dc768bf3fbeaea37b --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/NotesListWidgetData.java @@ -0,0 +1,89 @@ +package it.niedermann.owncloud.notes.persistence.entity; + +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.Entity; +import androidx.room.ForeignKey; +import androidx.room.Ignore; +import androidx.room.Index; + +import it.niedermann.owncloud.notes.widget.AbstractWidgetData; + +@Entity( + foreignKeys = { + @ForeignKey( + entity = Account.class, + parentColumns = "id", + childColumns = "accountId", + onDelete = ForeignKey.CASCADE + ) + }, + indices = { + @Index(name = "IDX_NOTESLISTWIDGETDATA_ACCOUNTID", value = "accountId"), + @Index(name = "IDX_NOTESLISTWIDGETDATA_CATEGORY", value = "category"), + @Index(name = "IDX_NOTESLISTWIDGETDATA_ACCOUNT_CATEGORY", value = {"accountId", "category"}) + } +) +public class NotesListWidgetData extends AbstractWidgetData { + + @Ignore + public static final int MODE_DISPLAY_ALL = 0; + @Ignore + public static final int MODE_DISPLAY_STARRED = 1; + @Ignore + public static final int MODE_DISPLAY_CATEGORY = 2; + + @IntRange(from = 0, to = 2) + private int mode; + + @Nullable + private String category; + + @Nullable + public String getCategory() { + return category; + } + + public void setCategory(@Nullable String category) { + this.category = category; + } + + public void setMode(@IntRange(from = 0, to = 2) int mode) { + this.mode = mode; + } + + @IntRange(from = 0, to = 2) + public int getMode() { + return mode; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof NotesListWidgetData)) return false; + if (!super.equals(o)) return false; + + NotesListWidgetData that = (NotesListWidgetData) o; + + if (mode != that.mode) return false; + return category != null ? category.equals(that.category) : that.category == null; + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + mode; + result = 31 * result + (category != null ? category.hashCode() : 0); + return result; + } + + @NonNull + @Override + public String toString() { + return "NotesListWidgetData{" + + "mode=" + mode + + ", category='" + category + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/SingleNoteWidgetData.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/SingleNoteWidgetData.java new file mode 100644 index 0000000000000000000000000000000000000000..3e726242b53e053cd3a7a120f22047252bc49cac --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/SingleNoteWidgetData.java @@ -0,0 +1,65 @@ +package it.niedermann.owncloud.notes.persistence.entity; + +import androidx.room.Entity; +import androidx.room.ForeignKey; +import androidx.room.Ignore; +import androidx.room.Index; + +import it.niedermann.owncloud.notes.widget.AbstractWidgetData; + +@Entity( + foreignKeys = { + @ForeignKey( + entity = Account.class, + parentColumns = "id", + childColumns = "accountId", + onDelete = ForeignKey.CASCADE + ), + @ForeignKey( + entity = Note.class, + parentColumns = "id", + childColumns = "noteId", + onDelete = ForeignKey.CASCADE + ) + }, + indices = { + @Index(name = "IDX_SINGLENOTEWIDGETDATA_ACCOUNTID", value = "accountId"), + @Index(name = "IDX_SINGLENOTEWIDGETDATA_NOTEID", value = "noteId"), + } +) +public class SingleNoteWidgetData extends AbstractWidgetData { + private long noteId; + + public SingleNoteWidgetData() { + // Default constructor + } + + @Ignore + public SingleNoteWidgetData(int id, long accountId, long noteId, int modeId) { + super(id, accountId, modeId); + setNoteId(noteId); + } + + public long getNoteId() { + return noteId; + } + + public void setNoteId(long noteId) { + this.noteId = noteId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SingleNoteWidgetData)) return false; + + SingleNoteWidgetData that = (SingleNoteWidgetData) o; + + return noteId == that.noteId; + } + + @Override + public int hashCode() { + return (int) (noteId ^ (noteId >>> 32)); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_10_11.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_10_11.java new file mode 100644 index 0000000000000000000000000000000000000000..84ef21056f6b8d6ed3f4446920eee9abd111ac97 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_10_11.java @@ -0,0 +1,42 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import java.util.Map; + +import it.niedermann.owncloud.notes.preferences.DarkModeSetting; + +public class Migration_10_11 extends Migration { + @NonNull + private final Context context; + + public Migration_10_11(@NonNull Context context) { + super(10, 11); + this.context = context; + } + + /** + * Changes the boolean for light / dark mode to {@link DarkModeSetting} to also be able to represent system default value + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + final var sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + final var editor = sharedPreferences.edit(); + final var prefs = sharedPreferences.getAll(); + for (final var pref : prefs.entrySet()) { + final String key = pref.getKey(); + final String DARK_THEME_KEY = "NLW_darkTheme"; + if ("darkTheme".equals(key) || key.startsWith(DARK_THEME_KEY) || key.startsWith("SNW_darkTheme")) { + final Boolean darkTheme = (Boolean) pref.getValue(); + editor.putString(pref.getKey(), darkTheme ? DarkModeSetting.DARK.name() : DarkModeSetting.LIGHT.name()); + } + } + editor.apply(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_11_12.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_11_12.java new file mode 100644 index 0000000000000000000000000000000000000000..d37916e81e08de2c7d90fb28ea859d7c3610bd32 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_11_12.java @@ -0,0 +1,32 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; + +import androidx.annotation.NonNull; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import it.niedermann.owncloud.notes.persistence.CapabilitiesWorker; +import it.niedermann.owncloud.notes.shared.model.ApiVersion; + +public class Migration_11_12 extends Migration { + @NonNull + private final Context context; + + public Migration_11_12(@NonNull Context context) { + super(11, 12); + this.context = context; + } + + /** + * Adds columns to store the {@link ApiVersion} and the theme colors + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE ACCOUNTS ADD COLUMN API_VERSION TEXT"); + db.execSQL("ALTER TABLE ACCOUNTS ADD COLUMN COLOR VARCHAR(6) NOT NULL DEFAULT '000000'"); + db.execSQL("ALTER TABLE ACCOUNTS ADD COLUMN TEXT_COLOR VARCHAR(6) NOT NULL DEFAULT '0082C9'"); + CapabilitiesWorker.update(context); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_12_13.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_12_13.java new file mode 100644 index 0000000000000000000000000000000000000000..07c405b65554ab48732136d67a535f573c9cbf73 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_12_13.java @@ -0,0 +1,31 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; + +import androidx.annotation.NonNull; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.work.WorkManager; + +import it.niedermann.owncloud.notes.shared.model.Capabilities; + +public class Migration_12_13 extends Migration { + @NonNull + private final Context context; + + public Migration_12_13(@NonNull Context context) { + super(12, 13); + this.context = context; + } + + /** + * Adds a column to store the ETag of the server {@link Capabilities} + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE ACCOUNTS ADD COLUMN CAPABILITIES_ETAG TEXT"); + WorkManager.getInstance(context.getApplicationContext()).cancelUniqueWork("it.niedermann.owncloud.notes.persistence.SyncWorker"); + WorkManager.getInstance(context.getApplicationContext()).cancelUniqueWork("SyncWorker"); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_13_14.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_13_14.java new file mode 100644 index 0000000000000000000000000000000000000000..805204f69695d888ad3558305e692a4e1fe119e7 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_13_14.java @@ -0,0 +1,93 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.appwidget.AppWidgetManager; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; +import androidx.room.OnConflictStrategy; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import java.util.Map; + +import it.niedermann.owncloud.notes.preferences.DarkModeSetting; +import it.niedermann.owncloud.notes.widget.notelist.NoteListWidget; +import it.niedermann.owncloud.notes.widget.singlenote.SingleNoteWidget; + +public class Migration_13_14 extends Migration { + + private static final String TAG = Migration_13_14.class.getSimpleName(); + @NonNull + private final Context context; + + public Migration_13_14(@NonNull Context context) { + super(13, 14); + this.context = context; + } + + /** + * Move single note widget preferences to database + * https://github.com/stefan-niedermann/nextcloud-notes/issues/754 + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("CREATE TABLE WIDGET_SINGLE_NOTES ( " + + "ID INTEGER PRIMARY KEY, " + + "ACCOUNT_ID INTEGER, " + + "NOTE_ID INTEGER, " + + "THEME_MODE INTEGER NOT NULL, " + + "FOREIGN KEY(ACCOUNT_ID) REFERENCES ACCOUNTS(ID), " + + "FOREIGN KEY(NOTE_ID) REFERENCES NOTES(ID))"); + + final String SP_WIDGET_KEY = "single_note_widget"; + final String SP_ACCOUNT_ID_KEY = "SNW_accountId"; + final String SP_DARK_THEME_KEY = "SNW_darkTheme"; + final var sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + final var editor = sharedPreferences.edit(); + final var prefs = sharedPreferences.getAll(); + for (final var pref : prefs.entrySet()) { + final String key = pref.getKey(); + Integer widgetId = null; + Long noteId = null; + Long accountId = null; + Integer themeMode = null; + if (key != null && key.startsWith(SP_WIDGET_KEY)) { + try { + widgetId = Integer.parseInt(key.substring(SP_WIDGET_KEY.length())); + noteId = (Long) pref.getValue(); + accountId = sharedPreferences.getLong(SP_ACCOUNT_ID_KEY + widgetId, -1); + + try { + themeMode = DarkModeSetting.valueOf(sharedPreferences.getString(SP_DARK_THEME_KEY + widgetId, DarkModeSetting.SYSTEM_DEFAULT.name())).getModeId(); + } catch (ClassCastException e) { + //DARK_THEME was a boolean in older versions of the app. We thereofre have to still support the old setting. + themeMode = sharedPreferences.getBoolean(SP_DARK_THEME_KEY + widgetId, false) ? DarkModeSetting.DARK.getModeId() : DarkModeSetting.LIGHT.getModeId(); + } + + final var migratedWidgetValues = new ContentValues(); + migratedWidgetValues.put("ID", widgetId); + migratedWidgetValues.put("ACCOUNT_ID", accountId); + migratedWidgetValues.put("NOTE_ID", noteId); + migratedWidgetValues.put("THEME_MODE", themeMode); + db.insert("WIDGET_SINGLE_NOTES", OnConflictStrategy.REPLACE, migratedWidgetValues); + } catch (Throwable t) { + Log.e(TAG, "Could not migrate widget {widgetId: " + widgetId + ", accountId: " + accountId + ", noteId: " + noteId + ", themeMode: " + themeMode + "}"); + t.printStackTrace(); + } finally { + // Clean up old shared preferences + editor.remove(SP_WIDGET_KEY + widgetId); + editor.remove(SP_DARK_THEME_KEY + widgetId); + editor.remove(SP_ACCOUNT_ID_KEY + widgetId); + } + } + } + editor.apply(); + context.sendBroadcast(new Intent(context, SingleNoteWidget.class).setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE)); + context.sendBroadcast(new Intent(context, NoteListWidget.class).setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE)); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_14_15.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_14_15.java new file mode 100644 index 0000000000000000000000000000000000000000..bda4d04624009fdb55841e130bff1eff013f4ff0 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_14_15.java @@ -0,0 +1,106 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.room.OnConflictStrategy; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import java.util.Hashtable; + +public class Migration_14_15 extends Migration { + + private static final String TAG = Migration_14_15.class.getSimpleName(); + + public Migration_14_15() { + super(14, 15); + } + + /** + * Normalize database (move category from string field to own table) + * https://github.com/stefan-niedermann/nextcloud-notes/issues/814 + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + // Rename a tmp_NOTES table. + final String tmpTableNotes = String.format("tmp_%s", "NOTES"); + db.execSQL("ALTER TABLE NOTES RENAME TO " + tmpTableNotes); + db.execSQL("CREATE TABLE NOTES ( " + + "ID INTEGER PRIMARY KEY AUTOINCREMENT, " + + "REMOTEID INTEGER, " + + "ACCOUNT_ID INTEGER, " + + "STATUS VARCHAR(50), " + + "TITLE TEXT, " + + "MODIFIED INTEGER DEFAULT 0, " + + "CONTENT TEXT, " + + "FAVORITE INTEGER DEFAULT 0, " + + "CATEGORY INTEGER, " + + "ETAG TEXT," + + "EXCERPT TEXT NOT NULL DEFAULT '', " + + "FOREIGN KEY(CATEGORY) REFERENCES CATEGORIES(CATEGORY_ID), " + + "FOREIGN KEY(ACCOUNT_ID) REFERENCES ACCOUNTS(ID))"); + createIndex(db, "NOTES", "REMOTEID", "ACCOUNT_ID", "STATUS", "FAVORITE", "CATEGORY", "MODIFIED"); + db.execSQL("CREATE TABLE CATEGORIES(" + + "CATEGORY_ID INTEGER PRIMARY KEY AUTOINCREMENT, " + + "CATEGORY_ACCOUNT_ID INTEGER NOT NULL, " + + "CATEGORY_TITLE TEXT NOT NULL, " + + "UNIQUE( CATEGORY_ACCOUNT_ID , CATEGORY_TITLE), " + + "FOREIGN KEY(CATEGORY_ACCOUNT_ID) REFERENCES ACCOUNTS(ID))"); + createIndex(db, "CATEGORIES", "CATEGORY_ID", "CATEGORY_ACCOUNT_ID", "CATEGORY_TITLE"); + // A hashtable storing categoryTitle - categoryId Mapping + // This is used to prevent too many searches in database + final var categoryTitleIdMap = new Hashtable(); + int id = 1; + final var tmpNotesCursor = db.query("SELECT * FROM " + tmpTableNotes, null); + while (tmpNotesCursor.moveToNext()) { + final String categoryTitle = tmpNotesCursor.getString(8); + final int accountId = tmpNotesCursor.getInt(2); + Log.e("###", accountId + ""); + final Integer categoryId; + if (categoryTitleIdMap.containsKey(categoryTitle) && categoryTitleIdMap.get(categoryTitle) != null) { + categoryId = categoryTitleIdMap.get(categoryTitle); + } else { + // The category does not exists in the database, create it. + categoryId = id++; + ContentValues values = new ContentValues(); + values.put("CATEGORY_ID", categoryId); + values.put("CATEGORY_ACCOUNT_ID", accountId); + values.put("CATEGORY_TITLE", categoryTitle); + db.insert("CATEGORIES", OnConflictStrategy.REPLACE, values); + categoryTitleIdMap.put(categoryTitle, categoryId); + } + // Move the data in tmp_NOTES to NOTES + final ContentValues values = new ContentValues(); + values.put("ID", tmpNotesCursor.getInt(0)); + values.put("REMOTEID", tmpNotesCursor.getInt(1)); + values.put("ACCOUNT_ID", tmpNotesCursor.getInt(2)); + values.put("STATUS", tmpNotesCursor.getString(3)); + values.put("TITLE", tmpNotesCursor.getString(4)); + values.put("MODIFIED", tmpNotesCursor.getLong(5)); + values.put("CONTENT", tmpNotesCursor.getString(6)); + values.put("FAVORITE", tmpNotesCursor.getInt(7)); + values.put("CATEGORY", categoryId); + values.put("ETAG", tmpNotesCursor.getString(9)); + values.put("EXCERPT", tmpNotesCursor.getString(10)); + db.insert("NOTES", OnConflictStrategy.REPLACE, values); + } + tmpNotesCursor.close(); + db.execSQL("DROP TABLE IF EXISTS " + tmpTableNotes); + } + + private static void createIndex(@NonNull SupportSQLiteDatabase db, @NonNull String table, @NonNull String... columns) { + for (String column : columns) { + createIndex(db, table, column); + } + } + + private static void createIndex(@NonNull SupportSQLiteDatabase db, @NonNull String table, @NonNull String column) { + final String indexName = table + "_" + column + "_idx"; + Log.v(TAG, "Creating database index: CREATE INDEX IF NOT EXISTS " + indexName + " ON " + table + "(" + column + ")"); + db.execSQL("CREATE INDEX " + indexName + " ON " + table + "(" + column + ")"); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_15_16.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_15_16.java new file mode 100644 index 0000000000000000000000000000000000000000..7be78511264a78e82de5c589e31220545d5f3808 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_15_16.java @@ -0,0 +1,111 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.appwidget.AppWidgetManager; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; +import androidx.room.OnConflictStrategy; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import java.util.Map; + +import it.niedermann.owncloud.notes.preferences.DarkModeSetting; +import it.niedermann.owncloud.notes.widget.notelist.NoteListWidget; +import it.niedermann.owncloud.notes.widget.singlenote.SingleNoteWidget; + +public class Migration_15_16 extends Migration { + + private static final String TAG = Migration_15_16.class.getSimpleName(); + @NonNull + private final Context context; + + public Migration_15_16(@NonNull Context context) { + super(15, 16); + this.context = context; + } + + /** + * Moves note list widget preferences from {@link SharedPreferences} to database + * https://github.com/stefan-niedermann/nextcloud-notes/issues/832 + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("CREATE TABLE WIDGET_NOTE_LISTS ( " + + "ID INTEGER PRIMARY KEY, " + + "ACCOUNT_ID INTEGER, " + + "CATEGORY_ID INTEGER, " + + "MODE INTEGER NOT NULL, " + + "THEME_MODE INTEGER NOT NULL, " + + "FOREIGN KEY(ACCOUNT_ID) REFERENCES ACCOUNTS(ID), " + + "FOREIGN KEY(CATEGORY_ID) REFERENCES CATEGORIES(CATEGORY_ID))"); + + final String SP_WIDGET_KEY = "NLW_mode"; + final String SP_ACCOUNT_ID_KEY = "NLW_account"; + final String SP_DARK_THEME_KEY = "NLW_darkTheme"; + final String SP_CATEGORY_KEY = "NLW_cat"; + + final var sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + final var editor = sharedPreferences.edit(); + final var prefs = sharedPreferences.getAll(); + for (final var pref : prefs.entrySet()) { + final String key = pref.getKey(); + Integer widgetId = null; + Integer mode = null; + Long accountId = null; + Integer themeMode = null; + Integer categoryId = null; + if (key != null && key.startsWith(SP_WIDGET_KEY)) { + try { + widgetId = Integer.parseInt(key.substring(SP_WIDGET_KEY.length())); + mode = (Integer) pref.getValue(); + accountId = sharedPreferences.getLong(SP_ACCOUNT_ID_KEY + widgetId, -1); + + try { + themeMode = DarkModeSetting.valueOf(sharedPreferences.getString(SP_DARK_THEME_KEY + widgetId, DarkModeSetting.SYSTEM_DEFAULT.name())).getModeId(); + } catch (ClassCastException e) { + //DARK_THEME was a boolean in older versions of the app. We thereofre have to still support the old setting. + themeMode = sharedPreferences.getBoolean(SP_DARK_THEME_KEY + widgetId, false) ? DarkModeSetting.DARK.getModeId() : DarkModeSetting.LIGHT.getModeId(); + } + + if (mode == 2) { + final String categoryTitle = sharedPreferences.getString(SP_CATEGORY_KEY + widgetId, null); + final var cursor = db.query("SELECT CATEGORY_ID FROM CATEGORIES WHERE CATEGORY_TITLE = ? AND CATEGORY_ACCOUNT_ID = ?", new String[]{categoryTitle, String.valueOf(accountId)}); + if (cursor.moveToNext()) { + categoryId = cursor.getInt(0); + } else { + throw new IllegalStateException("No category id found for title \"" + categoryTitle + "\""); + } + cursor.close(); + } + + final var migratedWidgetValues = new ContentValues(); + migratedWidgetValues.put("ID", widgetId); + migratedWidgetValues.put("ACCOUNT_ID", accountId); + migratedWidgetValues.put("CATEGORY_ID", categoryId); + migratedWidgetValues.put("MODE", mode); + migratedWidgetValues.put("THEME_MODE", themeMode); + db.insert("WIDGET_NOTE_LISTS", OnConflictStrategy.REPLACE, migratedWidgetValues); + } catch (Throwable t) { + Log.e(TAG, "Could not migrate widget {widgetId: " + widgetId + ", accountId: " + accountId + ", mode: " + mode + ", categoryId: " + categoryId + ", themeMode: " + themeMode + "}"); + t.printStackTrace(); + } finally { + // Clean up old shared preferences + editor.remove(SP_WIDGET_KEY + widgetId); + editor.remove(SP_CATEGORY_KEY + widgetId); + editor.remove(SP_DARK_THEME_KEY + widgetId); + editor.remove(SP_ACCOUNT_ID_KEY + widgetId); + } + } + } + editor.apply(); + context.sendBroadcast(new Intent(context, SingleNoteWidget.class).setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE)); + context.sendBroadcast(new Intent(context, NoteListWidget.class).setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE)); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_16_17.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_16_17.java new file mode 100644 index 0000000000000000000000000000000000000000..b61262ebc19d399c7b5bf294c1ae790e354a2beb --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_16_17.java @@ -0,0 +1,21 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import androidx.annotation.NonNull; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +public class Migration_16_17 extends Migration { + + public Migration_16_17() { + super(16, 17); + } + + /** + * Adds a column to store the current scroll position per note + * https://github.com/stefan-niedermann/nextcloud-notes/issues/227 + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE NOTES ADD COLUMN SCROLL_Y INTEGER DEFAULT 0"); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_17_18.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_17_18.java new file mode 100644 index 0000000000000000000000000000000000000000..1a7a96e770f95ced17a43ff081cb97ad8ec57ebb --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_17_18.java @@ -0,0 +1,22 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.database.sqlite.SQLiteDatabase; + +import androidx.annotation.NonNull; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +public class Migration_17_18 extends Migration { + + public Migration_17_18() { + super(17, 18); + } + + /** + * Add a new column to store the sorting method for a category note list + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE CATEGORIES ADD COLUMN CATEGORY_SORTING_METHOD INTEGER DEFAULT 0"); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_18_19.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_18_19.java new file mode 100644 index 0000000000000000000000000000000000000000..576e2204a9f7103ce2e5a798239d526c8d6bbf74 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_18_19.java @@ -0,0 +1,39 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import com.bumptech.glide.Glide; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class Migration_18_19 extends Migration { + + private static final String TAG = Migration_18_19.class.getSimpleName(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + @NonNull + private final Context context; + + + public Migration_18_19(@NonNull Context context) { + super(18, 19); + this.context = context; + } + + /** + * Clears the {@link Glide} disk cache to fix wrong avatars in a multi user setup + * https://github.com/stefan-niedermann/nextcloud-deck/issues/531 + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + executor.submit(() -> { + Log.i(TAG, "Clearing Glide disk cache"); + Glide.get(context.getApplicationContext()).clearDiskCache(); + }); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_19_20.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_19_20.java new file mode 100644 index 0000000000000000000000000000000000000000..7a1d6ff3e4d389139e21f39e549ddfb017a476fb --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_19_20.java @@ -0,0 +1,30 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +public class Migration_19_20 extends Migration { + + @NonNull + private final Context context; + + /** + * Removes branding from {@link SharedPreferences} because we do no longer allow to disable it. + * + * @param context {@link Context} + */ + public Migration_19_20(@NonNull Context context) { + super(19, 20); + this.context = context; + } + + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + PreferenceManager.getDefaultSharedPreferences(context).edit().remove("branding").apply(); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_20_21.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_20_21.java new file mode 100644 index 0000000000000000000000000000000000000000..32ede8b1b33d8a19f412dfa374197d7e96bdc6c6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_20_21.java @@ -0,0 +1,232 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteOpenHelper; +import android.graphics.Color; + +import androidx.annotation.NonNull; +import androidx.room.OnConflictStrategy; +import androidx.room.RoomDatabase; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import it.niedermann.android.util.ColorUtil; + +public final class Migration_20_21 extends Migration { + + public Migration_20_21() { + super(20, 21); + } + + /** + * From {@link SQLiteOpenHelper} to {@link RoomDatabase} + * https://github.com/stefan-niedermann/nextcloud-deck/issues/531 + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + dropOldIndices(db); + + createNewTables(db); + createNewIndices(db); + + migrateAccounts(db); + migrateCategories(db); + migrateNotes(db); + migrateNotesListWidgets(db); + migrateSingleNotesWidgets(db); + + dropOldTables(db); + } + + private static void dropOldIndices(@NonNull SupportSQLiteDatabase db) { + db.execSQL("DROP INDEX IF EXISTS ACCOUNTS_URL_idx"); + db.execSQL("DROP INDEX IF EXISTS ACCOUNTS_USERNAME_idx"); + db.execSQL("DROP INDEX IF EXISTS ACCOUNTS_ACCOUNT_NAME_idx"); + db.execSQL("DROP INDEX IF EXISTS ACCOUNTS_ETAG_idx"); + db.execSQL("DROP INDEX IF EXISTS ACCOUNTS_MODIFIED_idx"); + db.execSQL("DROP INDEX IF EXISTS NOTES_REMOTEID_idx"); + db.execSQL("DROP INDEX IF EXISTS NOTES_ACCOUNT_ID_idx"); + db.execSQL("DROP INDEX IF EXISTS NOTES_STATUS_idx"); + db.execSQL("DROP INDEX IF EXISTS NOTES_FAVORITE_idx"); + db.execSQL("DROP INDEX IF EXISTS NOTES_CATEGORY_idx"); + db.execSQL("DROP INDEX IF EXISTS NOTES_MODIFIED_idx"); + } + + private static void createNewTables(@NonNull SupportSQLiteDatabase db) { + db.execSQL("CREATE TABLE `Account` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL DEFAULT '', `userName` TEXT NOT NULL DEFAULT '', `accountName` TEXT NOT NULL DEFAULT '', `eTag` TEXT, `modified` INTEGER, `apiVersion` TEXT, `color` INTEGER NOT NULL DEFAULT -16743735, `textColor` INTEGER NOT NULL DEFAULT -16777216, `capabilitiesETag` TEXT)"); + db.execSQL("CREATE TABLE `CategoryOptions` (`accountId` INTEGER NOT NULL, `category` TEXT NOT NULL, `sortingMethod` INTEGER, PRIMARY KEY(`accountId`, `category`), FOREIGN KEY(`accountId`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )"); + db.execSQL("CREATE TABLE `Note` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteId` INTEGER, `accountId` INTEGER NOT NULL, `status` TEXT NOT NULL, `title` TEXT NOT NULL DEFAULT '', `category` TEXT NOT NULL DEFAULT '', `modified` INTEGER, `content` TEXT NOT NULL DEFAULT '', `favorite` INTEGER NOT NULL DEFAULT 0, `eTag` TEXT, `excerpt` TEXT NOT NULL DEFAULT '', `scrollY` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`accountId`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )"); + db.execSQL("CREATE TABLE `NotesListWidgetData` (`mode` INTEGER NOT NULL, `category` TEXT, `id` INTEGER NOT NULL, `accountId` INTEGER NOT NULL, `themeMode` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )"); + db.execSQL("CREATE TABLE `SingleNoteWidgetData` (`noteId` INTEGER NOT NULL, `id` INTEGER NOT NULL, `accountId` INTEGER NOT NULL, `themeMode` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`noteId`) REFERENCES `Note`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )"); + } + + private static void createNewIndices(@NonNull SupportSQLiteDatabase db) { + db.execSQL("CREATE INDEX `IDX_ACCOUNT_ACCOUNTNAME` ON `Account` (`accountName`)"); + db.execSQL("CREATE INDEX `IDX_ACCOUNT_ETAG` ON `Account` (`eTag`)"); + db.execSQL("CREATE INDEX `IDX_ACCOUNT_MODIFIED` ON `Account` (`modified`)"); + db.execSQL("CREATE INDEX `IDX_ACCOUNT_URL` ON `Account` (`url`)"); + db.execSQL("CREATE INDEX `IDX_ACCOUNT_USERNAME` ON `Account` (`userName`)"); + db.execSQL("CREATE INDEX `IDX_CATEGORIYOPTIONS_ACCOUNTID` ON `CategoryOptions` (`accountId`)"); + db.execSQL("CREATE INDEX `IDX_CATEGORIYOPTIONS_CATEGORY` ON `CategoryOptions` (`category`)"); + db.execSQL("CREATE INDEX `IDX_CATEGORIYOPTIONS_SORTING_METHOD` ON `CategoryOptions` (`sortingMethod`)"); + db.execSQL("CREATE INDEX `IDX_NOTESLISTWIDGETDATA_ACCOUNTID` ON `NotesListWidgetData` (`accountId`)"); + db.execSQL("CREATE INDEX `IDX_NOTESLISTWIDGETDATA_CATEGORY` ON `NotesListWidgetData` (`category`)"); + db.execSQL("CREATE INDEX `IDX_NOTESLISTWIDGETDATA_ACCOUNT_CATEGORY` ON `NotesListWidgetData` (`accountId`, `category`)"); + db.execSQL("CREATE INDEX `IDX_NOTE_ACCOUNTID` ON `Note` (`accountId`)"); + db.execSQL("CREATE INDEX `IDX_NOTE_CATEGORY` ON `Note` (`category`)"); + db.execSQL("CREATE INDEX `IDX_NOTE_FAVORITE` ON `Note` (`favorite`)"); + db.execSQL("CREATE INDEX `IDX_NOTE_MODIFIED` ON `Note` (`modified`)"); + db.execSQL("CREATE INDEX `IDX_NOTE_REMOTEID` ON `Note` (`remoteId`)"); + db.execSQL("CREATE INDEX `IDX_NOTE_STATUS` ON `Note` (`status`)"); + db.execSQL("CREATE INDEX `IDX_SINGLENOTEWIDGETDATA_ACCOUNTID` ON `SingleNoteWidgetData` (`accountId`)"); + db.execSQL("CREATE INDEX `IDX_SINGLENOTEWIDGETDATA_NOTEID` ON `SingleNoteWidgetData` (`noteId`)"); + + db.execSQL("CREATE UNIQUE INDEX `IDX_UNIQUE_CATEGORYOPTIONS_ACCOUNT_CATEGORY` ON `CategoryOptions` (`accountId`, `category`)"); + + db.execSQL("CREATE TRIGGER TRG_CLEANUP_CATEGORIES_DEL AFTER DELETE ON Note BEGIN DELETE FROM CategoryOptions WHERE CategoryOptions.category NOT IN (SELECT Note.category FROM Note WHERE Note.accountId = CategoryOptions.accountId); END;"); + db.execSQL("CREATE TRIGGER TRG_CLEANUP_CATEGORIES_UPD AFTER UPDATE ON Note BEGIN DELETE FROM CategoryOptions WHERE CategoryOptions.category NOT IN (SELECT Note.category FROM Note WHERE Note.accountId = CategoryOptions.accountId); END;"); + + } + + private static void migrateAccounts(@NonNull SupportSQLiteDatabase db) { + final var cursor = db.query("SELECT * FROM ACCOUNTS", null); + final var values = new ContentValues(10); + + final int COLUMN_POSITION_ID = cursor.getColumnIndex("ID"); + final int COLUMN_POSITION_URL = cursor.getColumnIndex("URL"); + final int COLUMN_POSITION_USERNAME = cursor.getColumnIndex("USERNAME"); + final int COLUMN_POSITION_ACCOUNT_NAME = cursor.getColumnIndex("ACCOUNT_NAME"); + final int COLUMN_POSITION_ETAG = cursor.getColumnIndex("ETAG"); + final int COLUMN_POSITION_MODIFIED = cursor.getColumnIndex("MODIFIED"); + final int COLUMN_POSITION_API_VERSION = cursor.getColumnIndex("API_VERSION"); + final int COLUMN_POSITION_COLOR = cursor.getColumnIndex("COLOR"); + final int COLUMN_POSITION_TEXT_COLOR = cursor.getColumnIndex("TEXT_COLOR"); + final int COLUMN_POSITION_CAPABILITIES_ETAG = cursor.getColumnIndex("CAPABILITIES_ETAG"); + + while (cursor.moveToNext()) { + values.put("ID", cursor.getInt(COLUMN_POSITION_ID)); + values.put("URL", cursor.getString(COLUMN_POSITION_URL)); + values.put("USERNAME", cursor.getString(COLUMN_POSITION_USERNAME)); + values.put("ACCOUNTNAME", cursor.getString(COLUMN_POSITION_ACCOUNT_NAME)); + values.put("ETAG", cursor.getString(COLUMN_POSITION_ETAG)); + values.put("MODIFIED", cursor.getLong(COLUMN_POSITION_MODIFIED) * 1_000); + values.put("APIVERSION", cursor.getString(COLUMN_POSITION_API_VERSION)); + try { + values.put("COLOR", Color.parseColor(ColorUtil.INSTANCE.formatColorToParsableHexString(cursor.getString(COLUMN_POSITION_COLOR)))); + } catch (Exception e) { + e.printStackTrace(); + values.put("COLOR", -16743735); + } + try { + values.put("TEXTCOLOR", Color.parseColor(ColorUtil.INSTANCE.formatColorToParsableHexString(cursor.getString(COLUMN_POSITION_TEXT_COLOR)))); + } catch (Exception e) { + e.printStackTrace(); + values.put("TEXTCOLOR", -16777216); + } + values.put("CAPABILITIESETAG", cursor.getString(COLUMN_POSITION_CAPABILITIES_ETAG)); + db.insert("ACCOUNT", OnConflictStrategy.REPLACE, values); + } + cursor.close(); + } + + private static void migrateCategories(@NonNull SupportSQLiteDatabase db) { + final var cursor = db.query("SELECT * FROM CATEGORIES", null); + final var values = new ContentValues(3); + + final int COLUMN_POSITION_ACCOUNT_ID = cursor.getColumnIndex("CATEGORY_ACCOUNT_ID"); + final int COLUMN_POSITION_TITLE = cursor.getColumnIndex("CATEGORY_TITLE"); + final int COLUMN_POSITION_SORTING_METHOD = cursor.getColumnIndex("CATEGORY_SORTING_METHOD"); + + while (cursor.moveToNext()) { + values.put("ACCOUNTID", cursor.getInt(COLUMN_POSITION_ACCOUNT_ID)); + values.put("CATEGORY", cursor.getString(COLUMN_POSITION_TITLE)); + values.put("SORTINGMETHOD", cursor.getInt(COLUMN_POSITION_SORTING_METHOD)); + db.insert("CATEGORYOPTIONS", OnConflictStrategy.REPLACE, values); + } + cursor.close(); + } + + private static void migrateNotes(@NonNull SupportSQLiteDatabase db) { + final var cursor = db.query("SELECT NOTES.*, CATEGORIES.category_title as `CAT_TITLE` FROM NOTES LEFT JOIN CATEGORIES ON NOTES.category = CATEGORIES.category_id", null); + final var values = new ContentValues(12); + + final int COLUMN_POSITION_ID = cursor.getColumnIndex("ID"); + final int COLUMN_POSITION_REMOTEID = cursor.getColumnIndex("REMOTEID"); + final int COLUMN_POSITION_ACCOUNT_ID = cursor.getColumnIndex("ACCOUNT_ID"); + final int COLUMN_POSITION_STATUS = cursor.getColumnIndex("STATUS"); + final int COLUMN_POSITION_TITLE = cursor.getColumnIndex("TITLE"); + final int COLUMN_POSITION_MODIFIED = cursor.getColumnIndex("MODIFIED"); + final int COLUMN_POSITION_CONTENT = cursor.getColumnIndex("CONTENT"); + final int COLUMN_POSITION_FAVORITE = cursor.getColumnIndex("FAVORITE"); + final int COLUMN_POSITION_CAT_TITLE = cursor.getColumnIndex("CAT_TITLE"); + final int COLUMN_POSITION_ETAG = cursor.getColumnIndex("ETAG"); + final int COLUMN_POSITION_EXCERPT = cursor.getColumnIndex("EXCERPT"); + final int COLUMN_POSITION_SCROLL_Y = cursor.getColumnIndex("SCROLL_Y"); + + while (cursor.moveToNext()) { + values.put("ID", cursor.getInt(COLUMN_POSITION_ID)); + values.put("REMOTEID", cursor.getInt(COLUMN_POSITION_REMOTEID)); + values.put("ACCOUNTID", cursor.getInt(COLUMN_POSITION_ACCOUNT_ID)); + values.put("STATUS", cursor.getString(COLUMN_POSITION_STATUS)); + values.put("TITLE", cursor.getString(COLUMN_POSITION_TITLE)); + values.put("MODIFIED", cursor.getLong(COLUMN_POSITION_MODIFIED) * 1_000); + values.put("CONTENT", cursor.getString(COLUMN_POSITION_CONTENT)); + values.put("FAVORITE", cursor.getInt(COLUMN_POSITION_FAVORITE)); + values.put("CATEGORY", cursor.getString(COLUMN_POSITION_CAT_TITLE)); + values.put("ETAG", cursor.getString(COLUMN_POSITION_ETAG)); + values.put("EXCERPT", cursor.getString(COLUMN_POSITION_EXCERPT)); + values.put("SCROLLY", cursor.getString(COLUMN_POSITION_SCROLL_Y)); + db.insert("NOTE", OnConflictStrategy.REPLACE, values); + } + cursor.close(); + } + + private static void migrateNotesListWidgets(@NonNull SupportSQLiteDatabase db) { + final var cursor = db.query("SELECT WIDGET_NOTE_LISTS.*, CATEGORIES.category_title as `CATEGORY` FROM WIDGET_NOTE_LISTS LEFT JOIN CATEGORIES ON WIDGET_NOTE_LISTS.CATEGORY_ID = CATEGORIES.category_id", null); + final var values = new ContentValues(5); + + final int COLUMN_POSITION_ID = cursor.getColumnIndex("ID"); + final int COLUMN_POSITION_ACCOUNT_ID = cursor.getColumnIndex("ACCOUNT_ID"); + final int COLUMN_POSITION_CATEGORY = cursor.getColumnIndex("CATEGORY"); + final int COLUMN_POSITION_MODE = cursor.getColumnIndex("MODE"); + final int COLUMN_POSITION_THEME_MODE = cursor.getColumnIndex("THEME_MODE"); + + while (cursor.moveToNext()) { + values.put("ID", cursor.getInt(COLUMN_POSITION_ID)); + values.put("ACCOUNTID", cursor.getInt(COLUMN_POSITION_ACCOUNT_ID)); + values.put("CATEGORY", cursor.getString(COLUMN_POSITION_CATEGORY)); + values.put("MODE", cursor.getInt(COLUMN_POSITION_MODE)); + values.put("THEMEMODE", cursor.getInt(COLUMN_POSITION_THEME_MODE)); + db.insert("NOTESLISTWIDGETDATA", OnConflictStrategy.REPLACE, values); + } + cursor.close(); + } + + private static void migrateSingleNotesWidgets(@NonNull SupportSQLiteDatabase db) { + final var cursor = db.query("SELECT * FROM WIDGET_SINGLE_NOTES", null); + final var values = new ContentValues(4); + + final int COLUMN_POSITION_ID = cursor.getColumnIndex("ID"); + final int COLUMN_POSITION_ACCOUNT_ID = cursor.getColumnIndex("ACCOUNT_ID"); + final int COLUMN_POSITION_NOTE_ID = cursor.getColumnIndex("NOTE_ID"); + final int COLUMN_POSITION_THEME_MODE = cursor.getColumnIndex("THEME_MODE"); + + while (cursor.moveToNext()) { + values.put("ID", cursor.getInt(COLUMN_POSITION_ID)); + values.put("ACCOUNTID", cursor.getInt(COLUMN_POSITION_ACCOUNT_ID)); + values.put("NOTEID", cursor.getInt(COLUMN_POSITION_NOTE_ID)); + values.put("THEMEMODE", cursor.getInt(COLUMN_POSITION_THEME_MODE)); + db.insert("SINGLENOTEWIDGETDATA", OnConflictStrategy.REPLACE, values); + } + cursor.close(); + } + + private static void dropOldTables(@NonNull SupportSQLiteDatabase db) { + db.execSQL("DROP TABLE IF EXISTS WIDGET_SINGLE_NOTES"); + db.execSQL("DROP TABLE IF EXISTS WIDGET_NOTE_LISTS"); + db.execSQL("DROP TABLE IF EXISTS CATEGORIES"); + db.execSQL("DROP TABLE IF EXISTS NOTES"); + db.execSQL("DROP TABLE IF EXISTS ACCOUNTS"); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_21_22.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_21_22.java new file mode 100644 index 0000000000000000000000000000000000000000..c076d38e87a941699fad26aea63175513e5be6a6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_21_22.java @@ -0,0 +1,43 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import it.niedermann.owncloud.notes.persistence.SyncWorker; + +/** + * Enabling backgroundSync, set from {@link String} values to {@link Boolean} values + * https://github.com/stefan-niedermann/nextcloud-notes/issues/1168 + */ +public class Migration_21_22 extends Migration { + @NonNull + private final Context context; + + public Migration_21_22(@NonNull Context context) { + super(21, 22); + this.context = context; + } + + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + final var sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + final var editor = sharedPreferences.edit(); + if (sharedPreferences.contains("backgroundSync")) { + editor.remove("backgroundSync"); + if (sharedPreferences.getString("backgroundSync", "").equals("off")) { + editor.putBoolean("backgroundSync", false); + } else { + editor.putBoolean("backgroundSync", true); + SyncWorker.update(context, true); + } + } else { + SyncWorker.update(context, true); + } + editor.apply(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_22_23.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_22_23.java new file mode 100644 index 0000000000000000000000000000000000000000..1ba08a3c92f4b753b033080b60710e7080a01c98 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_22_23.java @@ -0,0 +1,101 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.content.ContentValues; +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.OnConflictStrategy; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Objects; +import java.util.stream.Collectors; + +import it.niedermann.owncloud.notes.persistence.ApiProvider; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.shared.model.ApiVersion; + +/** + * Add displayName property to {@link Account}. + *

+ * See: #1079 Show DisplayName instead of uid attribute for LDAP users + *

+ * Sanitizes the stored API versions in the database. + */ +public class Migration_22_23 extends Migration { + + public Migration_22_23() { + super(22, 23); + } + + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + addDisplayNameToAccounts(db); + sanitizeAccounts(db); + } + + private static void addDisplayNameToAccounts(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE Account ADD COLUMN displayName TEXT"); + } + + private static void sanitizeAccounts(@NonNull SupportSQLiteDatabase db) { + final var cursor = db.query("SELECT id, apiVersion FROM ACCOUNT", null); + final var values = new ContentValues(1); + + final int COLUMN_POSITION_ID = cursor.getColumnIndex("id"); + final int COLUMN_POSITION_API_VERSION = cursor.getColumnIndex("apiVersion"); + + while (cursor.moveToNext()) { + values.put("APIVERSION", sanitizeApiVersion(cursor.getString(COLUMN_POSITION_API_VERSION))); + db.update("ACCOUNT", OnConflictStrategy.REPLACE, values, "ID = ?", new String[]{String.valueOf(cursor.getLong(COLUMN_POSITION_ID))}); + } + cursor.close(); + ApiProvider.getInstance().invalidateAPICache(); + } + + @Nullable + public static String sanitizeApiVersion(@Nullable String raw) { + if (TextUtils.isEmpty(raw)) { + return null; + } + + JSONArray a; + try { + a = new JSONArray(raw); + } catch (JSONException e) { + try { + a = new JSONArray("[" + raw + "]"); + } catch (JSONException e1) { + return null; + } + } + + final var result = new ArrayList(); + for (int i = 0; i < a.length(); i++) { + try { + final var version = ApiVersion.of(a.getString(i)); + if (version.getMajor() != 0 || version.getMinor() != 0) { + result.add(version); + } + } catch (Exception ignored) { + } + } + if (result.isEmpty()) { + return null; + } + return "[" + + result + .stream() + .filter(Objects::nonNull) + .map(v -> v.getMajor() + "." + v.getMinor()) + .collect(Collectors.joining(",")) + + "]"; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_9_10.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_9_10.java new file mode 100644 index 0000000000000000000000000000000000000000..9b4b328fa3e8dc0863f9bc56c4c2fe5fe83634ab --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_9_10.java @@ -0,0 +1,35 @@ +package it.niedermann.owncloud.notes.persistence.migration; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import androidx.annotation.NonNull; +import androidx.room.OnConflictStrategy; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import it.niedermann.owncloud.notes.shared.util.NoteUtil; + +public class Migration_9_10 extends Migration { + + public Migration_9_10() { + super(9, 10); + } + + /** + * Adds a column to store excerpt instead of regenerating it each time + * https://github.com/stefan-niedermann/nextcloud-notes/issues/528 + */ + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE NOTES ADD COLUMN EXCERPT INTEGER NOT NULL DEFAULT ''"); + final var cursor = db.query("NOTES", new String[]{"ID", "CONTENT", "TITLE"}); + while (cursor.moveToNext()) { + final var values = new ContentValues(); + values.put("EXCERPT", NoteUtil.generateNoteExcerpt(cursor.getString(1), cursor.getString(2))); + db.update("NOTES", OnConflictStrategy.REPLACE, values, "ID" + " = ? ", new String[]{cursor.getString(0)}); + } + cursor.close(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java new file mode 100644 index 0000000000000000000000000000000000000000..d5ae7b494f4e8ea84279b81a017a8dd677a6f157 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java @@ -0,0 +1,67 @@ +package it.niedermann.owncloud.notes.persistence.sync; + +import android.graphics.Color; + +import androidx.annotation.ColorInt; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; + +import java.lang.reflect.Type; + +import it.niedermann.android.util.ColorUtil; +import it.niedermann.owncloud.notes.shared.model.Capabilities; + +/** + * Deserialization of OcsCapabilities to {@link Capabilities} is more complex than just mapping the JSON values to the Pojo properties. + * + *

    + *
  • The supported API versions of the Notes app are checked and nulled in case they are not present to maintain backward compatibility
  • + *
  • The color hex codes of the theming app are sanitized and mapped to {@link ColorInt}s
  • + *
+ */ +public class CapabilitiesDeserializer implements JsonDeserializer { + + private static final String CAPABILITIES = "capabilities"; + private static final String CAPABILITIES_NOTES = "notes"; + private static final String CAPABILITIES_NOTES_API_VERSION = "api_version"; + private static final String CAPABILITIES_THEMING = "theming"; + private static final String CAPABILITIES_THEMING_COLOR = "color"; + private static final String CAPABILITIES_THEMING_COLOR_TEXT = "color-text"; + + @Override + public Capabilities deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + final var response = new Capabilities(); + final var data = json.getAsJsonObject(); + if (data.has(CAPABILITIES)) { + final var capabilities = data.getAsJsonObject(CAPABILITIES); + if (capabilities.has(CAPABILITIES_NOTES)) { + final var notes = capabilities.getAsJsonObject(CAPABILITIES_NOTES); + if (notes.has(CAPABILITIES_NOTES_API_VERSION)) { + response.setApiVersion(notes.get(CAPABILITIES_NOTES_API_VERSION).toString()); + } + } + if (capabilities.has(CAPABILITIES_THEMING)) { + final var theming = capabilities.getAsJsonObject(CAPABILITIES_THEMING); + if (theming.has(CAPABILITIES_THEMING_COLOR)) { + try { + response.setColor(Color.parseColor(ColorUtil.INSTANCE.formatColorToParsableHexString(theming.get(CAPABILITIES_THEMING_COLOR).getAsString()))); + } catch (Exception e) { + e.printStackTrace(); + } + } + if (theming.has(CAPABILITIES_THEMING_COLOR_TEXT)) { + try { + response.setTextColor(Color.parseColor(ColorUtil.INSTANCE.formatColorToParsableHexString(theming.get(CAPABILITIES_THEMING_COLOR_TEXT).getAsString()))); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } + return response; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI.java new file mode 100644 index 0000000000000000000000000000000000000000..4f8bee926e1775d9cd9e83921be49baa9f971c71 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI.java @@ -0,0 +1,167 @@ +package it.niedermann.owncloud.notes.persistence.sync; + + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.gson.annotations.Expose; +import com.nextcloud.android.sso.api.NextcloudAPI; +import com.nextcloud.android.sso.api.ParsedResponse; + +import java.util.Calendar; +import java.util.List; +import java.util.stream.Collectors; + +import io.reactivex.Observable; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.ApiVersion; +import it.niedermann.owncloud.notes.shared.model.NotesSettings; +import retrofit2.Call; +import retrofit2.NextcloudRetrofitApiBuilder; + +/** + * Compatibility layer to support multiple API versions + */ +public class NotesAPI { + + private static final String TAG = NotesAPI.class.getSimpleName(); + + private static final String API_ENDPOINT_NOTES_1_0 = "/index.php/apps/notes/api/v1/"; + private static final String API_ENDPOINT_NOTES_0_2 = "/index.php/apps/notes/api/v0.2/"; + + @NonNull + private final ApiVersion usedApiVersion; + private final NotesAPI_0_2 notesAPI_0_2; + private final NotesAPI_1_0 notesAPI_1_0; + + public NotesAPI(@NonNull NextcloudAPI nextcloudAPI, @Nullable ApiVersion preferredApiVersion) { + if (preferredApiVersion == null) { + Log.i(TAG, "Using " + ApiVersion.API_VERSION_0_2 + ", preferredApiVersion is null"); + usedApiVersion = ApiVersion.API_VERSION_0_2; + notesAPI_0_2 = new NextcloudRetrofitApiBuilder(nextcloudAPI, API_ENDPOINT_NOTES_0_2).create(NotesAPI_0_2.class); + notesAPI_1_0 = null; + } else if (ApiVersion.API_VERSION_1_0.equals(preferredApiVersion)) { + Log.i(TAG, "Using " + ApiVersion.API_VERSION_1_0); + usedApiVersion = ApiVersion.API_VERSION_1_0; + notesAPI_0_2 = null; + notesAPI_1_0 = new NextcloudRetrofitApiBuilder(nextcloudAPI, API_ENDPOINT_NOTES_1_0).create(NotesAPI_1_0.class); + } else if (ApiVersion.API_VERSION_0_2.equals(preferredApiVersion)) { + Log.i(TAG, "Using " + ApiVersion.API_VERSION_0_2); + usedApiVersion = ApiVersion.API_VERSION_0_2; + notesAPI_0_2 = new NextcloudRetrofitApiBuilder(nextcloudAPI, API_ENDPOINT_NOTES_0_2).create(NotesAPI_0_2.class); + notesAPI_1_0 = null; + } else { + Log.w(TAG, "Unsupported API version " + preferredApiVersion + " - try using " + ApiVersion.API_VERSION_0_2); + usedApiVersion = ApiVersion.API_VERSION_0_2; + notesAPI_0_2 = new NextcloudRetrofitApiBuilder(nextcloudAPI, API_ENDPOINT_NOTES_0_2).create(NotesAPI_0_2.class); + notesAPI_1_0 = null; + } + } + + public Observable>> getNotes(@NonNull Calendar lastModified, String lastETag) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.getNotes(lastModified.getTimeInMillis() / 1_000, lastETag); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.getNotes(lastModified.getTimeInMillis() / 1_000, lastETag); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support getNotes()."); + } + } + + public Observable> getNotesIDs() { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.getNotesIDs().map(response -> response.getResponse().stream().map(Note::getRemoteId).collect(Collectors.toList())); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.getNotesIDs().map(response -> response.getResponse().stream().map(Note::getRemoteId).collect(Collectors.toList())); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support getNotesIDs()."); + } + } + + public Observable> getNote(long remoteId) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.getNote(remoteId); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.getNote(remoteId); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support getNote()."); + } + } + + public Call createNote(Note note) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.createNote(note); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.createNote(new Note_0_2(note)); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support createNote()."); + } + } + + public Call editNote(@NonNull Note note) { + final Long remoteId = note.getRemoteId(); + if (remoteId == null) { + throw new IllegalArgumentException("remoteId of a " + Note.class.getSimpleName() + " must not be null if this object is used for editing a remote note."); + } + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.editNote(note, remoteId); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.editNote(new Note_0_2(note), remoteId); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support editNote()."); + } + } + + public Call deleteNote(long noteId) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.deleteNote(noteId); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.deleteNote(noteId); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support createNote()."); + } + } + + + public Call getSettings() { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.getSettings(); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support getSettings()."); + } + } + + public Call putSettings(NotesSettings settings) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.putSettings(settings); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support putSettings()."); + } + } + + /** + * {@link ApiVersion#API_VERSION_0_2} didn't have a separate title property. + */ + static class Note_0_2 { + @Expose + public final String category; + @Expose + public final Calendar modified; + @Expose + public final String content; + @Expose + public final boolean favorite; + + private Note_0_2(Note note) { + if (note == null) { + throw new IllegalArgumentException(Note.class.getSimpleName() + " can not be converted to " + Note_0_2.class.getSimpleName() + " because it is null."); + } + this.category = note.getCategory(); + this.modified = note.getModified(); + this.content = note.getContent(); + this.favorite = note.getFavorite(); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_0_2.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_0_2.java new file mode 100644 index 0000000000000000000000000000000000000000..13d66c03dc882d49fb117ad1d5308f2f633f2300 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_0_2.java @@ -0,0 +1,42 @@ +package it.niedermann.owncloud.notes.persistence.sync; + + +import com.nextcloud.android.sso.api.ParsedResponse; + +import java.util.List; + +import io.reactivex.Observable; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.DELETE; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.POST; +import retrofit2.http.PUT; +import retrofit2.http.Path; +import retrofit2.http.Query; + +/** + * @link Notes API v0.2 + */ +public interface NotesAPI_0_2 { + + @GET("notes") + Observable>> getNotes(@Query("pruneBefore") long lastModified, @Header("If-None-Match") String lastETag); + + @GET("notes?exclude=etag,readonly,content,title,category,favorite,modified") + Observable>> getNotesIDs(); + + @POST("notes") + Call createNote(@Body NotesAPI.Note_0_2 note); + + @GET("notes/{remoteId}") + Observable> getNote(@Path("remoteId") long remoteId); + + @PUT("notes/{remoteId}") + Call editNote(@Body NotesAPI.Note_0_2 note, @Path("remoteId") long remoteId); + + @DELETE("notes/{remoteId}") + Call deleteNote(@Path("remoteId") long noteId); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_1_0.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_1_0.java new file mode 100644 index 0000000000000000000000000000000000000000..20f6f9a765b3a39951ca66a1d8866c84bbfd474d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_1_0.java @@ -0,0 +1,49 @@ +package it.niedermann.owncloud.notes.persistence.sync; + + +import com.nextcloud.android.sso.api.ParsedResponse; + +import java.util.List; + +import io.reactivex.Observable; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.NotesSettings; +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.DELETE; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.POST; +import retrofit2.http.PUT; +import retrofit2.http.Path; +import retrofit2.http.Query; + +/** + * @link Notes API v1 + */ +public interface NotesAPI_1_0 { + + @GET("notes") + Observable>> getNotes(@Query("pruneBefore") long lastModified, @Header("If-None-Match") String lastETag); + + @GET("notes?exclude=etag,readonly,content,title,category,favorite,modified") + Observable>> getNotesIDs(); + + @POST("notes") + Call createNote(@Body Note note); + + @GET("notes/{remoteId}") + Observable> getNote(@Path("remoteId") long remoteId); + + @PUT("notes/{remoteId}") + Call editNote(@Body Note note, @Path("remoteId") long remoteId); + + @DELETE("notes/{remoteId}") + Call deleteNote(@Path("remoteId") long noteId); + + @GET("settings") + Call getSettings(); + + @PUT("settings") + Call putSettings(@Body NotesSettings settings); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/OcsAPI.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/OcsAPI.java new file mode 100644 index 0000000000000000000000000000000000000000..e24ef7effce9a02fbd22f8383095dbe7c1d7e1c1 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/OcsAPI.java @@ -0,0 +1,25 @@ +package it.niedermann.owncloud.notes.persistence.sync; + + +import com.nextcloud.android.sso.api.ParsedResponse; + +import io.reactivex.Observable; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import it.niedermann.owncloud.notes.shared.model.OcsResponse; +import it.niedermann.owncloud.notes.shared.model.OcsUser; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.Path; + +/** + * @link Deck REST API + */ +public interface OcsAPI { + + @GET("capabilities?format=json") + Observable>> getCapabilities(@Header("If-None-Match") String eTag); + + @GET("users/{userId}?format=json") + Call> getUser(@Path("userId") String userId); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/preferences/DarkModeSetting.java b/app/src/main/java/it/niedermann/owncloud/notes/preferences/DarkModeSetting.java new file mode 100644 index 0000000000000000000000000000000000000000..1c7c10f3370db63c2303137077375a00de628257 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/preferences/DarkModeSetting.java @@ -0,0 +1,69 @@ +package it.niedermann.owncloud.notes.preferences; + +import androidx.appcompat.app.AppCompatDelegate; +import androidx.appcompat.app.AppCompatDelegate.NightMode; + +import java.util.NoSuchElementException; + +/** + * Possible values of the Dark Mode Setting. + *

+ * The Dark Mode Setting can be stored in {@link android.content.SharedPreferences} as String by using {@link DarkModeSetting#name()} and received via {@link DarkModeSetting#valueOf(String)}. + *

+ * Additionally, the equivalent {@link AppCompatDelegate}-Mode can be received via {@link #getModeId()}. To convert a {@link AppCompatDelegate}-Mode to a {@link DarkModeSetting}, use {@link #fromModeID(int)} + * + * @see AppCompatDelegate#MODE_NIGHT_YES + * @see AppCompatDelegate#MODE_NIGHT_NO + * @see AppCompatDelegate#MODE_NIGHT_FOLLOW_SYSTEM + */ +public enum DarkModeSetting { + // WARNING - The names of the constants must *NOT* be changed since they are used as keys in SharedPreferences + + /** + * Always use light mode. + */ + LIGHT(AppCompatDelegate.MODE_NIGHT_NO), + /** + * Always use dark mode. + */ + DARK(AppCompatDelegate.MODE_NIGHT_YES), + /** + * Follow the global system setting for dark mode. + */ + SYSTEM_DEFAULT(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + + @NightMode + private final int modeId; + + DarkModeSetting(int modeId) { + this.modeId = modeId; + } + + @NightMode + public int getModeId() { + return modeId; + } + + /** + * Returns the instance of {@link DarkModeSetting} that corresponds to the ModeID of {@link AppCompatDelegate} + *

+ * Possible ModeIDs are: + *

    + *
  • {@link AppCompatDelegate#MODE_NIGHT_YES}
  • + *
  • {@link AppCompatDelegate#MODE_NIGHT_NO}
  • + *
  • {@link AppCompatDelegate#MODE_NIGHT_FOLLOW_SYSTEM}
  • + *
+ * + * @param id One of the {@link AppCompatDelegate}-Night-Modes + * @return An instance of {@link DarkModeSetting} + */ + public static DarkModeSetting fromModeID(int id) { + for (final var value : DarkModeSetting.values()) { + if (value.modeId == id) { + return value; + } + } + + throw new NoSuchElementException("No NightMode with ID " + id + " found"); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..2dcd99ec1bd1efa9e6514fafe059ef0b2f5a3469 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesActivity.java @@ -0,0 +1,37 @@ +package it.niedermann.owncloud.notes.preferences; + +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProvider; + +import it.niedermann.owncloud.notes.LockedActivity; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ActivityPreferencesBinding; + +public class PreferencesActivity extends LockedActivity { + + private PreferencesViewModel viewModel; + private ActivityPreferencesBinding binding; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + viewModel = new ViewModelProvider(this).get(PreferencesViewModel.class); + viewModel.resultCode$.observe(this, this::setResult); + + binding = ActivityPreferencesBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + setSupportActionBar(binding.toolbar); + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container_view, new PreferencesFragment()) + .commit(); + } + + @Override + public void applyBrand(int mainColor, int textColor) { + applyBrandToPrimaryToolbar(binding.appBar, binding.toolbar); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..e09c1ed4c96839735c351b2f2558005c54c9597f --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesFragment.java @@ -0,0 +1,129 @@ +package it.niedermann.owncloud.notes.preferences; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; + +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; +import androidx.lifecycle.ViewModelProvider; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; + +import it.niedermann.owncloud.notes.NotesApplication; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.Branded; +import it.niedermann.owncloud.notes.branding.BrandedSwitchPreference; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.persistence.SyncWorker; +import it.niedermann.owncloud.notes.shared.util.DeviceCredentialUtil; + +public class PreferencesFragment extends PreferenceFragmentCompat implements Branded { + + private static final String TAG = PreferencesFragment.class.getSimpleName(); + + private PreferencesViewModel viewModel; + + private BrandedSwitchPreference fontPref; + private BrandedSwitchPreference lockPref; + private BrandedSwitchPreference wifiOnlyPref; + private BrandedSwitchPreference gridViewPref; + private BrandedSwitchPreference preventScreenCapturePref; + private BrandedSwitchPreference backgroundSyncPref; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences); + + viewModel = new ViewModelProvider(requireActivity()).get(PreferencesViewModel.class); + + fontPref = findPreference(getString(R.string.pref_key_font)); + + gridViewPref = findPreference(getString(R.string.pref_key_gridview)); + if (gridViewPref != null) { + gridViewPref.setOnPreferenceChangeListener((Preference preference, Object newValue) -> { + final Boolean gridView = (Boolean) newValue; + Log.v(TAG, "gridView: " + gridView); + viewModel.resultCode$.setValue(Activity.RESULT_OK); + NotesApplication.updateGridViewEnabled(gridView); + return true; + }); + } else { + Log.e(TAG, "Could not find preference with key: \"" + getString(R.string.pref_key_gridview) + "\""); + } + + preventScreenCapturePref = findPreference(getString(R.string.pref_key_prevent_screen_capture)); + if (preventScreenCapturePref == null) { + Log.e(TAG, "Could not find \"" + getString(R.string.pref_key_prevent_screen_capture) + "\"-preference."); + } + + lockPref = findPreference(getString(R.string.pref_key_lock)); + if (lockPref != null) { + if (!DeviceCredentialUtil.areCredentialsAvailable(requireContext())) { + lockPref.setVisible(false); + } else { + lockPref.setOnPreferenceChangeListener((preference, newValue) -> { + NotesApplication.setLockedPreference((Boolean) newValue); + return true; + }); + } + } else { + Log.e(TAG, "Could not find \"" + getString(R.string.pref_key_lock) + "\"-preference."); + } + + final var themePref = findPreference(getString(R.string.pref_key_theme)); + assert themePref != null; + themePref.setOnPreferenceChangeListener((preference, newValue) -> { + NotesApplication.setAppTheme(DarkModeSetting.valueOf((String) newValue)); + viewModel.resultCode$.setValue(Activity.RESULT_OK); + ActivityCompat.recreate(requireActivity()); + return true; + }); + + wifiOnlyPref = findPreference(getString(R.string.pref_key_wifi_only)); + assert wifiOnlyPref != null; + wifiOnlyPref.setOnPreferenceChangeListener((preference, newValue) -> { + Log.i(TAG, "syncOnWifiOnly: " + newValue); + return true; + }); + + backgroundSyncPref = findPreference(getString(R.string.pref_key_background_sync)); + assert backgroundSyncPref != null; + backgroundSyncPref.setOnPreferenceChangeListener((preference, newValue) -> { + Log.i(TAG, "backgroundSync: " + newValue); + SyncWorker.update(requireContext(), (Boolean) newValue); + return true; + }); + } + + + @Override + public void onStart() { + super.onStart(); + final var context = requireContext(); + @ColorInt final int mainColor = BrandingUtil.readBrandMainColor(context); + @ColorInt final int textColor = BrandingUtil.readBrandTextColor(context); + applyBrand(mainColor, textColor); + } + + /** + * Change color for backgroundSyncPref as well + * https://github.com/stefan-niedermann/nextcloud-deck/issues/531 + * + * @param mainColor color of main brand + * @param textColor color of text + */ + + @Override + public void applyBrand(int mainColor, int textColor) { + fontPref.applyBrand(mainColor, textColor); + lockPref.applyBrand(mainColor, textColor); + wifiOnlyPref.applyBrand(mainColor, textColor); + gridViewPref.applyBrand(mainColor, textColor); + preventScreenCapturePref.applyBrand(mainColor, textColor); + backgroundSyncPref.applyBrand(mainColor, textColor); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesViewModel.java new file mode 100644 index 0000000000000000000000000000000000000000..dfde6c92d65300a931255e3b1d3f7d6364b4756a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesViewModel.java @@ -0,0 +1,9 @@ +package it.niedermann.owncloud.notes.preferences; + +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +public class PreferencesViewModel extends ViewModel { + + public final MutableLiveData resultCode$ = new MutableLiveData<>(); +} diff --git a/app/src/main/java/foundation/e/notes/android/quicksettings/NewNoteTileService.java b/app/src/main/java/it/niedermann/owncloud/notes/quicksettings/NewNoteTileService.java similarity index 75% rename from app/src/main/java/foundation/e/notes/android/quicksettings/NewNoteTileService.java rename to app/src/main/java/it/niedermann/owncloud/notes/quicksettings/NewNoteTileService.java index 9c17120e7edbde00543a35b65e6c130f4b4ee7c1..d660f182eac62dc9c61185897510f10922c435af 100644 --- a/app/src/main/java/foundation/e/notes/android/quicksettings/NewNoteTileService.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/quicksettings/NewNoteTileService.java @@ -1,4 +1,4 @@ -package foundation.e.notes.android.quicksettings; +package it.niedermann.owncloud.notes.quicksettings; import android.annotation.TargetApi; import android.content.Intent; @@ -6,8 +6,7 @@ import android.os.Build; import android.service.quicksettings.Tile; import android.service.quicksettings.TileService; -import foundation.e.notes.android.activity.EditNoteActivity; -import foundation.e.notes.android.activity.EditNoteActivity; +import it.niedermann.owncloud.notes.edit.EditNoteActivity; /** * This {@link TileService} adds a quick settings tile that leads to the new note view. @@ -17,16 +16,15 @@ public class NewNoteTileService extends TileService { @Override public void onStartListening() { - Tile tile = getQsTile(); + final var tile = getQsTile(); tile.setState(Tile.STATE_ACTIVE); - tile.updateTile(); } @Override public void onClick() { // create new note intent - final Intent newNoteIntent = new Intent(getApplicationContext(), EditNoteActivity.class); + final var newNoteIntent = new Intent(getApplicationContext(), EditNoteActivity.class); // ensure it won't open twice if already running newNoteIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); // ask to unlock the screen if locked, then start new note intent diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/account/AccountChooserAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/account/AccountChooserAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..6804ee6214b532d4f47476a294c3e2f6b7121f90 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/account/AccountChooserAdapter.java @@ -0,0 +1,44 @@ +package it.niedermann.owncloud.notes.shared.account; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +import it.niedermann.owncloud.notes.databinding.ItemAccountChooseBinding; +import it.niedermann.owncloud.notes.persistence.entity.Account; + +public class AccountChooserAdapter extends RecyclerView.Adapter { + + @NonNull + private final List localAccounts; + @NonNull + private final Consumer targetAccountConsumer; + + public AccountChooserAdapter(@NonNull List localAccounts, @NonNull Consumer targetAccountConsumer) { + super(); + this.localAccounts = localAccounts; + this.targetAccountConsumer = targetAccountConsumer; + } + + @NonNull + @Override + public AccountChooserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new AccountChooserViewHolder(ItemAccountChooseBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull AccountChooserViewHolder holder, int position) { + holder.bind(localAccounts.get(position), targetAccountConsumer); + } + + @Override + public int getItemCount() { + return localAccounts.size(); + } + +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/account/AccountChooserViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/account/AccountChooserViewHolder.java new file mode 100644 index 0000000000000000000000000000000000000000..5d3d296328e37cb02a170ae2c9ae505007bd2092 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/account/AccountChooserViewHolder.java @@ -0,0 +1,37 @@ +package it.niedermann.owncloud.notes.shared.account; + +import android.net.Uri; + +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; + +import it.niedermann.nextcloud.sso.glide.SingleSignOnUrl; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ItemAccountChooseBinding; +import it.niedermann.owncloud.notes.persistence.entity.Account; + +public class AccountChooserViewHolder extends RecyclerView.ViewHolder { + private final ItemAccountChooseBinding binding; + + protected AccountChooserViewHolder(ItemAccountChooseBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(Account localAccount, Consumer targetAccountConsumer) { + Glide + .with(binding.accountItemAvatar.getContext()) + .load(new SingleSignOnUrl(localAccount.getAccountName(), localAccount.getUrl() + "/index.php/avatar/" + Uri.encode(localAccount.getUserName()) + "/64")) + .placeholder(R.drawable.ic_account_circle_grey_24dp) + .error(R.drawable.ic_account_circle_grey_24dp) + .apply(RequestOptions.circleCropTransform()) + .into(binding.accountItemAvatar); + + binding.accountLayout.setOnClickListener((v) -> targetAccountConsumer.accept(localAccount)); + binding.accountName.setText(localAccount.getDisplayName()); + binding.accountHost.setText(Uri.parse(localAccount.getUrl()).getHost()); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ApiVersion.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ApiVersion.java new file mode 100644 index 0000000000000000000000000000000000000000..7ac09d66fae6f5af33b5dc4b7190141c1dc80bd4 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ApiVersion.java @@ -0,0 +1,112 @@ +package it.niedermann.owncloud.notes.shared.model; + + +import androidx.annotation.NonNull; + +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@SuppressWarnings("WeakerAccess") +public class ApiVersion implements Comparable { + private static final Pattern NUMBER_EXTRACTION_PATTERN = Pattern.compile("[0-9]+"); + private static final ApiVersion VERSION_1_2 = new ApiVersion("1.2", 1, 2); + + public static final ApiVersion API_VERSION_0_2 = new ApiVersion(0, 2); + public static final ApiVersion API_VERSION_1_0 = new ApiVersion(1, 0); + + public static final ApiVersion[] SUPPORTED_API_VERSIONS = new ApiVersion[]{ + API_VERSION_1_0, + API_VERSION_0_2 + }; + + private String originalVersion = "?"; + private final int major; + private final int minor; + + public ApiVersion(String originalVersion, int major, int minor) { + this(major, minor); + this.originalVersion = originalVersion; + } + + public ApiVersion(int major, int minor) { + this.major = major; + this.minor = minor; + } + + public int getMajor() { + return major; + } + + public int getMinor() { + return minor; + } + + public static ApiVersion of(String versionString) { + int major = 0, minor = 0; + if (versionString != null) { + String[] split = versionString.split("\\."); + if (split.length > 0) { + major = extractNumber(split[0]); + if (split.length > 1) { + minor = extractNumber(split[1]); + } + } + } + return new ApiVersion(versionString, major, minor); + } + + private static int extractNumber(String containsNumbers) { + final var matcher = NUMBER_EXTRACTION_PATTERN.matcher(containsNumbers); + if (matcher.find()) { + return Integer.parseInt(matcher.group()); + } + return 0; + } + + /** + * @param compare another version object + * @return -1 if the compared major version is higher than the current major version + * 0 if the compared major version is equal to the current major version + * 1 if the compared major version is lower than the current major version + */ + @Override + public int compareTo(@NonNull ApiVersion compare) { + if (compare.getMajor() > getMajor()) { + return -1; + } else if (compare.getMajor() < getMajor()) { + return 1; + } + return 0; + } + + public boolean supportsSettings() { + return getMajor() >= 1 && getMinor() >= 2; + } + + /** + * Checks only the {@link #major} version. + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ApiVersion that = (ApiVersion) o; + return compareTo(that) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(major, minor); + } + + @NonNull + @Override + public String toString() { + return "Version{" + + "originalVersion='" + originalVersion + '\'' + + ", major=" + major + + ", minor=" + minor + + '}'; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java new file mode 100644 index 0000000000000000000000000000000000000000..06bd867d34ab3072ccbb9f9d9677b9b7f0a76018 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java @@ -0,0 +1,62 @@ +package it.niedermann.owncloud.notes.shared.model; + +import android.graphics.Color; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class Capabilities { + + private String apiVersion = null; + @ColorInt + private int color = -16743735; // #0082C9 + @ColorInt + private int textColor = Color.WHITE; + @Nullable + private String eTag; + + public void setApiVersion(String apiVersion) { + this.apiVersion = apiVersion; + } + + public String getApiVersion() { + return apiVersion; + } + + @Nullable + public String getETag() { + return eTag; + } + + public void setETag(@Nullable String eTag) { + this.eTag = eTag; + } + + public int getColor() { + return color; + } + + public void setColor(@ColorInt int color) { + this.color = color; + } + + public int getTextColor() { + return textColor; + } + + public void setTextColor(@ColorInt int textColor) { + this.textColor = textColor; + } + + @NonNull + @Override + public String toString() { + return "Capabilities{" + + "apiVersion='" + apiVersion + '\'' + + ", color=" + color + + ", textColor=" + textColor + + ", eTag='" + eTag + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/CategorySortingMethod.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/CategorySortingMethod.java new file mode 100644 index 0000000000000000000000000000000000000000..b743fcaba9bba33ff156fde35f82becc4b31ee7a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/CategorySortingMethod.java @@ -0,0 +1,48 @@ +package it.niedermann.owncloud.notes.shared.model; + +public enum CategorySortingMethod { + SORT_MODIFIED_DESC(0, "MODIFIED DESC"), + SORT_LEXICOGRAPHICAL_ASC(1, "TITLE COLLATE NOCASE ASC"); + + private final int id; + private final String title; // sorting method OrderBy for SQL + + /** + * Constructor + * @param title given sorting method OrderBy + */ + CategorySortingMethod(int id, String title) { + this.id = id; + this.title = title; + } + + /** + * Retrieve the sorting method id represented in database + * @return the sorting method id for the enum item + */ + public int getId() { + return this.id; + } + + /** + * Retrieve the sorting method order for SQL + * @return the sorting method order for the enum item + */ + public String getTitle() { + return this.title; + } + + /** + * Retrieve the corresponding enum value with given the index (ordinal) + * @param id the id of the corresponding enum value stored in DB + * @return the corresponding enum item with the index (ordinal) + */ + public static CategorySortingMethod findById(int id) { + for (final var csm : values()) { + if (csm.getId() == id) { + return csm; + } + } + return SORT_MODIFIED_DESC; + } +} diff --git a/app/src/main/java/foundation/e/notes/model/DBStatus.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/DBStatus.java similarity index 59% rename from app/src/main/java/foundation/e/notes/model/DBStatus.java rename to app/src/main/java/it/niedermann/owncloud/notes/shared/model/DBStatus.java index 5ce7235d237c5681c8cccd87c976a47fd8eb0db3..ef06a277d2661c6355ad946f2c3e95250d2cee5b 100644 --- a/app/src/main/java/foundation/e/notes/model/DBStatus.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/DBStatus.java @@ -1,4 +1,6 @@ -package foundation.e.notes.model; +package it.niedermann.owncloud.notes.shared.model; + +import androidx.annotation.NonNull; /** * Helps to distinguish between different local change types for Server Synchronization. @@ -11,12 +13,6 @@ public enum DBStatus { */ VOID(""), - /** - * LOCAL_CREATED is not used anymore, since a newly created note has REMOTE_ID=0 - */ - @Deprecated - LOCAL_CREATED("LOCAL_CREATED"), - /** * LOCAL_EDITED means that a Note was created and/or changed since the last successful synchronization. * If it was newly created, then REMOTE_ID is 0 @@ -30,27 +26,15 @@ public enum DBStatus { */ LOCAL_DELETED("LOCAL_DELETED"); + @NonNull private final String title; + @NonNull public String getTitle() { return title; } - DBStatus(String title) { + DBStatus(@NonNull String title) { this.title = title; } - - /** - * Parse a String an get the appropriate DBStatus enum element. - * - * @param str The String containing the DBStatus identifier. Must not null. - * @return The DBStatus fitting to the String. - */ - public static DBStatus parse(String str) { - if (str.isEmpty()) { - return DBStatus.VOID; - } else { - return DBStatus.valueOf(str); - } - } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ENavigationCategoryType.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ENavigationCategoryType.java new file mode 100644 index 0000000000000000000000000000000000000000..871a12d43de6d4c25ce2518d38032ffe9e1fef42 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ENavigationCategoryType.java @@ -0,0 +1,10 @@ +package it.niedermann.owncloud.notes.shared.model; + +import java.io.Serializable; + +public enum ENavigationCategoryType implements Serializable { + RECENT, + FAVORITES, + UNCATEGORIZED, + DEFAULT_CATEGORY +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/IResponseCallback.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/IResponseCallback.java new file mode 100644 index 0000000000000000000000000000000000000000..707931b0811665c89688f38a72a644faf554214d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/IResponseCallback.java @@ -0,0 +1,9 @@ +package it.niedermann.owncloud.notes.shared.model; + +import androidx.annotation.NonNull; + +public interface IResponseCallback { + void onSuccess(T result); + + void onError(@NonNull Throwable t); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ISyncCallback.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ISyncCallback.java new file mode 100644 index 0000000000000000000000000000000000000000..b5b9093c39eaeb80cc9f6a711f20c3aa70048314 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ISyncCallback.java @@ -0,0 +1,13 @@ +package it.niedermann.owncloud.notes.shared.model; + +/** + * Callback + * Created by stefan on 01.10.15. + */ +public interface ISyncCallback { + void onFinish(); + + default void onScheduled() { + + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ImportStatus.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ImportStatus.java new file mode 100644 index 0000000000000000000000000000000000000000..7ae189efdbe06e46eecc5a9d9e9e3126d2f40004 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ImportStatus.java @@ -0,0 +1,10 @@ +package it.niedermann.owncloud.notes.shared.model; + +import java.util.Collection; +import java.util.LinkedList; + +public class ImportStatus { + public int count = 0; + public int total = 0; + public final Collection warnings = new LinkedList<>(); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Item.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Item.java new file mode 100644 index 0000000000000000000000000000000000000000..234acb9109e516bd9e666695822119644ea5d197 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Item.java @@ -0,0 +1,7 @@ +package it.niedermann.owncloud.notes.shared.model; + +public interface Item { + default boolean isSection() { + return false; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NavigationCategory.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NavigationCategory.java new file mode 100644 index 0000000000000000000000000000000000000000..52a3a11722979befb0b1242aaa9ab2630a6847d9 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NavigationCategory.java @@ -0,0 +1,76 @@ +package it.niedermann.owncloud.notes.shared.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.Serializable; + +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.DEFAULT_CATEGORY; + +public class NavigationCategory implements Serializable { + + @NonNull + private final ENavigationCategoryType type; + @Nullable + private final String category; + private final long accountId; + + public NavigationCategory(@NonNull ENavigationCategoryType type) { + if (type == DEFAULT_CATEGORY) { + throw new IllegalArgumentException("If you want to provide a " + DEFAULT_CATEGORY + ", call the constructor with an accountId and category as arguments"); + } + this.type = type; + this.category = null; + this.accountId = Long.MIN_VALUE; + } + + public NavigationCategory(long accountId, @Nullable String category) { + this.type = DEFAULT_CATEGORY; + this.category = category; + this.accountId = accountId; + } + + @NonNull + public ENavigationCategoryType getType() { + return type; + } + + public long getAccountId() { + return accountId; + } + + @Nullable + public String getCategory() { + return category; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof NavigationCategory)) return false; + + NavigationCategory that = (NavigationCategory) o; + + if (accountId != that.accountId) return false; + if (type != that.type) return false; + return category != null ? category.equals(that.category) : that.category == null; + } + + @Override + public int hashCode() { + int result = type.hashCode(); + result = 31 * result + (category != null ? category.hashCode() : 0); + result = 31 * result + (int) (accountId ^ (accountId >>> 32)); + return result; + } + + @NonNull + @Override + public String toString() { + return "NavigationCategory{" + + "type=" + type + + ", category='" + category + '\'' + + ", accountId=" + accountId + + '}'; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NoteClickListener.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NoteClickListener.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c005b681cb5444ce6ed88540b5e9dabd037d8 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NoteClickListener.java @@ -0,0 +1,9 @@ +package it.niedermann.owncloud.notes.shared.model; + +import android.view.View; + +public interface NoteClickListener { + void onNoteClick(int position, View v); + + void onNoteFavoriteClick(int position, View v); +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NotesSettings.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NotesSettings.java new file mode 100644 index 0000000000000000000000000000000000000000..bffb23441caa17158eef6b1229d2727542486c34 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NotesSettings.java @@ -0,0 +1,38 @@ +package it.niedermann.owncloud.notes.shared.model; + +import androidx.annotation.Nullable; + +import com.google.gson.annotations.Expose; + +public class NotesSettings { + + @Expose + @Nullable + private String notesPath; + @Expose + @Nullable + private String fileSuffix; + + public NotesSettings(@Nullable String notesPath, @Nullable String fileSuffix) { + this.notesPath = notesPath; + this.fileSuffix = fileSuffix; + } + + @Nullable + public String getNotesPath() { + return notesPath; + } + + public void setNotesPath(@Nullable String notesPath) { + this.notesPath = notesPath; + } + + @Nullable + public String getFileSuffix() { + return fileSuffix; + } + + public void setFileSuffix(@Nullable String fileSuffix) { + this.fileSuffix = fileSuffix; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/OcsResponse.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/OcsResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..0fea9a92d28fe5b367b44131ebddc3f3da0b13a9 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/OcsResponse.java @@ -0,0 +1,30 @@ +package it.niedermann.owncloud.notes.shared.model; + +import com.google.gson.annotations.Expose; + +/** + * OpenCollaborationServices + * + * @param defines the payload of this {@link OcsResponse}. + */ +public class OcsResponse { + + @Expose + public OcsWrapper ocs; + + public static class OcsWrapper { + @Expose + public OcsMeta meta; + @Expose + public T data; + } + + public static class OcsMeta { + @Expose + public String status; + @Expose + public int statuscode; + @Expose + public String message; + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/OcsUser.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/OcsUser.java new file mode 100644 index 0000000000000000000000000000000000000000..9248abdfda81adabe6e4cb577bbd54dc9b87a26c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/OcsUser.java @@ -0,0 +1,16 @@ +package it.niedermann.owncloud.notes.shared.model; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +/** + * Equivalent of an OcsUser + */ +public class OcsUser { + @Expose + @SerializedName("id") + public String userId; + @Expose + @SerializedName("displayname") + public String displayName; +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/SyncResultStatus.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/SyncResultStatus.java new file mode 100644 index 0000000000000000000000000000000000000000..41ba850abc6a0521f4b3dca86854a115ca4b542f --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/SyncResultStatus.java @@ -0,0 +1,13 @@ +package it.niedermann.owncloud.notes.shared.model; + +public class SyncResultStatus { + public boolean pullSuccessful = true; + public boolean pushSuccessful = true; + + public static final SyncResultStatus FAILED = new SyncResultStatus(); + + static { + FAILED.pullSuccessful = false; + FAILED.pushSuccessful = false; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ApiVersionUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ApiVersionUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..e6444658d7d0785de6546ab17350c51f85f4863a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ApiVersionUtil.java @@ -0,0 +1,105 @@ +package it.niedermann.owncloud.notes.shared.util; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Objects; +import java.util.stream.Collectors; + +import it.niedermann.owncloud.notes.shared.model.ApiVersion; + +public class ApiVersionUtil { + + private ApiVersionUtil() { + throw new UnsupportedOperationException("Do not instantiate this util class."); + } + + /** + * @return a {@link Collection} of all valid {@link ApiVersion}s which have been found in {@param raw}. + */ + @NonNull + public static Collection parse(@Nullable String raw) { + if (TextUtils.isEmpty(raw)) { + return Collections.emptyList(); + } + + JSONArray a; + try { + a = new JSONArray(raw); + } catch (JSONException e) { + try { + a = new JSONArray("[" + raw + "]"); + } catch (JSONException e1) { + return Collections.emptyList(); + } + } + + final var result = new ArrayList(); + for (int i = 0; i < a.length(); i++) { + try { + final var version = ApiVersion.of(a.getString(i)); + if (version.getMajor() != 0 || version.getMinor() != 0) { + result.add(version); + } + } catch (Exception ignored) { + } + } + return result; + } + + /** + * @return a serialized {@link String} of the given {@param apiVersions} or null. + */ + @Nullable + public static String serialize(@Nullable Collection apiVersions) { + if (apiVersions == null || apiVersions.isEmpty()) { + return null; + } + return "[" + + apiVersions + .stream() + .filter(Objects::nonNull) + .map(v -> v.getMajor() + "." + v.getMinor()) + .collect(Collectors.joining(",")) + + "]"; + } + + @Nullable + public static String sanitize(@Nullable String raw) { + return serialize(parse(raw)); + } + + /** + * @return the highest {@link ApiVersion} that is supported by the server according to {@param raw}, + * whose major version is also supported by this app (see {@link ApiVersion#SUPPORTED_API_VERSIONS}). + * Returns null if no better version could be found. + */ + @Nullable + public static ApiVersion getPreferredApiVersion(@Nullable String raw) { + return parse(raw) + .stream() + .filter(version -> Arrays.asList(ApiVersion.SUPPORTED_API_VERSIONS).contains(version)) + .max((o1, o2) -> { + if (o2.getMajor() > o1.getMajor()) { + return -1; + } else if (o2.getMajor() < o1.getMajor()) { + return 1; + } else if (o2.getMinor() > o1.getMinor()) { + return -1; + } else if (o2.getMinor() < o1.getMinor()) { + return 1; + } + return 0; + }) + .orElse(null); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/CustomAppGlideModule.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/CustomAppGlideModule.java new file mode 100644 index 0000000000000000000000000000000000000000..35625119dd5e4309bb7d93f8692797340eeaae56 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/CustomAppGlideModule.java @@ -0,0 +1,37 @@ +package it.niedermann.owncloud.notes.shared.util; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.UiThread; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.Registry; +import com.bumptech.glide.annotation.GlideModule; +import com.bumptech.glide.module.AppGlideModule; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@GlideModule +public class CustomAppGlideModule extends AppGlideModule { + + private static final String TAG = CustomAppGlideModule.class.getSimpleName(); + private static final ExecutorService clearDiskCacheExecutor = Executors.newSingleThreadExecutor(); + + @Override + public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { + super.registerComponents(context, glide, registry); + } + + @UiThread + public static void clearCache(@NonNull Context context) { + Log.i(TAG, "Clearing Glide memory cache"); + Glide.get(context).clearMemory(); + clearDiskCacheExecutor.submit(() -> { + Log.i(TAG, "Clearing Glide disk cache"); + Glide.get(context.getApplicationContext()).clearDiskCache(); + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DeviceCredentialUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DeviceCredentialUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..d0a60fcf4d10185491539deb2a9759a06eb2f52a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DeviceCredentialUtil.java @@ -0,0 +1,28 @@ +package it.niedermann.owncloud.notes.shared.util; + +import android.app.KeyguardManager; +import android.content.Context; +import android.util.Log; + +/** + * Utility class with methods for handling device credentials. + */ +public class DeviceCredentialUtil { + + private static final String TAG = DeviceCredentialUtil.class.getSimpleName(); + + private DeviceCredentialUtil() { + throw new UnsupportedOperationException("Do not instantiate this util class."); + } + + public static boolean areCredentialsAvailable(Context context) { + final var keyguardManager = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); + + if (keyguardManager != null) { + return keyguardManager.isKeyguardSecure(); + } else { + Log.e(TAG, "Keyguard manager is null"); + return false; + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..abba8e002136302ef169f1aead1a1a4ce4813398 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java @@ -0,0 +1,105 @@ +package it.niedermann.owncloud.notes.shared.util; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Rect; +import android.os.Build; +import android.util.TypedValue; +import android.view.View; +import android.view.WindowInsets; + +import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; +import it.niedermann.owncloud.notes.main.navigation.NavigationItem; +import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; + +public class DisplayUtils { + + private static final Map> SPECIAL_CATEGORY_REPLACEMENTS = Map.of( + R.drawable.ic_library_music_grey600_24dp, singletonList(R.string.category_music), + R.drawable.ic_local_movies_grey600_24dp, asList(R.string.category_movies, R.string.category_movie), + R.drawable.ic_work_grey600_24dp, singletonList(R.string.category_work), + R.drawable.ic_baseline_checklist_24, asList(R.string.category_todo, R.string.category_todos, R.string.category_tasks, R.string.category_checklists), + R.drawable.ic_baseline_fastfood_24, asList(R.string.category_recipe, R.string.category_recipes, R.string.category_restaurant, R.string.category_restaurants, R.string.category_food, R.string.category_bake), + R.drawable.ic_baseline_vpn_key_24, asList(R.string.category_key, R.string.category_keys, R.string.category_password, R.string.category_passwords, R.string.category_credentials), + R.drawable.ic_baseline_games_24, asList(R.string.category_game, R.string.category_games, R.string.category_play), + R.drawable.ic_baseline_card_giftcard_24, asList(R.string.category_gift, R.string.category_gifts, R.string.category_present, R.string.category_presents) + ); + + private DisplayUtils() { + throw new UnsupportedOperationException("Do not instantiate this util class."); + } + + public static List convertToCategoryNavigationItem(@NonNull Context context, @NonNull Collection counter) { + return counter.stream() + .map(ctr -> convertToCategoryNavigationItem(context, ctr)) + .collect(Collectors.toList()); + } + + public static NavigationItem.CategoryNavigationItem convertToCategoryNavigationItem(@NonNull Context context, @NonNull CategoryWithNotesCount counter) { + final var res = context.getResources(); + final var englishRes = getEnglishResources(context); + final String category = counter.getCategory().replaceAll("\\s+", ""); + int icon = NavigationAdapter.ICON_FOLDER; + + for (Map.Entry> replacement : SPECIAL_CATEGORY_REPLACEMENTS.entrySet()) { + if (Stream.concat( + replacement.getValue().stream().map(res::getString), + replacement.getValue().stream().map(englishRes::getString) + ).map(str -> str.replaceAll("\\s+", "")) + .anyMatch(r -> r.equalsIgnoreCase(category))) { + icon = replacement.getKey(); + break; + } + } + return new NavigationItem.CategoryNavigationItem("category:" + counter.getCategory(), counter.getCategory(), counter.getTotalNotes(), icon, counter.getAccountId(), counter.getCategory()); + } + + @NonNull + private static Resources getEnglishResources(@NonNull Context context) { + final var config = new Configuration(context.getResources().getConfiguration()); + config.setLocale(new Locale("en")); + return context.createConfigurationContext(config).getResources(); + } + + /** + * Detect if the soft keyboard is open. + * On API prior to 30 we fall back to workaround which might be less reliable + * + * @param parentView View + * @return keyboardVisibility Boolean + */ + @SuppressLint("WrongConstant") + public static boolean isSoftKeyboardVisible(@NonNull View parentView) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + final var insets = ViewCompat.getRootWindowInsets(parentView); + if (insets != null) { + return insets.isVisible(WindowInsets.Type.ime()); + } + } + + //Arbitrary keyboard height + final int defaultKeyboardHeightDP = 100; + final int EstimatedKeyboardDP = defaultKeyboardHeightDP + 48; + final var rect = new Rect(); + final int estimatedKeyboardHeight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, EstimatedKeyboardDP, parentView.getResources().getDisplayMetrics()); + parentView.getWindowVisibleDisplayFrame(rect); + final int heightDiff = parentView.getRootView().getHeight() - (rect.bottom - rect.top); + return heightDiff >= estimatedKeyboardHeight; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..aae6c472e695b8dc1c4fbe98e08641fb34092c69 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java @@ -0,0 +1,146 @@ +package it.niedermann.owncloud.notes.shared.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import it.niedermann.owncloud.notes.R; + +import static it.niedermann.android.markdown.MarkdownUtil.removeMarkdown; +import static it.niedermann.android.markdown.MarkdownUtil.replaceCheckboxesWithEmojis; + +/** + * Provides basic functionality for Note operations. + * Created by stefan on 06.10.15. + */ +@SuppressWarnings("WeakerAccess") +public class NoteUtil { + + public static final String EXCERPT_LINE_SEPARATOR = " "; + + private NoteUtil() { + throw new UnsupportedOperationException("Do not instantiate this util class."); + } + + /** + * Checks if a line is empty. + *
+     * " "    -> empty
+     * "\n"   -> empty
+     * "\n "  -> empty
+     * " \n"  -> empty
+     * " \n " -> empty
+     * 
+ * + * @param line String - a single Line which ends with \n + * @return boolean isEmpty + */ + public static boolean isEmptyLine(@Nullable String line) { + return removeMarkdown(line).trim().length() == 0; + } + + /** + * Truncates a string to a desired maximum length. + * Like String.substring(int,int), but throw no exception if desired length is longer than the string. + * + * @param str String to truncate + * @param len Maximum length of the resulting string + * @return truncated string + */ + @NonNull + private static String truncateString(@NonNull String str, @SuppressWarnings("SameParameterValue") int len) { + return str.substring(0, Math.min(len, str.length())); + } + + /** + * Generates an excerpt of a content that does not match the given title + * + * @param content {@link String} + * @param title {@link String} In case the content starts with the title, the excerpt should be generated starting from this point + * @return excerpt String + */ + @NonNull + public static String generateNoteExcerpt(@NonNull String content, @Nullable String title) { + content = removeMarkdown(replaceCheckboxesWithEmojis(content.trim())); + if (TextUtils.isEmpty(content)) { + return ""; + } + if (!TextUtils.isEmpty(title)) { + assert title != null; + final String trimmedTitle = removeMarkdown(replaceCheckboxesWithEmojis(title.trim())); + if (content.startsWith(trimmedTitle)) { + content = content.substring(trimmedTitle.length()); + } + } + return truncateString(content.trim(), 200).replace("\n", EXCERPT_LINE_SEPARATOR); + } + + @NonNull + public static String generateNonEmptyNoteTitle(@NonNull String content, Context context) { + String title = generateNoteTitle(content); + if (title.isEmpty()) { + title = context.getString(R.string.action_create); + } + return title; + } + + /** + * Generates a title of a content String (reads fist linew which is not empty) + * + * @param content String + * @return excerpt String + */ + @NonNull + public static String generateNoteTitle(@NonNull String content) { + return getLineWithoutMarkdown(content, 0); + } + + /** + * Reads the requested line and strips all Markdown. If line is empty, it will go ahead to find the next not-empty line. + * + * @param content String + * @param lineNumber int + * @return lineContent String + */ + @NonNull + public static String getLineWithoutMarkdown(@NonNull String content, int lineNumber) { + String line = ""; + if (content.contains("\n")) { + String[] lines = content.split("\n"); + int currentLine = lineNumber; + while (currentLine < lines.length && NoteUtil.isEmptyLine(lines[currentLine])) { + currentLine++; + } + if (currentLine < lines.length) { + line = removeMarkdown(lines[currentLine]); + } + } else { + line = removeMarkdown(content); + } + return line; + } + + @NonNull + public static String extendCategory(@NonNull String category) { + return category.replace("/", " / "); + } + + @SuppressWarnings("WeakerAccess") //PMD... + public static float getFontSizeFromPreferences(@NonNull Context context, @NonNull SharedPreferences sp) { + final String prefValueSmall = context.getString(R.string.pref_value_font_size_small); + final String prefValueMedium = context.getString(R.string.pref_value_font_size_medium); + // final String prefValueLarge = getString(R.string.pref_value_font_size_large); + String fontSize = sp.getString(context.getString(R.string.pref_key_font_size), prefValueMedium); + + if (fontSize.equals(prefValueSmall)) { + return context.getResources().getDimension(R.dimen.note_font_size_small); + } else if (fontSize.equals(prefValueMedium)) { + return context.getResources().getDimension(R.dimen.note_font_size_medium); + } else { + return context.getResources().getDimension(R.dimen.note_font_size_large); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NotesColorUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NotesColorUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..035aab9a1fd3e6821ac1e4ea17bcf7e4e159727c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NotesColorUtil.java @@ -0,0 +1,52 @@ +package it.niedermann.owncloud.notes.shared.util; + +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.core.util.Pair; + +import java.util.HashMap; +import java.util.Map; + +import it.niedermann.android.util.ColorUtil; + +public final class NotesColorUtil { + + private static final Map CONTRAST_RATIO_SUFFICIENT_CACHE = new HashMap<>(); + + private NotesColorUtil() { + throw new UnsupportedOperationException("Do not instantiate this util class."); + } + + public static boolean contrastRatioIsSufficient(@ColorInt int colorOne, @ColorInt int colorTwo) { + final var key = new ColorPair(colorOne, colorTwo); + Boolean ret = CONTRAST_RATIO_SUFFICIENT_CACHE.get(key); + if (ret == null) { + ret = ColorUtil.INSTANCE.getContrastRatio(colorOne, colorTwo) > 3d; + CONTRAST_RATIO_SUFFICIENT_CACHE.put(key, ret); + return ret; + } + return ret; + } + + private static class ColorPair extends Pair { + + private ColorPair(@Nullable Integer first, @Nullable Integer second) { + super(first, second); + } + + @SuppressWarnings({"EqualsWhichDoesntCheckParameterClass", "NumberEquality"}) + @Override + public boolean equals(Object o) { + final var colorPair = (ColorPair) o; + if (first != colorPair.first) return false; + return second == colorPair.second; + } + + @Override + public int hashCode() { + int result = first; + result = 31 * result + second; + return result; + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/SSOUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/SSOUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..1e2542b07604910d449d35ca65de35dff297d987 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/SSOUtil.java @@ -0,0 +1,52 @@ +package it.niedermann.owncloud.notes.shared.util; + +import android.app.Activity; +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.exceptions.AndroidGetAccountsPermissionNotGranted; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppNotInstalledException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; +import com.nextcloud.android.sso.ui.UiExceptionManager; + +public class SSOUtil { + + private static final String TAG = SSOUtil.class.getSimpleName(); + + private SSOUtil() { + throw new UnsupportedOperationException("Do not instantiate this util class."); + } + + /** + * Opens a dialog which allows the user to pick a Nextcloud account (which previously has to be configured in the files app). + * Also allows to configure a new Nextcloud account in the files app and directly import it. + * + * @param activity should implement AccountImporter.onActivityResult + */ + public static void askForNewAccount(@NonNull Activity activity) { + try { + AccountImporter.pickNewAccount(activity); + } catch (NextcloudFilesAppNotInstalledException e1) { + UiExceptionManager.showDialogForException(activity, e1); + Log.w(TAG, "============================================================="); + Log.w(TAG, "Nextcloud app is not installed. Cannot choose account"); + e1.printStackTrace(); + } catch (AndroidGetAccountsPermissionNotGranted e2) { + AccountImporter.requestAndroidAccountPermissionsAndPickAccount(activity); + } + } + + public static boolean isConfigured(Context context) { + try { + SingleAccountHelper.getCurrentSingleSignOnAccount(context); + return true; + } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + return false; + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ShareUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ShareUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..4b7aebd97622b736af1945c63513b4df1ab58127 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ShareUtil.java @@ -0,0 +1,53 @@ +package it.niedermann.owncloud.notes.shared.util; + +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.net.MalformedURLException; +import java.net.URL; + +import it.niedermann.android.markdown.MarkdownUtil; + +import static android.content.ClipDescription.MIMETYPE_TEXT_PLAIN; + +public class ShareUtil { + + private ShareUtil() { + throw new UnsupportedOperationException("Do not instantiate this util class."); + } + + public static void openShareDialog(@NonNull Context context, @Nullable String subject, @Nullable String text) { + context.startActivity(Intent.createChooser(new Intent() + .setAction(Intent.ACTION_SEND) + .setType(MIMETYPE_TEXT_PLAIN) + .putExtra(Intent.EXTRA_SUBJECT, subject) + .putExtra(Intent.EXTRA_TITLE, subject) + .putExtra(Intent.EXTRA_TEXT, text), subject)); + } + + public static String extractSharedText(@NonNull Intent intent) { + final String text = intent.getStringExtra(Intent.EXTRA_TEXT); + if (intent.hasExtra(Intent.EXTRA_SUBJECT)) { + final String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT); + try { + new URL(text); + if (text != null && subject != null && !subject.trim().isEmpty()) { + return MarkdownUtil.getMarkdownLink(subject, text); + } else { + return text; + } + } catch (MalformedURLException e) { + if (subject != null && !subject.trim().isEmpty()) { + return subject + ": " + text; + } else { + return text; + } + } + } else { + return text; + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/SupportUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/SupportUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..d914c13fa6a3ae0414e4ec43b871de2560af0e63 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/SupportUtil.java @@ -0,0 +1,42 @@ +package it.niedermann.owncloud.notes.shared.util; + +import android.content.res.Resources; +import android.graphics.Typeface; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.text.style.StyleSpan; +import android.text.style.URLSpan; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +public class SupportUtil { + + private SupportUtil() { + throw new UnsupportedOperationException("Do not instantiate this util class."); + } + + public static SpannableString strong(@NonNull CharSequence text) { + final var spannable = new SpannableString(text); + spannable.setSpan(new StyleSpan(Typeface.BOLD), 0, spannable.length(), 0); + return spannable; + } + + public static SpannableString url(@NonNull CharSequence text, @NonNull String target) { + final var spannable = new SpannableString(text); + spannable.setSpan(new URLSpan(target), 0, spannable.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static void setTextWithURL(@NonNull TextView textView, @NonNull Resources resources, @StringRes int containerTextId, @StringRes int linkLabelId, @StringRes int urlId) { + final String linkLabel = resources.getString(linkLabelId); + final String finalText = resources.getString(containerTextId, linkLabel); + final var spannable = new SpannableString(finalText); + spannable.setSpan(new URLSpan(resources.getString(urlId)), finalText.indexOf(linkLabel), finalText.indexOf(linkLabel) + linkLabel.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + textView.setText(spannable); + textView.setMovementMethod(new LinkMovementMethod()); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/WidgetUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/WidgetUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..dc53fb3f409df0ce8724fac28706d2ad962c301c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/WidgetUtil.java @@ -0,0 +1,30 @@ +package it.niedermann.owncloud.notes.shared.util; + +import android.app.PendingIntent; +import android.os.Build; + +public class WidgetUtil { + + private WidgetUtil() { + throw new UnsupportedOperationException("This class must not get instantiated"); + } + + /** + * Android S requires either {@link PendingIntent#FLAG_MUTABLE} or + * {@link PendingIntent#FLAG_IMMUTABLE} to be set on a {@link PendingIntent}. + * This is enforced by Android and will lead to an app crash if neither of those flags is + * present. + * To keep the app working, this compatibility method can be used to add the + * {@link PendingIntent#FLAG_MUTABLE} flag on Android S and higher to restore the behavior of + * older SDK versions. + * + * @param flags wanted flags for {@link PendingIntent} + * @return {@param flags} | {@link PendingIntent#FLAG_MUTABLE} + */ + public static int pendingIntentFlagCompat(int flags) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return flags | PendingIntent.FLAG_MUTABLE; + } + return flags; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/AbstractWidgetData.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/AbstractWidgetData.java new file mode 100644 index 0000000000000000000000000000000000000000..f561f638c6e46b369de55d80efd2751a63f70068 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/AbstractWidgetData.java @@ -0,0 +1,68 @@ +package it.niedermann.owncloud.notes.widget; + +import androidx.annotation.IntRange; +import androidx.room.PrimaryKey; + +public abstract class AbstractWidgetData { + + @PrimaryKey + private int id; + private long accountId; + @IntRange(from = 0, to = 2) + private int themeMode; + + protected AbstractWidgetData() { + // Default constructor + } + + protected AbstractWidgetData(int id, long accountId, @IntRange(from = 0, to = 2) int themeMode) { + this.id = id; + this.accountId = accountId; + this.themeMode = themeMode; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public long getAccountId() { + return accountId; + } + + public void setAccountId(long accountId) { + this.accountId = accountId; + } + + @IntRange(from = 0, to = 2) + public int getThemeMode() { + return themeMode; + } + + public void setThemeMode(@IntRange(from = 0, to = 2) int themeMode) { + this.themeMode = themeMode; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof AbstractWidgetData)) return false; + + AbstractWidgetData that = (AbstractWidgetData) o; + + if (id != that.id) return false; + if (accountId != that.accountId) return false; + return themeMode == that.themeMode; + } + + @Override + public int hashCode() { + int result = id; + result = 31 * result + (int) (accountId ^ (accountId >>> 32)); + result = 31 * result + themeMode; + return result; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListViewModel.java new file mode 100644 index 0000000000000000000000000000000000000000..61d6869d61bb0e1b194bd9aa7ca99c05de09ef9f --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListViewModel.java @@ -0,0 +1,68 @@ +package it.niedermann.owncloud.notes.widget.notelist; + +import android.app.Application; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; + +import java.util.ArrayList; +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.main.MainActivity; +import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; +import it.niedermann.owncloud.notes.main.navigation.NavigationItem; +import it.niedermann.owncloud.notes.persistence.NotesRepository; + +import static androidx.lifecycle.Transformations.distinctUntilChanged; +import static androidx.lifecycle.Transformations.map; +import static androidx.lifecycle.Transformations.switchMap; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.FAVORITES; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.RECENT; +import static it.niedermann.owncloud.notes.shared.util.DisplayUtils.convertToCategoryNavigationItem; + +public class NoteListViewModel extends AndroidViewModel { + + private static final String TAG = NoteListViewModel.class.getSimpleName(); + + @NonNull + private final NotesRepository repo; + + public NoteListViewModel(@NonNull Application application) { + super(application); + this.repo = NotesRepository.getInstance(application); + } + + public LiveData> getAdapterCategories(Long accountId) { + return distinctUntilChanged( + switchMap(distinctUntilChanged(repo.count$(accountId)), (count) -> { + Log.v(TAG, "[getAdapterCategories] countLiveData: " + count); + return switchMap(distinctUntilChanged(repo.countFavorites$(accountId)), (favoritesCount) -> { + Log.v(TAG, "[getAdapterCategories] getFavoritesCountLiveData: " + favoritesCount); + return map(distinctUntilChanged(repo.getCategories$(accountId)), fromDatabase -> { + final var categories = convertToCategoryNavigationItem(getApplication(), fromDatabase); + + final var items = new ArrayList(fromDatabase.size() + 3); + items.add(new NavigationItem(MainActivity.ADAPTER_KEY_RECENT, getApplication().getString(R.string.label_all_notes), count, R.drawable.ic_access_time_grey600_24dp, RECENT)); + items.add(new NavigationItem(MainActivity.ADAPTER_KEY_STARRED, getApplication().getString(R.string.label_favorites), favoritesCount, R.drawable.ic_star_yellow_24dp, FAVORITES)); + + if (categories.size() > 2 && categories.get(2).label.isEmpty()) { + items.add(new NavigationItem(MainActivity.ADAPTER_KEY_UNCATEGORIZED, "", null, NavigationAdapter.ICON_NOFOLDER)); + } + + for (final var item : categories) { + final int slashIndex = item.label.indexOf('/'); + + item.label = slashIndex < 0 ? item.label : item.label.substring(0, slashIndex); + item.id = "category:" + item.label; + items.add(item); + } + return items; + }); + }); + }) + ); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidget.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidget.java new file mode 100644 index 0000000000000000000000000000000000000000..7342b08c84d52ed207eadff2ec04895dc2be835b --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidget.java @@ -0,0 +1,99 @@ +package it.niedermann.owncloud.notes.widget.notelist; + +import static it.niedermann.owncloud.notes.shared.util.WidgetUtil.pendingIntentFlagCompat; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; +import android.widget.RemoteViews; + +import java.util.NoSuchElementException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; + +public class NoteListWidget extends AppWidgetProvider { + private static final String TAG = NoteListWidget.class.getSimpleName(); + private final ExecutorService executor = Executors.newCachedThreadPool(); + + static void updateAppWidget(Context context, AppWidgetManager awm, int[] appWidgetIds) { + final var repo = NotesRepository.getInstance(context); + + RemoteViews views; + + for (int appWidgetId : appWidgetIds) { + try { + final var data = repo.getNoteListWidgetData(appWidgetId); + + final var serviceIntent = new Intent(context, NoteListWidgetService.class); + serviceIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + serviceIntent.setData(Uri.parse(serviceIntent.toUri(Intent.URI_INTENT_SCHEME))); + + Log.v(TAG, "-- data - " + data); + + views = new RemoteViews(context.getPackageName(), R.layout.widget_note_list); + views.setRemoteAdapter(R.id.note_list_widget_lv, serviceIntent); + views.setPendingIntentTemplate(R.id.note_list_widget_lv, PendingIntent.getActivity(context, 0, new Intent(), pendingIntentFlagCompat(PendingIntent.FLAG_UPDATE_CURRENT | Intent.FILL_IN_COMPONENT))); + views.setEmptyView(R.id.note_list_widget_lv, R.id.widget_note_list_placeholder_tv); + + awm.notifyAppWidgetViewDataChanged(appWidgetId, R.id.note_list_widget_lv); + awm.updateAppWidget(appWidgetId, views); + } catch (NoSuchElementException e) { + Log.i(TAG, "onUpdate has been triggered before the user finished configuring the widget"); + } + } + } + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + super.onUpdate(context, appWidgetManager, appWidgetIds); + updateAppWidget(context, appWidgetManager, appWidgetIds); + } + + @Override + public void onReceive(Context context, Intent intent) { + super.onReceive(context, intent); + final var awm = AppWidgetManager.getInstance(context); + + if (intent.getAction() != null) { + if (intent.getAction().equals(AppWidgetManager.ACTION_APPWIDGET_UPDATE)) { + if (intent.hasExtra(AppWidgetManager.EXTRA_APPWIDGET_ID)) { + if (intent.getExtras() != null) { + updateAppWidget(context, awm, new int[]{intent.getExtras().getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, -1)}); + } else { + Log.w(TAG, "intent.getExtras() is null"); + } + } else { + updateAppWidget(context, awm, awm.getAppWidgetIds(new ComponentName(context, NoteListWidget.class))); + } + } + } else { + Log.w(TAG, "intent.getAction() is null"); + } + } + + @Override + public void onDeleted(Context context, int[] appWidgetIds) { + super.onDeleted(context, appWidgetIds); + final var repo = NotesRepository.getInstance(context); + + for (final int appWidgetId : appWidgetIds) { + executor.submit(() -> repo.removeNoteListWidget(appWidgetId)); + } + } + + /** + * Update note list widgets, if the note data was changed. + */ + public static void updateNoteListWidgets(Context context) { + context.sendBroadcast(new Intent(context, NoteListWidget.class).setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE)); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetConfigurationActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetConfigurationActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..fb94f7d8675f31772982f0020bf30696fcac82de --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetConfigurationActivity.java @@ -0,0 +1,147 @@ +package it.niedermann.owncloud.notes.widget.notelist; + +import android.app.Activity; +import android.appwidget.AppWidgetManager; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProvider; + +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.owncloud.notes.LockedActivity; +import it.niedermann.owncloud.notes.NotesApplication; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ActivityNoteListConfigurationBinding; +import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; +import it.niedermann.owncloud.notes.main.navigation.NavigationClickListener; +import it.niedermann.owncloud.notes.main.navigation.NavigationItem; +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; + +import static it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData.MODE_DISPLAY_ALL; +import static it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData.MODE_DISPLAY_CATEGORY; +import static it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData.MODE_DISPLAY_STARRED; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.RECENT; + + +public class NoteListWidgetConfigurationActivity extends LockedActivity { + private static final String TAG = Activity.class.getSimpleName(); + + private final ExecutorService executor = Executors.newCachedThreadPool(); + + private int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID; + + private Account localAccount = null; + + private ActivityNoteListConfigurationBinding binding; + private NoteListViewModel viewModel; + private NavigationAdapter adapterCategories; + private NotesRepository repo = null; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setResult(RESULT_CANCELED); + + repo = NotesRepository.getInstance(this); + final var args = getIntent().getExtras(); + + if (args != null) { + appWidgetId = args.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID); + } + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + Log.d(TAG, "INVALID_APPWIDGET_ID"); + finish(); + } + + viewModel = new ViewModelProvider(this).get(NoteListViewModel.class); + binding = ActivityNoteListConfigurationBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + adapterCategories = new NavigationAdapter(this, new NavigationClickListener() { + @Override + public void onItemClick(NavigationItem item) { + final var data = new NotesListWidgetData(); + + data.setId(appWidgetId); + if (item.type != null) { + switch (item.type) { + case RECENT: { + data.setMode(MODE_DISPLAY_ALL); + break; + } + case FAVORITES: { + data.setMode(MODE_DISPLAY_STARRED); + break; + } + case UNCATEGORIZED: { + data.setMode(MODE_DISPLAY_CATEGORY); + data.setCategory(null); + } + case DEFAULT_CATEGORY: + default: { + if (item.getClass() == NavigationItem.CategoryNavigationItem.class) { + data.setMode(MODE_DISPLAY_CATEGORY); + data.setCategory(((NavigationItem.CategoryNavigationItem) item).category); + } else { + data.setMode(MODE_DISPLAY_ALL); + Log.e(TAG, "Unknown item navigation type. Fallback to show " + RECENT); + } + } + } + } else { + data.setMode(MODE_DISPLAY_ALL); + Log.e(TAG, "Unknown item navigation type. Fallback to show " + RECENT); + } + + data.setAccountId(localAccount.getId()); + data.setThemeMode(NotesApplication.getAppTheme(getApplicationContext()).getModeId()); + + executor.submit(() -> { + repo.createOrUpdateNoteListWidgetData(data); + + final var updateIntent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, null, getApplicationContext(), NoteListWidget.class) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + setResult(RESULT_OK, updateIntent); + getApplicationContext().sendBroadcast(updateIntent); + finish(); + }); + } + + public void onIconClick(NavigationItem item) { + onItemClick(item); + } + }); + + binding.recyclerView.setAdapter(adapterCategories); + + executor.submit(() -> { + try { + this.localAccount = repo.getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(this).name); + } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + e.printStackTrace(); + Toast.makeText(this, R.string.widget_not_logged_in, Toast.LENGTH_LONG).show(); + // TODO Present user with app login screen + Log.w(TAG, "onCreate: user not logged in"); + finish(); + } + runOnUiThread(() -> viewModel.getAdapterCategories(localAccount.getId()).observe(this, (navigationItems) -> adapterCategories.setItems(navigationItems))); + }); + } + + @Override + public void applyBrand(int mainColor, int textColor) { + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetFactory.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..94548e982b13a406673bff33cf093b73d5c70b26 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetFactory.java @@ -0,0 +1,188 @@ +package it.niedermann.owncloud.notes.widget.notelist; + +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.widget.RemoteViews; +import android.widget.RemoteViewsService; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import java.util.ArrayList; +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.edit.EditNoteActivity; +import it.niedermann.owncloud.notes.main.MainActivity; +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; +import it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType; +import it.niedermann.owncloud.notes.shared.model.NavigationCategory; +import it.niedermann.owncloud.notes.shared.util.NotesColorUtil; + +import static it.niedermann.owncloud.notes.edit.EditNoteActivity.PARAM_CATEGORY; +import static it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData.MODE_DISPLAY_ALL; +import static it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData.MODE_DISPLAY_CATEGORY; +import static it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData.MODE_DISPLAY_STARRED; + +public class NoteListWidgetFactory implements RemoteViewsService.RemoteViewsFactory { + private static final String TAG = NoteListWidgetFactory.class.getSimpleName(); + + private final Context context; + private final int appWidgetId; + private final NotesRepository repo; + @NonNull + private final List dbNotes = new ArrayList<>(); + private NotesListWidgetData data; + + NoteListWidgetFactory(Context context, Intent intent) { + this.context = context; + this.appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + repo = NotesRepository.getInstance(context); + } + + @Override + public void onCreate() { + // Nothing to do here… + } + + @Override + public void onDataSetChanged() { + dbNotes.clear(); + try { + data = repo.getNoteListWidgetData(appWidgetId); + Log.v(TAG, "--- data - " + data); + switch (data.getMode()) { + case MODE_DISPLAY_ALL: + dbNotes.addAll(repo.searchRecentByModified(data.getAccountId(), "%")); + break; + case MODE_DISPLAY_STARRED: + dbNotes.addAll(repo.searchFavoritesByModified(data.getAccountId(), "%")); + break; + case MODE_DISPLAY_CATEGORY: + default: + if (data.getCategory() != null) { + dbNotes.addAll(repo.searchCategoryByModified(data.getAccountId(), "%", data.getCategory())); + } else { + dbNotes.addAll(repo.searchUncategorizedByModified(data.getAccountId(), "%")); + } + break; + } + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } + } + + @Override + public void onDestroy() { + //NoOp + } + + @Override + public int getCount() { + return dbNotes.size() + 1; + } + + @Override + public RemoteViews getViewAt(int position) { + final RemoteViews note_content; + + if (position == 0) { + final Account localAccount = repo.getAccountById(data.getAccountId()); + final Intent openIntent = new Intent(Intent.ACTION_MAIN).setComponent(new ComponentName(context.getPackageName(), MainActivity.class.getName())); + final Intent createIntent = new Intent(context, EditNoteActivity.class); + final Bundle extras = new Bundle(); + + extras.putSerializable(PARAM_CATEGORY, data.getMode() == MODE_DISPLAY_STARRED ? new NavigationCategory(ENavigationCategoryType.FAVORITES) : new NavigationCategory(localAccount.getId(), data.getCategory())); + extras.putLong(EditNoteActivity.PARAM_ACCOUNT_ID, data.getAccountId()); + + createIntent.putExtras(extras); + createIntent.setData(Uri.parse(createIntent.toUri(Intent.URI_INTENT_SCHEME))); + + note_content = new RemoteViews(context.getPackageName(), R.layout.widget_entry_add); + note_content.setOnClickFillInIntent(R.id.widget_entry_content_tv, openIntent); + note_content.setOnClickFillInIntent(R.id.widget_entry_fav_icon, createIntent); + note_content.setTextViewText(R.id.widget_entry_content_tv, getCategoryTitle(context, data.getMode(), data.getCategory())); + note_content.setImageViewResource(R.id.widget_entry_fav_icon, R.drawable.ic_add_blue_24dp); + note_content.setInt(R.id.widget_entry_fav_icon, "setColorFilter", NotesColorUtil.contrastRatioIsSufficient(ContextCompat.getColor(context, R.color.widget_background), localAccount.getColor()) + ? localAccount.getColor() + : ContextCompat.getColor(context, R.color.widget_foreground)); + } else { + position--; + if (position > dbNotes.size() - 1 || dbNotes.get(position) == null) { + Log.e(TAG, "Could not find position \"" + position + "\" in dbNotes list."); + return null; + } + + final Note note = dbNotes.get(position); + final Intent fillInIntent = new Intent(context, EditNoteActivity.class); + final Bundle extras = new Bundle(); + extras.putLong(EditNoteActivity.PARAM_NOTE_ID, note.getId()); + extras.putLong(EditNoteActivity.PARAM_ACCOUNT_ID, note.getAccountId()); + + fillInIntent.putExtras(extras); + fillInIntent.setData(Uri.parse(fillInIntent.toUri(Intent.URI_INTENT_SCHEME))); + + note_content = new RemoteViews(context.getPackageName(), R.layout.widget_entry); + note_content.setOnClickFillInIntent(R.id.widget_note_list_entry, fillInIntent); + note_content.setTextViewText(R.id.widget_entry_content_tv, note.getTitle()); + note_content.setImageViewResource(R.id.widget_entry_fav_icon, note.getFavorite() + ? R.drawable.ic_star_yellow_24dp + : R.drawable.ic_star_grey_ccc_24dp); + } + + return note_content; + + } + + @NonNull + private static String getCategoryTitle(@NonNull Context context, int displayMode, String category) { + switch (displayMode) { + case MODE_DISPLAY_STARRED: + return context.getString(R.string.label_favorites); + case MODE_DISPLAY_CATEGORY: + return "".equals(category) + ? context.getString(R.string.action_uncategorized) + : category; + case MODE_DISPLAY_ALL: + default: + return context.getString(R.string.app_name); + } + } + + @Override + public RemoteViews getLoadingView() { + return null; + } + + @Override + public int getViewTypeCount() { + return 2; + } + + @Override + public long getItemId(int position) { + if (position == 0) { + return -1; + } else { + position--; + if (position > dbNotes.size() - 1 || dbNotes.get(position) == null) { + Log.e(TAG, "Could not find position \"" + position + "\" in dbNotes list."); + return -2; + } + return dbNotes.get(position).getId(); + } + } + + @Override + public boolean hasStableIds() { + return true; + } +} diff --git a/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidgetService.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetService.java similarity index 84% rename from app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidgetService.java rename to app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetService.java index 6e621e0c9922d1b94cc87cae75d6eb4685ddc578..873cd24162a66d8189d5f58dc118dcbb43cea85d 100644 --- a/app/src/main/java/foundation/e/notes/android/appwidget/NoteListWidgetService.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetService.java @@ -1,4 +1,4 @@ -package foundation.e.notes.android.appwidget; +package it.niedermann.owncloud.notes.widget.notelist; import android.content.Intent; import android.widget.RemoteViewsService; diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidget.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidget.java new file mode 100644 index 0000000000000000000000000000000000000000..77339e3ef8df83671e82990a43b404fe916dda75 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidget.java @@ -0,0 +1,87 @@ +package it.niedermann.owncloud.notes.widget.singlenote; + +import static it.niedermann.owncloud.notes.shared.util.WidgetUtil.pendingIntentFlagCompat; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; +import android.widget.RemoteViews; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.edit.BaseNoteFragment; +import it.niedermann.owncloud.notes.edit.EditNoteActivity; +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; + +public class SingleNoteWidget extends AppWidgetProvider { + + private static final String TAG = SingleNoteWidget.class.getSimpleName(); + private final ExecutorService executor = Executors.newCachedThreadPool(); + + static void updateAppWidget(Context context, AppWidgetManager awm, int[] appWidgetIds) { + final var templateIntent = new Intent(context, EditNoteActivity.class); + final var repo = NotesRepository.getInstance(context); + + for (int appWidgetId : appWidgetIds) { + final var data = repo.getSingleNoteWidgetData(appWidgetId); + if (data != null) { + templateIntent.putExtra(BaseNoteFragment.PARAM_ACCOUNT_ID, data.getAccountId()); + + final var serviceIntent = new Intent(context, SingleNoteWidgetService.class); + serviceIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + serviceIntent.setData(Uri.parse(serviceIntent.toUri(Intent.URI_INTENT_SCHEME))); + + final var views = new RemoteViews(context.getPackageName(), R.layout.widget_single_note); + views.setPendingIntentTemplate(R.id.single_note_widget_lv, PendingIntent.getActivity(context, appWidgetId, templateIntent, + pendingIntentFlagCompat(PendingIntent.FLAG_UPDATE_CURRENT))); + views.setRemoteAdapter(R.id.single_note_widget_lv, serviceIntent); + views.setEmptyView(R.id.single_note_widget_lv, R.id.widget_single_note_placeholder_tv); + + awm.notifyAppWidgetViewDataChanged(appWidgetId, R.id.single_note_widget_lv); + awm.updateAppWidget(appWidgetId, views); + } else { + Log.i(TAG, "onUpdate has been triggered before the user finished configuring the widget"); + } + } + } + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + super.onUpdate(context, appWidgetManager, appWidgetIds); + updateAppWidget(context, appWidgetManager, appWidgetIds); + } + + @Override + public void onReceive(Context context, Intent intent) { + super.onReceive(context, intent); + final var awm = AppWidgetManager.getInstance(context); + + updateAppWidget(context, AppWidgetManager.getInstance(context), + (awm.getAppWidgetIds(new ComponentName(context, SingleNoteWidget.class)))); + } + + @Override + public void onDeleted(Context context, int[] appWidgetIds) { + final var repo = NotesRepository.getInstance(context); + + for (int appWidgetId : appWidgetIds) { + executor.submit(() -> repo.removeSingleNoteWidget(appWidgetId)); + } + super.onDeleted(context, appWidgetIds); + } + + /** + * Update single note widget, if the note data was changed. + */ + public static void updateSingleNoteWidgets(Context context) { + context.sendBroadcast(new Intent(context, SingleNoteWidget.class).setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE)); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetConfigurationActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetConfigurationActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..eb897def2cd297b65b345a6c239cc6fee2192b97 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetConfigurationActivity.java @@ -0,0 +1,76 @@ +package it.niedermann.owncloud.notes.widget.singlenote; + +import android.app.Activity; +import android.appwidget.AppWidgetManager; +import android.content.Intent; +import android.database.SQLException; +import android.os.Bundle; +import android.view.Menu; +import android.view.View; +import android.widget.Toast; + +import androidx.appcompat.widget.Toolbar; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import it.niedermann.owncloud.notes.NotesApplication; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.exception.ExceptionHandler; +import it.niedermann.owncloud.notes.main.MainActivity; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; + +public class SingleNoteWidgetConfigurationActivity extends MainActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler(this)); + setResult(Activity.RESULT_CANCELED); + + fabCreate.setVisibility(View.GONE); + final var searchToolbar = binding.activityNotesListView.searchToolbar; + final var swipeRefreshLayout = binding.activityNotesListView.swiperefreshlayout; + searchToolbar.setTitle(R.string.activity_select_single_note); + swipeRefreshLayout.setEnabled(false); + swipeRefreshLayout.setRefreshing(false); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + return true; + } + + @Override + public void onNoteClick(int position, View v) { + final var note = (Note) adapter.getItem(position); + final var args = getIntent().getExtras(); + + if (args == null) { + finish(); + return; + } + + final int appWidgetId = args.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + + executor.submit(() -> { + try { + mainViewModel.createOrUpdateSingleNoteWidgetData( + new SingleNoteWidgetData( + appWidgetId, + note.getAccountId(), + note.getId(), + NotesApplication.getAppTheme(this).getModeId() + ) + ); + final var updateIntent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, null, + getApplicationContext(), SingleNoteWidget.class) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + setResult(RESULT_OK, updateIntent); + getApplicationContext().sendBroadcast(updateIntent); + finish(); + } catch (SQLException e) { + Toast.makeText(this, e.getLocalizedMessage(), Toast.LENGTH_LONG).show(); + } + }); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetFactory.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..5369d3c7009c190beb2846e1fc104151258d191e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetFactory.java @@ -0,0 +1,120 @@ +package it.niedermann.owncloud.notes.widget.singlenote; + +import android.appwidget.AppWidgetManager; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.widget.RemoteViews; +import android.widget.RemoteViewsService; + +import androidx.annotation.Nullable; + +import it.niedermann.android.markdown.MarkdownUtil; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.edit.EditNoteActivity; +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; + +public class SingleNoteWidgetFactory implements RemoteViewsService.RemoteViewsFactory { + + private final Context context; + private final int appWidgetId; + + private final NotesRepository repo; + @Nullable + private Note note; + + private static final String TAG = SingleNoteWidget.class.getSimpleName(); + + SingleNoteWidgetFactory(Context context, Intent intent) { + this.context = context; + this.appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + this.repo = NotesRepository.getInstance(context); + } + + @Override + public void onCreate() { + + } + + @Override + public void onDataSetChanged() { + final var data = repo.getSingleNoteWidgetData(appWidgetId); + if (data != null) { + final long noteId = data.getNoteId(); + Log.v(TAG, "Fetch note with id " + noteId); + note = repo.getNoteById(noteId); + + if (note == null) { + Log.e(TAG, "Error: note not found"); + } + } else { + Log.w(TAG, "Widget with ID " + appWidgetId + " seems to be not configured yet."); + } + } + + @Override + public void onDestroy() { + //NoOp + } + + /** + * Returns the number of items in the data set. In this case, always 1 as a single note is + * being displayed. Will return 0 when the note can't be displayed. + */ + @Override + public int getCount() { + return (note != null) ? 1 : 0; + } + + /** + * Returns a RemoteView containing the note content in a TextView and + * a fillInIntent to handle the user tapping on the item in the list view. + * + * @param position The position of the item in the list + * @return The RemoteView at the specified position in the list + */ + @Override + public RemoteViews getViewAt(int position) { + if (note == null) { + return null; + } + + final var fillInIntent = new Intent(); + final var args = new Bundle(); + + args.putLong(EditNoteActivity.PARAM_NOTE_ID, note.getId()); + args.putLong(EditNoteActivity.PARAM_ACCOUNT_ID, note.getAccountId()); + fillInIntent.putExtras(args); + + final var note_content = new RemoteViews(context.getPackageName(), R.layout.widget_single_note_content); + note_content.setOnClickFillInIntent(R.id.single_note_content_tv, fillInIntent); + note_content.setTextViewText(R.id.single_note_content_tv, MarkdownUtil.renderForRemoteView(context, note.getContent())); + + return note_content; + } + + + // TODO Set loading view + @Override + public RemoteViews getLoadingView() { + return null; + } + + @Override + public int getViewTypeCount() { + return 1; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public boolean hasStableIds() { + return true; + } +} diff --git a/app/src/main/java/foundation/e/notes/android/appwidget/SingleNoteWidgetService.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetService.java similarity index 84% rename from app/src/main/java/foundation/e/notes/android/appwidget/SingleNoteWidgetService.java rename to app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetService.java index f5f09008a96839d208e0c234ec6429c8b6d84ae5..691acae998108d4ce6985f25256d1b9339f631d7 100644 --- a/app/src/main/java/foundation/e/notes/android/appwidget/SingleNoteWidgetService.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetService.java @@ -1,4 +1,4 @@ -package foundation.e.notes.android.appwidget; +package it.niedermann.owncloud.notes.widget.singlenote; import android.content.Intent; import android.widget.RemoteViewsService; diff --git a/app/src/main/res/animator/appbar_elevation_off.xml b/app/src/main/res/animator/appbar_elevation_off.xml new file mode 100644 index 0000000000000000000000000000000000000000..d24dcb340c452121138efd8f2bbf6dd6aed11495 --- /dev/null +++ b/app/src/main/res/animator/appbar_elevation_off.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/animator/appbar_elevation_on.xml b/app/src/main/res/animator/appbar_elevation_on.xml new file mode 100644 index 0000000000000000000000000000000000000000..6bd52cf3b468fe570a6ee9b41e00b858486e25bb --- /dev/null +++ b/app/src/main/res/animator/appbar_elevation_on.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/color/grid_item_background_selector.xml b/app/src/main/res/color/grid_item_background_selector.xml new file mode 100644 index 0000000000000000000000000000000000000000..cc84134543a37cf458e087f42568f58262bd6a1a --- /dev/null +++ b/app/src/main/res/color/grid_item_background_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_splashscreen.png b/app/src/main/res/drawable-hdpi/ic_splashscreen.png index c895c49c766363a1113e9f1cf8dfba805b6d6233..e0679173ca41658cb7b908f0102d1997edc419c2 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_splashscreen.png and b/app/src/main/res/drawable-hdpi/ic_splashscreen.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_widget_create.png b/app/src/main/res/drawable-hdpi/ic_widget_create.png index 02d5a39eca06976372e803f78bcb9ae817ef91a3..4f6a6c88c56d3f48de6d04ce7f0dadce9b64e23f 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_widget_create.png and b/app/src/main/res/drawable-hdpi/ic_widget_create.png differ diff --git a/app/src/main/res/drawable-mdpi/context_based_formatting.png b/app/src/main/res/drawable-mdpi/context_based_formatting.png new file mode 100644 index 0000000000000000000000000000000000000000..0b0182bd838edd63cc0c495320704f086a749c2c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/context_based_formatting.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_splashscreen.png b/app/src/main/res/drawable-mdpi/ic_splashscreen.png index 64eab2da2f915babe2700264698e54f9b1593bc6..73ab8acf0244f63ca738ceb5c8220fb5fada8316 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_splashscreen.png and b/app/src/main/res/drawable-mdpi/ic_splashscreen.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_widget_create.png b/app/src/main/res/drawable-mdpi/ic_widget_create.png index 9be48498fb5cdcb6b27fdb9b4bd5ae5d5da6f687..9b6903fb946dc515d040c81ea9bba8af1ec2b2c4 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_widget_create.png and b/app/src/main/res/drawable-mdpi/ic_widget_create.png differ diff --git a/app/src/main/res/drawable-night/border.xml b/app/src/main/res/drawable-night/border.xml index 48ec3e9be5d301b236c81a90b32cd5706479166c..ab9d52ea233fef0221ad777fbd4bf26663937ee9 100644 --- a/app/src/main/res/drawable-night/border.xml +++ b/app/src/main/res/drawable-night/border.xml @@ -1,6 +1,8 @@ - + - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/border.xml b/app/src/main/res/drawable-xhdpi/border.xml deleted file mode 100644 index 48ec3e9be5d301b236c81a90b32cd5706479166c..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable-xhdpi/border.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_splashscreen.png b/app/src/main/res/drawable-xhdpi/ic_splashscreen.png index 0638f98ab655fcf03ae38d3f28c502a95ddacaea..884e63e57e966a9a757ff802c9025a6e5fd20b04 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_splashscreen.png and b/app/src/main/res/drawable-xhdpi/ic_splashscreen.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_widget_create.png b/app/src/main/res/drawable-xhdpi/ic_widget_create.png index c24d9929f34bd82c16b5293b316e3728d464ee01..a7110ff9a1426143fbc277a4fe7a597792fec283 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_widget_create.png and b/app/src/main/res/drawable-xhdpi/ic_widget_create.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_splashscreen.png b/app/src/main/res/drawable-xxhdpi/ic_splashscreen.png index 03f2fbff3b460da351626ebb250d5eb6f755793e..83e9238c6d855a50ebf23d11910132dba017f0a9 100644 Binary files a/app/src/main/res/drawable-xxhdpi/ic_splashscreen.png and b/app/src/main/res/drawable-xxhdpi/ic_splashscreen.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_widget_create.png b/app/src/main/res/drawable-xxhdpi/ic_widget_create.png index c0ca652d87cb512cfa1478868166bda834ae2bcc..b076b8edd843694243c3a421712443f06b7b4fd5 100644 Binary files a/app/src/main/res/drawable-xxhdpi/ic_widget_create.png and b/app/src/main/res/drawable-xxhdpi/ic_widget_create.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_splashscreen.png b/app/src/main/res/drawable-xxxhdpi/ic_splashscreen.png index e12720f5681246380840d106d752c3fa3f698570..1fe0f64517d179de4266b9769fc56965d5e9c522 100644 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_splashscreen.png and b/app/src/main/res/drawable-xxxhdpi/ic_splashscreen.png differ diff --git a/app/src/main/res/drawable/alphabetical_asc.xml b/app/src/main/res/drawable/alphabetical_asc.xml new file mode 100644 index 0000000000000000000000000000000000000000..15fd95750a67f58056cebbabd7b5ccf0a939ac06 --- /dev/null +++ b/app/src/main/res/drawable/alphabetical_asc.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background.png b/app/src/main/res/drawable/background.png deleted file mode 100644 index 90856f4c889605689ea8658893fb0d4a7c1770bc..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable/background.png and /dev/null differ diff --git a/app/src/main/res/drawable/bg_navdrawer_item.xml b/app/src/main/res/drawable/bg_navdrawer_item.xml new file mode 100644 index 0000000000000000000000000000000000000000..a366c0d23b5d22f0c944e41890aeb9f86907d7ba --- /dev/null +++ b/app/src/main/res/drawable/bg_navdrawer_item.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/border.xml b/app/src/main/res/drawable/border.xml new file mode 100644 index 0000000000000000000000000000000000000000..319814a6022eb1f5e56dffaf505edab2a221a074 --- /dev/null +++ b/app/src/main/res/drawable/border.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/button_selector.xml b/app/src/main/res/drawable/button_selector.xml deleted file mode 100644 index 0664642045b755bcd393516a50d4eb05ff0757bc..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/button_selector.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/check.xml b/app/src/main/res/drawable/check.xml new file mode 100644 index 0000000000000000000000000000000000000000..4c6c6b760e1a8fb2dcd938f69ef8c58ab9c4b7b9 --- /dev/null +++ b/app/src/main/res/drawable/check.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/grid_item_background_selector.xml b/app/src/main/res/drawable/grid_item_background_selector.xml new file mode 100644 index 0000000000000000000000000000000000000000..0959be916fc186f7962841110f3c964df145d832 --- /dev/null +++ b/app/src/main/res/drawable/grid_item_background_selector.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_access_time_grey600_24dp.xml b/app/src/main/res/drawable/ic_access_time_grey600_24dp.xml index c5f03ddf5b1303b017f2647c98d8724eded29471..a5b3c814afd111259005585bf48cd9d482eb33b3 100644 --- a/app/src/main/res/drawable/ic_access_time_grey600_24dp.xml +++ b/app/src/main/res/drawable/ic_access_time_grey600_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_account_circle_grey_24dp.xml b/app/src/main/res/drawable/ic_account_circle_grey_24dp.xml index 68db351a2ff9aabde249f79505821235fff7c7ec..fb1be50b1fdab8eba70feb347c917a0247e85f92 100644 --- a/app/src/main/res/drawable/ic_account_circle_grey_24dp.xml +++ b/app/src/main/res/drawable/ic_account_circle_grey_24dp.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,5c1.66,0 3,1.34 3,3s-1.34,3 -3,3 -3,-1.34 -3,-3 1.34,-3 3,-3zM12,19.2c-2.5,0 -4.71,-1.28 -6,-3.22 0.03,-1.99 4,-3.08 6,-3.08 1.99,0 5.97,1.09 6,3.08 -1.29,1.94 -3.5,3.22 -6,3.22z" /> diff --git a/app/src/main/res/drawable/ic_add_blue_24dp.xml b/app/src/main/res/drawable/ic_add_blue_24dp.xml index 48a8df3f3b9b5ad249713b7253b368df9992ba30..669331520e63b5ec09d59eb6feefe9c1b02f6c3c 100644 --- a/app/src/main/res/drawable/ic_add_blue_24dp.xml +++ b/app/src/main/res/drawable/ic_add_blue_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_add_white_24dp.xml b/app/src/main/res/drawable/ic_add_white_24dp.xml index c5a76a4d74b4c0e5465d792ac4254e7572ebf02d..43bfcda20e5fe770ea676a00b53b9cdd0cbf0c11 100644 --- a/app/src/main/res/drawable/ic_add_white_24dp.xml +++ b/app/src/main/res/drawable/ic_add_white_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_arrow_back_grey600_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..d185cb76da71044083cce36c92c2fbb07ad30979 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_card_giftcard_24.xml b/app/src/main/res/drawable/ic_baseline_card_giftcard_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..c1c10515be23514965e0041de880808687201d53 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_card_giftcard_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_checklist_24.xml b/app/src/main/res/drawable/ic_baseline_checklist_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..4e310e8dcd5a465411e3e51744e4f5e949996516 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_checklist_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_dashboard_24.xml b/app/src/main/res/drawable/ic_baseline_dashboard_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..229f678c76f9a5737993243827d102a7a5e35c4d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_dashboard_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_fastfood_24.xml b/app/src/main/res/drawable/ic_baseline_fastfood_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..6a2c6e1e80fcceea928642fccf33f8ba7a9a8744 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_fastfood_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_games_24.xml b/app/src/main/res/drawable/ic_baseline_games_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..502d83f25f1a83e0ada6acaf09ae4a3402f35fdc --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_games_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_help_outline_24.xml b/app/src/main/res/drawable/ic_baseline_help_outline_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..19d5949a2809608b3a290900ddb193d6b2e1632c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_help_outline_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_menu_24.xml b/app/src/main/res/drawable/ic_baseline_menu_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..b2ceb45b5b7f3bc8769f8c5c67dc842162f31c2a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_menu_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_screen_lock_portrait_24.xml b/app/src/main/res/drawable/ic_baseline_screen_lock_portrait_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..8ec144f5697f337357a8c40fc5c5a0861dbfad68 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_screen_lock_portrait_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_vpn_key_24.xml b/app/src/main/res/drawable/ic_baseline_vpn_key_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..194d81a94196963eb02adf7e8adccba94fa78fc8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_vpn_key_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_brightness_2_grey_24dp.xml b/app/src/main/res/drawable/ic_brightness_2_grey_24dp.xml index 07c81fa6d42d815e6637f6ff139a327c2ceb3a86..7e5b2fc590db5bf44c669943c2e4603dd1c784f5 100644 --- a/app/src/main/res/drawable/ic_brightness_2_grey_24dp.xml +++ b/app/src/main/res/drawable/ic_brightness_2_grey_24dp.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:pathData="M10,2c-1.82,0 -3.53,0.5 -5,1.35C7.99,5.08 10,8.3 10,12s-2.01,6.92 -5,8.65C6.47,21.5 8.18,22 10,22c5.52,0 10,-4.48 10,-10S15.52,2 10,2z" /> diff --git a/app/src/main/res/drawable/ic_check_grey600_24dp.xml b/app/src/main/res/drawable/ic_check_grey600_24dp.xml deleted file mode 100644 index ec3888fa7b776fd5a5511c9dd8596e89c6bcfeec..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_check_grey600_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_check_white_24dp.xml b/app/src/main/res/drawable/ic_check_white_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..609f5657ab7e2bcd9119d1589797572d5f3f35ef --- /dev/null +++ b/app/src/main/res/drawable/ic_check_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_clear_grey_24dp.xml b/app/src/main/res/drawable/ic_clear_grey_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..c99d8c4227f9cd04c6bd5dfa452650628ccb0ad9 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear_grey_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_clear_white_24dp.xml b/app/src/main/res/drawable/ic_clear_white_24dp.xml index e602308d7198b978ec36470ff449e23d3461678b..e66c95229013a03bc6e68e3cd64f9b5ddc0f0601 100644 --- a/app/src/main/res/drawable/ic_clear_white_24dp.xml +++ b/app/src/main/res/drawable/ic_clear_white_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_create_new_folder_grey600_18dp.xml b/app/src/main/res/drawable/ic_create_new_folder_grey600_18dp.xml index b181dcd9ebc4f2c3caa5025968dfcb57b880aa46..da7b8b09b00163b301da4d1a7a09e72e82b5eda2 100644 --- a/app/src/main/res/drawable/ic_create_new_folder_grey600_18dp.xml +++ b/app/src/main/res/drawable/ic_create_new_folder_grey600_18dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_create_new_folder_grey600_24dp.xml b/app/src/main/res/drawable/ic_create_new_folder_grey600_24dp.xml index 979fea00b37a688b701cba4d60801102bd32e5cf..b6f2118f149002110f6f459dfe1b2efa080f2481 100644 --- a/app/src/main/res/drawable/ic_create_new_folder_grey600_24dp.xml +++ b/app/src/main/res/drawable/ic_create_new_folder_grey600_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_delete_grey600_24dp.xml b/app/src/main/res/drawable/ic_delete_grey600_24dp.xml index d3778e1ff2a423d6f018b6797100f984bfc4010b..ae12fa2c5be3e5e1bd4cb28a746d934a85c45eab 100644 --- a/app/src/main/res/drawable/ic_delete_grey600_24dp.xml +++ b/app/src/main/res/drawable/ic_delete_grey600_24dp.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_delete_white_24dp.xml b/app/src/main/res/drawable/ic_delete_white_24dp.xml deleted file mode 100644 index 4d020aff69bcc544c671b2ef8e223e248abd99f9..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_delete_white_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_delete_white_32dp.xml b/app/src/main/res/drawable/ic_delete_white_32dp.xml index 5f0b86e3fa7729db6e850c67aa575a843f5de509..309a7404c0c8c9f83c3e4fc65722200fc35ce967 100644 --- a/app/src/main/res/drawable/ic_delete_white_32dp.xml +++ b/app/src/main/res/drawable/ic_delete_white_32dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_edit_grey600_24dp.xml b/app/src/main/res/drawable/ic_edit_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..53bd7d023d36bd8dcf5322b08bf25a28c9b54e59 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_white_24dp.xml b/app/src/main/res/drawable/ic_edit_white_24dp.xml deleted file mode 100644 index 5af858dd551ba4f98c8eef86a4cfba69a10eeb27..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_edit_white_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_eye_grey600_24dp.xml b/app/src/main/res/drawable/ic_eye_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..dab8e80bade2ddd2d7f9cc22763ccaaeedbb5db1 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_eye_white_24dp.xml b/app/src/main/res/drawable/ic_eye_white_24dp.xml deleted file mode 100644 index 2386233ff96e5b2ea90ee8ca7fca006ddc2e43dd..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_eye_white_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_folder_grey600_18dp.xml b/app/src/main/res/drawable/ic_folder_grey600_18dp.xml index 4660a03b97ad267f0373db3f7c3d7e305b60bb65..dc540b580b137698d8bb00b15fafda3f7ec6d982 100644 --- a/app/src/main/res/drawable/ic_folder_grey600_18dp.xml +++ b/app/src/main/res/drawable/ic_folder_grey600_18dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_folder_grey600_24dp.xml b/app/src/main/res/drawable/ic_folder_grey600_24dp.xml index 72f211c730149eb4a03f37ee96e240312bf33dfd..dc78257239fb2bf56a59637b7323f3c07dfea762 100644 --- a/app/src/main/res/drawable/ic_folder_grey600_24dp.xml +++ b/app/src/main/res/drawable/ic_folder_grey600_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_folder_open_grey600_24dp.xml b/app/src/main/res/drawable/ic_folder_open_grey600_24dp.xml index 6ba7eee2e98286914957a574a7d2c9213e32c475..633b832ab346e7099a741c81c7c56b6e6a4ec851 100644 --- a/app/src/main/res/drawable/ic_folder_open_grey600_24dp.xml +++ b/app/src/main/res/drawable/ic_folder_open_grey600_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_folder_white_24dp.xml b/app/src/main/res/drawable/ic_folder_white_24dp.xml index 748ca9ba40c388698559929a7005ad512e0c421f..89dd53d0528fa26abbd56fe2f1d430eef48aa111 100644 --- a/app/src/main/res/drawable/ic_folder_white_24dp.xml +++ b/app/src/main/res/drawable/ic_folder_white_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_format_bold_black_24dp.xml b/app/src/main/res/drawable/ic_format_bold_black_24dp.xml deleted file mode 100644 index 625077f905b7dbe0720fb0fdecf8e57502e4c756..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_format_bold_black_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_format_italic_black_24dp.xml b/app/src/main/res/drawable/ic_format_italic_black_24dp.xml deleted file mode 100644 index 48f2605e5aba1a022a3a6b3126b53f05481566cf..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_format_italic_black_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_format_size_black_24dp.xml b/app/src/main/res/drawable/ic_format_size_black_24dp.xml index cc8872c1738c530734d3217dba2d5a361c7cf722..d903d80493b6d2501debdad977a23c50810be4a2 100644 --- a/app/src/main/res/drawable/ic_format_size_black_24dp.xml +++ b/app/src/main/res/drawable/ic_format_size_black_24dp.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_https_grey_24dp.xml b/app/src/main/res/drawable/ic_https_grey_24dp.xml deleted file mode 100644 index 3bfdaa16cc937c42899f988e95451d86db39caaf..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_https_grey_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_info_outline_grey600_24dp.xml b/app/src/main/res/drawable/ic_info_outline_grey600_24dp.xml index a626892387e1cabb1c3463d1ce56c444e931b2f5..4909d815881a2fa7d4da2de54772beea7883d3f8 100644 --- a/app/src/main/res/drawable/ic_info_outline_grey600_24dp.xml +++ b/app/src/main/res/drawable/ic_info_outline_grey600_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_insert_link_black_24dp.xml b/app/src/main/res/drawable/ic_insert_link_black_24dp.xml deleted file mode 100644 index 3672c276598e854fd89ecc514ed2d4d2822846dc..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_insert_link_black_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_down_white_24dp.xml b/app/src/main/res/drawable/ic_keyboard_arrow_down_white_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..657435fafba4e66c50fb2b7b4484427d2f086c25 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_down_white_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_up_white_24dp.xml b/app/src/main/res/drawable/ic_keyboard_arrow_up_white_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..e0c3e9012ab4477432d2020f95d9c4c75ab4c287 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_up_white_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 8e79dd794a6c456c57d316dae4330cde6251fdfe..9127e535acc52f109e185c26177b6a291b7c3349 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,906 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:fillType="evenOdd" + android:pathData="M0,0h1344v1344h-1344z" + android:strokeLineJoin="round"> + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index bee6d49092d56fa41a55f081f7fb1949f93a72ab..b3eb55f92783bb98a65c1a3c894112ee9c24a885 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,13 +1,14 @@ + + android:viewportWidth="66.666664" + android:viewportHeight="66.666664"> + android:translateX="17.333334" + android:translateY="17.333334"> + android:fillColor="#fff" + android:pathData="m24.484 3.5156c-1.0237 0-2.0471 0.38887-2.8281 1.1699l5.6582 5.6582c1.5621-1.5621 1.5621-4.0961 0-5.6582-0.78105-0.78105-1.8064-1.1699-2.8301-1.1699zm-4.2422 2.584-12.02 12.021 5.6562 5.6562 12.021-12.02-5.6582-5.6582zm-13.436 13.436-2.1211 7.7793 7.7793-2.1211-5.6582-5.6582z" /> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground_full.xml b/app/src/main/res/drawable/ic_launcher_foreground_full.xml new file mode 100644 index 0000000000000000000000000000000000000000..cf08e71a5b61cec5268bd0ab494d78019da2f642 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground_full.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_library_music_black_24dp.xml b/app/src/main/res/drawable/ic_library_music_black_24dp.xml deleted file mode 100644 index e90b1c40b790470200052c7920108350a1309214..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_library_music_black_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_library_music_grey600_24dp.xml b/app/src/main/res/drawable/ic_library_music_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..ae1f73a35c1c2c8404cf618c2c664d6b49254fd3 --- /dev/null +++ b/app/src/main/res/drawable/ic_library_music_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_lightbulb_outline_grey600_24dp.xml b/app/src/main/res/drawable/ic_lightbulb_outline_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..695de0a58a88425bbbf13b8763f21a7f509e0a98 --- /dev/null +++ b/app/src/main/res/drawable/ic_lightbulb_outline_grey600_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_local_movies_grey600_24dp.xml b/app/src/main/res/drawable/ic_local_movies_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..04d79cd4c7bf8fbfb5dfbbb775df58e5f0ec4823 --- /dev/null +++ b/app/src/main/res/drawable/ic_local_movies_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_lock_grey600_24dp.xml b/app/src/main/res/drawable/ic_lock_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..9c6d7f761a1e89591a5e958fef5d308d308d0bb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_network_wifi_grey600_24dp.xml b/app/src/main/res/drawable/ic_network_wifi_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..5180396cfe0ad5676bfd1bbe83e85207c958492d --- /dev/null +++ b/app/src/main/res/drawable/ic_network_wifi_grey600_24dp.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_person_add_grey600_24dp.xml b/app/src/main/res/drawable/ic_person_add_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..1ff8efa27bc5b3c085a88efa6fe3420da59e220f --- /dev/null +++ b/app/src/main/res/drawable/ic_person_add_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_quicksettings_new.xml b/app/src/main/res/drawable/ic_quicksettings_new.xml deleted file mode 100644 index c022080001bdba26d2b23d12aa51468dcfeba902..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_quicksettings_new.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_remove_red_eye_grey_24dp.xml b/app/src/main/res/drawable/ic_remove_red_eye_grey_24dp.xml index dc85cfe3c253ffad3a8779d940aeef3e464caf9d..d9b1702df6eecc05d8d33b498c0a1edddb1f873b 100644 --- a/app/src/main/res/drawable/ic_remove_red_eye_grey_24dp.xml +++ b/app/src/main/res/drawable/ic_remove_red_eye_grey_24dp.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" /> diff --git a/app/src/main/res/drawable/ic_search_grey600_24dp.xml b/app/src/main/res/drawable/ic_search_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..d46f2cb3509901b1ebd1f3c02adffb849a1f9c41 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_grey600_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_white_24dp.xml b/app/src/main/res/drawable/ic_search_white_24dp.xml deleted file mode 100644 index 3e71206e34300cadfc3fac83855c18982dbbf55b..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_search_white_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_send_grey600_24dp.xml b/app/src/main/res/drawable/ic_send_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..0579570bb7429884fa8a2b4866fa209972e778b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_send_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_grey600_24dp.xml b/app/src/main/res/drawable/ic_settings_grey600_24dp.xml index 62e3a173892dd13c60d1752ea9e8a3cd6c51852e..eca3850c96f1eff984c91fbe49ac342b2445da7a 100644 --- a/app/src/main/res/drawable/ic_settings_grey600_24dp.xml +++ b/app/src/main/res/drawable/ic_settings_grey600_24dp.xml @@ -1,5 +1,13 @@ - - + + diff --git a/app/src/main/res/drawable/ic_share_white_24dp.xml b/app/src/main/res/drawable/ic_share_white_24dp.xml index 579dfbd21a79302d5a3e8d6a1f9d0c2890d56cf8..9ad3ec3409311000a111cd3f9f24ef1678547762 100644 --- a/app/src/main/res/drawable/ic_share_white_24dp.xml +++ b/app/src/main/res/drawable/ic_share_white_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_star_border_white_24dp.xml b/app/src/main/res/drawable/ic_star_border_white_24dp.xml index 274314f930996ad7779b2d7bfb8cf3d17a8fb850..66887c8deff0970277f47948cc1ed04407173017 100644 --- a/app/src/main/res/drawable/ic_star_border_white_24dp.xml +++ b/app/src/main/res/drawable/ic_star_border_white_24dp.xml @@ -1,10 +1,10 @@ - + android:width="24dp" + xmlns:android="http://schemas.android.com/apk/res/android"> diff --git a/app/src/main/res/drawable/ic_star_grey_ccc_24dp.xml b/app/src/main/res/drawable/ic_star_grey_ccc_24dp.xml index 1c7ebd95791e0ae8c49be719f5bb2e737fb89f9e..cc1d9fb8d07ff14506a797f4ce543a7943be339a 100644 --- a/app/src/main/res/drawable/ic_star_grey_ccc_24dp.xml +++ b/app/src/main/res/drawable/ic_star_grey_ccc_24dp.xml @@ -1,10 +1,10 @@ - + android:width="24dp" + xmlns:android="http://schemas.android.com/apk/res/android"> diff --git a/app/src/main/res/drawable/ic_star_white_24dp.xml b/app/src/main/res/drawable/ic_star_white_24dp.xml index fd8103b86cfd90722f6116c49d7472a5463f4709..b7fb611c0bbdf41b18a8cab751800e32367a3966 100644 --- a/app/src/main/res/drawable/ic_star_white_24dp.xml +++ b/app/src/main/res/drawable/ic_star_white_24dp.xml @@ -1,10 +1,10 @@ - + android:width="24dp" + xmlns:android="http://schemas.android.com/apk/res/android"> diff --git a/app/src/main/res/drawable/ic_star_yellow_24dp.xml b/app/src/main/res/drawable/ic_star_yellow_24dp.xml index 1196fde751a1311efe6816ec8e92f3bbcf1198e6..4d22d96e3c1405b2dc143c98f24ad7a47884831b 100644 --- a/app/src/main/res/drawable/ic_star_yellow_24dp.xml +++ b/app/src/main/res/drawable/ic_star_yellow_24dp.xml @@ -1,9 +1,9 @@ - + android:width="24dp" + xmlns:android="http://schemas.android.com/apk/res/android"> diff --git a/app/src/main/res/drawable/ic_sync_black_24dp.xml b/app/src/main/res/drawable/ic_sync_black_24dp.xml index 3628dedbfc2351b625557570d7d96239b8694abc..1bb8fb710950ce2908128d39360d64cdabe15714 100644 --- a/app/src/main/res/drawable/ic_sync_black_24dp.xml +++ b/app/src/main/res/drawable/ic_sync_black_24dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_sync_blue_18dp.xml b/app/src/main/res/drawable/ic_sync_blue_18dp.xml index 4871a1da72808f2230aeae86d359cf6d697e2d2d..8432cfc615f2bebb3dad12d8b2caf493881c680c 100644 --- a/app/src/main/res/drawable/ic_sync_blue_18dp.xml +++ b/app/src/main/res/drawable/ic_sync_blue_18dp.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_text_fields_black_24dp.xml b/app/src/main/res/drawable/ic_text_fields_black_24dp.xml deleted file mode 100644 index b9117ad996af59ad71792c44e6b2f164d87c6c54..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_text_fields_black_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_text_format_grey600_24dp.xml b/app/src/main/res/drawable/ic_text_format_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..f6a970aef8ab85d396cd0b273071e3630d50c92f --- /dev/null +++ b/app/src/main/res/drawable/ic_text_format_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_title_grey600_24dp.xml b/app/src/main/res/drawable/ic_title_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..38f2bf625721a1b4a84a3e373ed31ef4f6c274e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_title_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_wifi_black_24dp.xml b/app/src/main/res/drawable/ic_wifi_black_24dp.xml deleted file mode 100644 index d579899132085a383740bb854d8cf52a0189b63b..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/ic_wifi_black_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_work_grey600_24dp.xml b/app/src/main/res/drawable/ic_work_grey600_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..030d0889edb96622a7fa08fb97118129bedf846c --- /dev/null +++ b/app/src/main/res/drawable/ic_work_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/list_item_background_selector.xml b/app/src/main/res/drawable/list_item_background_selector.xml index 20ab940e26044875fcc2b84a7a1336dc553eabd1..573bd924b6c0dbd700c254b5e5c88dde6277db20 100644 --- a/app/src/main/res/drawable/list_item_background_selector.xml +++ b/app/src/main/res/drawable/list_item_background_selector.xml @@ -1,6 +1,20 @@ - + - - - \ No newline at end of file + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/list_item_color_selector.xml b/app/src/main/res/drawable/list_item_color_selector.xml deleted file mode 100644 index 64395a9ae3a1a1e361d59f862cc291725c9d0a58..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/list_item_color_selector.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/list_item_color_selector_low.xml b/app/src/main/res/drawable/list_item_color_selector_low.xml deleted file mode 100644 index f232807026ca8f16b92c9934c18b1d6d53f6b58a..0000000000000000000000000000000000000000 --- a/app/src/main/res/drawable/list_item_color_selector_low.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/modification_desc.xml b/app/src/main/res/drawable/modification_desc.xml new file mode 100644 index 0000000000000000000000000000000000000000..acd49abad56f102650787326e6305568fb0cb1a8 --- /dev/null +++ b/app/src/main/res/drawable/modification_desc.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/note_list_widget_preview.png b/app/src/main/res/drawable/note_list_widget_preview.png deleted file mode 100644 index 5fec9ff5e2bbf7e28aad7dc2a270d186b0a5e7a3..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable/note_list_widget_preview.png and /dev/null differ diff --git a/app/src/main/res/drawable/note_list_widget_preview.webp b/app/src/main/res/drawable/note_list_widget_preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..e0ffc49fa2b1d7e609207632d9944cd1d92f4aa8 Binary files /dev/null and b/app/src/main/res/drawable/note_list_widget_preview.webp differ diff --git a/app/src/main/res/drawable/single_note_widget.png b/app/src/main/res/drawable/single_note_widget.png deleted file mode 100644 index b6f02ff41dce66b6f07f8fb37e1a4153d89b35c9..0000000000000000000000000000000000000000 Binary files a/app/src/main/res/drawable/single_note_widget.png and /dev/null differ diff --git a/app/src/main/res/drawable/single_note_widget.webp b/app/src/main/res/drawable/single_note_widget.webp new file mode 100644 index 0000000000000000000000000000000000000000..5ba84722c8fbbe84539df065ee9ee80ee9ffda76 Binary files /dev/null and b/app/src/main/res/drawable/single_note_widget.webp differ diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..1d348b58e8f71a0f146bb0eb32e1fb3c824ad1da --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index bdd3321311d0eb349ea0de4cfe7032b360f9f4db..aebbea0fa9331ca36fc0e6422d02c2b75e638fba 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -1,6 +1,41 @@ - \ No newline at end of file + android:orientation="vertical"> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml deleted file mode 100644 index 34b86389c59a17297591a25e4b5e9fd62bfb0582..0000000000000000000000000000000000000000 --- a/app/src/main/res/layout/activity_account.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - -