diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 81c72ff1e2..04fbee9c76 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -44,3 +44,4 @@ FairEmail uses: * [Apache Commons Compress](https://commons.apache.org/proper/commons-compress/). Copyright © 2002-2021 The Apache Software Foundation. All Rights Reserved. [Apache License 2.0](https://www.apache.org/licenses/). * [LeakCanary](https://github.com/square/leakcanary). Copyright 2015 Square, Inc. [Apache License 2.0](https://github.com/square/leakcanary/blob/main/LICENSE.txt). * [IPAddress](https://github.com/seancfoley/IPAddress). Copyright 2016-2018 Sean C Foley. [Apache License 2.0](https://github.com/seancfoley/IPAddress/blob/master/LICENSE). +* [MaterialDings](https://github.com/Accusoft/MaterialDings). Copyright (c) 2018 Accusoft Corporation. [MIT License](https://github.com/Accusoft/MaterialDings/blob/master/LICENSE.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index cf3e9d4b28..46850f4cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,101 @@ 🌎 [Google Translate](https://translate.google.com/translate?sl=en&u=https://github.com/M66B/FairEmail/blob/master/CHANGELOG.md) +### [Kinnareemimus](https://en.wikipedia.org/wiki/Kinnareemimus) + +### 1.1940 - 2022-07-21 + +* Added check for token expiration + +### 1.1939 - 2022-07-21 + +* Fixed saving searches + +### 1.1938 - 2022-07-21 + +* Enabled Gmail web OAuth flow again +* Small improvements and minor bug fixes +* Updated translations + +### 1.1937 - 2022-07-20 + +* Small improvements and minor bug fixes +* Updated translations + +### 1.1936 - 2022-07-20 + +* Disabled Gmail web OAuth flow +* Small improvements and minor bug fixes +* Updated translations + +### 1.1935 - 2022-07-20 + +* Added editing saved search name, order (new) and color +* Small improvements and minor bug fixes +* Updated libraries +* Updated translations + +### 1.1934 - 2022-07-17 + +* Added Gmail web OAuth flow +* Improved Wingdings support +* Small improvements and minor bug fixes +* Updated translations + +### 1.1933 - 2022-07-16 + +* Added 10 minutes check frequency +* Added option to enable/disable LanguageTool +* Added option to require TLS 1.3 +* Small improvements and minor bug fixes +* Updated translations + +### 1.1932 - 2022-07-08 + +* Fixed threading when Message-ID empty +* Small improvements and minor bug fixes +* Updated translations + +### 1.1931 - 2022-07-07 + +* Added option to delay notifications while connected to Android auto (*) +* Added option to show number of listed messages in the top action bar +* Added option to show HTML element titles +* Added configurable button to save raw message files +* Small improvements and minor bug fixes +* Updated translations + +(*) Due to Play store policies this feature is not available in the Play store version; Android version 6 or later is required + +### 1.1930 - 2022-07-04 + +* Small improvements and minor bug fixes +* Updated AndroidX +* Updated translations + +### 1.1929 - 2022-07-02 + +* Fixed hiding more than 300 messages +* Small improvements and minor bug fixes +* Updated translations + +### 1.1928 - 2022-06-30 + +* Added *Select app* to browser selection +* Small improvements and minor bug fixes +* Updated AndroidX +* Updated translations + +### 1.1927 - 2022-06-25 + +* Added import file to signature editor +* Added option to restore app state on start +* Added edit account color to folder list menu +* Added create/delete notification channel to account popup menu +* Small improvements and minor bug fixes +* Updated libraries +* Updated translations + ### [Juratyrant](https://en.wikipedia.org/wiki/Juratyrant) ### 1.1926 - 2022-06-23 diff --git a/FAQ.md b/FAQ.md index 74de53cdaf..7a3d0d9e66 100644 --- a/FAQ.md +++ b/FAQ.md @@ -1,7 +1,7 @@ # FairEmail support -🌎 [Google Translate](https://translate.google.com/translate?sl=en&u=https://github.com/M66B/FairEmail/blob/master/FAQ.md) +🌎 [Google Translate](https://translate.google.com/translate?sl=en&u=https%3A%2F%2Fgithub.com%2FM66B%2FFairEmail%2Fblob%2Fmaster%2FFAQ.md) If you have a question, please check the following frequently asked questions first. [At the bottom](#user-content-get-support), @@ -16,6 +16,9 @@ Si vous avez une question, veuillez d'abord vérifier les questions fréquemment [En bas](#user-content-get-support), vous pouvez découvrir comment poser d'autres questions, demander des fonctionnalités et signaler des bogues. Vous recevrez une réponse dans votre propre langue. +## Tutorials + +Please [see here](https://github.com/M66B/FairEmail/tree/master/tutorials) for tutorials. ## Index @@ -118,40 +121,74 @@ Related questions: * A [bug in Crowdin](https://crowdin.com/messages/536694) blocks updating FAQ.md (this text) for translation. * Search suggestions causes the keyboard losing focus on Android 12L + + +
+ +**Xiaomi Redmi Note** + +🌎 [Google Translate](https://translate.google.com/translate?sl=en&u=https://github.com/M66B/FairEmail/blob/master/FAQ.md%23redmi) + +On Xiaomi Redmi Note devices the database occasionally gets corrupted, resulting in total data loss +(on the device only, unless you are using a POP3 account with the option *Leave messages on server* disabled). + +The cause of this problem are disk I/O errors due to an Android bug or maybe a hardware issue, please [see here](https://www.sqlite.org/rescode.html#ioerr_write). + +"*This error might result from a hardware malfunction or because a filesystem came unmounted while the file was open.*" + +This can't be fixed by the app and should be fixed by Xiaomi / Redmi. + +**Please do not blame the app for this!** + +For the record the stack trace: + +``` +android.database.sqlite.SQLiteDiskIOException: disk I/O error (code 778) + at io.requery.android.database.sqlite.SQLiteConnection.nativeExecute(SourceFile:-2) + at io.requery.android.database.sqlite.SQLiteConnection.execute(SQLiteConnection:595) + at io.requery.android.database.sqlite.SQLiteSession.endTransactionUnchecked(SQLiteSession:447) + at io.requery.android.database.sqlite.SQLiteSession.endTransaction(SQLiteSession:411) + at io.requery.android.database.sqlite.SQLiteDatabase.endTransaction(SQLiteDatabase:551) + at androidx.room.RoomDatabase.internalEndTransaction(RoomDatabase:594) + at androidx.room.RoomDatabase.endTransaction(RoomDatabase:584) +``` +

Planned features

🌎 [Google Translate](https://translate.google.com/translate?sl=en&u=https://github.com/M66B/FairEmail/blob/master/FAQ.md%23user-content-planned-features) -* ~~Synchronize on demand (manual)~~ -* ~~Semi-automatic encryption~~ -* ~~Copy message~~ -* ~~Colored stars~~ -* ~~Notification settings per folder~~ -* ~~Select local images for signatures~~ (this will not be added because it requires image file management and because images are not shown by default in most email clients anyway) -* ~~Show messages matched by a rule~~ -* ~~[ManageSieve](https://tools.ietf.org/html/rfc5804)~~ (there are no maintained Java libraries with a suitable license and without dependencies and besides that, FairEmail has its own filter rules) -* ~~Search for messages with/without attachments~~ (this cannot be added because IMAP doesn't support searching for attachments) -* ~~Search for a folder~~ (filtering a hierarchical folder list is problematic) -* ~~Search suggestions~~ -* ~~[Autocrypt Setup Message](https://autocrypt.org/autocrypt-spec-1.0.0.pdf) (section 4.4)~~ (IMO it is not a good idea to let an email client handle sensitive encryption keys for an exceptional use case while OpenKeychain can export keys too) -* ~~Generic unified folders~~ -* ~~New per account message notification schedules~~ (implemented by adding a time condition to rules so messages can be snoozed during selected periods) -* ~~Copy accounts and identities~~ -* ~~Pinch zoom~~ (not reliably possible in a scrolling list; the full message view can be zoomed instead) -* ~~More compact folder view~~ -* ~~Compose lists and tables~~ (this requires a rich text editor, see [this FAQ](#user-content-faq99)) -* ~~Pinch zoom text size~~ -* ~~Display GIFs~~ -* ~~Themes~~ (a grey light and dark theme were added because this is what most people seems to want) -* ~~Any day time condition~~ (any day doesn't really fit into the from/to date/time condition) -* ~~Send as attachment~~ -* ~~Widget for selected account~~ -* ~~Remind to attach files~~ -* ~~Select domains to show images for~~ (this will be too complicated to use) -* ~~Unified starred messages view~~ (there is already a special search for this) -* ~~Move notification action~~ -* ~~S/MIME support~~ -* ~~Search for settings~~ +* ✔ ~~Synchronize on demand (manual)~~ +* ✔ ~~Semi-automatic encryption~~ +* ✔ ~~Copy message~~ +* ✔ ~~Colored stars~~ +* ✔ ~~Notification settings per folder~~ +* ✔ ~~Select local images for signatures~~ +* ✔ ~~Show messages matched by a rule~~ +* ❌ ~~[ManageSieve](https://tools.ietf.org/html/rfc5804)~~ (there are no maintained Java libraries with a suitable license and without dependencies and besides that, FairEmail has its own filter rules) +* ✔ ~~Search for messages with/without attachments~~ (on-device only because IMAP doesn't support searching for attachments) +* ✔ ~~Search for a folder~~ +* ✔ ~~Search suggestions~~ +* ❌ ~~[Autocrypt Setup Message](https://autocrypt.org/autocrypt-spec-1.0.0.pdf) (section 4.4)~~ (IMO it is not a good idea to let an email client handle sensitive encryption keys for an exceptional use case while OpenKeychain can export keys too) +* ✔ ~~Generic unified folders~~ +* ✔ ~~New per account message notification schedules~~ (implemented by adding a time condition to rules so messages can be snoozed during selected periods) +* ✔ ~~Copy accounts and identities~~ +* ✔ ~~Pinch zoom~~ +* ✔ ~~More compact folder view~~ +* ✔ ~~Compose lists~~ +* ❌ ~~Compose tables~~ (the Android editor doesn't suppor tables) +* ✔ ~~Pinch zoom text size~~ +* ✔ ~~Display GIFs~~ +* ✔ ~~Themes~~ +* ❌ ~~Any day time condition~~ (any day doesn't really fit into the from/to date/time condition) +* ✔ ~~Send as attachment~~ +* ✔ ~~Widget for selected account~~ +* ✔ ~~Remind to attach files~~ +* ✔ ~~Select domains to show images for~~ +* ✔ ~~Unified starred messages view~~ (implemented as saved search) +* ✔ ~~Move notification action~~ +* ✔ ~~S/MIME support~~ +* ✔ ~~Search for settings~~ +* ✔ Many more ... Anything on this list is in random order and *might* be added in the near future. @@ -247,7 +284,7 @@ Fonts, sizes, colors, etc should be material design whenever possible. * [(78) How do I use schedules?](#user-content-faq78) * [(79) How do I use synchronize on demand (manual)?](#user-content-faq79) * [~~(80) How do I fix the error 'Unable to load BODYSTRUCTURE'?~~](#user-content-faq80) -* [~~(81) Can you make the background of the original message dark in the dark theme?~~](#user-content-faq81) +* [(81) Can you make the background of the original message view dark in dark themes?](#user-content-faq81) * [(82) What is a tracking image?](#user-content-faq82) * [(84) What are local contacts for?](#user-content-faq84) * [(85) Why is an identity not available?](#user-content-faq85) @@ -900,11 +937,16 @@ All key handling is delegated to the OpenKey chain app for security reasons. Thi Inline encrypted PGP in received messages is supported, but inline PGP signatures and inline PGP in outgoing messages is not supported, see [here](https://josefsson.org/inline-openpgp-considered-harmful.html) about why not. +If you wish to verify a signature manually, check *Show inline attachments* and save the files *content.asc* (the signed content) and *signature.asc* (the digital signature). +Install [GnuPG](https://www.gnupg.org/) on your preferred operating system and execute this command: + +```gpg --verify signature.asc.pgp content.asc``` + Signed-only or encrypted-only messages are not a good idea, please see here about why not: -* [OpenPGP Considerations Part I](https://k9mail.github.io/2016/11/24/OpenPGP-Considerations-Part-I.html) -* [OpenPGP Considerations Part II](https://k9mail.github.io/2017/01/30/OpenPGP-Considerations-Part-II.html) -* [OpenPGP Considerations Part III Autocrypt](https://k9mail.github.io/2018/02/26/OpenPGP-Considerations-Part-III-Autocrypt.html) +* [OpenPGP Considerations Part I](https://www.openkeychain.org/openpgp-considerations-part-i) +* [OpenPGP Considerations Part II](https://www.openkeychain.org/openpgp-considerations-part-ii) +* [OpenPGP Considerations Part III Autocrypt](https://www.openkeychain.org/openpgp-considerations-part-iii-autocrypt) Signed-only messages are supported, encrypted-only messages are not supported. @@ -1348,7 +1390,7 @@ Some devices have a firewall, which you can access like this: Android *Settings, Data usage, Three-dots overflow menu, Data usage control* -The error *... Connection refused ...* means that the email server +The error *... Connection refused ...* (ECONNREFUSED) means that the email server or something between the email server and the app, like a firewall, actively refused the connection. The error *... Network unreachable ...* means that the email server was not reachable via the current internet connection, @@ -1389,6 +1431,8 @@ The error *... NO mailbox selected READ-ONLY ...* indicates [this Zimbra problem The Outlook specific error *... Command Error. 10 ...* probably means that the OAuth token expired or was invalidated. Authenticating the account again with the quick setup wizard will probably resolve this condition. +Another possible cause is a bug in an older Exchange version, please [see here](https://bugzilla.mozilla.org/show_bug.cgi?id=886261). +In this case the system administrator needs to update the server software. Please [see here](#user-content-faq4) for the errors *... Untrusted ... not in certificate ...*, *... Invalid security certificate (Can't verify identity of server) ...* or *... Trust anchor for certification path not found ...* @@ -2264,7 +2308,7 @@ but even Google's Chrome cannot handle this. * Did you know that you can long press the add contact button in the message composer to insert a contact group? (since version 1.1721) * Did you know that you can long press the image action to show the image dialog, even if it was disabled? (since version 1.1772) * Did you know that you can long press the "] \[" button to fit original messages to the screen width? (this might result in "thin" messages) -* Did you know that you can long press on the save drafts button for a grammar, style, and spell check? +* Did you know that you can long press on the save drafts button for a grammar, style, and spell check via [LanguageTools](https://languagetool.org/)?
@@ -2774,7 +2818,7 @@ To set the poll interval: (adb shell) adb shell am start-foreground-service -a eu.faircode.email.INTERVAL --ei minutes nnn ``` -Where *nnn* is one of 0, 15, 30, 60, 120, 240, 480, 1440. A value of 0 means push messages. +Where *nnn* is one of 0, 5, 15, 30, 60, 120, 240, 480, 1440. A value of 0 means push messages. You can automatically send commands with for example [Tasker](https://tasker.joaoapps.com/userguide/en/intents.html): @@ -2829,12 +2873,15 @@ You'll likely want to disabled [browse on server](#user-content-faq24) too.
-**~~(81) Can you make the background of the original message dark in the dark theme?~~** +**(81) Can you make the background of the original message view dark in dark themes?** 🌎 [Google Translate](https://translate.google.com/translate?sl=en&u=https://github.com/M66B/FairEmail/blob/master/FAQ.md%23user-content-faq81) -~~The original message is shown as the sender has sent it, including all colors.~~ -~~Changing the background color would not only make the original view not original anymore, it can also result in unreadable messages.~~ +The original message view will use a dark background when using a dark theme for Android version 10 and later. + +For Android before version 10 Google removed this feature from the [Android System WebView](https://play.google.com/store/apps/details?id=com.google.android.webview), +even though it worked fine in most cases. +Please see [this issue](https://issuetracker.google.com/issues/237785596) (requires a Google account login) requesting to restore this feature again for more information.
@@ -3062,7 +3109,8 @@ Please be aware that removing the storage space will inevitably result in proble When needed you can save (raw) messages via the three-dots menu just above the message text and save attachments by tapping on the floppy icon. -If you need to save on storage space, you can limit the number of days messages are being synchronized and kept for. +If you need to save on storage space, you can limit the number of days messages are being synchronized and kept on your device +and disable downloading and storing of message texts and attachments (which means only message headers will be stored). You can change these settings by long pressing a folder in the folder list and selecting *Edit properties*.
@@ -3352,6 +3400,7 @@ OAuth for Gmail is supported via the quick setup wizard. The Android account manager will be used to fetch and refresh OAuth tokens for selected on-device accounts. OAuth for non on-device accounts is not supported because Google requires a [yearly security audit](https://support.google.com/cloud/answer/9110914) ($15,000 to $75,000) for this. +Since FairEmail is basically offered free of charge, it is not an option to pay such an amount annually for a security audit. You can read more about this [here](https://www.theregister.com/2019/02/11/google_gmail_developer/). OAuth for Outlook/Office 365, Yahoo, Mail.ru and Yandex is supported via the quick setup wizard. @@ -3491,6 +3540,8 @@ Note that: * Play Store purchases cannot be transferred to another account * You can't restore purchases with [microG](https://microg.org/) +Please [see here](https://support.google.com/googleplay/answer/4646404) about how to add, remove, or edit your Google Play payment method. + If you cannot solve the problem with the purchase, you will have to contact Google about it.
@@ -4025,14 +4076,21 @@ Voice notes will automatically be attached. Account: -* Enable *Separate notifications* in the advanced account settings (Settings, tap Manual setup, tap Accounts, tap account, tap Advanced) -* Long press the account in the account list (Settings, tap Manual setup, tap Accounts) and select *Edit notification channel* to change the notification sound +* Version 1.1927-: enable *Separate notifications* in the advanced account settings +* Version 1.1927+: long press the account in the account list and select *Create notification channel* +* Long press the account in the account list and select *Edit notification channel* to change the notification sound + +To go to the account list: navigation menu (left side menu), tap *Settings*, tap *Manual setup and account options* and tap *Accounts*.
+To go to the advanced account settings from the account list: tap on the account and tap on *Advanced*. Folder: +* Long press the folder in the folder list and select *Notify on new messages* * Long press the folder in the folder list and select *Create notification channel* * Long press the folder in the folder list and select *Edit notification channel* to change the notification sound +To go to the folder list: tap on the account name in the navigation menu (left side menu). + Sender: * Open a message from the sender and expand it @@ -4099,6 +4157,13 @@ and [see here](#user-content-faq173) for the differences between the different r If you have a problem with the F-Droid build, please check if there is a newer GitHub version first. +You can see the source of the app in *About* of the navigation menu (left side menu), +either *Play store*, *GitHub*, *F-Droid*, or *?* (for example in the case of a custom build). + +[IzzyOnDroid](https://apt.izzysoft.de/fdroid/) hosts the GitHub release of the app. +[Aurora Store](https://f-droid.org/packages/com.aurora.store/) hosts the Play store version of the app, +even though the Aurora Store app was downloaded from F-Droid. +
@@ -4780,6 +4845,8 @@ Templates can have the following options: **(180) How do I use LanguageTool?** +LanguageTool need to be enabled in the miscellaneous settings. + After writing some text, you can long press on the save draft button to perform a grammar, style, and spell check via [LanguageTool](https://languagetool.org/). Texts with suggestions will be marked and if you tap on a marked suggestion, it will be shown by the keyboard if the keyboard supports this, diff --git a/PRIVACY.md b/PRIVACY.md index 9c38b53ee0..e22ae08a8f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -66,15 +66,15 @@ This table provides a complete overview of all shared data and the conditions un | Spamhaus | IP (network) address of domain names of links or email addresses | If spam blocklists are enabled, upon receiving a message | | Spamcop | IP (network) address of domain names of links or email addresses | If spam blocklists are enabled, upon receiving a message | | Barracuda | IP (network) address of domain names of links or email addresses | If spam blocklists are enabled, upon receiving a message | -| DeepL | Received or entered message text and target language code | Upon pressing a translate button | -| LanguageTool | Entered message texts | Upon long pressing the save draft button | +| DeepL | Received or entered message text and target language code | If translating is enabled, upon pressing a translate button | +| LanguageTool | Entered message texts | If LanguageTools is enabled, upon long pressing the save draft button | | Gravatar | [MD5 hash](https://en.wikipedia.org/wiki/MD5) of email addresses | If Gravatars are enabled, upon receiving a message (GitHub version only) | | Libravatar | [MD5 hash](https://en.wikipedia.org/wiki/MD5) of email addresses | If Libravatars are enabled, upon receiving a message (GitHub version only) | | GitHub | None, but see the remarks below | Upon downloading Disconnect's Tracker Protection lists | | | | Upon checking for updates (GitHub version only) | | BIMI | Domain name of email addresses | If BIMI is enabled, upon receiving a message | | Favicons | Domain name of email addresses | If favicons are enabled, upon receiving a message | -| Link title | Link address | Upon pressing a button in the insert link dialog | +| Link title | Link address | Upon pressing a download button in the insert link dialog | | Bugsnag | Information about warnings and errors | If error reporting is enabled, upon detecting an abnormal situation | All data is sent to improve the user experience in some way, diff --git a/app/build.gradle b/app/build.gradle index 91c23a8348..7e07656d7f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,9 +3,9 @@ apply plugin: 'com.bugsnag.android.gradle' apply plugin: 'kotlin-android' apply plugin: 'de.undercouch.download' -def getVersionCode = { -> return 1926 } +def getVersionCode = { -> return 1940 } def getRevision = { -> return "a" } -def getReleaseName = { -> return "Juratyrant" } +def getReleaseName = { -> return "Kinnareemimus" } // https://en.wikipedia.org/wiki/List_of_dinosaur_genera def keystoreProperties = new Properties() @@ -40,7 +40,8 @@ android { // https://developer.android.com/guide/topics/graphics/vector-drawable-resources vectorDrawables.useSupportLibrary = true - ndkVersion "23.1.7779620" + // https://developer.android.com/ndk/downloads + ndkVersion "23.2.8568313" // r23c ndk { // Bugsnag, sqlite abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64" @@ -280,6 +281,15 @@ task copyChangelog(type: Copy) { into "../metadata/en-US/changelogs" include "CHANGELOG.md" rename "CHANGELOG.md", getVersionCode() + ".txt" + filter { String line -> + line + .replaceAll(".*Google Translate.*", "--------------------") + .replaceAll("### ", "") + .replaceAll("## ", "") + .replaceAll("\\(https.*\\)", "") + .replaceAll("\\[", "") + .replaceAll("\\]", "") + } } preBuild.dependsOn copyChangelog @@ -326,13 +336,12 @@ dependencies { def startup_version = "1.1.0" def annotation_version_experimental = "1.2.0" def core_version = "1.8.0" // 1.9.0-alpha05 - def shortcuts_version = "1.0.1" def appcompat_version = "1.6.0-alpha05" - def emoji_version = "1.2.0-alpha04" - def activity_version = "1.5.0-rc01" // 1.6.0-alpha03 - def fragment_version = "1.5.0-rc01" + def emoji_version = "1.2.0-beta01" + def activity_version = "1.5.0" // 1.6.0-alpha03 + def fragment_version = "1.5.0" def windows_version = "1.0.0" // 1.1.0-alpha01 - def webkit_version = "1.5.0-alpha01" + def webkit_version = "1.5.0-beta01" def recyclerview_version = "1.2.1" // 1.3.0-alpha02 def coordinatorlayout_version = "1.2.0" def constraintlayout_version = "2.1.4" // 2.2.0-alpha01 @@ -341,7 +350,7 @@ dependencies { def lbm_version = "1.1.0" def swiperefresh_version = "1.2.0-alpha01" def documentfile_version = "1.1.0-alpha01" - def lifecycle_version = "2.5.0-rc02" + def lifecycle_version = "2.5.0" // 2.6.0-alpha01 def lifecycle_extensions_version = "2.2.0" def room_version = "2.4.2" // 2.5.0-alpha02 def sqlite_version = "2.2.0" // 2.3.0-alpha03 @@ -353,16 +362,16 @@ dependencies { def biometric_version = "1.2.0-alpha04" def billingclient_version = "4.1.0" def javamail_version = "1.6.7" - def jsoup_version = "1.14.3" + def jsoup_version = "1.15.2" def css_version = "0.9.29" def jax_version = "2.3.0-jaxb-1.0.6" def dnsjava_version = "2.1.9" def openpgp_version = "12.0" def badge_version = "1.1.22" - def bugsnag_version = "5.19.2" + def bugsnag_version = "5.23.0" def biweekly_version = "0.6.6" def vcard_version = "0.11.3" - def relinker_version = "1.4.3" + def relinker_version = "1.4.5" def markwon_version = "4.6.2" def bouncycastle_version = "1.70" def colorpicker_version = "0.0.15" @@ -376,7 +385,7 @@ dependencies { def svg_version = "1.4" def compress_version = "1.21" def ipaddress_version = "5.3.4" - def canary_version = "2.8.1" + def canary_version = "2.9.1" // https://developer.android.com/jetpack/androidx/releases/startup implementation "androidx.startup:startup-runtime:$startup_version" @@ -584,6 +593,9 @@ dependencies { // https://mvnrepository.com/artifact/com.github.seancfoley/ipaddress implementation "com.github.seancfoley:ipaddress:$ipaddress_version" + // https://mvnrepository.com/artifact/androidx.car.app/app?repo=google + // implementation "androidx.car.app:app:1.2.0-rc01" + // https://github.com/square/leakcanary // https://square.github.io/leakcanary/getting_started/ // https://mvnrepository.com/artifact/com.squareup.leakcanary/leakcanary-android diff --git a/app/src/amazon/AndroidManifest.xml b/app/src/amazon/AndroidManifest.xml index c987d1f0da..8ed266b802 100644 --- a/app/src/amazon/AndroidManifest.xml +++ b/app/src/amazon/AndroidManifest.xml @@ -465,6 +465,15 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + iki.fi +// iliad italia: https://www.iliad.it +// Submitted by Marios Makassikis +ibxos.it +iliadboxos.it + // Impertrix Solutions : // Submitted by Zhixiang Zhao impertrixcdn.com @@ -12458,9 +12464,11 @@ iopsys.se // Submitted by Matthew Hardeman ipifony.net -// IServ GmbH : https://iserv.eu -// Submitted by Kim-Alexander Brodowski +// IServ GmbH : https://iserv.de +// Submitted by Mario Hoberg +iservschule.de mein-iserv.de +schulplattform.de schulserver.de test-iserv.de iserv.dev @@ -12782,6 +12790,10 @@ hra.health miniserver.com memset.net +// Messerli Informatik AG : https://www.messerli.ch/ +// Submitted by Ruben Schmidmeister +messerli.app + // MetaCentrum, CESNET z.s.p.o. : https://www.metacentrum.cz/en/ // Submitted by Zdeněk Šustr *.cloud.metacentrum.cz @@ -13394,9 +13406,9 @@ rocky.page // Salesforce.com, Inc. https://salesforce.com/ // Submitted by Michael Biven -builder.code.com -dev-builder.code.com -stg-builder.code.com +*.builder.code.com +*.dev-builder.code.com +*.stg-builder.code.com // Sandstorm Development Group, Inc. : https://sandcats.io/ // Submitted by Asheesh Laroia diff --git a/app/src/main/java/androidx/car/app/connection/CarConnection.java b/app/src/main/java/androidx/car/app/connection/CarConnection.java new file mode 100644 index 0000000000..06c4691182 --- /dev/null +++ b/app/src/main/java/androidx/car/app/connection/CarConnection.java @@ -0,0 +1,112 @@ + /* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + package androidx.car.app.connection; + + import static androidx.annotation.RestrictTo.Scope.LIBRARY; + //import static androidx.car.app.utils.CommonUtils.isAutomotiveOS; + + import static java.util.Objects.requireNonNull; + + import android.content.Context; + + import androidx.annotation.IntDef; + import androidx.annotation.NonNull; + import androidx.annotation.RestrictTo; + import androidx.lifecycle.LiveData; + + import java.lang.annotation.ElementType; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + + /** + * A class that allows retrieval of information about connection to a car head unit. + */ + public final class CarConnection { + /** + * Defines current car connection state. + * + *

This is used for communication with the car host's content provider on queries for + * connection type. + */ + public static final String CAR_CONNECTION_STATE = "CarConnectionState"; + + /** + * Broadcast action that notifies that the car connection has changed and needs to be updated. + */ + public static final String ACTION_CAR_CONNECTION_UPDATED = + "androidx.car.app.connection.action.CAR_CONNECTION_UPDATED"; + + /** + * Represents the types of connections that exist to a car head unit. + * + * @hide + */ + @IntDef({CONNECTION_TYPE_NOT_CONNECTED, CONNECTION_TYPE_NATIVE, CONNECTION_TYPE_PROJECTION}) + @Retention(RetentionPolicy.SOURCE) + @Target({ElementType.TYPE_USE}) + @RestrictTo(LIBRARY) + public @interface ConnectionType { + } + + /** + * Not connected to any car head unit.z + */ + public static final int CONNECTION_TYPE_NOT_CONNECTED = 0; + + /** + * Natively running on a head unit (Android Automotive OS). + */ + public static final int CONNECTION_TYPE_NATIVE = 1; + + /** + * Connected to a car head unit by projecting to it. + */ + public static final int CONNECTION_TYPE_PROJECTION = 2; + + private final LiveData mConnectionTypeLiveData; + + /** + * Constructs a {@link CarConnection} that can be used to get connection information. + * + * @throws NullPointerException if {@code context} is {@code null} + */ + public CarConnection(@NonNull Context context) { + requireNonNull(context); + mConnectionTypeLiveData = /*isAutomotiveOS(context) + ? new AutomotiveCarConnectionTypeLiveData() + :*/ new CarConnectionTypeLiveData(context); + } + + /** + * Returns a {@link LiveData} that can be observed to get current connection type. + * + *

The recommended pattern is to observe the {@link LiveData} with the activity's + * lifecycle in order to get updates on the state change throughout the activity's lifetime. + * + *

Connection types are: + *

    + *
  1. {@link #CONNECTION_TYPE_NOT_CONNECTED} + *
  2. {@link #CONNECTION_TYPE_NATIVE} + *
  3. {@link #CONNECTION_TYPE_PROJECTION} + *
+ */ + @NonNull + public LiveData<@ConnectionType Integer> getType() { + return mConnectionTypeLiveData; + } + } diff --git a/app/src/main/java/androidx/car/app/connection/CarConnectionTypeLiveData.java b/app/src/main/java/androidx/car/app/connection/CarConnectionTypeLiveData.java new file mode 100644 index 0000000000..e688eb2d22 --- /dev/null +++ b/app/src/main/java/androidx/car/app/connection/CarConnectionTypeLiveData.java @@ -0,0 +1,120 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.car.app.connection; + +import static androidx.car.app.connection.CarConnection.ACTION_CAR_CONNECTION_UPDATED; +import static androidx.car.app.connection.CarConnection.CAR_CONNECTION_STATE; +//import static androidx.car.app.utils.LogTags.TAG_CONNECTION_TO_CAR; + +import android.content.AsyncQueryHandler; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.database.Cursor; +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.VisibleForTesting; +import androidx.car.app.connection.CarConnection.ConnectionType; +import androidx.lifecycle.LiveData; + +/** + * A {@link LiveData} that will query once while being observed and only again if it gets updates + * via a broadcast. + */ +final class CarConnectionTypeLiveData extends LiveData<@ConnectionType Integer> { + @VisibleForTesting + static final String CAR_CONNECTION_AUTHORITY = "androidx.car.app.connection"; + + private static final int QUERY_TOKEN = 42; + private static final Uri PROJECTION_HOST_URI = new Uri.Builder().scheme("content").authority( + CAR_CONNECTION_AUTHORITY).build(); + + private final Context mContext; + private final AsyncQueryHandler mQueryHandler; + private final CarConnectionBroadcastReceiver mBroadcastReceiver; + + CarConnectionTypeLiveData(Context context) { + mContext = context; + + mQueryHandler = new CarConnectionQueryHandler( + context.getContentResolver()); + mBroadcastReceiver = new CarConnectionBroadcastReceiver(); + } + + @Override + public void onActive() { + mContext.registerReceiver(mBroadcastReceiver, + new IntentFilter(ACTION_CAR_CONNECTION_UPDATED)); + queryForState(); + } + + @Override + public void onInactive() { + mContext.unregisterReceiver(mBroadcastReceiver); + mQueryHandler.cancelOperation(QUERY_TOKEN); + } + + void queryForState() { + mQueryHandler.startQuery(/* token= */ QUERY_TOKEN, /* cookie= */ null, + /* uri */ PROJECTION_HOST_URI, + /* projection= */ new String[]{CAR_CONNECTION_STATE}, /* selection= */ null, + /* selectionArgs= */ null, /* orderBy= */ null); + } + + class CarConnectionQueryHandler extends AsyncQueryHandler { + CarConnectionQueryHandler(ContentResolver resolver) { + super(resolver); + } + + @Override + protected void onQueryComplete(int token, Object cookie, Cursor response) { + if (response == null) { + //Log.w(TAG_CONNECTION_TO_CAR, "Null response from content provider when checking " + // + "connection to the car, treating as disconnected"); + postValue(CarConnection.CONNECTION_TYPE_NOT_CONNECTED); + return; + } + + int carConnectionTypeColumn = response.getColumnIndex(CAR_CONNECTION_STATE); + if (carConnectionTypeColumn < 0) { + //Log.e(TAG_CONNECTION_TO_CAR, "Connection to car response is missing the " + // + "connection type, treating as disconnected"); + postValue(CarConnection.CONNECTION_TYPE_NOT_CONNECTED); + return; + } + + if (!response.moveToNext()) { + //Log.e(TAG_CONNECTION_TO_CAR, "Connection to car response is empty, treating as " + // + "disconnected"); + postValue(CarConnection.CONNECTION_TYPE_NOT_CONNECTED); + return; + } + + postValue(response.getInt(carConnectionTypeColumn)); + } + } + + class CarConnectionBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + queryForState(); + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt b/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt index 4e763633f0..c171c23d50 100644 --- a/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt +++ b/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt @@ -158,18 +158,13 @@ internal class BackgroundTaskService( internalReportExecutor.shutdownNow() defaultExecutor.shutdownNow() - // shutdown the error/session executors first, waiting for existing tasks to complete. - // If a request fails it may perform IO to persist the payload for delivery next launch, - // which would submit tasks to the IO executor - therefore it's critical to - // shutdown the IO executor last. + // Wait a little while for these ones to shut down errorExecutor.shutdown() sessionExecutor.shutdown() + ioExecutor.shutdown() errorExecutor.awaitTerminationSafe() sessionExecutor.awaitTerminationSafe() - - // shutdown the IO executor last, waiting for any existing tasks to complete - ioExecutor.shutdown() ioExecutor.awaitTerminationSafe() } diff --git a/app/src/main/java/com/bugsnag/android/Bugsnag.java b/app/src/main/java/com/bugsnag/android/Bugsnag.java index f32b49d6ae..11538b580d 100644 --- a/app/src/main/java/com/bugsnag/android/Bugsnag.java +++ b/app/src/main/java/com/bugsnag/android/Bugsnag.java @@ -69,6 +69,15 @@ public final class Bugsnag { return client; } + /** + * Returns true if one of the start methods have been has been called and + * so Bugsnag is initialized; false if start has not been called and the + * other methods will throw IllegalStateException. + */ + public static boolean isStarted() { + return client != null; + } + private static void logClientInitWarning() { getClient().logger.w("Multiple Bugsnag.start calls detected. Ignoring."); } @@ -76,18 +85,19 @@ public final class Bugsnag { /** * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts * represent what was happening in your application at the time an error occurs. - * + *

* In an android app the "context" is automatically set as the foreground Activity. * If you would like to set this value manually, you should alter this property. */ - @Nullable public static String getContext() { + @Nullable + public static String getContext() { return getClient().getContext(); } /** * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts * represent what was happening in your application at the time an error occurs. - * + *

* In an android app the "context" is automatically set as the foreground Activity. * If you would like to set this value manually, you should alter this property. */ @@ -115,15 +125,15 @@ public final class Bugsnag { /** * Add a "on error" callback, to execute code at the point where an error report is * captured in Bugsnag. - * + *

* You can use this to add or modify information attached to an Event * before it is sent to your dashboard. You can also return * false from any callback to prevent delivery. "on error" * callbacks do not run before reports generated in the event * of immediate app termination from crashes in C/C++ code. - * + *

* For example: - * + *

* Bugsnag.addOnError(new OnErrorCallback() { * public boolean run(Event event) { * event.setSeverity(Severity.INFO); @@ -140,6 +150,7 @@ public final class Bugsnag { /** * Removes a previously added "on error" callback + * * @param onError the callback to remove */ public static void removeOnError(@NonNull OnErrorCallback onError) { @@ -149,12 +160,12 @@ public final class Bugsnag { /** * Add an "on breadcrumb" callback, to execute code before every * breadcrumb captured by Bugsnag. - * + *

* You can use this to modify breadcrumbs before they are stored by Bugsnag. * You can also return false from any callback to ignore a breadcrumb. - * + *

* For example: - * + *

* Bugsnag.onBreadcrumb(new OnBreadcrumbCallback() { * public boolean run(Breadcrumb breadcrumb) { * return false; // ignore the breadcrumb @@ -170,6 +181,7 @@ public final class Bugsnag { /** * Removes a previously added "on breadcrumb" callback + * * @param onBreadcrumb the callback to remove */ public static void removeOnBreadcrumb(@NonNull OnBreadcrumbCallback onBreadcrumb) { @@ -179,12 +191,12 @@ public final class Bugsnag { /** * Add an "on session" callback, to execute code before every * session captured by Bugsnag. - * + *

* You can use this to modify sessions before they are stored by Bugsnag. * You can also return false from any callback to ignore a session. - * + *

* For example: - * + *

* Bugsnag.onSession(new OnSessionCallback() { * public boolean run(Session session) { * return false; // ignore the session @@ -200,6 +212,7 @@ public final class Bugsnag { /** * Removes a previously added "on session" callback + * * @param onSession the callback to remove */ public static void removeOnSession(@NonNull OnSessionCallback onSession) { @@ -219,7 +232,7 @@ public final class Bugsnag { * Notify Bugsnag of a handled exception * * @param exception the exception to send to Bugsnag - * @param onError callback invoked on the generated error report for + * @param onError callback invoked on the generated error report for * additional modification */ public static void notify(@NonNull final Throwable exception, @@ -286,7 +299,8 @@ public final class Bugsnag { /** * Leave a "breadcrumb" log message representing an action or event which * occurred in your app, to aid with debugging - * @param message A short label + * + * @param message A short label * @param metadata Additional diagnostic information about the app environment * @param type A category for the breadcrumb */ @@ -332,11 +346,10 @@ public final class Bugsnag { * * stability score. * + * @return true if a previous session was resumed, false if a new session was started. * @see #startSession() * @see #pauseSession() * @see Configuration#setAutoTrackSessions(boolean) - * - * @return true if a previous session was resumed, false if a new session was started. */ public static boolean resumeSession() { return getClient().resumeSession(); @@ -365,7 +378,7 @@ public final class Bugsnag { * Returns the current buffer of breadcrumbs that will be sent with captured events. This * ordered list represents the most recent breadcrumbs to be captured up to the limit * set in {@link Configuration#getMaxBreadcrumbs()}. - * + *

* The returned collection is readonly and mutating the list will cause no effect on the * Client's state. If you wish to alter the breadcrumbs collected by the Client then you should * use {@link Configuration#setEnabledBreadcrumbTypes(Set)} and @@ -380,7 +393,7 @@ public final class Bugsnag { /** * Retrieves information about the last launch of the application, if it has been run before. - * + *

* For example, this allows checking whether the app crashed on its last launch, which could * be used to perform conditional behaviour to recover from crashes, such as clearing the * app data cache. @@ -394,7 +407,7 @@ public final class Bugsnag { * Informs Bugsnag that the application has finished launching. Once this has been called * {@link AppWithState#isLaunching()} will always be false in any new error reports, * and synchronous delivery will not be attempted on the next launch for any fatal crashes. - * + *

* By default this method will be called after Bugsnag is initialized when * {@link Configuration#getLaunchDurationMillis()} has elapsed. Invoking this method manually * has precedence over the value supplied via the launchDurationMillis configuration option. @@ -462,8 +475,12 @@ public final class Bugsnag { @NonNull public static Client getClient() { if (client == null) { - throw new IllegalStateException("You must call Bugsnag.start before any" - + " other Bugsnag methods"); + synchronized (lock) { + if (client == null) { + throw new IllegalStateException("You must call Bugsnag.start before any" + + " other Bugsnag methods"); + } + } } return client; diff --git a/app/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt b/app/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt index 8f17f4049d..d850562671 100644 --- a/app/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt +++ b/app/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt @@ -11,6 +11,10 @@ internal class BugsnagEventMapper( private val logger: Logger ) { + internal fun convertToEvent(map: Map, apiKey: String): Event { + return Event(convertToEventImpl(map, apiKey), logger) + } + @Suppress("UNCHECKED_CAST") internal fun convertToEventImpl(map: Map, apiKey: String): EventInternal { val event = EventInternal(apiKey) @@ -85,7 +89,11 @@ internal class BugsnagEventMapper( return event } - internal fun convertErrorInternal(error: Map): ErrorInternal { + internal fun convertError(error: Map): Error { + return Error(convertErrorInternal(error), logger) + } + + internal fun convertErrorInternal(error: Map): ErrorInternal { return ErrorInternal( error.readEntry("errorClass"), error["message"] as? String, diff --git a/app/src/main/java/com/bugsnag/android/Client.java b/app/src/main/java/com/bugsnag/android/Client.java index a6cdf8d932..c91becd799 100644 --- a/app/src/main/java/com/bugsnag/android/Client.java +++ b/app/src/main/java/com/bugsnag/android/Client.java @@ -170,7 +170,8 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF DataCollectionModule dataCollectionModule = new DataCollectionModule(contextModule, configModule, systemServiceModule, trackerModule, - bgTaskService, connectivity, storageModule.getDeviceId(), memoryTrimState); + bgTaskService, connectivity, storageModule.getDeviceId(), + storageModule.getInternalDeviceId(), memoryTrimState); dataCollectionModule.resolveDependencies(bgTaskService, TaskType.IO); appDataCollector = dataCollectionModule.getAppDataCollector(); deviceDataCollector = dataCollectionModule.getDeviceDataCollector(); diff --git a/app/src/main/java/com/bugsnag/android/ConfigInternal.kt b/app/src/main/java/com/bugsnag/android/ConfigInternal.kt index f3ec4939f9..9a967d1853 100644 --- a/app/src/main/java/com/bugsnag/android/ConfigInternal.kt +++ b/app/src/main/java/com/bugsnag/android/ConfigInternal.kt @@ -2,6 +2,7 @@ package com.bugsnag.android import android.content.Context import java.io.File +import java.util.EnumSet internal class ConfigInternal( var apiKey: String @@ -40,6 +41,7 @@ internal class ConfigInternal( var maxBreadcrumbs: Int = DEFAULT_MAX_BREADCRUMBS var maxPersistedEvents: Int = DEFAULT_MAX_PERSISTED_EVENTS var maxPersistedSessions: Int = DEFAULT_MAX_PERSISTED_SESSIONS + var maxReportedThreads: Int = DEFAULT_MAX_REPORTED_THREADS var context: String? = null var redactedKeys: Set @@ -51,6 +53,7 @@ internal class ConfigInternal( var discardClasses: Set = emptySet() var enabledReleaseStages: Set? = null var enabledBreadcrumbTypes: Set? = null + var telemetry: Set = EnumSet.of(Telemetry.INTERNAL_ERRORS) var projectPackages: Set = emptySet() var persistenceDirectory: File? = null @@ -99,6 +102,7 @@ internal class ConfigInternal( private const val DEFAULT_MAX_BREADCRUMBS = 50 private const val DEFAULT_MAX_PERSISTED_SESSIONS = 128 private const val DEFAULT_MAX_PERSISTED_EVENTS = 32 + private const val DEFAULT_MAX_REPORTED_THREADS = 200 private const val DEFAULT_LAUNCH_CRASH_THRESHOLD_MS: Long = 5000 @JvmStatic diff --git a/app/src/main/java/com/bugsnag/android/Configuration.java b/app/src/main/java/com/bugsnag/android/Configuration.java index 3b9e6dc2a2..0be3d0ff9a 100644 --- a/app/src/main/java/com/bugsnag/android/Configuration.java +++ b/app/src/main/java/com/bugsnag/android/Configuration.java @@ -561,6 +561,32 @@ public class Configuration implements CallbackAware, MetadataAware, UserAware, F } } + /** + * Gets the maximum number of threads that will be reported with an event. Once the threshold is + * reached, all remaining threads will be omitted. + * + * By default, up to 200 threads are reported. + */ + public int getMaxReportedThreads() { + return impl.getMaxReportedThreads(); + } + + /** + * Sets the maximum number of threads that will be reported with an event. Once the threshold is + * reached, all remaining threads will be omitted. + * + * By default, up to 200 threads are reported. + */ + public void setMaxReportedThreads(int maxReportedThreads) { + if (maxReportedThreads >= 0) { + impl.setMaxReportedThreads(maxReportedThreads); + } else { + getLogger().e("Invalid configuration value detected. " + + "Option maxReportedThreads should be a positive integer." + + "Supplied value is " + maxReportedThreads); + } + } + /** * Sets the maximum number of persisted sessions which will be stored. Once the threshold is * reached, the oldest session will be deleted. @@ -720,6 +746,26 @@ public class Configuration implements CallbackAware, MetadataAware, UserAware, F impl.setEnabledBreadcrumbTypes(enabledBreadcrumbTypes); } + @NonNull + public Set getTelemetry() { + return impl.getTelemetry(); + } + + /** + * Set which telemetry will be sent to Bugsnag. By default, all telemetry is enabled. + * + * The following telemetry can be enabled: + * + * - internal errors: Errors in the Bugsnag SDK itself. + */ + public void setTelemetry(@NonNull Set telemetry) { + if (telemetry != null) { + impl.setTelemetry(telemetry); + } else { + logNull("telemetry"); + } + } + /** * Sets which package names Bugsnag should consider as a part of the * running application. We mark stacktrace lines as in-project if they diff --git a/app/src/main/java/com/bugsnag/android/ContextExtensions.kt b/app/src/main/java/com/bugsnag/android/ContextExtensions.kt index 667d536e8a..da0c4c3c9e 100644 --- a/app/src/main/java/com/bugsnag/android/ContextExtensions.kt +++ b/app/src/main/java/com/bugsnag/android/ContextExtensions.kt @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.location.LocationManager import android.net.ConnectivityManager import android.os.RemoteException import android.os.storage.StorageManager @@ -69,3 +70,7 @@ internal fun Context.getConnectivityManager(): ConnectivityManager? = @JvmName("getStorageManagerFrom") internal fun Context.getStorageManager(): StorageManager? = safeGetSystemService(Context.STORAGE_SERVICE) + +@JvmName("getLocationManager") +internal fun Context.getLocationManager(): LocationManager? = + safeGetSystemService(Context.LOCATION_SERVICE) diff --git a/app/src/main/java/com/bugsnag/android/DataCollectionModule.kt b/app/src/main/java/com/bugsnag/android/DataCollectionModule.kt index 577b9bf7bc..20bf61229c 100644 --- a/app/src/main/java/com/bugsnag/android/DataCollectionModule.kt +++ b/app/src/main/java/com/bugsnag/android/DataCollectionModule.kt @@ -18,6 +18,7 @@ internal class DataCollectionModule( bgTaskService: BackgroundTaskService, connectivity: Connectivity, deviceId: String?, + internalDeviceId: String?, memoryTrimState: MemoryTrimState ) : DependencyModule() { @@ -49,6 +50,7 @@ internal class DataCollectionModule( ctx, ctx.resources, deviceId, + internalDeviceId, deviceBuildInfo, dataDir, rootDetector, diff --git a/app/src/main/java/com/bugsnag/android/DeviceDataCollector.kt b/app/src/main/java/com/bugsnag/android/DeviceDataCollector.kt index 69c4063e97..ed3573f3fe 100644 --- a/app/src/main/java/com/bugsnag/android/DeviceDataCollector.kt +++ b/app/src/main/java/com/bugsnag/android/DeviceDataCollector.kt @@ -13,7 +13,6 @@ import android.os.Build import android.provider.Settings import java.io.File import java.util.Date -import java.util.HashMap import java.util.Locale import java.util.concurrent.Callable import java.util.concurrent.Future @@ -28,6 +27,7 @@ internal class DeviceDataCollector( private val appContext: Context, resources: Resources, private val deviceId: String?, + private val internalDeviceId: String?, private val buildInfo: DeviceBuildInfo, private val dataDirectory: File, rootDetector: RootDetector, @@ -42,7 +42,7 @@ internal class DeviceDataCollector( private val screenResolution = getScreenResolution() private val locale = Locale.getDefault().toString() private val cpuAbi = getCpuAbi() - private val runtimeVersions: MutableMap + private var runtimeVersions: MutableMap private val rootedFuture: Future? private val totalMemoryFuture: Future? = retrieveTotalDeviceMemory() private var orientation = AtomicInteger(resources.configuration.orientation) @@ -89,6 +89,19 @@ internal class DeviceDataCollector( Date(now) ) + fun generateInternalDeviceWithState(now: Long) = DeviceWithState( + buildInfo, + checkIsRooted(), + internalDeviceId, + locale, + totalMemoryFuture.runCatching { this?.get() }.getOrNull(), + runtimeVersions.toMutableMap(), + calculateFreeDisk(), + calculateFreeMemory(), + getOrientationAsString(), + Date(now) + ) + fun getDeviceMetadata(): Map { val map = HashMap() populateBatteryInfo(into = map) @@ -163,19 +176,24 @@ internal class DeviceDataCollector( */ private fun getLocationStatus(): String? { try { - val cr = appContext.contentResolver - @Suppress("DEPRECATION") val providersAllowed = - Settings.Secure.getString(cr, Settings.Secure.LOCATION_PROVIDERS_ALLOWED) - return when { - providersAllowed != null && providersAllowed.isNotEmpty() -> "allowed" - else -> "disallowed" - } + return if (isLocationEnabled()) "allowed" else "disallowed" } catch (exception: Exception) { logger.w("Could not get locationStatus") } return null } + private fun isLocationEnabled() = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + appContext.getLocationManager()?.isLocationEnabled == true + else -> { + val cr = appContext.contentResolver + @Suppress("DEPRECATION") val providersAllowed = + Settings.Secure.getString(cr, Settings.Secure.LOCATION_PROVIDERS_ALLOWED) + providersAllowed != null && providersAllowed.isNotEmpty() + } + } + /** * Get the current status of network access, eg "cellular" */ @@ -293,6 +311,9 @@ internal class DeviceDataCollector( } fun addRuntimeVersionInfo(key: String, value: String) { - runtimeVersions[key] = value + // Use copy-on-write to avoid a ConcurrentModificationException in generateDeviceWithState + val newRuntimeVersions = runtimeVersions.toMutableMap() + newRuntimeVersions[key] = value + runtimeVersions = newRuntimeVersions } } diff --git a/app/src/main/java/com/bugsnag/android/DeviceIdFilePersistence.kt b/app/src/main/java/com/bugsnag/android/DeviceIdFilePersistence.kt new file mode 100644 index 0000000000..60fa1f9364 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DeviceIdFilePersistence.kt @@ -0,0 +1,163 @@ +package com.bugsnag.android + +import android.util.JsonReader +import java.io.File +import java.io.IOException +import java.lang.Thread +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException +import java.util.UUID + +/** + * This class is responsible for persisting and retrieving a device ID to a file. + * + * This class is made multi-process safe through the use of a [FileLock], and thread safe + * through the use of a [ReadWriteLock] in [SynchronizedStreamableStore]. + */ +class DeviceIdFilePersistence( + private val file: File, + private val deviceIdGenerator: () -> UUID, + private val logger: Logger +) : DeviceIdPersistence { + private val synchronizedStreamableStore: SynchronizedStreamableStore + + init { + try { + file.createNewFile() + } catch (exc: Throwable) { + logger.w("Failed to created device ID file", exc) + } + this.synchronizedStreamableStore = SynchronizedStreamableStore(file) + } + + /** + * Loads the device ID from its file system location. + * If no value is present then a UUID will be generated and persisted. + */ + override fun loadDeviceId(requestCreateIfDoesNotExist: Boolean): String? { + return try { + // optimistically read device ID without a lock - the majority of the time + // the device ID will already be present so no synchronization is required. + val deviceId = loadDeviceIdInternal() + + if (deviceId?.id != null) { + deviceId.id + } else { + return if (requestCreateIfDoesNotExist) persistNewDeviceUuid(deviceIdGenerator()) else null + } + } catch (exc: Throwable) { + logger.w("Failed to load device ID", exc) + null + } + } + + /** + * Loads the device ID from the file. + * + * If the file has zero length it can't contain device ID, so reading will be skipped. + */ + private fun loadDeviceIdInternal(): DeviceId? { + if (file.length() > 0) { + try { + return synchronizedStreamableStore.load(DeviceId.Companion::fromReader) + } catch (exc: Throwable) { // catch AssertionError which can be thrown by JsonReader + // on Android 8.0/8.1. see https://issuetracker.google.com/issues/79920590 + logger.w("Failed to load device ID", exc) + } + } + return null + } + + /** + * Write a new Device ID to the file. + */ + private fun persistNewDeviceUuid(uuid: UUID): String? { + return try { + // acquire a FileLock to prevent Clients in different processes writing + // to the same file concurrently + file.outputStream().channel.use { channel -> + persistNewDeviceIdWithLock(channel, uuid) + } + } catch (exc: IOException) { + logger.w("Failed to persist device ID", exc) + null + } + } + + private fun persistNewDeviceIdWithLock( + channel: FileChannel, + uuid: UUID + ): String? { + val lock = waitForFileLock(channel) ?: return null + + return try { + // read the device ID again as it could have changed + // between the last read and when the lock was acquired + val deviceId = loadDeviceIdInternal() + + if (deviceId?.id != null) { + // the device ID changed between the last read + // and acquiring the lock, so return the generated value + deviceId.id + } else { + // generate a new device ID and persist it + val newId = DeviceId(uuid.toString()) + synchronizedStreamableStore.persist(newId) + newId.id + } + } finally { + lock.release() + } + } + + /** + * Attempt to acquire a file lock. If [OverlappingFileLockException] is thrown + * then the method will wait for 50ms then try again, for a maximum of 10 attempts. + */ + private fun waitForFileLock(channel: FileChannel): FileLock? { + repeat(MAX_FILE_LOCK_ATTEMPTS) { + try { + return channel.tryLock() + } catch (exc: OverlappingFileLockException) { + Thread.sleep(FILE_LOCK_WAIT_MS) + } + } + return null + } + + companion object { + private const val MAX_FILE_LOCK_ATTEMPTS = 20 + private const val FILE_LOCK_WAIT_MS = 25L + } +} + +/** + * Serializes and deserializes the device ID to/from JSON. + */ +private class DeviceId(val id: String?) : JsonStream.Streamable { + + override fun toStream(stream: JsonStream) { + with(stream) { + beginObject() + name(KEY_ID) + value(id) + endObject() + } + } + + companion object : JsonReadable { + private const val KEY_ID = "id" + + override fun fromReader(reader: JsonReader): DeviceId { + var id: String? = null + with(reader) { + beginObject() + if (hasNext() && KEY_ID == nextName()) { + id = nextString() + } + } + return DeviceId(id) + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/DeviceIdPersistence.kt b/app/src/main/java/com/bugsnag/android/DeviceIdPersistence.kt new file mode 100644 index 0000000000..0c9bec8ac5 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DeviceIdPersistence.kt @@ -0,0 +1,14 @@ +package com.bugsnag.android + +interface DeviceIdPersistence { + /** + * Loads the device ID from storage. + * + * Device IDs are UUIDs which are persisted on a per-install basis. + * + * This method must be thread-safe and multi-process safe. + * + * Note: requestCreateIfDoesNotExist is only a request; an implementation may still refuse to create a new ID. + */ + fun loadDeviceId(requestCreateIfDoesNotExist: Boolean): String? +} diff --git a/app/src/main/java/com/bugsnag/android/DeviceIdStore.kt b/app/src/main/java/com/bugsnag/android/DeviceIdStore.kt index b9db53c296..31b7c7dc28 100644 --- a/app/src/main/java/com/bugsnag/android/DeviceIdStore.kt +++ b/app/src/main/java/com/bugsnag/android/DeviceIdStore.kt @@ -1,41 +1,33 @@ package com.bugsnag.android import android.content.Context -import android.util.JsonReader import java.io.File -import java.io.IOException -import java.lang.Thread -import java.nio.channels.FileChannel -import java.nio.channels.FileLock -import java.nio.channels.OverlappingFileLockException import java.util.UUID /** - * This class is responsible for persisting and retrieving the device ID which uniquely - * identifies this device. - * - * This class is made multi-process safe through the use of a [FileLock], and thread safe - * through the use of a [ReadWriteLock] in [SynchronizedStreamableStore]. + * This class is responsible for persisting and retrieving the device ID and internal device ID, + * which uniquely identify this device in various contexts. */ internal class DeviceIdStore @JvmOverloads constructor( context: Context, - private val file: File = File(context.filesDir, "device-id"), + deviceIdfile: File = File(context.filesDir, "device-id"), + deviceIdGenerator: () -> UUID = { UUID.randomUUID() }, + internalDeviceIdfile: File = File(context.filesDir, "internal-device-id"), + internalDeviceIdGenerator: () -> UUID = { UUID.randomUUID() }, private val sharedPrefMigrator: SharedPrefMigrator, - private val logger: Logger + logger: Logger ) { - private val synchronizedStreamableStore: SynchronizedStreamableStore + private val persistence: DeviceIdPersistence + private val internalPersistence: DeviceIdPersistence init { - try { - file.createNewFile() - } catch (exc: Throwable) { - logger.w("Failed to created device ID file", exc) - } - this.synchronizedStreamableStore = SynchronizedStreamableStore(file) + persistence = DeviceIdFilePersistence(deviceIdfile, deviceIdGenerator, logger) + internalPersistence = DeviceIdFilePersistence(internalDeviceIdfile, internalDeviceIdGenerator, logger) } /** + * Loads the device ID from * Loads the device ID from its file system location. Device IDs are UUIDs which are * persisted on a per-install basis. This method is thread-safe and multi-process safe. * @@ -43,137 +35,18 @@ internal class DeviceIdStore @JvmOverloads constructor( * be used. If no value is present then a random UUID will be generated and persisted. */ fun loadDeviceId(): String? { - return loadDeviceId { - when (val legacyDeviceId = sharedPrefMigrator.loadDeviceId()) { - null -> UUID.randomUUID() - else -> UUID.fromString(legacyDeviceId) - } - } - } - - internal fun loadDeviceId(uuidProvider: () -> UUID): String? { - return try { - // optimistically read device ID without a lock - the majority of the time - // the device ID will already be present so no synchronization is required. - val deviceId = loadDeviceIdInternal() - - if (deviceId?.id != null) { - deviceId.id - } else { - return persistNewDeviceUuid(uuidProvider) - } - } catch (exc: Throwable) { - logger.w("Failed to load device ID", exc) - null - } - } - - /** - * Loads the device ID from the file. - * - * If the file has zero length it can't contain device ID, so reading will be skipped. - */ - private fun loadDeviceIdInternal(): DeviceId? { - if (file.length() > 0) { - try { - return synchronizedStreamableStore.load(DeviceId.Companion::fromReader) - } catch (exc: Throwable) { // catch AssertionError which can be thrown by JsonReader - // on Android 8.0/8.1. see https://issuetracker.google.com/issues/79920590 - logger.w("Failed to load device ID", exc) - } - } - return null - } - - /** - * Write a new Device ID to the file. - */ - private fun persistNewDeviceUuid(uuidProvider: () -> UUID): String? { - return try { - // acquire a FileLock to prevent Clients in different processes writing - // to the same file concurrently - file.outputStream().channel.use { channel -> - persistNewDeviceIdWithLock(channel, uuidProvider) - } - } catch (exc: IOException) { - logger.w("Failed to persist device ID", exc) - null - } - } - - private fun persistNewDeviceIdWithLock( - channel: FileChannel, - uuidProvider: () -> UUID - ): String? { - val lock = waitForFileLock(channel) ?: return null - - return try { - // read the device ID again as it could have changed - // between the last read and when the lock was acquired - val deviceId = loadDeviceIdInternal() - - if (deviceId?.id != null) { - // the device ID changed between the last read - // and acquiring the lock, so return the generated value - deviceId.id - } else { - // generate a new device ID and persist it - val newId = DeviceId(uuidProvider().toString()) - synchronizedStreamableStore.persist(newId) - newId.id - } - } finally { - lock.release() + var result = persistence.loadDeviceId(false) + if (result != null) { + return result } - } - - /** - * Attempt to acquire a file lock. If [OverlappingFileLockException] is thrown - * then the method will wait for 50ms then try again, for a maximum of 10 attempts. - */ - private fun waitForFileLock(channel: FileChannel): FileLock? { - repeat(MAX_FILE_LOCK_ATTEMPTS) { - try { - return channel.tryLock() - } catch (exc: OverlappingFileLockException) { - Thread.sleep(FILE_LOCK_WAIT_MS) - } - } - return null - } - - companion object { - private const val MAX_FILE_LOCK_ATTEMPTS = 20 - private const val FILE_LOCK_WAIT_MS = 25L - } -} - -/** - * Serializes and deserializes the device ID to/from JSON. - */ -private class DeviceId(val id: String?) : JsonStream.Streamable { - - override fun toStream(stream: JsonStream) { - with(stream) { - beginObject() - name(KEY_ID) - value(id) - endObject() + result = sharedPrefMigrator.loadDeviceId(false) + if (result != null) { + return result } + return persistence.loadDeviceId(true) } - companion object : JsonReadable { - private const val KEY_ID = "id" - - override fun fromReader(reader: JsonReader): DeviceId { - var id: String? = null - with(reader) { - beginObject() - if (hasNext() && KEY_ID == nextName()) { - id = nextString() - } - } - return DeviceId(id) - } + fun loadInternalDeviceId(): String? { + return internalPersistence.loadDeviceId(true) } } diff --git a/app/src/main/java/com/bugsnag/android/ErrorType.kt b/app/src/main/java/com/bugsnag/android/ErrorType.kt index 6ab5f98a56..299b1a0b45 100644 --- a/app/src/main/java/com/bugsnag/android/ErrorType.kt +++ b/app/src/main/java/com/bugsnag/android/ErrorType.kt @@ -18,9 +18,16 @@ enum class ErrorType(internal val desc: String) { /** * An error captured from Android's C layer */ - C("c"); + C("c"), + + /** + * An error captured from a Dart / Flutter application + */ + DART("dart"); internal companion object { + @JvmStatic + @JvmName("fromDescriptor") internal fun fromDescriptor(desc: String) = values().find { it.desc == desc } } } diff --git a/app/src/main/java/com/bugsnag/android/EventFilenameInfo.kt b/app/src/main/java/com/bugsnag/android/EventFilenameInfo.kt index 6d9ff766c9..f7cfd5f9af 100644 --- a/app/src/main/java/com/bugsnag/android/EventFilenameInfo.kt +++ b/app/src/main/java/com/bugsnag/android/EventFilenameInfo.kt @@ -22,12 +22,8 @@ internal data class EventFilenameInfo( val errorTypes: Set ) { - /** - * Generates a filename for the Event in the format - * "[timestamp]_[apiKey]_[errorTypes]_[UUID]_[startupcrash|not-jvm].json" - */ fun encode(): String { - return "${timestamp}_${apiKey}_${serializeErrorTypeHeader(errorTypes)}_${uuid}_$suffix.json" + return toFilename(apiKey, uuid, timestamp, suffix, errorTypes) } fun isLaunchCrashReport(): Boolean = suffix == STARTUP_CRASH @@ -36,7 +32,21 @@ internal data class EventFilenameInfo( private const val STARTUP_CRASH = "startupcrash" private const val NON_JVM_CRASH = "not-jvm" - @JvmOverloads + /** + * Generates a filename for the Event in the format + * "[timestamp]_[apiKey]_[errorTypes]_[UUID]_[startupcrash|not-jvm].json" + */ + fun toFilename( + apiKey: String, + uuid: String, + timestamp: Long, + suffix: String, + errorTypes: Set + ): String { + return "${timestamp}_${apiKey}_${serializeErrorTypeHeader(errorTypes)}_${uuid}_$suffix.json" + } + + @JvmOverloads @JvmStatic fun fromEvent( obj: Any, uuid: String = UUID.randomUUID().toString(), @@ -63,11 +73,12 @@ internal data class EventFilenameInfo( /** * Reads event information from a filename. */ + @JvmStatic fun fromFile(file: File, config: ImmutableConfig): EventFilenameInfo { return EventFilenameInfo( findApiKeyInFilename(file, config), "", // ignore UUID field when reading from file as unused - -1, // ignore timestamp when reading from file as unused + findTimestampInFilename(file), findSuffixInFilename(file), findErrorTypesInFilename(file) ) @@ -77,7 +88,7 @@ internal data class EventFilenameInfo( * Retrieves the api key encoded in the filename, or an empty string if this information * is not encoded for the given event */ - private fun findApiKeyInFilename(file: File, config: ImmutableConfig): String { + internal fun findApiKeyInFilename(file: File, config: ImmutableConfig): String { val name = file.name.removeSuffix("_$STARTUP_CRASH.json") val start = name.indexOf("_") + 1 val end = name.indexOf("_", start) @@ -93,7 +104,7 @@ internal data class EventFilenameInfo( * Retrieves the error types encoded in the filename, or an empty string if this * information is not encoded for the given event */ - private fun findErrorTypesInFilename(eventFile: File): Set { + internal fun findErrorTypesInFilename(eventFile: File): Set { val name = eventFile.name val end = name.lastIndexOf("_", name.lastIndexOf("_") - 1) val start = name.lastIndexOf("_", end - 1) + 1 @@ -111,7 +122,7 @@ internal data class EventFilenameInfo( * Retrieves the error types encoded in the filename, or an empty string if this * information is not encoded for the given event */ - private fun findSuffixInFilename(eventFile: File): String { + internal fun findSuffixInFilename(eventFile: File): String { val name = eventFile.nameWithoutExtension val suffix = name.substring(name.lastIndexOf("_") + 1) return when (suffix) { @@ -120,10 +131,20 @@ internal data class EventFilenameInfo( } } + /** + * Retrieves the error types encoded in the filename, or an empty string if this + * information is not encoded for the given event + */ + @JvmStatic + fun findTimestampInFilename(eventFile: File): Long { + val name = eventFile.nameWithoutExtension + return name.substringBefore("_", missingDelimiterValue = "-1").toLongOrNull() ?: -1 + } + /** * Retrieves the error types for the given event */ - private fun findErrorTypesForEvent(obj: Any): Set { + internal fun findErrorTypesForEvent(obj: Any): Set { return when (obj) { is Event -> obj.impl.getErrorTypesFromStackframes() else -> setOf(ErrorType.C) @@ -133,7 +154,7 @@ internal data class EventFilenameInfo( /** * Calculates the suffix for the given event */ - private fun findSuffixForEvent(obj: Any, launching: Boolean?): String { + internal fun findSuffixForEvent(obj: Any, launching: Boolean?): String { return when { obj is Event && obj.app.isLaunching == true -> STARTUP_CRASH launching == true -> STARTUP_CRASH diff --git a/app/src/main/java/com/bugsnag/android/EventStorageModule.kt b/app/src/main/java/com/bugsnag/android/EventStorageModule.kt index 78980d1458..03f01f0a3e 100644 --- a/app/src/main/java/com/bugsnag/android/EventStorageModule.kt +++ b/app/src/main/java/com/bugsnag/android/EventStorageModule.kt @@ -22,17 +22,18 @@ internal class EventStorageModule( private val cfg = configModule.config private val delegate by future { - InternalReportDelegate( - contextModule.ctx, - cfg.logger, - cfg, - systemServiceModule.storageManager, - dataCollectionModule.appDataCollector, - dataCollectionModule.deviceDataCollector, - trackerModule.sessionTracker, - notifier, - bgTaskService - ) + if (cfg.telemetry.contains(Telemetry.INTERNAL_ERRORS) == true) + InternalReportDelegate( + contextModule.ctx, + cfg.logger, + cfg, + systemServiceModule.storageManager, + dataCollectionModule.appDataCollector, + dataCollectionModule.deviceDataCollector, + trackerModule.sessionTracker, + notifier, + bgTaskService + ) else null } val eventStore by future { EventStore(cfg, cfg.logger, notifier, bgTaskService, delegate, callbackState) } diff --git a/app/src/main/java/com/bugsnag/android/EventStore.java b/app/src/main/java/com/bugsnag/android/EventStore.java index e1365644ae..5ae0c04c50 100644 --- a/app/src/main/java/com/bugsnag/android/EventStore.java +++ b/app/src/main/java/com/bugsnag/android/EventStore.java @@ -7,9 +7,11 @@ import androidx.annotation.Nullable; import java.io.File; import java.util.ArrayList; +import java.util.Calendar; import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.Date; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -119,7 +121,7 @@ class EventStore extends FileStore { List launchCrashes = new ArrayList<>(); for (File file : storedFiles) { - EventFilenameInfo filenameInfo = EventFilenameInfo.Companion.fromFile(file, config); + EventFilenameInfo filenameInfo = EventFilenameInfo.fromFile(file, config); if (filenameInfo.isLaunchCrashReport()) { launchCrashes.add(file); } @@ -163,7 +165,7 @@ class EventStore extends FileStore { private void flushEventFile(File eventFile) { try { - EventFilenameInfo eventInfo = EventFilenameInfo.Companion.fromFile(eventFile, config); + EventFilenameInfo eventInfo = EventFilenameInfo.fromFile(eventFile, config); String apiKey = eventInfo.getApiKey(); EventPayload payload = createEventPayload(eventFile, apiKey); @@ -188,9 +190,21 @@ class EventStore extends FileStore { logger.i("Deleting sent error file " + eventFile.getName()); break; case UNDELIVERED: - cancelQueuedFiles(Collections.singleton(eventFile)); - logger.w("Could not send previously saved error(s)" - + " to Bugsnag, will try again later"); + if (isTooBig(eventFile)) { + logger.w("Discarding over-sized event (" + + eventFile.length() + + ") after failed delivery"); + deleteStoredFiles(Collections.singleton(eventFile)); + } else if (isTooOld(eventFile)) { + logger.w("Discarding historical event (from " + + getCreationDate(eventFile) + + ") after failed delivery"); + deleteStoredFiles(Collections.singleton(eventFile)); + } else { + cancelQueuedFiles(Collections.singleton(eventFile)); + logger.w("Could not send previously saved error(s)" + + " to Bugsnag, will try again later"); + } break; case FAILURE: Exception exc = new RuntimeException("Failed to deliver event payload"); @@ -234,13 +248,29 @@ class EventStore extends FileStore { @Override String getFilename(Object object) { EventFilenameInfo eventInfo - = EventFilenameInfo.Companion.fromEvent(object, null, config); + = EventFilenameInfo.fromEvent(object, null, config); return eventInfo.encode(); } String getNdkFilename(Object object, String apiKey) { EventFilenameInfo eventInfo - = EventFilenameInfo.Companion.fromEvent(object, apiKey, config); + = EventFilenameInfo.fromEvent(object, apiKey, config); return eventInfo.encode(); } + + private static long oneMegabyte = 1024 * 1024; + + public boolean isTooBig(File file) { + return file.length() > oneMegabyte; + } + + public boolean isTooOld(File file) { + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DATE, -60); + return EventFilenameInfo.findTimestampInFilename(file) < cal.getTimeInMillis(); + } + + public Date getCreationDate(File file) { + return new Date(EventFilenameInfo.findTimestampInFilename(file)); + } } diff --git a/app/src/main/java/com/bugsnag/android/FileStore.java b/app/src/main/java/com/bugsnag/android/FileStore.java index cbafdac431..b613cea988 100644 --- a/app/src/main/java/com/bugsnag/android/FileStore.java +++ b/app/src/main/java/com/bugsnag/android/FileStore.java @@ -39,7 +39,7 @@ abstract class FileStore { private final Lock lock = new ReentrantLock(); private final Collection queuedFiles = new ConcurrentSkipListSet<>(); - private final Logger logger; + protected final Logger logger; private final EventStore.Delegate delegate; FileStore(@NonNull File storageDir, diff --git a/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt b/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt index 7f76a24271..ad166e35b8 100644 --- a/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt +++ b/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt @@ -37,6 +37,7 @@ internal class ManifestConfigLoader { private const val MAX_BREADCRUMBS = "$BUGSNAG_NS.MAX_BREADCRUMBS" private const val MAX_PERSISTED_EVENTS = "$BUGSNAG_NS.MAX_PERSISTED_EVENTS" private const val MAX_PERSISTED_SESSIONS = "$BUGSNAG_NS.MAX_PERSISTED_SESSIONS" + private const val MAX_REPORTED_THREADS = "$BUGSNAG_NS.MAX_REPORTED_THREADS" private const val LAUNCH_CRASH_THRESHOLD_MS = "$BUGSNAG_NS.LAUNCH_CRASH_THRESHOLD_MS" private const val LAUNCH_DURATION_MILLIS = "$BUGSNAG_NS.LAUNCH_DURATION_MILLIS" private const val SEND_LAUNCH_CRASHES_SYNCHRONOUSLY = "$BUGSNAG_NS.SEND_LAUNCH_CRASHES_SYNCHRONOUSLY" @@ -77,6 +78,7 @@ internal class ManifestConfigLoader { maxBreadcrumbs = data.getInt(MAX_BREADCRUMBS, maxBreadcrumbs) maxPersistedEvents = data.getInt(MAX_PERSISTED_EVENTS, maxPersistedEvents) maxPersistedSessions = data.getInt(MAX_PERSISTED_SESSIONS, maxPersistedSessions) + maxReportedThreads = data.getInt(MAX_REPORTED_THREADS, maxReportedThreads) launchDurationMillis = data.getInt( LAUNCH_CRASH_THRESHOLD_MS, launchDurationMillis.toInt() diff --git a/app/src/main/java/com/bugsnag/android/NativeStackframe.kt b/app/src/main/java/com/bugsnag/android/NativeStackframe.kt index 5c47e1633c..3e82416a85 100644 --- a/app/src/main/java/com/bugsnag/android/NativeStackframe.kt +++ b/app/src/main/java/com/bugsnag/android/NativeStackframe.kt @@ -45,7 +45,12 @@ class NativeStackframe internal constructor( /** * The type of the error */ - var type: ErrorType? = null + var type: ErrorType? = null, + + /** + * Identifies the exact build this frame originates from. + */ + var codeIdentifier: String? = null, ) : JsonStream.Streamable { @Throws(IOException::class) @@ -57,6 +62,7 @@ class NativeStackframe internal constructor( writer.name("frameAddress").value(frameAddress) writer.name("symbolAddress").value(symbolAddress) writer.name("loadAddress").value(loadAddress) + writer.name("codeIdentifier").value(codeIdentifier) writer.name("isPC").value(isPC) type?.let { diff --git a/app/src/main/java/com/bugsnag/android/Notifier.kt b/app/src/main/java/com/bugsnag/android/Notifier.kt index b2f08dbab0..d131059fc1 100644 --- a/app/src/main/java/com/bugsnag/android/Notifier.kt +++ b/app/src/main/java/com/bugsnag/android/Notifier.kt @@ -7,7 +7,7 @@ import java.io.IOException */ class Notifier @JvmOverloads constructor( var name: String = "Android Bugsnag Notifier", - var version: String = "5.19.2", + var version: String = "5.23.0", var url: String = "https://bugsnag.com" ) : JsonStream.Streamable { diff --git a/app/src/main/java/com/bugsnag/android/SessionFilenameInfo.kt b/app/src/main/java/com/bugsnag/android/SessionFilenameInfo.kt new file mode 100644 index 0000000000..04efa4081b --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/SessionFilenameInfo.kt @@ -0,0 +1,55 @@ +package com.bugsnag.android + +import java.io.File +import java.util.UUID + +/** + * Represents important information about a session filename. + * Currently the following information is encoded: + * + * uuid - to disambiguate stored error reports + * timestamp - to sort error reports by time of capture + */ +internal data class SessionFilenameInfo( + val timestamp: Long, + val uuid: String, +) { + + fun encode(): String { + return toFilename(timestamp, uuid) + } + + internal companion object { + + const val uuidLength = 36 + + /** + * Generates a filename for the session in the format + * "[UUID][timestamp]_v2.json" + */ + fun toFilename(timestamp: Long, uuid: String): String { + return "${uuid}${timestamp}_v2.json" + } + + @JvmStatic + fun defaultFilename(): String { + return toFilename(System.currentTimeMillis(), UUID.randomUUID().toString()) + } + + fun fromFile(file: File): SessionFilenameInfo { + return SessionFilenameInfo( + findTimestampInFilename(file), + findUuidInFilename(file) + ) + } + + private fun findUuidInFilename(file: File): String { + return file.name.substring(0, uuidLength - 1) + } + + @JvmStatic + fun findTimestampInFilename(file: File): Long { + return file.name.substring(uuidLength, file.name.indexOf("_")).toLongOrNull() ?: -1 + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/SessionLifecycleCallback.kt b/app/src/main/java/com/bugsnag/android/SessionLifecycleCallback.kt index b43f860813..2299fa87c9 100644 --- a/app/src/main/java/com/bugsnag/android/SessionLifecycleCallback.kt +++ b/app/src/main/java/com/bugsnag/android/SessionLifecycleCallback.kt @@ -2,17 +2,32 @@ package com.bugsnag.android import android.app.Activity import android.app.Application +import android.os.Build import android.os.Bundle internal class SessionLifecycleCallback( private val sessionTracker: SessionTracker ) : Application.ActivityLifecycleCallbacks { - override fun onActivityStarted(activity: Activity) = + override fun onActivityStarted(activity: Activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + sessionTracker.onActivityStarted(activity.javaClass.simpleName) + } + } + + override fun onActivityPostStarted(activity: Activity) { sessionTracker.onActivityStarted(activity.javaClass.simpleName) + } + + override fun onActivityStopped(activity: Activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + sessionTracker.onActivityStopped(activity.javaClass.simpleName) + } + } - override fun onActivityStopped(activity: Activity) = + override fun onActivityPostStopped(activity: Activity) { sessionTracker.onActivityStopped(activity.javaClass.simpleName) + } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityResumed(activity: Activity) {} diff --git a/app/src/main/java/com/bugsnag/android/SessionStore.java b/app/src/main/java/com/bugsnag/android/SessionStore.java index 0d84d8a677..a0238a5feb 100644 --- a/app/src/main/java/com/bugsnag/android/SessionStore.java +++ b/app/src/main/java/com/bugsnag/android/SessionStore.java @@ -6,7 +6,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.io.File; +import java.util.Calendar; import java.util.Comparator; +import java.util.Date; import java.util.UUID; /** @@ -46,7 +48,16 @@ class SessionStore extends FileStore { @NonNull @Override String getFilename(Object object) { - return UUID.randomUUID().toString() + System.currentTimeMillis() + "_v2.json"; + return SessionFilenameInfo.defaultFilename(); } + public boolean isTooOld(File file) { + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DATE, -60); + return SessionFilenameInfo.findTimestampInFilename(file) < cal.getTimeInMillis(); + } + + public Date getCreationDate(File file) { + return new Date(SessionFilenameInfo.findTimestampInFilename(file)); + } } diff --git a/app/src/main/java/com/bugsnag/android/SessionTracker.java b/app/src/main/java/com/bugsnag/android/SessionTracker.java index 2a5e95c7f1..ddb2f052d5 100644 --- a/app/src/main/java/com/bugsnag/android/SessionTracker.java +++ b/app/src/main/java/com/bugsnag/android/SessionTracker.java @@ -270,8 +270,15 @@ class SessionTracker extends BaseObservable { logger.d("Sent 1 new session to Bugsnag"); break; case UNDELIVERED: - sessionStore.cancelQueuedFiles(Collections.singletonList(storedFile)); - logger.w("Leaving session payload for future delivery"); + if (sessionStore.isTooOld(storedFile)) { + logger.w("Discarding historical session (from {" + + sessionStore.getCreationDate(storedFile) + + "}) after failed delivery"); + sessionStore.deleteStoredFiles(Collections.singletonList(storedFile)); + } else { + sessionStore.cancelQueuedFiles(Collections.singletonList(storedFile)); + logger.w("Leaving session payload for future delivery"); + } break; case FAILURE: // drop bad data diff --git a/app/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt b/app/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt index e8af658a60..d664576791 100644 --- a/app/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt +++ b/app/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt @@ -6,12 +6,15 @@ import android.content.Context /** * Reads legacy information left in SharedPreferences and migrates it to the new location. */ -internal class SharedPrefMigrator(context: Context) { +internal class SharedPrefMigrator(context: Context) : DeviceIdPersistence { private val prefs = context .getSharedPreferences("com.bugsnag.android", Context.MODE_PRIVATE) - fun loadDeviceId() = prefs.getString(INSTALL_ID_KEY, null) + /** + * This implementation will never create an ID; it will only fetch one if present. + */ + override fun loadDeviceId(requestCreateIfDoesNotExist: Boolean) = prefs.getString(INSTALL_ID_KEY, null) fun loadUser(deviceId: String?) = User( prefs.getString(USER_ID_KEY, deviceId), diff --git a/app/src/main/java/com/bugsnag/android/Stackframe.kt b/app/src/main/java/com/bugsnag/android/Stackframe.kt index 3e4e063a76..7ac08889bd 100644 --- a/app/src/main/java/com/bugsnag/android/Stackframe.kt +++ b/app/src/main/java/com/bugsnag/android/Stackframe.kt @@ -53,6 +53,11 @@ class Stackframe : JsonStream.Streamable { */ var loadAddress: Long? = null + /** + * Identifies the exact build this frame originates from. + */ + var codeIdentifier: String? = null + /** * Whether this frame identifies the program counter */ @@ -90,6 +95,7 @@ class Stackframe : JsonStream.Streamable { this.frameAddress = nativeFrame.frameAddress this.symbolAddress = nativeFrame.symbolAddress this.loadAddress = nativeFrame.loadAddress + this.codeIdentifier = nativeFrame.codeIdentifier this.isPC = nativeFrame.isPC this.type = nativeFrame.type } @@ -103,6 +109,7 @@ class Stackframe : JsonStream.Streamable { frameAddress = (json["frameAddress"] as? Number)?.toLong() symbolAddress = (json["symbolAddress"] as? Number)?.toLong() loadAddress = (json["loadAddress"] as? Number)?.toLong() + codeIdentifier = (json["codeIdentifier"] as? String) isPC = json["isPC"] as? Boolean @Suppress("UNCHECKED_CAST") @@ -124,6 +131,7 @@ class Stackframe : JsonStream.Streamable { frameAddress?.let { writer.name("frameAddress").value(it) } symbolAddress?.let { writer.name("symbolAddress").value(it) } loadAddress?.let { writer.name("loadAddress").value(it) } + codeIdentifier?.let { writer.name("codeIdentifier").value(it) } isPC?.let { writer.name("isPC").value(it) } type?.let { diff --git a/app/src/main/java/com/bugsnag/android/StorageModule.kt b/app/src/main/java/com/bugsnag/android/StorageModule.kt index 2d4bff14ee..caa419d7b9 100644 --- a/app/src/main/java/com/bugsnag/android/StorageModule.kt +++ b/app/src/main/java/com/bugsnag/android/StorageModule.kt @@ -25,6 +25,8 @@ internal class StorageModule( val deviceId by future { deviceIdStore.loadDeviceId() } + val internalDeviceId by future { deviceIdStore.loadInternalDeviceId() } + val userStore by future { UserStore( immutableConfig, diff --git a/app/src/main/java/com/bugsnag/android/Telemetry.kt b/app/src/main/java/com/bugsnag/android/Telemetry.kt new file mode 100644 index 0000000000..7d5a070a71 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Telemetry.kt @@ -0,0 +1,16 @@ +package com.bugsnag.android + +/** + * Types of telemetry that may be sent to Bugsnag for product improvement purposes. + */ +enum class Telemetry { + + /** + * Errors within the Bugsnag SDK. + */ + INTERNAL_ERRORS; + + internal companion object { + fun fromString(str: String) = values().find { it.name == str } ?: INTERNAL_ERRORS + } +} diff --git a/app/src/main/java/com/bugsnag/android/ThreadState.kt b/app/src/main/java/com/bugsnag/android/ThreadState.kt index 06e00268ac..536ed89168 100644 --- a/app/src/main/java/com/bugsnag/android/ThreadState.kt +++ b/app/src/main/java/com/bugsnag/android/ThreadState.kt @@ -2,25 +2,27 @@ package com.bugsnag.android import com.bugsnag.android.internal.ImmutableConfig import java.io.IOException +import java.lang.Thread as JavaThread /** * Capture and serialize the state of all threads at the time of an exception. */ -internal class ThreadState @Suppress("LongParameterList") @JvmOverloads constructor( +internal class ThreadState @Suppress("LongParameterList") constructor( exc: Throwable?, isUnhandled: Boolean, + maxThreads: Int, sendThreads: ThreadSendPolicy, projectPackages: Collection, logger: Logger, - currentThread: java.lang.Thread? = null, - stackTraces: MutableMap>? = null + currentThread: JavaThread = JavaThread.currentThread(), + allThreads: List = allThreads() ) : JsonStream.Streamable { internal constructor( exc: Throwable?, isUnhandled: Boolean, config: ImmutableConfig - ) : this(exc, isUnhandled, config.sendThreads, config.projectPackages, config.logger) + ) : this(exc, isUnhandled, config.maxReportedThreads, config.sendThreads, config.projectPackages, config.logger) val threads: MutableList @@ -30,10 +32,11 @@ internal class ThreadState @Suppress("LongParameterList") @JvmOverloads construc threads = when { recordThreads -> captureThreadTrace( - stackTraces ?: java.lang.Thread.getAllStackTraces(), - currentThread ?: java.lang.Thread.currentThread(), + allThreads, + currentThread, exc, isUnhandled, + maxThreads, projectPackages, logger ) @@ -41,37 +44,88 @@ internal class ThreadState @Suppress("LongParameterList") @JvmOverloads construc } } + companion object { + private fun rootThreadGroup(): ThreadGroup { + var group = JavaThread.currentThread().threadGroup!! + + while (group.parent != null) { + group = group.parent + } + + return group + } + + internal fun allThreads(): List { + val rootGroup = rootThreadGroup() + val threadCount = rootGroup.activeCount() + val threads: Array = arrayOfNulls(threadCount) + rootGroup.enumerate(threads) + return threads.filterNotNull() + } + } + private fun captureThreadTrace( - stackTraces: MutableMap>, - currentThread: java.lang.Thread, + allThreads: List, + currentThread: JavaThread, exc: Throwable?, isUnhandled: Boolean, + maxThreadCount: Int, projectPackages: Collection, logger: Logger ): MutableList { - // API 24/25 don't record the currentThread, add it in manually - // https://issuetracker.google.com/issues/64122757 - if (!stackTraces.containsKey(currentThread)) { - stackTraces[currentThread] = currentThread.stackTrace - } - if (exc != null && isUnhandled) { // unhandled errors use the exception trace for thread traces - stackTraces[currentThread] = exc.stackTrace + fun toBugsnagThread(thread: JavaThread): Thread { + val isErrorThread = thread.id == currentThread.id + val stackTrace = Stacktrace( + if (isErrorThread) { + if (exc != null && isUnhandled) { // unhandled errors use the exception trace for thread traces + exc.stackTrace + } else { + currentThread.stackTrace + } + } else { + thread.stackTrace + }, + projectPackages, logger + ) + + return Thread( + thread.id, + thread.name, + ThreadType.ANDROID, + isErrorThread, + Thread.State.forThread(thread), + stackTrace, + logger + ) } - val currentThreadId = currentThread.id - return stackTraces.keys - .sortedBy { it.id } - .mapNotNull { thread -> - val trace = stackTraces[thread] + // Keep the lowest ID threads (ordered). Anything after maxThreadCount is lost. + // Note: We must ensure that currentThread is always present in the final list regardless. + val keepThreads = allThreads.sortedBy { it.id }.take(maxThreadCount) - if (trace != null) { - val stacktrace = Stacktrace(trace, projectPackages, logger) - val errorThread = thread.id == currentThreadId - Thread(thread.id, thread.name, ThreadType.ANDROID, errorThread, Thread.State.forThread(thread), stacktrace, logger) - } else { - null - } - }.toMutableList() + val reportThreads = if (keepThreads.contains(currentThread)) { + keepThreads + } else { + // API 24/25 don't record the currentThread, so add it in manually + // https://issuetracker.google.com/issues/64122757 + // currentThread may also have been removed if its ID occurred after maxThreadCount + keepThreads.take(Math.max(maxThreadCount - 1, 0)).plus(currentThread).sortedBy { it.id } + }.map { toBugsnagThread(it) }.toMutableList() + + if (allThreads.size > maxThreadCount) { + reportThreads.add( + Thread( + -1, + "[${allThreads.size - maxThreadCount} threads omitted as the maxReportedThreads limit ($maxThreadCount) was exceeded]", + ThreadType.EMPTY, + false, + Thread.State.UNKNOWN, + Stacktrace(arrayOf(StackTraceElement("", "", "-", 0)), projectPackages, logger), + logger + ) + ) + } + return reportThreads } @Throws(IOException::class) diff --git a/app/src/main/java/com/bugsnag/android/ThreadType.kt b/app/src/main/java/com/bugsnag/android/ThreadType.kt index c1c3cbb5d7..60f834741c 100644 --- a/app/src/main/java/com/bugsnag/android/ThreadType.kt +++ b/app/src/main/java/com/bugsnag/android/ThreadType.kt @@ -5,6 +5,11 @@ package com.bugsnag.android */ enum class ThreadType(internal val desc: String) { + /** + * A thread captured from Android's JVM layer + */ + EMPTY(""), + /** * A thread captured from Android's JVM layer */ diff --git a/app/src/main/java/com/bugsnag/android/internal/BugsnagMapper.kt b/app/src/main/java/com/bugsnag/android/internal/BugsnagMapper.kt new file mode 100644 index 0000000000..c004241bc7 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/internal/BugsnagMapper.kt @@ -0,0 +1,44 @@ +package com.bugsnag.android.internal + +import com.bugsnag.android.BugsnagEventMapper +import com.bugsnag.android.Event +import com.bugsnag.android.JsonStream +import com.bugsnag.android.Logger +import java.io.ByteArrayOutputStream +import com.bugsnag.android.Error as BugsnagError + +class BugsnagMapper(logger: Logger) { + private val eventMapper = BugsnagEventMapper(logger) + + /** + * Convert the given `Map` of data to an `Event` object + */ + fun convertToEvent(data: Map, fallbackApiKey: String): Event { + return eventMapper.convertToEvent(data, fallbackApiKey) + } + + /** + * Convert the given `Map` of data to an `Error` object + */ + fun convertToError(data: Map): BugsnagError { + return eventMapper.convertError(data) + } + + /** + * Convert a given `Event` object to a `Map` + */ + fun convertToMap(event: Event): Map { + val byteStream = ByteArrayOutputStream() + byteStream.writer().use { writer -> JsonStream(writer).value(event) } + return JsonHelper.deserialize(byteStream.toByteArray()) + } + + /** + * Convert a given `Error` object to a `Map` + */ + fun convertToMap(error: BugsnagError): Map { + val byteStream = ByteArrayOutputStream() + byteStream.writer().use { writer -> JsonStream(writer).value(error) } + return JsonHelper.deserialize(byteStream.toByteArray()) + } +} diff --git a/app/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt b/app/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt index 9ace0f6686..06558cfca2 100644 --- a/app/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt +++ b/app/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt @@ -18,6 +18,7 @@ import com.bugsnag.android.EventPayload import com.bugsnag.android.Logger import com.bugsnag.android.ManifestConfigLoader.Companion.BUILD_UUID import com.bugsnag.android.NoopLogger +import com.bugsnag.android.Telemetry import com.bugsnag.android.ThreadSendPolicy import com.bugsnag.android.errorApiHeaders import com.bugsnag.android.safeUnrollCauses @@ -34,6 +35,7 @@ data class ImmutableConfig( val enabledReleaseStages: Collection?, val projectPackages: Collection, val enabledBreadcrumbTypes: Set?, + val telemetry: Set, val releaseStage: String?, val buildUuid: String?, val appVersion: String?, @@ -47,6 +49,7 @@ data class ImmutableConfig( val maxBreadcrumbs: Int, val maxPersistedEvents: Int, val maxPersistedSessions: Int, + val maxReportedThreads: Int, val persistenceDirectory: Lazy, val sendLaunchCrashesSynchronously: Boolean, @@ -159,7 +162,9 @@ internal fun convertToImmutableConfig( maxBreadcrumbs = config.maxBreadcrumbs, maxPersistedEvents = config.maxPersistedEvents, maxPersistedSessions = config.maxPersistedSessions, + maxReportedThreads = config.maxReportedThreads, enabledBreadcrumbTypes = config.enabledBreadcrumbTypes?.toSet(), + telemetry = config.telemetry.toSet(), persistenceDirectory = persistenceDir, sendLaunchCrashesSynchronously = config.sendLaunchCrashesSynchronously, packageInfo = packageInfo, diff --git a/app/src/main/java/eu/faircode/email/ActivityAMP.java b/app/src/main/java/eu/faircode/email/ActivityAMP.java index bbe0c30aee..9f76007bf8 100644 --- a/app/src/main/java/eu/faircode/email/ActivityAMP.java +++ b/app/src/main/java/eu/faircode/email/ActivityAMP.java @@ -24,6 +24,7 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; import android.view.Menu; @@ -161,7 +162,7 @@ public class ActivityAMP extends ActivityBase { WebSettings settings = wvAmp.getSettings(); boolean dark = (Helper.isDarkTheme(this) && !force_light); boolean canDarken = WebViewEx.isFeatureSupported(this, WebViewFeature.ALGORITHMIC_DARKENING); - if (canDarken) + if (canDarken && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, dark); } diff --git a/app/src/main/java/eu/faircode/email/ActivityBase.java b/app/src/main/java/eu/faircode/email/ActivityBase.java index df18d06ab8..6784476551 100644 --- a/app/src/main/java/eu/faircode/email/ActivityBase.java +++ b/app/src/main/java/eu/faircode/email/ActivityBase.java @@ -265,6 +265,11 @@ abstract class ActivityBase extends AppCompatActivity implements SharedPreferenc visible = true; + if (!(this instanceof ActivityMain)) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + prefs.edit().putString("last_activity", this.getClass().getName()).apply(); + } + boolean contacts = hasPermission(Manifest.permission.READ_CONTACTS); if (this.contacts != contacts && !this.getClass().equals(ActivitySetup.class) && @@ -778,6 +783,7 @@ abstract class ActivityBase extends AppCompatActivity implements SharedPreferenc public void performBack() { if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) { + // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/app/Activity.java#3896 ActionBar ab = getSupportActionBar(); if (ab != null && ab.collapseActionView()) return; @@ -788,7 +794,6 @@ abstract class ActivityBase extends AppCompatActivity implements SharedPreferenc finish(); } - public void onBackPressedFragment() { performBack(); } diff --git a/app/src/main/java/eu/faircode/email/ActivityCompose.java b/app/src/main/java/eu/faircode/email/ActivityCompose.java index 7d867aa69f..cd2b19d693 100644 --- a/app/src/main/java/eu/faircode/email/ActivityCompose.java +++ b/app/src/main/java/eu/faircode/email/ActivityCompose.java @@ -86,6 +86,9 @@ public class ActivityCompose extends ActivityBase implements FragmentManager.OnB } } + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + prefs.edit().remove("last_composing").apply(); + finishAndRemoveTask(); } } diff --git a/app/src/main/java/eu/faircode/email/ActivityEML.java b/app/src/main/java/eu/faircode/email/ActivityEML.java index 4575c390b0..dc06d93e3e 100644 --- a/app/src/main/java/eu/faircode/email/ActivityEML.java +++ b/app/src/main/java/eu/faircode/email/ActivityEML.java @@ -375,6 +375,7 @@ public class ActivityEML extends ActivityBase { Intent create = new Intent(Intent.ACTION_CREATE_DOCUMENT); create.addCategory(Intent.CATEGORY_OPENABLE); + create.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); create.setType(apart.attachment.getMimeType()); if (!TextUtils.isEmpty(apart.attachment.name)) create.putExtra(Intent.EXTRA_TITLE, apart.attachment.name); diff --git a/app/src/main/java/eu/faircode/email/ActivityMain.java b/app/src/main/java/eu/faircode/email/ActivityMain.java index 5eccf24546..21065512cd 100644 --- a/app/src/main/java/eu/faircode/email/ActivityMain.java +++ b/app/src/main/java/eu/faircode/email/ActivityMain.java @@ -42,8 +42,9 @@ import java.util.Date; import java.util.List; public class ActivityMain extends ActivityBase implements FragmentManager.OnBackStackChangedListener, SharedPreferences.OnSharedPreferenceChangeListener { + static final int RESTORE_STATE_INTERVAL = 3; // minutes + private static final long SPLASH_DELAY = 1500L; // milliseconds - private static final long RESTORE_STATE_INTERVAL = 3 * 60 * 1000L; // milliseconds private static final long SERVICE_START_DELAY = 5 * 1000L; // milliseconds @Override @@ -111,7 +112,7 @@ public class ActivityMain extends ActivityBase implements FragmentManager.OnBack Intent thread = new Intent(ActivityMain.this, ActivityView.class); thread.setAction("thread:" + message.id); - thread.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + thread.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); thread.putExtra("account", message.account); thread.putExtra("folder", message.folder); thread.putExtra("thread", message.thread); @@ -173,11 +174,23 @@ public class ActivityMain extends ActivityBase implements FragmentManager.OnBack @Override protected Boolean onExecute(Context context, Bundle args) { + DB db = DB.getInstance(context); + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String last_activity = prefs.getString("last_activity", null); + long composing = prefs.getLong("last_composing", -1L); + if (ActivityCompose.class.getName().equals(last_activity) && composing >= 0) { + EntityMessage draft = db.message().getMessage(composing); + if (draft == null || draft.ui_hide) + prefs.edit() + .remove("last_activity") + .remove("last_composing") + .apply(); + } + if (prefs.getBoolean("has_accounts", false)) return true; - DB db = DB.getInstance(context); List accounts = db.account().getSynchronizingAccounts(null); boolean hasAccounts = (accounts != null && accounts.size() > 0); @@ -205,9 +218,18 @@ public class ActivityMain extends ActivityBase implements FragmentManager.OnBack // https://developer.android.com/docs/quality-guidelines/core-app-quality long now = new Date().getTime(); long last = prefs.getLong("last_launched", 0L); - if (!BuildConfig.PLAY_STORE_RELEASE && - now - last > RESTORE_STATE_INTERVAL) + boolean restore_on_launch = prefs.getBoolean("restore_on_launch", false); + if (!restore_on_launch || now - last > RESTORE_STATE_INTERVAL * 60 * 1000L) view.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); + else { + String last_activity = prefs.getString("last_activity", null); + long composing = prefs.getLong("last_composing", -1L); + if (ActivityCompose.class.getName().equals(last_activity) && composing >= 0) + view = new Intent(ActivityMain.this, ActivityCompose.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra("action", "edit") + .putExtra("id", composing); + } Intent saved = args.getParcelable("intent"); if (saved == null) { diff --git a/app/src/main/java/eu/faircode/email/ActivitySetup.java b/app/src/main/java/eu/faircode/email/ActivitySetup.java index 7aacea9414..90ecc36c87 100644 --- a/app/src/main/java/eu/faircode/email/ActivitySetup.java +++ b/app/src/main/java/eu/faircode/email/ActivitySetup.java @@ -146,8 +146,10 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac static final int REQUEST_IMPORT_CERTIFICATE = 7; static final int REQUEST_OAUTH = 8; static final int REQUEST_STILL = 9; - static final int REQUEST_DELETE_ACCOUNT = 10; - static final int REQUEST_IMPORT_PROVIDERS = 11; + static final int REQUEST_SELECT_IDENTITY = 10; + static final int REQUEST_EDIT_SIGNATURE = 11; + static final int REQUEST_DELETE_ACCOUNT = 12; + static final int REQUEST_IMPORT_PROVIDERS = 13; static final int PI_MISC = 1; @@ -162,6 +164,7 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac static final String ACTION_MANAGE_LOCAL_CONTACTS = BuildConfig.APPLICATION_ID + ".MANAGE_LOCAL_CONTACTS"; static final String ACTION_MANAGE_CERTIFICATES = BuildConfig.APPLICATION_ID + ".MANAGE_CERTIFICATES"; static final String ACTION_IMPORT_CERTIFICATE = BuildConfig.APPLICATION_ID + ".IMPORT_CERTIFICATE"; + static final String ACTION_SETUP_REORDER = BuildConfig.APPLICATION_ID + ".SETUP_REORDER"; static final String ACTION_SETUP_MORE = BuildConfig.APPLICATION_ID + ".SETUP_MORE"; @Override @@ -238,7 +241,7 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac @Override public void run() { drawerLayout.closeDrawer(drawerContainer); - onMenuOrder(R.string.title_setup_reorder_accounts, EntityAccount.class); + onMenuOrder(R.string.title_setup_reorder_accounts, EntityAccount.class.getName()); } })); @@ -246,7 +249,7 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac @Override public void run() { drawerLayout.closeDrawer(drawerContainer); - onMenuOrder(R.string.title_setup_reorder_folders, TupleFolderSort.class); + onMenuOrder(R.string.title_setup_reorder_folders, TupleFolderSort.class.getName()); } }).setSeparated()); @@ -414,6 +417,7 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac iff.addAction(ACTION_MANAGE_LOCAL_CONTACTS); iff.addAction(ACTION_MANAGE_CERTIFICATES); iff.addAction(ACTION_IMPORT_CERTIFICATE); + iff.addAction(ACTION_SETUP_REORDER); iff.addAction(ACTION_SETUP_MORE); lbm.registerReceiver(receiver, iff); } @@ -435,6 +439,11 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac if (drawerLayout.isDrawerOpen(drawerContainer)) drawerLayout.closeDrawer(drawerContainer); else { + if (getSupportFragmentManager().getBackStackEntryCount() > 1) { + performBack(); + return; + } + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); boolean setup_reminder = prefs.getBoolean("setup_reminder", true); @@ -550,13 +559,13 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac } } - private void onMenuOrder(int title, Class clazz) { + private void onMenuOrder(int title, String className) { if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) getSupportFragmentManager().popBackStack("order", FragmentManager.POP_BACK_STACK_INCLUSIVE); Bundle args = new Bundle(); args.putInt("title", title); - args.putString("class", clazz.getName()); + args.putString("class", className); FragmentOrder fragment = new FragmentOrder(); fragment.setArguments(args); @@ -709,8 +718,39 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac } JSONArray jrules = new JSONArray(); - for (EntityRule rule : db.rule().getRules(folder.id)) + for (EntityRule rule : db.rule().getRules(folder.id)) { + try { + JSONObject jaction = new JSONObject(rule.action); + int type = jaction.getInt("type"); + switch (type) { + case EntityRule.TYPE_MOVE: + case EntityRule.TYPE_COPY: + long target = jaction.getLong("target"); + EntityFolder f = db.folder().getFolder(target); + EntityAccount a = (f == null ? null : db.account().getAccount(f.account)); + if (a != null) + jaction.put("targetAccountUuid", a.uuid); + if (f != null) + jaction.put("targetFolderName", f.name); + break; + case EntityRule.TYPE_ANSWER: + long identity = jaction.getLong("identity"); + long answer = jaction.getLong("answer"); + EntityIdentity i = db.identity().getIdentity(identity); + EntityAnswer t = db.answer().getAnswer(answer); + if (i != null) + jaction.put("identityUuid", i.uuid); + if (t != null) + jaction.put("answerUuid", t.uuid); + break; + } + rule.action = jaction.toString(); + } catch (Throwable ex) { + Log.e(ex); + } + jrules.put(rule.toJSON()); + } jfolder.put("rules", jrules); jfolders.put(jfolder); @@ -984,6 +1024,10 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac long id = answer.id; answer.id = null; + EntityAnswer existing = db.answer().getAnswerByUUID(answer.uuid); + if (existing != null) + db.answer().deleteAnswer(existing.id); + answer.id = db.answer().insertAnswer(answer); xAnswer.put(id, answer.id); @@ -1165,17 +1209,48 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac switch (type) { case EntityRule.TYPE_MOVE: case EntityRule.TYPE_COPY: + String targetAccountUuid = jaction.optString("targetAccountUuid"); + String targetFolderName = jaction.optString("targetFolderName"); + if (!TextUtils.isEmpty(targetAccountUuid) && !TextUtils.isEmpty(targetFolderName)) { + EntityAccount a = db.account().getAccountByUUID(targetAccountUuid); + if (a != null) { + EntityFolder f = db.folder().getFolderByName(a.id, targetFolderName); + if (f != null) { + jaction.put("target", f.id); + break; + } + } + } + + // Legacy long target = jaction.getLong("target"); - Log.i("XLAT target " + target + " > " + xFolder.get(target)); - jaction.put("target", xFolder.get(target)); + Long tid = xFolder.get(target); + Log.i("XLAT target " + target + " > " + tid); + if (tid != null) + jaction.put("target", tid); break; case EntityRule.TYPE_ANSWER: + String identityUuid = jaction.optString("identityUuid"); + String answerUuid = jaction.optString("answerUuid"); + if (!TextUtils.isEmpty(identityUuid) && !TextUtils.isEmpty(answerUuid)) { + EntityIdentity i = db.identity().getIdentityByUUID(identityUuid); + EntityAnswer a = db.answer().getAnswerByUUID(answerUuid); + if (i != null && a != null) { + jaction.put("identity", i.id); + jaction.put("answer", a.id); + break; + } + } + + // Legacy long identity = jaction.getLong("identity"); long answer = jaction.getLong("answer"); - Log.i("XLAT identity " + identity + " > " + xIdentity.get(identity)); - Log.i("XLAT answer " + answer + " > " + xAnswer.get(answer)); - jaction.put("identity", xIdentity.get(identity)); - jaction.put("answer", xAnswer.get(answer)); + Long iid = xIdentity.get(identity); + Long aid = xAnswer.get(answer); + Log.i("XLAT identity " + identity + " > " + iid); + Log.i("XLAT answer " + answer + " > " + aid); + jaction.put("identity", iid); + jaction.put("answer", aid); break; } @@ -1752,6 +1827,7 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac private void onImportCertificate(Intent intent) { Intent open = new Intent(Intent.ACTION_GET_CONTENT); open.addCategory(Intent.CATEGORY_OPENABLE); + open.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); open.setType("*/*"); if (open.resolveActivity(getPackageManager()) == null) // system whitelisted ToastEx.makeText(this, R.string.title_no_saf, Toast.LENGTH_LONG).show(); @@ -1766,6 +1842,7 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac private static Intent getIntentExport() { Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); intent.setType("*/*"); intent.putExtra(Intent.EXTRA_TITLE, "fairemail_" + new SimpleDateFormat("yyyyMMdd").format(new Date().getTime()) + ".backup"); @@ -1776,6 +1853,7 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac private static Intent getIntentImport() { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setType("*/*"); return intent; } @@ -1931,6 +2009,8 @@ public class ActivitySetup extends ActivityBase implements FragmentManager.OnBac onManageCertificates(intent); else if (ACTION_IMPORT_CERTIFICATE.equals(action)) onImportCertificate(intent); + else if (ACTION_SETUP_REORDER.equals(action)) + onMenuOrder(R.string.title_setup_reorder_accounts, intent.getStringExtra("className")); else if (ACTION_SETUP_MORE.equals(action)) onSetupMore(intent); } diff --git a/app/src/main/java/eu/faircode/email/ActivitySignature.java b/app/src/main/java/eu/faircode/email/ActivitySignature.java index 92db7bd51f..ea940a7ecb 100644 --- a/app/src/main/java/eu/faircode/email/ActivitySignature.java +++ b/app/src/main/java/eu/faircode/email/ActivitySignature.java @@ -20,6 +20,7 @@ package eu.faircode.email; */ import android.content.ClipboardManager; +import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; @@ -55,6 +56,8 @@ import com.google.android.material.bottomnavigation.BottomNavigationView; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; +import java.io.FileNotFoundException; +import java.io.InputStream; import java.util.Objects; public class ActivitySignature extends ActivityBase { @@ -69,6 +72,7 @@ public class ActivitySignature extends ActivityBase { private boolean dirty = false; private static final int REQUEST_IMAGE = 1; + private static final int REQUEST_FILE = 2; @Override protected void onCreate(Bundle savedInstanceState) { @@ -239,6 +243,9 @@ public class ActivitySignature extends ActivityBase { item.setChecked(!item.isChecked()); html(item.isChecked()); return true; + } else if (itemId == R.id.menu_import_file) { + onMenuSelectFile(); + return true; } return super.onOptionsItemSelected(item); } @@ -247,6 +254,15 @@ public class ActivitySignature extends ActivityBase { Helper.viewFAQ(this, 57); } + private void onMenuSelectFile() { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.setType("text/*"); + Helper.openAdvanced(intent); + startActivityForResult(intent, REQUEST_FILE); + } + @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); @@ -257,6 +273,10 @@ public class ActivitySignature extends ActivityBase { if (resultCode == RESULT_OK && data != null) onImageSelected(data.getData()); break; + case REQUEST_FILE: + if (resultCode == RESULT_OK && data != null) + onFileSelected(data.getData()); + break; } } catch (Throwable ex) { Log.e(ex); @@ -284,14 +304,18 @@ public class ActivitySignature extends ActivityBase { } private void delete() { - Intent result = new Intent(); + Intent result = getIntent(); + if (result == null) + result = new Intent(); result.putExtra("html", (String) null); setResult(RESULT_OK, result); finish(); } private void save() { - Intent result = new Intent(); + Intent result = getIntent(); + if (result == null) + result = new Intent(); result.putExtra("html", getHtml()); setResult(RESULT_OK, result); finish(); @@ -413,4 +437,36 @@ public class ActivitySignature extends ActivityBase { Log.unexpectedError(getSupportFragmentManager(), ex); } } + + private void onFileSelected(Uri uri) { + Bundle args = new Bundle(); + args.putParcelable("uri", uri); + + new SimpleTask() { + @Override + protected String onExecute(Context context, Bundle args) throws Throwable { + try (InputStream is = getContentResolver().openInputStream(uri)) { + if (is == null) + throw new FileNotFoundException(uri.toString()); + return Helper.readStream(is); + } + } + + @Override + protected void onExecuted(Bundle args, String text) { + int start = etText.getSelectionStart(); + if (start < 0) + start = 0; + etText.getText().insert(start, text); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + if (ex instanceof NoStreamException) + ((NoStreamException) ex).report(ActivitySignature.this); + else + Log.unexpectedError(getSupportFragmentManager(), ex); + } + }.execute(this, args, "signature:file"); + } } diff --git a/app/src/main/java/eu/faircode/email/ActivityView.java b/app/src/main/java/eu/faircode/email/ActivityView.java index 039b5591b8..96278532f2 100644 --- a/app/src/main/java/eu/faircode/email/ActivityView.java +++ b/app/src/main/java/eu/faircode/email/ActivityView.java @@ -718,6 +718,11 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB Shortcuts.update(this, this); } + public boolean isSplit() { + return (layoutId == R.layout.activity_view_portrait_split || + layoutId == R.layout.activity_view_landscape_split); + } + @Override public void onBackPressedFragment() { backPressedCallback.handleOnBackPressed(); @@ -1427,7 +1432,7 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB Intent intent = getIntent(); String action = (intent == null ? null : intent.getAction()); if (action != null && - (action.startsWith("thread") || action.equals("widget"))) + (action.startsWith("thread") || action.startsWith("widget"))) return; String last = prefs.getString("changelog", null); @@ -1623,14 +1628,14 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB .setVisibility(NotificationCompat.VISIBILITY_SECRET); Intent update = new Intent(Intent.ACTION_VIEW, Uri.parse(info.html_url)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent piUpdate = PendingIntentCompat.getActivity( ActivityView.this, PI_UPDATE, update, PendingIntent.FLAG_UPDATE_CURRENT); builder.setContentIntent(piUpdate); Intent manage = new Intent(ActivityView.this, ActivitySetup.class) .setAction("misc") - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK) .putExtra("tab", "misc"); PendingIntent piManage = PendingIntentCompat.getActivity( ActivityView.this, ActivitySetup.PI_MISC, manage, PendingIntent.FLAG_UPDATE_CURRENT); @@ -1642,7 +1647,7 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB if (!TextUtils.isEmpty(info.download_url)) { Intent download = new Intent(Intent.ACTION_VIEW, Uri.parse(info.download_url)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent piDownload = PendingIntentCompat.getActivity( ActivityView.this, 0, download, 0); NotificationCompat.Action.Builder actionDownload = new NotificationCompat.Action.Builder( @@ -1753,7 +1758,7 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB intent.putExtra("id", id); onViewThread(intent); - } else if (action.equals("widget")) { + } else if (action.startsWith("widget")) { long account = intent.getLongExtra("widget_account", -1); long folder = intent.getLongExtra("widget_folder", -1); String type = intent.getStringExtra("widget_type"); diff --git a/app/src/main/java/eu/faircode/email/AdapterAccount.java b/app/src/main/java/eu/faircode/email/AdapterAccount.java index b5c358f0f3..2e7fdb61c4 100644 --- a/app/src/main/java/eu/faircode/email/AdapterAccount.java +++ b/app/src/main/java/eu/faircode/email/AdapterAccount.java @@ -19,6 +19,7 @@ package eu.faircode.email; Copyright 2018-2022 by Marcel Bokhorst (M66B) */ +import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_GMAIL; import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_PASSWORD; import android.annotation.TargetApi; @@ -51,6 +52,7 @@ import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.PopupMenu; import androidx.constraintlayout.widget.Group; @@ -201,6 +203,8 @@ public class AdapterAccount extends RecyclerView.Adapter= Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { String channelId = EntityAccount.getNotificationChannelId(account.id); NotificationManager nm = Helper.getSystemService(context, NotificationManager.class); NotificationChannel channel = nm.getNotificationChannel(channelId); - if (channel != null) + if (channel == null) + popupMenu.getMenu().add(Menu.NONE, R.string.title_create_channel, order++, R.string.title_create_channel); + else { popupMenu.getMenu().add(Menu.NONE, R.string.title_edit_channel, order++, R.string.title_edit_channel); + popupMenu.getMenu().add(Menu.NONE, R.string.title_delete_channel, order++, R.string.title_delete_channel); + } } if (settings) @@ -418,8 +425,17 @@ public class AdapterAccount extends RecyclerView.Adapter= Build.VERSION_CODES.O) + onActionCreateChannel(); + return true; } else if (itemId == R.string.title_edit_channel) { - onActionEditChannel(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + onActionEditChannel(); + return true; + } else if (itemId == R.string.title_delete_channel) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + onActionDeleteChannel(); return true; } else if (itemId == R.string.title_edit_properties) { ViewHolder.this.onClick(view); @@ -514,6 +530,41 @@ public class AdapterAccount extends RecyclerView.Adapter() { + @Override + protected Void onExecute(Context context, Bundle args) { + long id = args.getLong("id"); + + DB db = DB.getInstance(context); + db.account().setAccountNotify(id, true); + + return null; + } + + @Override + protected void onExecuted(Bundle args, Void data) { + onActionEditChannel(); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.unexpectedError(parentFragment.getParentFragmentManager(), ex); + } + }.execute(context, owner, args, "create:channel"); + } + @TargetApi(Build.VERSION_CODES.O) private void onActionEditChannel() { Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) @@ -526,6 +577,34 @@ public class AdapterAccount extends RecyclerView.Adapter() { + @Override + protected Void onExecute(Context context, Bundle args) { + long id = args.getLong("id"); + + DB db = DB.getInstance(context); + db.account().setAccountNotify(id, false); + + return null; + } + + @Override + protected void onExecuted(Bundle args, Void data) { + account.deleteNotificationChannel(context); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.unexpectedError(parentFragment.getParentFragmentManager(), ex); + } + }.execute(context, owner, args, "create:channel"); + } + private void onActionCopy() { LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); lbm.sendBroadcast( diff --git a/app/src/main/java/eu/faircode/email/AdapterAttachment.java b/app/src/main/java/eu/faircode/email/AdapterAttachment.java index 5c8440d4b7..943ecc8064 100644 --- a/app/src/main/java/eu/faircode/email/AdapterAttachment.java +++ b/app/src/main/java/eu/faircode/email/AdapterAttachment.java @@ -237,7 +237,7 @@ public class AdapterAttachment extends RecyclerView.Adapter Log.w(ex); } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && + !"image/svg+xml".equals(type) && + !"svg".equals(Helper.getExtension(file.getName()))) try { return ImageHelper.getScaledDrawable(context, file, type, max); } catch (Throwable ex) { diff --git a/app/src/main/java/eu/faircode/email/AdapterLog.java b/app/src/main/java/eu/faircode/email/AdapterLog.java index 51838eb633..da2c86b68d 100644 --- a/app/src/main/java/eu/faircode/email/AdapterLog.java +++ b/app/src/main/java/eu/faircode/email/AdapterLog.java @@ -106,7 +106,8 @@ public class AdapterLog extends RecyclerView.Adapter { public void set(@NonNull List logs, Long account, Long folder, Long message, - @NonNull List types) { + @NonNull List types, + Runnable callback) { Log.i("Set logs=" + logs.size()); this.all = logs; @@ -162,6 +163,8 @@ public class AdapterLog extends RecyclerView.Adapter { try { diff.dispatchUpdatesTo(AdapterLog.this); + if (callback != null) + callback.run(); } catch (Throwable ex) { Log.e(ex); } @@ -175,7 +178,7 @@ public class AdapterLog extends RecyclerView.Adapter { } public void setTypes(@NonNull List types) { - set(all, account, folder, message, types); + set(all, account, folder, message, types, null); } private static class DiffCallback extends DiffUtil.Callback { diff --git a/app/src/main/java/eu/faircode/email/AdapterMessage.java b/app/src/main/java/eu/faircode/email/AdapterMessage.java index cf512bd2ae..04c66ef551 100644 --- a/app/src/main/java/eu/faircode/email/AdapterMessage.java +++ b/app/src/main/java/eu/faircode/email/AdapterMessage.java @@ -230,6 +230,7 @@ public class AdapterMessage extends RecyclerView.Adapter 0 || tos > 0) ? View.VISIBLE : View.GONE); ibTranslate.setVisibility(tools && !outbox && button_translate && DeepL.isAvailable(context) && message.content ? View.VISIBLE : View.GONE); ibForceLight.setVisibility(tools && full && dark && button_force_light && message.content ? View.VISIBLE : View.GONE); - ibForceLight.setImageLevel(!canDarken || force_light ? 1 : 0); + ibForceLight.setImageLevel(!(canDarken || fake_dark) || force_light ? 1 : 0); ibImportance.setVisibility(tools && button_importance && !outbox && seen ? View.VISIBLE : View.GONE); ibHide.setVisibility(tools && button_hide && !outbox ? View.VISIBLE : View.GONE); ibSeen.setVisibility(tools && button_seen && !outbox && seen ? View.VISIBLE : View.GONE); - ibAnswer.setVisibility(!tools || outbox || (!expand_all && expand_one) || !threading || swipe_reply ? View.GONE : View.VISIBLE); ibNotes.setVisibility(tools && button_notes && !outbox ? View.VISIBLE : View.GONE); ibLabels.setVisibility(tools && labels_header && labels ? View.VISIBLE : View.GONE); ibKeywords.setVisibility(tools && button_keywords && keywords ? View.VISIBLE : View.GONE); @@ -2516,6 +2533,7 @@ public class AdapterMessage extends RecyclerView.Adapter() { @Override protected void onPreExecute(Bundle args) { @@ -2820,6 +2840,10 @@ public class AdapterMessage extends RecyclerView.Adapter= Build.VERSION_CODES.O && !BuildConfig.DEBUG) diff --git a/app/src/main/java/eu/faircode/email/BoundaryCallbackMessages.java b/app/src/main/java/eu/faircode/email/BoundaryCallbackMessages.java index 1274612a46..c99b91b29e 100644 --- a/app/src/main/java/eu/faircode/email/BoundaryCallbackMessages.java +++ b/app/src/main/java/eu/faircode/email/BoundaryCallbackMessages.java @@ -21,6 +21,7 @@ package eu.faircode.email; import android.content.Context; import android.content.SharedPreferences; +import android.graphics.Color; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -860,8 +861,7 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback 0) { + List keywords = new ArrayList<>(Arrays.asList(userFlags)); + Collections.sort(keywords); + userFlags = keywords.toArray(new String[0]); + if (!Arrays.equals(folder.keywords, userFlags)) { + Log.i(folder.name + " updating flags=" + TextUtils.join(",", userFlags)); + folder.keywords = userFlags; + db.folder().setFolderKeywords(folder.id, DB.Converters.fromStringArray(userFlags)); + } + } + // Check uid validity try { long uidv = ifolder.getUIDValidity(); @@ -4134,6 +4148,9 @@ class Core { reply.setPersonal(from.getPersonal()); } + if (helper.isReport() && EntityFolder.DRAFTS.equals(folder.type)) + message.dsn = EntityMessage.DSN_HARD_BOUNCE; + EntityIdentity identity = matchIdentity(context, folder, message); message.identity = (identity == null ? null : identity.id); @@ -4226,12 +4243,16 @@ class Core { if (experiments && helper.isReport()) try { MessageHelper.Report r = parts.getReport(); - if (r != null) { + boolean client_id = prefs.getBoolean("client_id", true); + String we = "dns;" + (client_id ? EmailService.getDefaultEhlo() : "example.com"); + if (r != null && !we.equals(r.reporter)) { String label = null; if (r.isDeliveryStatus()) label = (r.isDelivered() ? MessageHelper.FLAG_DELIVERED : MessageHelper.FLAG_NOT_DELIVERED); else if (r.isDispositionNotification()) label = (r.isMdnDisplayed() ? MessageHelper.FLAG_DISPLAYED : MessageHelper.FLAG_NOT_DISPLAYED); + else if (r.isFeedbackReport()) + label = MessageHelper.FLAG_COMPLAINT; if (label != null) { Map map = new HashMap<>(); @@ -4242,12 +4263,11 @@ class Core { List all = new ArrayList<>(); - if (message.inreplyto != null) - for (String inreplyto : message.inreplyto.split(" ")) { - List replied = db.message().getMessagesByMsgId(folder.account, inreplyto); - if (replied != null) - all.addAll(replied); - } + if (message.inreplyto != null) { + List replied = db.message().getMessagesByMsgId(folder.account, message.inreplyto); + if (replied != null) + all.addAll(replied); + } if (r.refid != null) { List refs = db.message().getMessagesByMsgId(folder.account, r.refid); if (refs != null) @@ -4261,10 +4281,8 @@ class Core { map.put(f.id, f); } - if (message.inreplyto != null) - for (EntityFolder f : map.values()) - for (String inreplyto : message.inreplyto.split(" ")) - EntityOperation.queue(context, f, EntityOperation.REPORT, inreplyto, label); + for (EntityFolder f : map.values()) + EntityOperation.queue(context, f, EntityOperation.REPORT, message.inreplyto, label); } } } catch (Throwable ex) { @@ -5256,7 +5274,7 @@ class Core { } else content = new Intent(context, ActivityView.class) .setAction("unified" + (notify_remove ? ":" + group : "")); - content.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + content.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); PendingIntent piContent = PendingIntentCompat.getActivity( context, ActivityView.PI_UNIFIED, content, PendingIntent.FLAG_UPDATE_CURRENT); @@ -5381,7 +5399,7 @@ class Core { // Build pending intents Intent thread = new Intent(context, ActivityView.class); thread.setAction("thread:" + message.id); - thread.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + thread.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); thread.putExtra("account", message.account); thread.putExtra("folder", message.folder); thread.putExtra("thread", message.thread); @@ -5853,7 +5871,7 @@ class Core { intent.putExtra("protocol", account.protocol); intent.putExtra("auth_type", account.auth_type); intent.putExtra("faq", 22); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent pi = PendingIntentCompat.getActivity( context, ActivityError.PI_ERROR, intent, PendingIntent.FLAG_UPDATE_CURRENT); diff --git a/app/src/main/java/eu/faircode/email/DB.java b/app/src/main/java/eu/faircode/email/DB.java index 09cb045add..e316eec2af 100644 --- a/app/src/main/java/eu/faircode/email/DB.java +++ b/app/src/main/java/eu/faircode/email/DB.java @@ -71,7 +71,7 @@ import io.requery.android.database.sqlite.SQLiteDatabase; // https://developer.android.com/topic/libraries/architecture/room.html @Database( - version = 236, + version = 240, entities = { EntityIdentity.class, EntityAccount.class, @@ -137,7 +137,7 @@ public abstract class DB extends RoomDatabase { "page_count", "page_size", "max_page_count", "freelist_count", "cache_size", "cache_spill", "soft_heap_limit", "hard_heap_limit", "mmap_size", - "foreign_keys" + "foreign_keys", "auto_vacuum" )); @Override @@ -408,6 +408,15 @@ public abstract class DB extends RoomDatabase { crumb.put("WAL", Boolean.toString(db.isWriteAheadLoggingEnabled())); Log.breadcrumb("Database", crumb); + // https://www.sqlite.org/pragma.html#pragma_auto_vacuum + // https://android.googlesource.com/platform/external/sqlite.git/+/6ab557bdc070f11db30ede0696888efd19800475%5E!/ + boolean sqlite_auto_vacuum = prefs.getBoolean("sqlite_auto_vacuum", !Helper.isRedmiNote()); + String mode = (sqlite_auto_vacuum ? "FULL" : "INCREMENTAL"); + Log.i("Set PRAGMA auto_vacuum = " + mode); + try (Cursor cursor = db.query("PRAGMA auto_vacuum = " + mode + ";", null)) { + cursor.moveToNext(); // required + } + // https://www.sqlite.org/pragma.html#pragma_cache_size Integer cache_size = getCacheSizeKb(context); if (cache_size != null) { @@ -2113,19 +2122,14 @@ public abstract class DB extends RoomDatabase { public void migrate(@NonNull SupportSQLiteDatabase db) { logMigration(startVersion, endVersion); db.execSQL("ALTER TABLE `account` ADD COLUMN `uuid` TEXT NOT NULL DEFAULT ''"); - Cursor cursor = null; - try { - cursor = db.query("SELECT id FROM account"); + try (Cursor cursor = db.query("SELECT id FROM account")) { while (cursor != null && cursor.moveToNext()) { long id = cursor.getLong(0); String uuid = UUID.randomUUID().toString(); - Log.i("MMM account=" + id + " uuid=" + uuid); - db.execSQL("UPDATE account SET uuid = ? WHERE id = ?", - new Object[]{uuid, id}); + db.execSQL("UPDATE account SET uuid = ? WHERE id = ?", new Object[]{uuid, id}); } } catch (Throwable ex) { - if (cursor != null) - cursor.close(); + Log.e(ex); } } }).addMigrations(new Migration(204, 205) { @@ -2355,6 +2359,57 @@ public abstract class DB extends RoomDatabase { logMigration(startVersion, endVersion); db.execSQL("ALTER TABLE `identity` ADD COLUMN `octetmime` INTEGER NOT NULL DEFAULT 0"); } + }).addMigrations(new Migration(236, 237) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + logMigration(startVersion, endVersion); + db.execSQL("ALTER TABLE `rule` ADD COLUMN `uuid` TEXT NOT NULL DEFAULT ''"); + try (Cursor cursor = db.query("SELECT id FROM rule")) { + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(0); + String uuid = UUID.randomUUID().toString(); + db.execSQL("UPDATE rule SET uuid = ? WHERE id = ?", new Object[]{uuid, id}); + } + } catch (Throwable ex) { + Log.e(ex); + } + } + }).addMigrations(new Migration(237, 238) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + logMigration(startVersion, endVersion); + db.execSQL("ALTER TABLE `answer` ADD COLUMN `uuid` TEXT NOT NULL DEFAULT ''"); + try (Cursor cursor = db.query("SELECT id FROM answer")) { + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(0); + String uuid = UUID.randomUUID().toString(); + db.execSQL("UPDATE answer SET uuid = ? WHERE id = ?", new Object[]{uuid, id}); + } + } catch (Throwable ex) { + Log.e(ex); + } + } + }).addMigrations(new Migration(238, 239) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + logMigration(startVersion, endVersion); + db.execSQL("ALTER TABLE `identity` ADD COLUMN `uuid` TEXT NOT NULL DEFAULT ''"); + try (Cursor cursor = db.query("SELECT id FROM identity")) { + while (cursor != null && cursor.moveToNext()) { + long id = cursor.getLong(0); + String uuid = UUID.randomUUID().toString(); + db.execSQL("UPDATE identity SET uuid = ? WHERE id = ?", new Object[]{uuid, id}); + } + } catch (Throwable ex) { + Log.e(ex); + } + } + }).addMigrations(new Migration(239, 240) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + logMigration(startVersion, endVersion); + db.execSQL("ALTER TABLE `search` ADD COLUMN `order` INTEGER"); + } }).addMigrations(new Migration(998, 999) { @Override public void migrate(@NonNull SupportSQLiteDatabase db) { @@ -2378,7 +2433,8 @@ public abstract class DB extends RoomDatabase { long start = new Date().getTime(); StringBuilder sb = new StringBuilder(); SupportSQLiteDatabase sdb = db.getOpenHelper().getWritableDatabase(); - try (Cursor cursor = sdb.query("PRAGMA wal_checkpoint(PASSIVE);")) { + String mode = (true ? "RESTART" : "PASSIVE"); + try (Cursor cursor = sdb.query("PRAGMA wal_checkpoint(" + mode + ");")) { if (cursor.moveToNext()) { for (int i = 0; i < cursor.getColumnCount(); i++) { if (i > 0) @@ -2389,7 +2445,8 @@ public abstract class DB extends RoomDatabase { } long elapse = new Date().getTime() - start; - Log.i("PRAGMA wal_checkpoint=" + sb + " elapse=" + elapse); + EntityLog.log(context, "PRAGMA wal_checkpoint(" + mode + ")=" + sb + + " elapse=" + elapse + " ms"); } catch (Throwable ex) { Log.e(ex); } diff --git a/app/src/main/java/eu/faircode/email/DaoAccount.java b/app/src/main/java/eu/faircode/email/DaoAccount.java index 437b8b9a5e..c827d457cc 100644 --- a/app/src/main/java/eu/faircode/email/DaoAccount.java +++ b/app/src/main/java/eu/faircode/email/DaoAccount.java @@ -156,9 +156,8 @@ public interface DaoAccount { @Query("SELECT * FROM account" + " WHERE user = :user" + " AND pop = :protocol" + - " AND auth_type IN (:auth_type)" + " AND tbd IS NULL") - List getAccounts(String user, int protocol, int[] auth_type); + List getAccounts(String user, int protocol); @Query("SELECT * FROM account WHERE `primary`") EntityAccount getPrimaryAccount(); @@ -216,11 +215,14 @@ public interface DaoAccount { @Query("UPDATE account SET name = :name WHERE id = :id AND NOT (name IS :name)") int setAccountName(long id, String name); + @Query("UPDATE account SET color = :color WHERE id = :id AND NOT (color IS :color)") + int setAccountColor(long id, Integer color); + @Query("UPDATE account" + - " SET password = :password, auth_type = :auth_type" + + " SET password = :password, auth_type = :auth_type, provider = :provider" + " WHERE id = :id" + - " AND NOT (password IS :password AND auth_type = :auth_type)") - int setAccountPassword(long id, String password, int auth_type); + " AND NOT (password IS :password AND auth_type = :auth_type AND provider = :provider)") + int setAccountPassword(long id, String password, int auth_type, String provider); @Query("UPDATE account" + " SET fingerprint = :fingerprint" + diff --git a/app/src/main/java/eu/faircode/email/DaoAnswer.java b/app/src/main/java/eu/faircode/email/DaoAnswer.java index a430dd8028..3276a16424 100644 --- a/app/src/main/java/eu/faircode/email/DaoAnswer.java +++ b/app/src/main/java/eu/faircode/email/DaoAnswer.java @@ -55,6 +55,9 @@ public interface DaoAnswer { @Query("SELECT * FROM answer WHERE id = :id") EntityAnswer getAnswer(long id); + @Query("SELECT * FROM answer WHERE uuid = :uuid") + EntityAnswer getAnswerByUUID(String uuid); + @Query("SELECT * FROM answer" + " WHERE standard AND NOT hide") EntityAnswer getStandardAnswer(); diff --git a/app/src/main/java/eu/faircode/email/DaoIdentity.java b/app/src/main/java/eu/faircode/email/DaoIdentity.java index b238b4090c..72048cd74c 100644 --- a/app/src/main/java/eu/faircode/email/DaoIdentity.java +++ b/app/src/main/java/eu/faircode/email/DaoIdentity.java @@ -86,6 +86,9 @@ public interface DaoIdentity { @Query("SELECT * FROM identity WHERE id = :id") EntityIdentity getIdentity(long id); + @Query("SELECT * FROM identity WHERE uuid = :uuid") + EntityIdentity getIdentityByUUID(String uuid); + @Insert long insertIdentity(EntityIdentity identity); @@ -113,12 +116,12 @@ public interface DaoIdentity { int setIdentityPassword(long account, String user, String password, int auth_type, String domain); @Query("UPDATE identity" + - " SET password = :password, auth_type = :new_auth_type" + + " SET password = :password, auth_type = :new_auth_type, provider = :provider" + " WHERE account = :account" + " AND user = :user" + " AND auth_type = :auth_type" + - " AND NOT (password IS :password AND auth_type IS :new_auth_type)") - int setIdentityPassword(long account, String user, String password, int auth_type, int new_auth_type); + " AND NOT (password IS :password AND auth_type IS :new_auth_type AND provider = :provider)") + int setIdentityPassword(long account, String user, String password, int auth_type, int new_auth_type, String provider); @Query("UPDATE identity" + " SET fingerprint = :fingerprint" + @@ -141,6 +144,9 @@ public interface DaoIdentity { @Query("UPDATE identity SET max_size = :max_size WHERE id = :id AND NOT (max_size IS :max_size)") int setIdentityMaxSize(long id, Long max_size); + @Query("UPDATE identity SET signature = :hmtl WHERE id = :id AND NOT (signature IS :hmtl)") + int setIdentitySignature(long id, String hmtl); + @Query("UPDATE identity SET error = :error WHERE id = :id AND NOT (error IS :error)") int setIdentityError(long id, String error); diff --git a/app/src/main/java/eu/faircode/email/DaoMessage.java b/app/src/main/java/eu/faircode/email/DaoMessage.java index 449dd4270f..12a6ec78a4 100644 --- a/app/src/main/java/eu/faircode/email/DaoMessage.java +++ b/app/src/main/java/eu/faircode/email/DaoMessage.java @@ -647,6 +647,11 @@ public interface DaoMessage { " AND NOT ui_snoozed IS NULL") List getSnoozed(Long folder); + @Query("SELECT COUNT(*) FROM message" + + " WHERE NOT ui_snoozed IS NULL" + + " AND ui_snoozed <> " + Long.MAX_VALUE) + int getSnoozedCount(); + @Query("SELECT id AS _id, subject AS suggestion FROM message" + " WHERE (:account IS NULL OR message.account = :account)" + " AND (:folder IS NULL OR message.folder = :folder)" + diff --git a/app/src/main/java/eu/faircode/email/DaoRule.java b/app/src/main/java/eu/faircode/email/DaoRule.java index 6a4381dcb4..3114b30c31 100644 --- a/app/src/main/java/eu/faircode/email/DaoRule.java +++ b/app/src/main/java/eu/faircode/email/DaoRule.java @@ -46,6 +46,9 @@ public interface DaoRule { " WHERE rule.id = :id") TupleRuleEx getRule(long id); + @Query("SELECT * FROM rule WHERE uuid = :uuid") + EntityRule getRuleByUUID(String uuid); + @Query("SELECT rule.*, folder.account, folder.name AS folderName, account.name AS accountName FROM rule" + " JOIN folder ON folder.id = rule.folder" + " JOIN account ON account.id = folder.account" + diff --git a/app/src/main/java/eu/faircode/email/DaoSearch.java b/app/src/main/java/eu/faircode/email/DaoSearch.java index 697d1af90e..9120732ec4 100644 --- a/app/src/main/java/eu/faircode/email/DaoSearch.java +++ b/app/src/main/java/eu/faircode/email/DaoSearch.java @@ -23,18 +23,26 @@ import androidx.lifecycle.LiveData; import androidx.room.Dao; import androidx.room.Insert; import androidx.room.Query; +import androidx.room.Update; import java.util.List; @Dao public interface DaoSearch { @Query("SELECT * FROM search" + - " ORDER BY name COLLATE NOCASE") + " ORDER BY `order`, name COLLATE NOCASE") LiveData> liveSearch(); @Insert long insertSearch(EntitySearch search); + @Query("SELECT * FROM search" + + " WHERE id = :id") + EntitySearch getSearch(long id); + + @Update + int updateSearch(EntitySearch search); + @Query("DELETE FROM search" + " WHERE id = :id") int deleteSearch(long id); diff --git a/app/src/main/java/eu/faircode/email/EditTextCompose.java b/app/src/main/java/eu/faircode/email/EditTextCompose.java index fb132e3cc9..385ec20842 100644 --- a/app/src/main/java/eu/faircode/email/EditTextCompose.java +++ b/app/src/main/java/eu/faircode/email/EditTextCompose.java @@ -34,6 +34,7 @@ import android.os.Parcelable; import android.text.Editable; import android.text.SpannableStringBuilder; import android.text.Spanned; +import android.text.TextUtils; import android.text.style.QuoteSpan; import android.text.style.StyleSpan; import android.util.AttributeSet; @@ -110,6 +111,8 @@ public class EditTextCompose extends FixedEditText { public boolean onCreateActionMode(ActionMode mode, Menu menu) { try { int order = 1000; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + menu.add(Menu.CATEGORY_SECONDARY, android.R.id.pasteAsPlainText, order++, getTitle(R.string.title_paste_plain)); if (undo_manager && can(android.R.id.undo)) menu.add(Menu.CATEGORY_SECONDARY, R.string.title_undo, order++, getTitle(R.string.title_undo)); if (undo_manager && can(android.R.id.redo)) @@ -142,7 +145,9 @@ public class EditTextCompose extends FixedEditText { public boolean onActionItemClicked(ActionMode mode, MenuItem item) { if (item.getGroupId() == Menu.CATEGORY_SECONDARY) { int id = item.getItemId(); - if (id == R.string.title_undo) + if (id == android.R.id.pasteAsPlainText) + return insertPlain(); + else if (id == R.string.title_undo) return EditTextCompose.super.onTextContextMenuItem(android.R.id.undo); else if (id == R.string.title_redo) return EditTextCompose.super.onTextContextMenuItem(android.R.id.redo); @@ -163,6 +168,31 @@ public class EditTextCompose extends FixedEditText { // Do nothing } + private boolean insertPlain() { + ClipboardManager cbm = Helper.getSystemService(context, ClipboardManager.class); + if (!cbm.hasPrimaryClip()) + return true; + + ClipData clip = cbm.getPrimaryClip(); + if (clip == null || clip.getItemCount() < 1) + return true; + + ClipData.Item item = clip.getItemAt(0); + if (item == null) + return true; + + CharSequence text = item.getText(); + if (TextUtils.isEmpty(text)) + return true; + + int start = getSelectionStart(); + if (start < 0) + start = 0; + getText().insert(start, text.toString()); + + return true; + } + private boolean insertLine() { try { int start = getSelectionStart(); diff --git a/app/src/main/java/eu/faircode/email/EditTextMultiAutoComplete.java b/app/src/main/java/eu/faircode/email/EditTextMultiAutoComplete.java index 391713f04d..77f945a3bd 100644 --- a/app/src/main/java/eu/faircode/email/EditTextMultiAutoComplete.java +++ b/app/src/main/java/eu/faircode/email/EditTextMultiAutoComplete.java @@ -127,6 +127,8 @@ public class EditTextMultiAutoComplete extends AppCompatMultiAutoCompleteTextVie @Override public void run() { try { + if (edit == null) + return; ClipImageSpan[] spans = edit.getSpans(backspace, backspace, ClipImageSpan.class); if (spans.length == 1) { int start = edit.getSpanStart(spans[0]); @@ -369,6 +371,7 @@ public class EditTextMultiAutoComplete extends AppCompatMultiAutoCompleteTextVie if (kar == ',' && (i + 1 == edit.length() || edit.charAt(i + 1) != ' ')) edit.insert(++i, " "); + added = true; } } @@ -382,8 +385,10 @@ public class EditTextMultiAutoComplete extends AppCompatMultiAutoCompleteTextVie for (ClipImageSpan span : tbd) edit.removeSpan(span); - if (tbd.size() > 0 || added) - invalidate(); + if (tbd.size() > 0 || added) { + setText(edit); + setSelection(selStart, selEnd); + } } catch (Throwable ex) { Log.e(ex); } diff --git a/app/src/main/java/eu/faircode/email/EmailService.java b/app/src/main/java/eu/faircode/email/EmailService.java index ea3002013f..4567bc0953 100644 --- a/app/src/main/java/eu/faircode/email/EmailService.java +++ b/app/src/main/java/eu/faircode/email/EmailService.java @@ -104,6 +104,7 @@ public class EmailService implements AutoCloseable { private boolean insecure; private int purpose; private boolean ssl_harden; + private boolean ssl_harden_strict; private boolean cert_strict; private boolean useip; private String ehlo; @@ -149,10 +150,17 @@ public class EmailService implements AutoCloseable { "SSLv2", "SSLv3", "TLSv1", "TLSv1.1" )); + private static final List SSL_PROTOCOL_BLACKLIST_STRICT = Collections.unmodifiableList(Arrays.asList( + "SSLv2", "SSLv3", "TLSv1", "TLSv1.1", "TLSv1.2" + )); + // https://developer.android.com/reference/javax/net/ssl/SSLSocket.html#cipher-suites private static final Pattern SSL_CIPHER_BLACKLIST = Pattern.compile(".*(_DES|DH_|DSS|EXPORT|MD5|NULL|RC4|TLS_FALLBACK_SCSV).*"); + private static final Pattern SSL_CIPHER_BLACKLIST_STRICT = + Pattern.compile("(.*(_DES|DH_|DSS|EXPORT|MD5|NULL|RC4|TLS_FALLBACK_SCSV|RSA).*)|(.*SHA$)"); + // TLS_FALLBACK_SCSV https://tools.ietf.org/html/rfc7507 // TLS_EMPTY_RENEGOTIATION_INFO_SCSV https://tools.ietf.org/html/rfc5746 @@ -183,6 +191,7 @@ public class EmailService implements AutoCloseable { this.log = prefs.getBoolean("protocol", false); this.level = prefs.getInt("log_level", Log.getDefaultLogLevel()); this.ssl_harden = prefs.getBoolean("ssl_harden", false); + this.ssl_harden_strict = prefs.getBoolean("ssl_harden_strict", false); this.cert_strict = prefs.getBoolean("cert_strict", !BuildConfig.PLAY_STORE_RELEASE); boolean auth_plain = prefs.getBoolean("auth_plain", true); @@ -190,12 +199,14 @@ public class EmailService implements AutoCloseable { boolean auth_ntlm = prefs.getBoolean("auth_ntlm", true); boolean auth_sasl = prefs.getBoolean("auth_sasl", true); boolean auth_apop = prefs.getBoolean("auth_apop", false); + boolean use_top = prefs.getBoolean("use_top", true); Log.i("Authenticate" + " plain=" + auth_plain + " login=" + auth_login + " ntlm=" + auth_ntlm + " sasl=" + auth_sasl + - " apop=" + auth_apop); + " apop=" + auth_apop + + " use_top=" + use_top); properties.put("mail.event.scope", "folder"); properties.put("mail.event.executor", executor); @@ -208,6 +219,8 @@ public class EmailService implements AutoCloseable { properties.put("mail." + protocol + ".auth.ntlm.disable", "true"); if (auth_apop) properties.put("mail." + protocol + ".apop.enable", "true"); + if (!use_top) + properties.put("mail." + protocol + ".disabletop", "true"); // SASL is attempted before other authentication methods properties.put("mail." + protocol + ".sasl.enable", Boolean.toString(auth_sasl)); @@ -336,15 +349,15 @@ public class EmailService implements AutoCloseable { public void connect(EntityAccount account) throws MessagingException { connect( account.host, account.port, - account.auth_type, account.provider, account.poll_interval, + account.auth_type, account.provider, account.user, account.password, new ServiceAuthenticator.IAuthenticated() { @Override public void onPasswordChanged(Context context, String newPassword) { DB db = DB.getInstance(context); account.password = newPassword; - int accounts = db.account().setAccountPassword(account.id, account.password, account.auth_type); - int identities = db.identity().setIdentityPassword(account.id, account.user, account.password, account.auth_type, account.auth_type); + int accounts = db.account().setAccountPassword(account.id, account.password, account.auth_type, account.provider); + int identities = db.identity().setIdentityPassword(account.id, account.user, account.password, account.auth_type, account.auth_type, account.provider); EntityLog.log(context, EntityLog.Type.Account, account, "token refreshed=" + accounts + "/" + identities); } @@ -355,7 +368,7 @@ public class EmailService implements AutoCloseable { public void connect(EntityIdentity identity) throws MessagingException { connect( identity.host, identity.port, - identity.auth_type, identity.provider, 0, + identity.auth_type, identity.provider, identity.user, identity.password, new ServiceAuthenticator.IAuthenticated() { @Override @@ -376,12 +389,12 @@ public class EmailService implements AutoCloseable { int auth, String provider, String user, String password, String certificate, String fingerprint) throws MessagingException { - connect(host, port, auth, provider, 0, user, password, null, certificate, fingerprint); + connect(host, port, auth, provider, user, password, null, certificate, fingerprint); } private void connect( String host, int port, - int auth, String provider, int keep_alive, + int auth, String provider, String user, String password, ServiceAuthenticator.IAuthenticated intf, String certificate, String fingerprint) throws MessagingException { @@ -417,7 +430,7 @@ public class EmailService implements AutoCloseable { } } - factory = new SSLSocketFactoryService(host, insecure, ssl_harden, cert_strict, key, chain, fingerprint); + factory = new SSLSocketFactoryService(host, insecure, ssl_harden, ssl_harden_strict, cert_strict, key, chain, fingerprint); properties.put("mail." + protocol + ".ssl.socketFactory", factory); properties.put("mail." + protocol + ".socketFactory.fallback", "false"); properties.put("mail." + protocol + ".ssl.checkserveridentity", "false"); @@ -429,8 +442,7 @@ public class EmailService implements AutoCloseable { } properties.put("mail." + protocol + ".forcepasswordrefresh", "true"); - authenticator = new ServiceAuthenticator(context, - auth, provider, keep_alive, user, password, intf); + authenticator = new ServiceAuthenticator(context, auth, provider, user, password, intf); if ("imap.wp.pl".equals(host)) properties.put("mail.idledone", "false"); @@ -464,6 +476,8 @@ public class EmailService implements AutoCloseable { if (auth == AUTH_TYPE_GMAIL || auth == AUTH_TYPE_OAUTH) { try { + EntityLog.log(context, EntityLog.Type.Debug, + ex + "\n" + android.util.Log.getStackTraceString(ex)); authenticator.refreshToken(true); connect(host, port, auth, user, factory); } catch (Exception ex1) { @@ -876,8 +890,8 @@ public class EmailService implements AutoCloseable { return false; } - public void check() { - authenticator.checkToken(); + public Long getAccessTokenExpirationTime() { + return authenticator.getAccessTokenExpirationTime(); } public boolean isOpen() { @@ -942,15 +956,17 @@ public class EmailService implements AutoCloseable { private String server; private boolean secure; private boolean ssl_harden; + private boolean ssl_harden_strict; private boolean cert_strict; private String trustedFingerprint; private SSLSocketFactory factory; private X509Certificate certificate; - SSLSocketFactoryService(String host, boolean insecure, boolean ssl_harden, boolean cert_strict, PrivateKey key, X509Certificate[] chain, String fingerprint) throws GeneralSecurityException { + SSLSocketFactoryService(String host, boolean insecure, boolean ssl_harden, boolean ssl_harden_strict, boolean cert_strict, PrivateKey key, X509Certificate[] chain, String fingerprint) throws GeneralSecurityException { this.server = host; this.secure = !insecure; this.ssl_harden = ssl_harden; + this.ssl_harden_strict = ssl_harden_strict; this.cert_strict = cert_strict; this.trustedFingerprint = fingerprint; @@ -1148,6 +1164,27 @@ public class EmailService implements AutoCloseable { ciphers.addAll(Arrays.asList(sslSocket.getSupportedCipherSuites())); ciphers.remove("TLS_FALLBACK_SCSV"); sslSocket.setEnabledCipherSuites(ciphers.toArray(new String[0])); + } else if (ssl_harden && ssl_harden_strict && + !BuildConfig.PLAY_STORE_RELEASE && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Protocols + List protocols = new ArrayList<>(); + for (String protocol : sslSocket.getEnabledProtocols()) + if (SSL_PROTOCOL_BLACKLIST_STRICT.contains(protocol)) + Log.i("SSL disabling protocol=" + protocol); + else + protocols.add(protocol); + sslSocket.setEnabledProtocols(protocols.toArray(new String[0])); + + // Ciphers + List ciphers = new ArrayList<>(); + for (String cipher : sslSocket.getEnabledCipherSuites()) { + if (SSL_CIPHER_BLACKLIST_STRICT.matcher(cipher).matches()) + Log.i("SSL disabling cipher=" + cipher); + else + ciphers.add(cipher); + } + sslSocket.setEnabledCipherSuites(ciphers.toArray(new String[0])); } else if (ssl_harden) { // Protocols List protocols = new ArrayList<>(); diff --git a/app/src/main/java/eu/faircode/email/EntityAnswer.java b/app/src/main/java/eu/faircode/email/EntityAnswer.java index 492adac97f..a879b1b8ce 100644 --- a/app/src/main/java/eu/faircode/email/EntityAnswer.java +++ b/app/src/main/java/eu/faircode/email/EntityAnswer.java @@ -56,6 +56,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.UUID; import javax.mail.Address; import javax.mail.internet.InternetAddress; @@ -75,6 +76,8 @@ public class EntityAnswer implements Serializable { @PrimaryKey(autoGenerate = true) public Long id; @NonNull + public String uuid = UUID.randomUUID().toString(); + @NonNull public String name; public String group; @NonNull @@ -336,7 +339,7 @@ public class EntityAnswer implements Serializable { ssb.append(p.link).append("\n\n"); profiles.add(999, profiles.size(), profiles.size() + 1, p.name + - (p.appPassword ? "+" : "")) + (p.appPassword ? "+" : "")) .setIntent(new Intent().putExtra("config", ssb)); } } @@ -374,6 +377,7 @@ public class EntityAnswer implements Serializable { public JSONObject toJSON() throws JSONException { JSONObject json = new JSONObject(); json.put("id", id); + json.put("uuid", uuid); json.put("name", name); json.put("group", group); json.put("standard", standard); @@ -392,6 +396,8 @@ public class EntityAnswer implements Serializable { public static EntityAnswer fromJSON(JSONObject json) throws JSONException { EntityAnswer answer = new EntityAnswer(); answer.id = json.getLong("id"); + if (json.has("uuid")) + answer.uuid = json.getString("uuid"); answer.name = json.getString("name"); answer.group = json.optString("group"); if (TextUtils.isEmpty(answer.group)) @@ -415,7 +421,8 @@ public class EntityAnswer implements Serializable { public boolean equals(Object obj) { if (obj instanceof EntityAnswer) { EntityAnswer other = (EntityAnswer) obj; - return (this.name.equals(other.name) && + return (Objects.equals(this.uuid, other.uuid) && + this.name.equals(other.name) && Objects.equals(this.group, other.group) && this.standard.equals(other.standard) && this.receipt.equals(other.receipt) && diff --git a/app/src/main/java/eu/faircode/email/EntityAttachment.java b/app/src/main/java/eu/faircode/email/EntityAttachment.java index d6fcc96bc0..56d9a35095 100644 --- a/app/src/main/java/eu/faircode/email/EntityAttachment.java +++ b/app/src/main/java/eu/faircode/email/EntityAttachment.java @@ -202,6 +202,17 @@ public class EntityAttachment { if (encryption != null) return type; + if ("audio/mid".equals(type)) + return "audio/midi"; + + // https://www.rfc-editor.org/rfc/rfc3555.txt + if ("image/jpg".equals(type) || "video/jpeg".equals(type)) + return "image/jpeg"; + + if (!TextUtils.isEmpty(type) && + (type.endsWith("/pdf") || type.endsWith("/x-pdf"))) + return "application/pdf"; + String extension = Helper.getExtension(name); if (extension == null) return type; @@ -244,6 +255,9 @@ public class EntityAttachment { if ("ppt".equals(extension)) return "application/vnd.ms-powerpoint"; + if ("application/vnd.ms-pps".equals(type)) + return "application/vnd.ms-powerpoint"; + if ("pptx".equals(extension)) return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; @@ -300,17 +314,6 @@ public class EntityAttachment { if ("text/plain".equals(type) && "ovpn".equals(extension)) return "application/x-openvpn-profile"; - if ("audio/mid".equals(type)) - return "audio/midi"; - - // https://www.rfc-editor.org/rfc/rfc3555.txt - if ("image/jpg".equals(type) || "video/jpeg".equals(type)) - return "image/jpeg"; - - if (!TextUtils.isEmpty(type) && - (type.endsWith("/pdf") || type.endsWith("/x-pdf"))) - return "application/pdf"; - // Guess types if (gtype != null) { if (TextUtils.isEmpty(type) || diff --git a/app/src/main/java/eu/faircode/email/EntityFolder.java b/app/src/main/java/eu/faircode/email/EntityFolder.java index c24cca6274..64f90dd9d6 100644 --- a/app/src/main/java/eu/faircode/email/EntityFolder.java +++ b/app/src/main/java/eu/faircode/email/EntityFolder.java @@ -304,6 +304,7 @@ public class EntityFolder extends EntityOrder implements Serializable { // und deren Absender nicht in Ihrem Adressbuch oder auf Ihrer Erwünschtliste stehen. synchronize = true; unified = true; + notify = true; } } diff --git a/app/src/main/java/eu/faircode/email/EntityIdentity.java b/app/src/main/java/eu/faircode/email/EntityIdentity.java index 660e59c0a4..766c55e1f7 100644 --- a/app/src/main/java/eu/faircode/email/EntityIdentity.java +++ b/app/src/main/java/eu/faircode/email/EntityIdentity.java @@ -35,6 +35,7 @@ import org.json.JSONObject; import java.util.Locale; import java.util.Objects; +import java.util.UUID; import java.util.regex.Pattern; import javax.mail.Address; @@ -56,6 +57,8 @@ public class EntityIdentity { @PrimaryKey(autoGenerate = true) public Long id; @NonNull + public String uuid = UUID.randomUUID().toString(); + @NonNull public String name; @NonNull public String email; @@ -187,6 +190,7 @@ public class EntityIdentity { public JSONObject toJSON() throws JSONException { JSONObject json = new JSONObject(); json.put("id", id); + json.put("uuid", uuid); json.put("name", name); json.put("email", email); // not account @@ -244,6 +248,10 @@ public class EntityIdentity { public static EntityIdentity fromJSON(JSONObject json) throws JSONException { EntityIdentity identity = new EntityIdentity(); identity.id = json.getLong("id"); + + if (json.has("uuid")) + identity.uuid = json.getString("uuid"); + identity.name = json.getString("name"); identity.email = json.getString("email"); if (json.has("display") && !json.isNull("display")) @@ -315,7 +323,8 @@ public class EntityIdentity { public boolean equals(Object obj) { if (obj instanceof EntityIdentity) { EntityIdentity other = (EntityIdentity) obj; - return (this.name.equals(other.name) && + return (Objects.equals(this.uuid, other.uuid) && + this.name.equals(other.name) && this.email.equals(other.email) && this.account.equals(other.account) && Objects.equals(this.display, other.display) && diff --git a/app/src/main/java/eu/faircode/email/EntityMessage.java b/app/src/main/java/eu/faircode/email/EntityMessage.java index 8a999ec1ad..0b6505dba1 100644 --- a/app/src/main/java/eu/faircode/email/EntityMessage.java +++ b/app/src/main/java/eu/faircode/email/EntityMessage.java @@ -121,6 +121,8 @@ public class EntityMessage implements Serializable { static final Long SWIPE_ACTION_JUNK = -8L; static final Long SWIPE_ACTION_REPLY = -9L; + private static final int MAX_SNOOZED = 300; + @PrimaryKey(autoGenerate = true) public Long id; @NonNull @@ -354,7 +356,7 @@ public class EntityMessage implements Serializable { EntityMessage.SMIME_SIGNENCRYPT.equals(ui_encrypt)); } - boolean isVerifiable(){ + boolean isVerifiable() { return (EntityMessage.PGP_SIGNONLY.equals(encrypt) || EntityMessage.SMIME_SIGNONLY.equals(encrypt)); } @@ -582,6 +584,30 @@ public class EntityMessage implements Serializable { } static void snooze(Context context, long id, Long wakeup) { + if (wakeup != null && wakeup != Long.MAX_VALUE) { + /* + java.lang.IllegalStateException: Maximum limit of concurrent alarms 500 reached for uid: u0a601, callingPackage: eu.faircode.email + at android.os.Parcel.createExceptionOrNull(Parcel.java:2433) + at android.os.Parcel.createException(Parcel.java:2409) + at android.os.Parcel.readException(Parcel.java:2392) + at android.os.Parcel.readException(Parcel.java:2334) + at android.app.IAlarmManager$Stub$Proxy.set(IAlarmManager.java:359) + at android.app.AlarmManager.setImpl(AlarmManager.java:947) + at android.app.AlarmManager.setImpl(AlarmManager.java:907) + at android.app.AlarmManager.setExactAndAllowWhileIdle(AlarmManager.java:1175) + at androidx.core.app.AlarmManagerCompat$Api23Impl.setExactAndAllowWhileIdle(Unknown Source:0) + at androidx.core.app.AlarmManagerCompat.setExactAndAllowWhileIdle(SourceFile:2) + at eu.faircode.email.AlarmManagerCompatEx.setAndAllowWhileIdle(SourceFile:2) + at eu.faircode.email.EntityMessage.snooze(SourceFile:7) + */ + DB db = DB.getInstance(context); + int count = db.message().getSnoozedCount(); + Log.i("Snoozed=" + count + "/" + MAX_SNOOZED); + if (count > MAX_SNOOZED) + throw new IllegalArgumentException( + String.format("Due to Android limitations, no more than %d messages can be snoozed or delayed", MAX_SNOOZED)); + } + Intent snoozed = new Intent(context, ServiceSynchronize.class); snoozed.setAction("unsnooze:" + id); PendingIntent pi = PendingIntentCompat.getForegroundService( diff --git a/app/src/main/java/eu/faircode/email/EntityOperation.java b/app/src/main/java/eu/faircode/email/EntityOperation.java index a7210635a3..75dbd5f9b0 100644 --- a/app/src/main/java/eu/faircode/email/EntityOperation.java +++ b/app/src/main/java/eu/faircode/email/EntityOperation.java @@ -169,6 +169,20 @@ public class EntityOperation { message.keywords = keywords.toArray(new String[0]); db.message().setMessageKeywords(message.id, DB.Converters.fromStringArray(message.keywords)); + if (set) { + EntityFolder folder = db.folder().getFolder(message.folder); + if (folder != null) { + List fkeywords = new ArrayList<>(); + if (folder.keywords != null) + fkeywords.addAll(Arrays.asList(folder.keywords)); + if (!fkeywords.contains(keyword)) + fkeywords.add(keyword); + Collections.sort(fkeywords); + db.folder().setFolderKeywords(folder.id, + DB.Converters.fromStringArray(fkeywords.toArray(new String[0]))); + } + } + } else if (LABEL.equals(name)) { String label = jargs.getString(0); boolean set = jargs.getBoolean(1); @@ -257,6 +271,7 @@ public class EntityOperation { if (message.ui_found) db.message().setMessageFound(message.id, false); + boolean premove = true; if (source.account.equals(target.account)) { EntityAccount account = db.account().getAccount(message.account); if ((account != null && !account.isGmail()) || @@ -269,6 +284,10 @@ public class EntityOperation { EntityFolder.ARCHIVE.equals(source.type) && !(EntityFolder.TRASH.equals(target.type) || EntityFolder.JUNK.equals(target.type))) name = COPY; + + if (account != null && account.isGmail() && + (EntityFolder.DRAFTS.equals(source.type) || EntityFolder.DRAFTS.equals(target.type))) + premove = false; } if (message.ui_snoozed != null && @@ -296,7 +315,8 @@ public class EntityOperation { // Create copy without uid in target folder // Message with same msgid can be in archive - if (message.uid != null && + if (premove && + message.uid != null && !TextUtils.isEmpty(message.msgid) && db.message().countMessageByMsgId(target.id, message.msgid) == 0) { File msource = message.getFile(context); diff --git a/app/src/main/java/eu/faircode/email/EntityRule.java b/app/src/main/java/eu/faircode/email/EntityRule.java index 9a47701c79..6db4fe6da8 100644 --- a/app/src/main/java/eu/faircode/email/EntityRule.java +++ b/app/src/main/java/eu/faircode/email/EntityRule.java @@ -56,6 +56,7 @@ import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.regex.Pattern; @@ -83,6 +84,8 @@ public class EntityRule { @PrimaryKey(autoGenerate = true) public Long id; @NonNull + public String uuid = UUID.randomUUID().toString(); + @NonNull public Long folder; @NonNull public String name; @@ -344,10 +347,7 @@ public class EntityRule { } // Body - JSONObject jbody = null; - if (message.encrypt == null || - EntityMessage.ENCRYPT_NONE.equals(message.encrypt)) - jbody = jcondition.optJSONObject("body"); + JSONObject jbody = jcondition.optJSONObject("body"); if (jbody != null) { String value = jbody.getString("value"); boolean regex = jbody.getBoolean("regex"); @@ -366,7 +366,10 @@ public class EntityRule { } if (html == null) - throw new IllegalArgumentException(context.getString(R.string.title_rule_no_body)); + if (message.encrypt == null || EntityMessage.ENCRYPT_NONE.equals(message.encrypt)) + throw new IllegalArgumentException(context.getString(R.string.title_rule_no_body)); + else + return false; Document d = JsoupEx.parse(html); if (skip_quotes) @@ -1161,7 +1164,8 @@ public class EntityRule { public boolean equals(Object obj) { if (obj instanceof EntityRule) { EntityRule other = (EntityRule) obj; - return this.folder.equals(other.folder) && + return Objects.equals(this.uuid, other.uuid) && + this.folder.equals(other.folder) && this.name.equals(other.name) && this.order == other.order && this.enabled == other.enabled && @@ -1212,6 +1216,7 @@ public class EntityRule { public JSONObject toJSON() throws JSONException { JSONObject json = new JSONObject(); json.put("id", id); + json.put("uuid", uuid); json.put("name", name); json.put("order", order); json.put("enabled", enabled); @@ -1226,6 +1231,8 @@ public class EntityRule { public static EntityRule fromJSON(JSONObject json) throws JSONException { EntityRule rule = new EntityRule(); // id + if (json.has("uuid")) + rule.uuid = json.getString("uuid"); rule.name = json.getString("name"); rule.order = json.getInt("order"); rule.enabled = json.getBoolean("enabled"); diff --git a/app/src/main/java/eu/faircode/email/EntitySearch.java b/app/src/main/java/eu/faircode/email/EntitySearch.java index c495a28bd8..12eee1bf09 100644 --- a/app/src/main/java/eu/faircode/email/EntitySearch.java +++ b/app/src/main/java/eu/faircode/email/EntitySearch.java @@ -39,6 +39,7 @@ public class EntitySearch { public Long id; @NonNull public String name; + public Integer order; public Integer color; @NonNull public String data; @@ -49,6 +50,7 @@ public class EntitySearch { EntitySearch other = (EntitySearch) obj; return (this.id.equals(other.id) && this.name.equals(other.name) && + Objects.equals(this.order, other.order) && Objects.equals(this.color, other.color) && this.data.equals(other.data)); } else diff --git a/app/src/main/java/eu/faircode/email/FragmentBase.java b/app/src/main/java/eu/faircode/email/FragmentBase.java index 02c7de0fb0..519bd597c2 100644 --- a/app/src/main/java/eu/faircode/email/FragmentBase.java +++ b/app/src/main/java/eu/faircode/email/FragmentBase.java @@ -29,6 +29,7 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.IntentSender; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.graphics.Rect; @@ -61,6 +62,7 @@ import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.OnLifecycleEvent; import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.preference.PreferenceManager; import java.io.File; import java.io.FileInputStream; @@ -72,6 +74,7 @@ import java.util.List; import java.util.Map; public class FragmentBase extends Fragment { + private CharSequence count = null; private CharSequence title = null; private CharSequence subtitle = " "; private boolean finish = false; @@ -97,6 +100,11 @@ public class FragmentBase extends Fragment { return null; } + protected void setCount(String count) { + this.count = count; + updateSubtitle(); + } + protected void setTitle(int resid) { setTitle(getString(resid)); } @@ -410,9 +418,18 @@ public class FragmentBase extends Fragment { actionbar.setTitle(title == null ? getString(R.string.app_name) : title); actionbar.setSubtitle(subtitle); } else { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); + boolean list_count = prefs.getBoolean("list_count", false); + View custom = actionbar.getCustomView(); + TextView tvCount = custom.findViewById(R.id.count); TextView tvTitle = custom.findViewById(R.id.title); TextView tvSubtitle = custom.findViewById(R.id.subtitle); + if (tvCount != null) { + tvCount.setText(count); + tvCount.setVisibility(!list_count || TextUtils.isEmpty(count) + ? View.GONE : View.VISIBLE); + } if (tvTitle != null) tvTitle.setText(title == null ? getString(R.string.app_name) : title); if (tvSubtitle != null) @@ -479,6 +496,7 @@ public class FragmentBase extends Fragment { Intent create = new Intent(Intent.ACTION_CREATE_DOCUMENT); create.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); create.setType(intent.getStringExtra("type")); create.putExtra(Intent.EXTRA_TITLE, intent.getStringExtra("name")); Helper.openAdvanced(create); diff --git a/app/src/main/java/eu/faircode/email/FragmentCompose.java b/app/src/main/java/eu/faircode/email/FragmentCompose.java index 570369f2f4..cd04ee222f 100644 --- a/app/src/main/java/eu/faircode/email/FragmentCompose.java +++ b/app/src/main/java/eu/faircode/email/FragmentCompose.java @@ -544,6 +544,7 @@ public class FragmentCompose extends FragmentBase { // https://developer.android.com/guide/topics/providers/contacts-provider#Intents Intent pick = new Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI); + pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivityForResult(Helper.getChooser(getContext(), pick), request); } }; @@ -914,8 +915,17 @@ public class FragmentCompose extends FragmentBase { ibReferenceImages.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - ibReferenceImages.setVisibility(View.GONE); - onReferenceImages(); + new AlertDialog.Builder(v.getContext()) + .setMessage(R.string.title_ask_show_image) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + ibReferenceImages.setVisibility(View.GONE); + onReferenceImages(); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); } }); @@ -1192,6 +1202,10 @@ public class FragmentCompose extends FragmentBase { if (typed == null) return result; + final Context context = getContext(); + if (context == null) + return result; + String wildcard = "%" + typed + "%"; Map map = new HashMap<>(); @@ -1209,7 +1223,7 @@ public class FragmentCompose extends FragmentBase { .replace("?", "[?]") + "*"; - boolean contacts = Helper.hasPermission(getContext(), Manifest.permission.READ_CONTACTS); + boolean contacts = Helper.hasPermission(context, Manifest.permission.READ_CONTACTS); if (contacts) { try (Cursor cursor = resolver.query( ContactsContract.CommonDataKinds.Email.CONTENT_URI, @@ -1373,7 +1387,7 @@ public class FragmentCompose extends FragmentBase { String email = args.getString("email"); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); - boolean auto_identity = prefs.getBoolean("auto_identity", true); + boolean auto_identity = prefs.getBoolean("auto_identity", false); boolean suggest_sent = prefs.getBoolean("suggest_sent", true); boolean suggest_received = prefs.getBoolean("suggest_received", false); @@ -1448,6 +1462,22 @@ public class FragmentCompose extends FragmentBase { } private void convertRef(boolean plain) { + if (plain) + _convertRef(plain); + else + new AlertDialog.Builder(getContext()) + .setMessage(R.string.title_ask_show_html) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + _convertRef(false); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void _convertRef(boolean plain) { etBody.clearComposingText(); Bundle args = new Bundle(); @@ -1664,6 +1694,9 @@ public class FragmentCompose extends FragmentBase { onAction(R.id.action_save, extras, "pause"); } + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().putLong("last_composing", working).apply(); + ConnectivityManager cm = Helper.getSystemService(context, ConnectivityManager.class); cm.unregisterNetworkCallback(networkCallback); @@ -1867,9 +1900,12 @@ public class FragmentCompose extends FragmentBase { bottom_navigation.findViewById(R.id.action_save).setOnLongClickListener(new View.OnLongClickListener() { @Override - public boolean onLongClick(View view) { - onLanguageTool(); - return true; + public boolean onLongClick(View v) { + if (LanguageTool.isEnabled(v.getContext())) { + onLanguageTool(); + return true; + } else + return false; } }); @@ -2593,6 +2629,7 @@ public class FragmentCompose extends FragmentBase { private void onActionAttachment() { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setType("*/*"); intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); PackageManager pm = getContext().getPackageManager(); @@ -3038,6 +3075,7 @@ public class FragmentCompose extends FragmentBase { Log.i("Using file picker"); Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setType("image/*"); intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); if (intent.resolveActivity(pm) == null) // GET_CONTENT whitelisted @@ -3719,6 +3757,8 @@ public class FragmentCompose extends FragmentBase { // Check public key validity try { chain[0].checkValidity(); + // TODO: check digitalSignature/nonRepudiation key usage + // https://datatracker.ietf.org/doc/html/rfc3850#section-4.4.2 } catch (CertificateException ex) { String msg = ex.getMessage(); throw new IllegalArgumentException( @@ -4565,7 +4605,7 @@ public class FragmentCompose extends FragmentBase { boolean receipt_default = prefs.getBoolean("receipt_default", false); boolean write_below = prefs.getBoolean("write_below", false); boolean save_drafts = prefs.getBoolean("save_drafts", true); - boolean auto_identity = prefs.getBoolean("auto_identity", true); + boolean auto_identity = prefs.getBoolean("auto_identity", false); boolean suggest_sent = prefs.getBoolean("suggest_sent", true); boolean suggest_received = prefs.getBoolean("suggest_received", false); boolean forward_new = prefs.getBoolean("forward_new", true); @@ -5710,7 +5750,7 @@ public class FragmentCompose extends FragmentBase { Intent thread = new Intent(v.getContext(), ActivityView.class); thread.setAction("thread:" + message.id); - thread.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + thread.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); thread.putExtra("account", message.account); thread.putExtra("folder", message.folder); thread.putExtra("thread", message.thread); @@ -6453,13 +6493,12 @@ public class FragmentCompose extends FragmentBase { if (extras.getBoolean("archive")) { EntityFolder archive = db.folder().getFolderByType(draft.account, EntityFolder.ARCHIVE); - if (archive != null) - for (String inreplyto : draft.inreplyto.split(" ")) { - List messages = db.message().getMessagesByMsgId(draft.account, inreplyto); - if (messages != null) - for (EntityMessage message : messages) - EntityOperation.queue(context, message, EntityOperation.MOVE, archive.id); - } + if (archive != null) { + List messages = db.message().getMessagesByMsgId(draft.account, draft.inreplyto); + if (messages != null) + for (EntityMessage message : messages) + EntityOperation.queue(context, message, EntityOperation.MOVE, archive.id); + } } } } @@ -6986,6 +7025,9 @@ public class FragmentCompose extends FragmentBase { } private void endSearch() { + if (etSearch == null) + return; + Helper.hideKeyboard(etSearch); etSearch.setVisibility(View.GONE); clearSearch(); @@ -7944,15 +7986,18 @@ public class FragmentCompose extends FragmentBase { return false; } - for (String inreplyto : draft.inreplyto.split(" ")) { - List messages = db.message().getMessagesByMsgId(draft.account, inreplyto); - for (EntityMessage message : messages) { - EntityFolder folder = db.folder().getFolder(message.folder); - if (folder == null) - continue; - if (EntityFolder.INBOX.equals(folder.type) || EntityFolder.USER.equals(folder.type)) - return true; - } + List messages = db.message().getMessagesByMsgId(draft.account, draft.inreplyto); + if (messages == null || messages.size() == 0) { + args.putString("reason", "In-reply-to gone"); + return false; + } + + for (EntityMessage message : messages) { + EntityFolder folder = db.folder().getFolder(message.folder); + if (folder == null) + continue; + if (EntityFolder.INBOX.equals(folder.type) || EntityFolder.USER.equals(folder.type)) + return true; } args.putString("reason", "Not in inbox or unread"); diff --git a/app/src/main/java/eu/faircode/email/FragmentContacts.java b/app/src/main/java/eu/faircode/email/FragmentContacts.java index 1b54763940..892c7bfb09 100644 --- a/app/src/main/java/eu/faircode/email/FragmentContacts.java +++ b/app/src/main/java/eu/faircode/email/FragmentContacts.java @@ -389,6 +389,7 @@ public class FragmentContacts extends FragmentBase { if (export) { Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); intent.setType("*/*"); intent.putExtra(Intent.EXTRA_TITLE, "fairemail.vcf"); Helper.openAdvanced(intent); @@ -396,6 +397,7 @@ public class FragmentContacts extends FragmentBase { } else { Intent open = new Intent(Intent.ACTION_GET_CONTENT); open.addCategory(Intent.CATEGORY_OPENABLE); + open.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); open.setType("*/*"); if (open.resolveActivity(pm) == null) // system whitelisted ToastEx.makeText(context, R.string.title_no_saf, Toast.LENGTH_LONG).show(); diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogBase.java b/app/src/main/java/eu/faircode/email/FragmentDialogBase.java index f8ffeb2e80..782bcf3f6c 100644 --- a/app/src/main/java/eu/faircode/email/FragmentDialogBase.java +++ b/app/src/main/java/eu/faircode/email/FragmentDialogBase.java @@ -183,7 +183,7 @@ public class FragmentDialogBase extends DialogFragment { targetRequestCode = requestCode; } - public void setTargetActivity(ActivityBase activity, int requestCode){ + public void setTargetActivity(ActivityBase activity, int requestCode) { targetRequestKey = activity.getRequestKey(); targetRequestCode = requestCode; } @@ -197,14 +197,22 @@ public class FragmentDialogBase extends DialogFragment { if (!hasResult || resultCode == RESULT_OK) { hasResult = true; - if (targetRequestKey != null) { - Bundle args = getArguments(); - if (args == null) // onDismiss - args = new Bundle(); - args.putInt("requestCode", targetRequestCode); - args.putInt("resultCode", resultCode); - getParentFragmentManager().setFragmentResult(targetRequestKey, args); - } + if (targetRequestKey != null) + try { + Bundle args = getArguments(); + if (args == null) // onDismiss + args = new Bundle(); + args.putInt("requestCode", targetRequestCode); + args.putInt("resultCode", resultCode); + getParentFragmentManager().setFragmentResult(targetRequestKey, args); + } catch (Throwable ex) { + Log.w(ex); + /* + java.lang.IllegalStateException: Fragment FragmentDialog... not associated with a fragment manager. + at androidx.fragment.app.Fragment.getParentFragmentManager(SourceFile:2) + at eu.faircode.email.FragmentDialogBase.sendResult(SourceFile:9) + */ + } } } diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogForwardRaw.java b/app/src/main/java/eu/faircode/email/FragmentDialogForwardRaw.java index 72665d5b81..781e45bc3a 100644 --- a/app/src/main/java/eu/faircode/email/FragmentDialogForwardRaw.java +++ b/app/src/main/java/eu/faircode/email/FragmentDialogForwardRaw.java @@ -297,7 +297,7 @@ public class FragmentDialogForwardRaw extends FragmentDialogBase { send.setPackage(BuildConfig.APPLICATION_ID); send.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); send.setType("message/rfc822"); - send.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); send.putExtra("fair:account", account); startActivity(send); diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogOpenFull.java b/app/src/main/java/eu/faircode/email/FragmentDialogOpenFull.java index 47c2daa250..d69a2113eb 100644 --- a/app/src/main/java/eu/faircode/email/FragmentDialogOpenFull.java +++ b/app/src/main/java/eu/faircode/email/FragmentDialogOpenFull.java @@ -23,6 +23,7 @@ import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import android.app.Dialog; import android.content.Context; +import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -84,7 +85,7 @@ public class FragmentDialogOpenFull extends FragmentDialogBase { boolean dark = (Helper.isDarkTheme(context) && !force_light); boolean canDarken = WebViewEx.isFeatureSupported(context, WebViewFeature.ALGORITHMIC_DARKENING); - if (canDarken) + if (canDarken && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, dark); settings.setLoadsImagesAutomatically(true); diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogOpenLink.java b/app/src/main/java/eu/faircode/email/FragmentDialogOpenLink.java index 0bf9921505..99dac707ed 100644 --- a/app/src/main/java/eu/faircode/email/FragmentDialogOpenLink.java +++ b/app/src/main/java/eu/faircode/email/FragmentDialogOpenLink.java @@ -60,6 +60,7 @@ import android.widget.CheckBox; import android.widget.CompoundButton; import android.widget.EditText; import android.widget.ImageButton; +import android.widget.ScrollView; import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; @@ -83,6 +84,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; public class FragmentDialogOpenLink extends FragmentDialogBase { + private ScrollView scroll; private ImageButton ibMore; private TextView tvMore; private Button btnOwner; @@ -135,8 +137,29 @@ public class FragmentDialogOpenLink extends FragmentDialogBase { } else uriTitle = null; + MailTo mailto = null; + if ("mailto".equals(uri.getScheme())) + try { + mailto = MailTo.parse(uri); + } catch (Throwable ex) { + Log.w(ex); + } + + String host = uri.getHost(); + String thost = (uriTitle == null ? null : uriTitle.getHost()); + + String puny = null; + try { + if (host != null) + puny = IDN.toASCII(host, IDN.ALLOW_UNASSIGNED); + } catch (Throwable ex) { + Log.i(ex); + puny = host; + } + // Get views final View dview = LayoutInflater.from(context).inflate(R.layout.dialog_open_link, null); + scroll = dview.findViewById(R.id.scroll); final ImageButton ibInfo = dview.findViewById(R.id.ibInfo); final TextView tvTitle = dview.findViewById(R.id.tvTitle); final ImageButton ibDifferent = dview.findViewById(R.id.ibDifferent); @@ -180,7 +203,10 @@ public class FragmentDialogOpenLink extends FragmentDialogBase { ibDifferent.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - etLink.setText(format(uriTitle, context)); + Package pkg = (Package) spOpenWith.getSelectedItem(); + Log.i("Open title uri=" + uri + " with=" + pkg); + boolean tabs = (pkg != null && pkg.tabs); + Helper.view(context, uriTitle, !tabs, !tabs); } }); @@ -417,29 +443,10 @@ public class FragmentDialogOpenLink extends FragmentDialogBase { tvTitle.setText(title); tvTitle.setVisibility(TextUtils.isEmpty(title) ? View.GONE : View.VISIBLE); - MailTo mailto = null; - if ("mailto".equals(uri.getScheme())) - try { - mailto = MailTo.parse(uri); - } catch (Throwable ex) { - Log.w(ex); - } ibSearch.setVisibility( mailto != null && !TextUtils.isEmpty(mailto.getTo()) ? View.VISIBLE : View.GONE); - String host = uri.getHost(); - String thost = (uriTitle == null ? null : uriTitle.getHost()); - - String puny = null; - try { - if (host != null) - puny = IDN.toASCII(host, IDN.ALLOW_UNASSIGNED); - } catch (Throwable ex) { - Log.i(ex); - puny = host; - } - if (host != null && !host.equals(puny)) { etLink.setText(format(uri.buildUpon().encodedAuthority(puny).build(), context)); tvLink.setText(uri.toString()); @@ -542,7 +549,7 @@ public class FragmentDialogOpenLink extends FragmentDialogBase { else label = res.getString(ri.activityInfo.applicationInfo.labelRes); if (label == null) - Log.e("Missing label" + + Log.w("Missing label" + " pkg=" + ri.activityInfo.packageName + " res=" + ri.activityInfo.applicationInfo.labelRes); } catch (Throwable ex) { @@ -580,6 +587,21 @@ public class FragmentDialogOpenLink extends FragmentDialogBase { } } + Drawable android = context.getDrawable(R.drawable.android_robot); + android.setBounds(0, 0, dp24, dp24); + pkgs.add(new Package( + android, + context.getString(R.string.title_select_app), + "chooser", + false, + true)); + pkgs.add(new Package( + android, + context.getString(R.string.title_select_app), + "chooser", + true, + true)); + return pkgs; } @@ -659,6 +681,16 @@ public class FragmentDialogOpenLink extends FragmentDialogBase { btnSettings.setVisibility(show ? View.VISIBLE : View.GONE); btnDefault.setVisibility(show && n ? View.VISIBLE : View.GONE); tvReset.setVisibility(show ? View.VISIBLE : View.GONE); + if (show) + scroll.post(new RunnableEx("link:scroll#1") { + public void delegate() { + scroll.getChildAt(0).post(new RunnableEx("link:scroll#2") { + public void delegate() { + scroll.scrollTo(0, scroll.getBottom()); + } + }); + } + }); } private Spanned format(Uri uri, Context context) { @@ -733,6 +765,11 @@ public class FragmentDialogOpenLink extends FragmentDialogBase { this.tabs = tabs; this.enabled = enabled; } + + @Override + public String toString() { + return name + ":" + tabs; + } } public static class AdapterPackage extends ArrayAdapter { diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogSelectAccount.java b/app/src/main/java/eu/faircode/email/FragmentDialogSelectAccount.java index 941ad91010..108b725019 100644 --- a/app/src/main/java/eu/faircode/email/FragmentDialogSelectAccount.java +++ b/app/src/main/java/eu/faircode/email/FragmentDialogSelectAccount.java @@ -47,7 +47,6 @@ public class FragmentDialogSelectAccount extends FragmentDialogBase { final int dp12 = Helper.dp2pixels(context, 12); final ArrayAdapter adapter = new ArrayAdapter(context, R.layout.spinner_account, android.R.id.text1) { - @NonNull @Override public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { @@ -89,7 +88,7 @@ public class FragmentDialogSelectAccount extends FragmentDialogBase { protected void onException(Bundle args, Throwable ex) { Log.unexpectedError(getParentFragmentManager(), ex); } - }.execute(this, getArguments(), "messages:accounts"); + }.execute(this, getArguments(), "select:account"); return new AlertDialog.Builder(context) .setIcon(R.drawable.twotone_account_circle_24) diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogSelectIdentity.java b/app/src/main/java/eu/faircode/email/FragmentDialogSelectIdentity.java new file mode 100644 index 0000000000..52b53d02be --- /dev/null +++ b/app/src/main/java/eu/faircode/email/FragmentDialogSelectIdentity.java @@ -0,0 +1,104 @@ +package eu.faircode.email; + +/* + This file is part of FairEmail. + + FairEmail 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. + + FairEmail 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 FairEmail. If not, see . + + Copyright 2018-2022 by Marcel Bokhorst (M66B) +*/ + +import static android.app.Activity.RESULT_OK; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.Color; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import java.util.List; + +public class FragmentDialogSelectIdentity extends FragmentDialogBase { + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + final Context context = getContext(); + + final int dp6 = Helper.dp2pixels(context, 6); + final int dp12 = Helper.dp2pixels(context, 12); + + final ArrayAdapter adapter = new ArrayAdapter(context, R.layout.spinner_account, android.R.id.text1) { + @NonNull + @Override + public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + View view = super.getView(position, convertView, parent); + TupleIdentityEx identity = (TupleIdentityEx) getItem(position); + + View vwColor = view.findViewById(R.id.vwColor); + TextView tv = view.findViewById(android.R.id.text1); + + int vpad = (getCount() > 10 ? dp6 : dp12); + tv.setPadding(0, vpad, 0, vpad); + + vwColor.setBackgroundColor(identity.color == null ? Color.TRANSPARENT : identity.color); + tv.setText(identity.name); + + return view; + } + }; + + // TODO: spinner + new SimpleTask>() { + @Override + protected List onExecute(Context context, Bundle args) { + DB db = DB.getInstance(context); + return db.identity().getComposableIdentities(null); + } + + @Override + protected void onExecuted(Bundle args, List identities) { + adapter.addAll(identities); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex); + } + }.execute(this, getArguments(), "select:identity"); + + return new AlertDialog.Builder(context) + .setIcon(R.drawable.twotone_person_24) + .setTitle(R.string.title_list_identities) + .setAdapter(adapter, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + TupleIdentityEx identity = adapter.getItem(which); + Bundle args = getArguments(); + args.putLong("id", identity.id); + args.putString("html", identity.signature); + sendResult(RESULT_OK); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .create(); + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/faircode/email/FragmentFolders.java b/app/src/main/java/eu/faircode/email/FragmentFolders.java index a0779532c1..f4e4cfafaa 100644 --- a/app/src/main/java/eu/faircode/email/FragmentFolders.java +++ b/app/src/main/java/eu/faircode/email/FragmentFolders.java @@ -134,6 +134,7 @@ public class FragmentFolders extends FragmentBase { static final int REQUEST_EXECUTE_RULES = 4; static final int REQUEST_EXPORT_MESSAGES = 5; static final int REQUEST_EDIT_ACCOUNT_NAME = 6; + static final int REQUEST_EDIT_ACCOUNT_COLOR = 7; private static final long EXPORT_PROGRESS_INTERVAL = 5000L; // milliseconds @@ -382,7 +383,7 @@ public class FragmentFolders extends FragmentBase { intent.putExtra("protocol", account.protocol); intent.putExtra("auth_type", account.auth_type); intent.putExtra("faq", 22); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); } @@ -690,6 +691,7 @@ public class FragmentFolders extends FragmentBase { menu.findItem(R.id.menu_sort_unread_atop).setChecked(sort_unread_atop); menu.findItem(R.id.menu_apply_all).setVisible(account >= 0 && imap); menu.findItem(R.id.menu_edit_account_name).setVisible(account >= 0); + menu.findItem(R.id.menu_edit_account_color).setVisible(account >= 0); super.onPrepareOptionsMenu(menu); } @@ -730,6 +732,9 @@ public class FragmentFolders extends FragmentBase { } else if (itemId == R.id.menu_edit_account_name) { onMenuEditAccount(); return true; + } else if (itemId == R.id.menu_edit_account_color) { + onMenuEditColor(); + return true; } else if (itemId == R.id.menu_force_sync) { onMenuForceSync(); return true; @@ -877,6 +882,41 @@ public class FragmentFolders extends FragmentBase { }.execute(this, args, "account:name"); } + private void onMenuEditColor() { + Bundle args = new Bundle(); + args.putLong("id", account); + + new SimpleTask() { + @Override + protected EntityAccount onExecute(Context context, Bundle args) { + long id = args.getLong("id"); + + DB db = DB.getInstance(context); + return db.account().getAccount(id); + } + + @Override + protected void onExecuted(Bundle args, EntityAccount account) { + if (account == null) + return; + + args.putInt("color", account.color == null ? Color.TRANSPARENT : account.color); + args.putString("title", getString(R.string.title_color)); + args.putBoolean("reset", true); + + FragmentDialogColor fragment = new FragmentDialogColor(); + fragment.setArguments(args); + fragment.setTargetFragment(FragmentFolders.this, REQUEST_EDIT_ACCOUNT_COLOR); + fragment.show(getParentFragmentManager(), "edit:color"); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex); + } + }.execute(this, args, "edit:color"); + } + private void onMenuForceSync() { refresh(true); ToastEx.makeText(getContext(), R.string.title_executing, Toast.LENGTH_LONG).show(); @@ -912,6 +952,10 @@ public class FragmentFolders extends FragmentBase { if (resultCode == RESULT_OK && data != null) onEditAccountName(data.getBundleExtra("args")); break; + case REQUEST_EDIT_ACCOUNT_COLOR: + if (resultCode == RESULT_OK && data != null) + onEditAccountColor(data.getBundleExtra("args")); + break; } } catch (Throwable ex) { Log.e(ex); @@ -1345,7 +1389,7 @@ public class FragmentFolders extends FragmentBase { protected void onException(Bundle args, Throwable ex) { Log.unexpectedError(getParentFragmentManager(), ex); } - }.execute(this, args, "folder:export"); + }.setKeepAwake(true).execute(this, args, "folder:export"); } private void onEditAccountName(Bundle args) { @@ -1387,6 +1431,33 @@ public class FragmentFolders extends FragmentBase { }.execute(this, args, "edit:name"); } + private void onEditAccountColor(Bundle args) { + if (!ActivityBilling.isPro(getContext())) { + startActivity(new Intent(getContext(), ActivityBilling.class)); + return; + } + + new SimpleTask() { + @Override + protected Void onExecute(Context context, Bundle args) { + long id = args.getLong("id"); + Integer color = args.getInt("color"); + + if (color == Color.TRANSPARENT) + color = null; + + DB db = DB.getInstance(context); + db.account().setAccountColor(id, color); + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex); + } + }.execute(this, args, "edit:color"); + } + public static class FragmentDialogApply extends FragmentDialogBase { @NonNull @Override diff --git a/app/src/main/java/eu/faircode/email/FragmentGmail.java b/app/src/main/java/eu/faircode/email/FragmentGmail.java index 2a8878046b..a957bd5b93 100644 --- a/app/src/main/java/eu/faircode/email/FragmentGmail.java +++ b/app/src/main/java/eu/faircode/email/FragmentGmail.java @@ -23,7 +23,6 @@ import static android.accounts.AccountManager.newChooseAccountIntent; import static android.app.Activity.RESULT_OK; import static eu.faircode.email.GmailState.TYPE_GOOGLE; import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_GMAIL; -import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_PASSWORD; import android.Manifest; import android.accounts.Account; @@ -86,6 +85,7 @@ public class FragmentGmail extends FragmentBase { private Button btnSelect; private ContentLoadingProgressBar pbSelect; + private TextView tvOnDevice; private TextView tvAppPassword; private TextView tvError; @@ -129,6 +129,7 @@ public class FragmentGmail extends FragmentBase { btnSelect = view.findViewById(R.id.btnSelect); pbSelect = view.findViewById(R.id.pbSelect); + tvOnDevice = view.findViewById(R.id.tvOnDevice); tvAppPassword = view.findViewById(R.id.tvAppPassword); tvError = view.findViewById(R.id.tvError); @@ -200,6 +201,14 @@ public class FragmentGmail extends FragmentBase { } }); + tvOnDevice.setPaintFlags(tvOnDevice.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); + tvOnDevice.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Helper.viewFAQ(v.getContext(), 111); + } + }); + tvAppPassword.setPaintFlags(tvAppPassword.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); tvAppPassword.setOnClickListener(new View.OnClickListener() { @Override @@ -494,10 +503,7 @@ public class FragmentGmail extends FragmentBase { db.beginTransaction(); if (args.getBoolean("update")) { - List accounts = - db.account().getAccounts(user, - protocol, - new int[]{AUTH_TYPE_GMAIL, AUTH_TYPE_PASSWORD}); + List accounts = db.account().getAccounts(user, protocol); if (accounts != null && accounts.size() == 1) update = accounts.get(0); } @@ -521,8 +527,11 @@ public class FragmentGmail extends FragmentBase { account.synchronize = true; account.primary = (primary == null); - if (pop) + if (pop) { + // https://support.google.com/mail/answer/7104828 + account.leave_on_device = true; account.max_messages = EntityAccount.DEFAULT_MAX_MESSAGES; + } account.created = new Date().getTime(); account.last_connected = account.created; @@ -583,8 +592,8 @@ public class FragmentGmail extends FragmentBase { args.putLong("account", update.id); EntityLog.log(context, "Gmail update account=" + update.name); db.account().setAccountSynchronize(update.id, true); - db.account().setAccountPassword(update.id, password, AUTH_TYPE_GMAIL); - db.identity().setIdentityPassword(update.id, update.user, password, update.auth_type, AUTH_TYPE_GMAIL); + db.account().setAccountPassword(update.id, password, AUTH_TYPE_GMAIL, null); + db.identity().setIdentityPassword(update.id, update.user, password, update.auth_type, AUTH_TYPE_GMAIL, null); } db.setTransactionSuccessful(); @@ -592,12 +601,8 @@ public class FragmentGmail extends FragmentBase { db.endTransaction(); } - if (update == null) - ServiceSynchronize.eval(context, "Gmail"); - else { - args.putBoolean("updated", true); - ServiceSynchronize.reload(context, update.id, true, "Gmail"); - } + ServiceSynchronize.eval(context, "Gmail"); + args.putBoolean("updated", update != null); return null; } diff --git a/app/src/main/java/eu/faircode/email/FragmentIdentity.java b/app/src/main/java/eu/faircode/email/FragmentIdentity.java index 9b35d09014..d3299d8918 100644 --- a/app/src/main/java/eu/faircode/email/FragmentIdentity.java +++ b/app/src/main/java/eu/faircode/email/FragmentIdentity.java @@ -668,7 +668,7 @@ public class FragmentIdentity extends FragmentBase { args.putBoolean("sign_default", cbSignDefault.isChecked()); args.putBoolean("encrypt_default", cbEncryptDefault.isChecked()); args.putBoolean("unicode", cbUnicode.isChecked()); - args.putBoolean("octetmime",cbOctetMime.isChecked()); + args.putBoolean("octetmime", cbOctetMime.isChecked()); args.putString("max_size", etMaxSize.getText().toString()); args.putLong("account", account == null ? -1 : account.id); args.putString("host", etHost.getText().toString().trim().replace(" ", "")); @@ -1432,7 +1432,7 @@ public class FragmentIdentity extends FragmentBase { onDelete(); break; case REQUEST_SIGNATURE: - if (resultCode == RESULT_OK) + if (resultCode == RESULT_OK && data != null) onHtml(data.getExtras()); break; } diff --git a/app/src/main/java/eu/faircode/email/FragmentLogs.java b/app/src/main/java/eu/faircode/email/FragmentLogs.java index e190e8b118..584d6c36ba 100644 --- a/app/src/main/java/eu/faircode/email/FragmentLogs.java +++ b/app/src/main/java/eu/faircode/email/FragmentLogs.java @@ -105,6 +105,17 @@ public class FragmentLogs extends FragmentBase { adapter = new AdapterLog(this); rvLog.setAdapter(adapter); + rvLog.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + try { + autoScroll = (llm.findFirstVisibleItemPosition() <= 0); + } catch (Throwable ex) { + Log.e(ex); + } + } + }); + // Initialize grpReady.setVisibility(View.GONE); pbWait.setVisibility(View.VISIBLE); @@ -125,9 +136,13 @@ public class FragmentLogs extends FragmentBase { if (logs == null) logs = new ArrayList<>(); - adapter.set(logs, account, folder, message, getTypes()); - if (autoScroll) - rvLog.scrollToPosition(0); + adapter.set(logs, account, folder, message, getTypes(), new Runnable() { + @Override + public void run() { + if (autoScroll) + rvLog.scrollToPosition(0); + } + }); pbWait.setVisibility(View.GONE); grpReady.setVisibility(View.VISIBLE); @@ -155,7 +170,6 @@ public class FragmentLogs extends FragmentBase { boolean all = (account == null && folder == null && message == null); menu.findItem(R.id.menu_enabled).setChecked(main_log); - menu.findItem(R.id.menu_auto_scroll).setChecked(autoScroll); menu.findItem(R.id.menu_show).setVisible(all); menu.findItem(R.id.menu_clear).setVisible(all); @@ -170,11 +184,6 @@ public class FragmentLogs extends FragmentBase { item.setChecked(enabled); onMenuEnable(enabled); return true; - } else if (itemId == R.id.menu_auto_scroll) { - boolean enabled = !item.isChecked(); - item.setChecked(enabled); - onMenuAutoScoll(enabled); - return true; } else if (itemId == R.id.menu_show) { onMenuShow(); } else if (itemId == R.id.menu_clear) { @@ -189,10 +198,6 @@ public class FragmentLogs extends FragmentBase { prefs.edit().putBoolean("main_log", enabled).apply(); } - private void onMenuAutoScoll(boolean enabled) { - autoScroll = enabled; - } - private void onMenuShow() { final Context context = getContext(); diff --git a/app/src/main/java/eu/faircode/email/FragmentMessages.java b/app/src/main/java/eu/faircode/email/FragmentMessages.java index b6713c1abe..f43f2eea95 100644 --- a/app/src/main/java/eu/faircode/email/FragmentMessages.java +++ b/app/src/main/java/eu/faircode/email/FragmentMessages.java @@ -19,6 +19,7 @@ package eu.faircode.email; Copyright 2018-2022 by Marcel Bokhorst (M66B) */ +import static android.app.Activity.RESULT_FIRST_USER; import static android.app.Activity.RESULT_OK; import static android.text.format.DateUtils.DAY_IN_MILLIS; import static android.text.format.DateUtils.FORMAT_SHOW_DATE; @@ -247,7 +248,6 @@ import java.util.function.Consumer; import javax.mail.Address; import javax.mail.MessageRemovedException; import javax.mail.MessagingException; -import javax.mail.Part; import javax.mail.Session; import javax.mail.internet.InternetAddress; import javax.mail.internet.MimeMessage; @@ -257,7 +257,8 @@ import me.everything.android.ui.overscroll.IOverScrollUpdateListener; import me.everything.android.ui.overscroll.VerticalOverScrollBounceEffectDecorator; import me.everything.android.ui.overscroll.adapters.RecyclerViewOverScrollDecorAdapter; -public class FragmentMessages extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { +public class FragmentMessages extends FragmentBase + implements SharedPreferences.OnSharedPreferenceChangeListener, FragmentManager.OnBackStackChangedListener { private ViewGroup view; private SwipeRefreshLayoutEx swipeRefresh; private TextView tvAirplane; @@ -428,9 +429,8 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. static final int REQUEST_BUTTONS = 24; private static final int REQUEST_ALL_READ = 25; private static final int REQUEST_SAVE_SEARCH = 26; - private static final int REQUEST_DELETE_SEARCH = 27; - private static final int REQUEST_QUICK_ACTIONS = 28; - static final int REQUEST_BLOCK_SENDERS = 29; + private static final int REQUEST_QUICK_ACTIONS = 27; + static final int REQUEST_BLOCK_SENDERS = 28; static final String ACTION_STORE_RAW = BuildConfig.APPLICATION_ID + ".STORE_RAW"; static final String ACTION_DECRYPT = BuildConfig.APPLICATION_ID + ".DECRYPT"; @@ -522,14 +522,20 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. if (viewType != AdapterMessage.ViewType.THREAD && EntityFolder.ARCHIVE.equals(type)) filter_archive = false; - if (viewType != AdapterMessage.ViewType.THREAD) - getParentFragmentManager().setFragmentResultListener("message.selected", this, new FragmentResultListener() { - @Override - public void onFragmentResult(@NonNull String requestKey, @NonNull Bundle result) { - long id = result.getLong("id", -1); - iProperties.setValue("selected", id, true); - } - }); + try { + FragmentManager fm = getParentFragmentManager(); + if (viewType != AdapterMessage.ViewType.THREAD) + fm.setFragmentResultListener("message.selected", this, new FragmentResultListener() { + @Override + public void onFragmentResult(@NonNull String requestKey, @NonNull Bundle result) { + long id = result.getLong("id", -1); + iProperties.setValue("selected", id, true); + } + }); + fm.addOnBackStackChangedListener(this); + } catch (Throwable ex) { + Log.e(ex); + } } @Override @@ -617,7 +623,7 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. @Override public void onClick(View v) { Intent intent = new Intent(android.provider.Settings.ACTION_AIRPLANE_MODE_SETTINGS) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); v.getContext().startActivity(intent); } }); @@ -1431,10 +1437,9 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. if (result == null) return; - if (result.accounts.size() == 1) { - for (EntityAccount account : result.accounts.keySet()) - onActionMoveSelectionAccount(account.id, false, result.folders); - } else { + if (result.account != null) + onActionMoveSelectionAccount(result.account.id, false, result.folders); + else { PopupMenuLifecycle popupMenu = new PopupMenuLifecycle(v.getContext(), getViewLifecycleOwner(), ibMove); int order = 0; @@ -1946,9 +1951,32 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. @Override public void onDestroy() { + try { + FragmentManager fm = getParentFragmentManager(); + fm.removeOnBackStackChangedListener(this); + } catch (Throwable ex) { + Log.e(ex); + } super.onDestroy(); } + @Override + public void onBackStackChanged() { + if (viewType == AdapterMessage.ViewType.THREAD) + return; + + FragmentActivity activity = getActivity(); + FragmentManager fm = getParentFragmentManager(); + int count = fm.getBackStackEntryCount(); + boolean split = (activity instanceof ActivityView && + ((ActivityView) activity).isSplit() && + count > 0 && "thread".equals(fm.getBackStackEntryAt(count - 1).getName())); + List ids = values.get("selected"); + if (ids != null) + for (long id : ids) + iProperties.setValue("split", id, split); + } + private void scrollToVisibleItem(LinearLayoutManager llm, boolean bottom) { int first = llm.findFirstVisibleItemPosition(); int last = llm.findLastVisibleItemPosition(); @@ -2097,7 +2125,7 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. } else values.get(name).remove(id); - if ("selected".equals(name) && enabled) { + if ("split".equals(name) || ("selected".equals(name) && enabled)) { if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) return; @@ -2107,7 +2135,7 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. if (pos != NO_POSITION) changed.add(pos); - for (Long other : new ArrayList<>(values.get("selected"))) + for (Long other : new ArrayList<>(values.get(name))) if (!other.equals(id)) { values.get(name).remove(other); @@ -4527,12 +4555,13 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. // Restart spinner swipeRefresh.resetRefreshing(); - if (!checkDoze()) - if (!checkReporting()) - if (!checkReview()) - if (!checkFingerprint()) - if (!checkGmail()) - checkOutlook(); + if (!checkRedmiNote()) + if (!checkDoze()) + if (!checkReporting()) + if (!checkReview()) + if (!checkFingerprint()) + if (!checkGmail()) + checkOutlook(); prefs.registerOnSharedPreferenceChangeListener(this); onSharedPreferenceChanged(prefs, "pro"); @@ -4642,6 +4671,30 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. grpAirplane.setVisibility(on ? View.VISIBLE : View.GONE); } + private boolean checkRedmiNote() { + if (!Helper.isRedmiNote()) + return false; + + final Context context = getContext(); + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + boolean redmi_note = prefs.getBoolean("redmi_note", true); + if (!redmi_note) + return false; + + final Snackbar snackbar = Snackbar.make(view, R.string.app_data_loss, Snackbar.LENGTH_INDEFINITE) + .setGestureInsetBottomIgnored(true); + snackbar.setAction(R.string.title_info, new View.OnClickListener() { + @Override + public void onClick(View v) { + prefs.edit().putBoolean("redmi_note", false).apply(); + Helper.view(v.getContext(), Uri.parse("https://github.com/M66B/FairEmail/blob/master/FAQ.md#redmi"), false); + } + }); + snackbar.show(); + + return true; + } + private boolean checkDoze() { if (viewType != AdapterMessage.ViewType.UNIFIED) return false; @@ -4796,6 +4849,10 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. cal.set(Calendar.MONTH, Calendar.MAY); cal.set(Calendar.YEAR, 2022); + long now = new Date().getTime(); + if (now < cal.getTimeInMillis() - 30 * 24 * 3600 * 1000L) + return false; // Not yet + if (Helper.getInstallTime(context) > cal.getTimeInMillis()) { prefs.edit().putBoolean("gmail_checked", true).apply(); return false; @@ -4803,7 +4860,6 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. cal.add(Calendar.MONTH, 2); - long now = new Date().getTime(); if (now > cal.getTimeInMillis()) { prefs.edit().putBoolean("gmail_checked", true).apply(); return false; @@ -4865,9 +4921,6 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. } private boolean checkOutlook() { - if (!BuildConfig.DEBUG) - return false; - final Context context = getContext(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); if (prefs.getBoolean("outlook_checked", false)) @@ -4882,6 +4935,10 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. cal.set(Calendar.MONTH, Calendar.OCTOBER); cal.set(Calendar.YEAR, 2022); + long now = new Date().getTime(); + if (now < cal.getTimeInMillis() - 30 * 24 * 3600 * 1000L) + return false; // Not yet + if (Helper.getInstallTime(context) > cal.getTimeInMillis()) { prefs.edit().putBoolean("outlook_checked", true).apply(); return false; @@ -4889,7 +4946,6 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. cal.add(Calendar.MONTH, 2); - long now = new Date().getTime(); if (now > cal.getTimeInMillis()) { prefs.edit().putBoolean("outlook_checked", true).apply(); return false; @@ -5038,10 +5094,10 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. menu.findItem(R.id.menu_save_search).setVisible( viewType == AdapterMessage.ViewType.SEARCH && - criteria != null && criteria.id < 0); - menu.findItem(R.id.menu_delete_search).setVisible( + criteria != null && criteria.id == null); + menu.findItem(R.id.menu_edit_search).setVisible( viewType == AdapterMessage.ViewType.SEARCH && - criteria != null && criteria.id >= 0); + criteria != null && criteria.id != null); menu.findItem(R.id.menu_folders).setVisible( viewType == AdapterMessage.ViewType.UNIFIED && @@ -5181,12 +5237,9 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. if (itemId == R.id.menu_search) { onMenuSearch(); return true; - } else if (itemId == R.id.menu_save_search) { + } else if (itemId == R.id.menu_save_search || itemId == R.id.menu_edit_search) { onMenuSaveSearch(); return true; - } else if (itemId == R.id.menu_delete_search) { - onMenuDeleteSearch(); - return true; } else if (itemId == R.id.menu_folders) { // Obsolete onMenuFolders(primary); return true; @@ -5325,22 +5378,6 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. fragment.show(getParentFragmentManager(), "search:save"); } - private void onMenuDeleteSearch() { - if (criteria == null) - return; - - Bundle args = new Bundle(); - args.putString("question", getString(R.string.title_search_delete)); - args.putString("remark", criteria.getTitle(getContext())); - args.putLong("id", criteria.id); - args.putBoolean("warning", true); - - FragmentDialogAsk ask = new FragmentDialogAsk(); - ask.setArguments(args); - ask.setTargetFragment(FragmentMessages.this, REQUEST_DELETE_SEARCH); - ask.show(getParentFragmentManager(), "swipe:delete"); - } - private void onMenuFolders(long account) { if (!isAdded()) return; @@ -5724,16 +5761,28 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. BoundaryCallbackMessages.SearchCriteria criteria = (BoundaryCallbackMessages.SearchCriteria) args.getSerializable("criteria"); - EntitySearch search = new EntitySearch(); + DB db = DB.getInstance(context); + + EntitySearch search = null; + if (criteria.id != null) + search = db.search().getSearch(criteria.id); + if (search == null) + search = new EntitySearch(); + + int order = args.getInt("order"); + search.name = args.getString("name"); + search.order = (order < 0 ? null : order); search.color = args.getInt("color", Color.TRANSPARENT); search.data = criteria.toJson().toString(); if (search.color == Color.TRANSPARENT) search.color = null; - DB db = DB.getInstance(context); - search.id = db.search().insertSearch(search); + if (search.id == null) + search.id = db.search().insertSearch(search); + else + db.search().updateSearch(search); return null; } @@ -5754,10 +5803,11 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. new SimpleTask() { @Override protected Void onExecute(Context context, Bundle args) throws Throwable { - long id = args.getLong("id"); + BoundaryCallbackMessages.SearchCriteria criteria = + (BoundaryCallbackMessages.SearchCriteria) args.getSerializable("criteria"); DB db = DB.getInstance(context); - db.search().deleteSearch(id); + db.search().deleteSearch(criteria.id); return null; } @@ -6167,6 +6217,9 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. if (messages == null) return; + if (viewType != AdapterMessage.ViewType.SEARCH) + setCount(messages.size() <= 1 ? null : NF.format(messages.size())); + if (viewType == AdapterMessage.ViewType.THREAD) { if (handleThreadActions(messages, null, null)) return; @@ -7204,6 +7257,9 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. } private void endSearch() { + if (etSearch == null) + return; + Helper.hideKeyboard(etSearch); etSearch.setVisibility(View.GONE); clearSearch(); @@ -7524,6 +7580,7 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. Intent create = new Intent(Intent.ACTION_CREATE_DOCUMENT); create.addCategory(Intent.CATEGORY_OPENABLE); + create.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); create.setType("*/*"); create.putExtra(Intent.EXTRA_TITLE, name); Helper.openAdvanced(create); @@ -7741,9 +7798,7 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. case REQUEST_SAVE_SEARCH: if (resultCode == RESULT_OK && data != null) onSaveSearch(data.getBundleExtra("args")); - break; - case REQUEST_DELETE_SEARCH: - if (resultCode == RESULT_OK && data != null) + else if (resultCode == RESULT_FIRST_USER && data != null) onDeleteSearch(data.getBundleExtra("args")); break; case REQUEST_QUICK_ACTIONS: @@ -8353,11 +8408,14 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. for (EntityCertificate ec : ecs) local.add(ec.getCertificate()); + // TODO: check digitalSignature/nonRepudiation key usage + // https://datatracker.ietf.org/doc/html/rfc3850#section-4.4.2 + for (X509Certificate c : certs) { boolean[] usage = c.getKeyUsage(); - boolean root = (usage != null && usage[5]); + boolean keyCertSign = (usage != null && usage.length > 5 && usage[5]); boolean selfSigned = c.getIssuerX500Principal().equals(c.getSubjectX500Principal()); - if (root && !selfSigned && ks.getCertificateAlias(c) == null) { + if (keyCertSign && !selfSigned && ks.getCertificateAlias(c) == null) { boolean found = false; String issuer = (c.getIssuerDN() == null ? "" : c.getIssuerDN().getName()); EntityCertificate record = EntityCertificate.from(c, true, issuer); @@ -8789,12 +8847,28 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. try { X509Certificate cert = (X509Certificate) c; boolean[] usage = cert.getKeyUsage(); - boolean keyCertSign = (usage != null && usage[5]); + boolean digitalSignature = (usage != null && usage.length > 0 && usage[0]); + boolean nonRepudiation = (usage != null && usage.length > 1 && usage[1]); + boolean keyEncipherment = (usage != null && usage.length > 2 && usage[2]); + boolean dataEncipherment = (usage != null && usage.length > 3 && usage[4]); + boolean keyAgreement = (usage != null && usage.length > 4 && usage[4]); + boolean keyCertSign = (usage != null && usage.length > 5 && usage[5]); + boolean cRLSign = (usage != null && usage.length > 6 && usage[6]); + boolean encipherOnly = (usage != null && usage.length > 7 && usage[7]); + boolean decipherOnly = (usage != null && usage.length > 8 && usage[8]); boolean selfSigned = cert.getIssuerX500Principal().equals(cert.getSubjectX500Principal()); EntityCertificate record = EntityCertificate.from(cert, null); trace.add(record.subject + " (" + (selfSigned ? "selfSigned" : cert.getIssuerX500Principal()) + ")" + + (digitalSignature ? " (digitalSignature)" : "") + + (nonRepudiation ? " (nonRepudiation)" : "") + + (keyEncipherment ? " (keyEncipherment)" : "") + + (dataEncipherment ? " (dataEncipherment)" : "") + + (keyAgreement ? " (keyAgreement)" : "") + (keyCertSign ? " (keyCertSign)" : "") + + (cRLSign ? " (cRLSign)" : "") + + (encipherOnly ? " (encipherOnly)" : "") + + (decipherOnly ? " (decipherOnly)" : "") + (ks != null && ks.getCertificateAlias(cert) != null ? " (Android)" : "")); } catch (Throwable ex) { Log.e(ex); @@ -9842,6 +9916,7 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. Boolean leave_deleted; boolean read_only; List folders; + EntityAccount account; Map accounts; EntityAccount copyto; @@ -10026,6 +10101,9 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. if (result.hasTrash == null) result.hasTrash = false; if (result.hasJunk == null) result.hasJunk = false; + if (!result.hasPop && accounts.size() == 1) + result.account = accounts.values().iterator().next(); + result.accounts = new LinkedHashMap<>(); if (!result.hasPop) { List syncing = db.account().getSynchronizingAccounts(EntityAccount.TYPE_IMAP); @@ -10422,6 +10500,7 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. final Context context = getContext(); View dview = LayoutInflater.from(context).inflate(R.layout.dialog_save_search, null); EditText etName = dview.findViewById(R.id.etName); + EditText etOrder = dview.findViewById(R.id.etOrder); btnColor = dview.findViewById(R.id.btnColor); btnColor.setOnClickListener(new View.OnClickListener() { @@ -10441,15 +10520,20 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. } }); - etName.setText(criteria.getTitle(context)); - btnColor.setColor(Color.TRANSPARENT); + etName.setText(criteria.name == null ? criteria.getTitle(context) : criteria.name); + etOrder.setText(criteria.order == null ? null : Integer.toString(criteria.order)); + btnColor.setColor(criteria.color); - return new AlertDialog.Builder(context) + AlertDialog.Builder dialog = new AlertDialog.Builder(context) .setView(dview) .setPositiveButton(R.string.title_save, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { + String order = etOrder.getText().toString(); args.putString("name", etName.getText().toString()); + args.putInt("order", + !TextUtils.isEmpty(order) && TextUtils.isDigitsOnly(order) + ? Integer.parseInt(order) : -1); args.putInt("color", btnColor.getColor()); sendResult(Activity.RESULT_OK); } @@ -10459,8 +10543,17 @@ public class FragmentMessages extends FragmentBase implements SharedPreferences. public void onClick(DialogInterface dialogInterface, int i) { sendResult(Activity.RESULT_CANCELED); } - }) - .create(); + }); + + if (criteria.id != null) + dialog.setNeutralButton(R.string.title_delete, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + sendResult(Activity.RESULT_FIRST_USER); + } + }); + + return dialog.create(); } @Override diff --git a/app/src/main/java/eu/faircode/email/FragmentOAuth.java b/app/src/main/java/eu/faircode/email/FragmentOAuth.java index c29448250b..e97594208a 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOAuth.java +++ b/app/src/main/java/eu/faircode/email/FragmentOAuth.java @@ -21,7 +21,6 @@ package eu.faircode.email; import static android.app.Activity.RESULT_OK; import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_OAUTH; -import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_PASSWORD; import android.content.ActivityNotFoundException; import android.content.Context; @@ -29,7 +28,10 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; +import android.content.res.ColorStateList; +import android.graphics.Color; import android.graphics.Paint; +import android.graphics.drawable.Drawable; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.net.Uri; @@ -121,7 +123,6 @@ public class FragmentOAuth extends FragmentBase { private TextView tvGmailHint; private TextView tvError; - private TextView tvGmailDraftsHint; private TextView tvOfficeAuthHint; private Button btnSupport; private Button btnHelp; @@ -172,7 +173,6 @@ public class FragmentOAuth extends FragmentBase { tvGmailHint = view.findViewById(R.id.tvGmailHint); tvError = view.findViewById(R.id.tvError); - tvGmailDraftsHint = view.findViewById(R.id.tvGmailDraftsHint); tvOfficeAuthHint = view.findViewById(R.id.tvOfficeAuthHint); btnSupport = view.findViewById(R.id.btnSupport); btnHelp = view.findViewById(R.id.btnHelp); @@ -191,6 +191,33 @@ public class FragmentOAuth extends FragmentBase { } }); + if ("gmail".equals(id)) { + // https://developers.google.com/identity/branding-guidelines + final Context context = getContext(); + final boolean dark = Helper.isDarkTheme(context); + int dp12 = Helper.dp2pixels(context, 12); + int dp24 = Helper.dp2pixels(context, 24); + Drawable g = context.getDrawable(R.drawable.google_logo); + g.setBounds(0, 0, g.getIntrinsicWidth(), g.getIntrinsicHeight()); + btnOAuth.setCompoundDrawablesRelative(g, null, null, null); + btnOAuth.setCompoundDrawablePadding(dp24); + btnOAuth.setText(R.string.title_setup_google_sign_in); + btnOAuth.setTextColor(new ColorStateList( + new int[][]{ + new int[]{android.R.attr.state_enabled}, + new int[]{-android.R.attr.state_enabled}, + }, + new int[]{ + dark ? Color.WHITE : Color.DKGRAY, // 0xff444444 + Color.LTGRAY // 0xffcccccc + } + )); + btnOAuth.setBackground(context.getDrawable(dark + ? R.drawable.google_signin_background_dark + : R.drawable.google_signin_background_light)); + btnOAuth.setPaddingRelative(dp12, 0, dp12, 0); + } + btnOAuth.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { @@ -380,12 +407,19 @@ public class FragmentOAuth extends FragmentBase { ? new LinkedHashMap<>() : provider.oauth.parameters); + String clientId = provider.oauth.clientId; + Uri redirectUri = Uri.parse(provider.oauth.redirectUri); + if ("gmail".equals(id) && BuildConfig.DEBUG) { + clientId = "803253368361-hr8kelm53hqodj7c6brdjeb2ctn5jg3p.apps.googleusercontent.com"; + redirectUri = Uri.parse("eu.faircode.email.debug:/"); + } + AuthorizationRequest.Builder authRequestBuilder = new AuthorizationRequest.Builder( serviceConfig, - provider.oauth.clientId, + clientId, ResponseTypeValues.CODE, - Uri.parse(provider.oauth.redirectUri)) + redirectUri) .setScopes(provider.oauth.scopes) .setState(provider.id) .setAdditionalParameters(params); @@ -577,7 +611,7 @@ public class FragmentOAuth extends FragmentBase { List usernames = new ArrayList<>(); usernames.add(sharedname == null ? username : sharedname); - if (token != null && sharedname == null) { + if (token != null && sharedname == null && !"gmail".equals(id)) { // https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens String[] segments = token.split("\\."); if (segments.length > 1) @@ -774,10 +808,7 @@ public class FragmentOAuth extends FragmentBase { db.beginTransaction(); if (args.getBoolean("update")) { - List accounts = - db.account().getAccounts(username, - protocol, - new int[]{AUTH_TYPE_OAUTH, AUTH_TYPE_PASSWORD}); + List accounts = db.account().getAccounts(username, protocol); if (accounts != null && accounts.size() == 1) update = accounts.get(0); } @@ -876,8 +907,8 @@ public class FragmentOAuth extends FragmentBase { args.putLong("account", update.id); EntityLog.log(context, "OAuth update account=" + update.name); db.account().setAccountSynchronize(update.id, true); - db.account().setAccountPassword(update.id, state, AUTH_TYPE_OAUTH); - db.identity().setIdentityPassword(update.id, update.user, state, update.auth_type, AUTH_TYPE_OAUTH); + db.account().setAccountPassword(update.id, state, AUTH_TYPE_OAUTH, provider.id); + db.identity().setIdentityPassword(update.id, update.user, state, update.auth_type, AUTH_TYPE_OAUTH, provider.id); } db.setTransactionSuccessful(); @@ -885,12 +916,8 @@ public class FragmentOAuth extends FragmentBase { db.endTransaction(); } - if (update == null) - ServiceSynchronize.eval(context, "OAuth"); - else { - args.putBoolean("updated", true); - ServiceSynchronize.reload(context, update.id, true, "OAuth"); - } + ServiceSynchronize.eval(context, "OAuth"); + args.putBoolean("updated", update != null); return null; } @@ -942,9 +969,6 @@ public class FragmentOAuth extends FragmentBase { grpError.setVisibility(View.VISIBLE); - if ("gmail".equals(id)) - tvGmailDraftsHint.setVisibility(View.VISIBLE); - if ("office365".equals(id) || "outlook".equals(id)) { if (ex instanceof AuthenticationFailedException) tvOfficeAuthHint.setVisibility(View.VISIBLE); @@ -982,7 +1006,6 @@ public class FragmentOAuth extends FragmentBase { private void hideError() { btnHelp.setVisibility(View.GONE); grpError.setVisibility(View.GONE); - tvGmailDraftsHint.setVisibility(View.GONE); tvOfficeAuthHint.setVisibility(View.GONE); } } diff --git a/app/src/main/java/eu/faircode/email/FragmentOptions.java b/app/src/main/java/eu/faircode/email/FragmentOptions.java index 70db5589d3..cde9f01538 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptions.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptions.java @@ -145,7 +145,8 @@ public class FragmentOptions extends FragmentBase { "keywords_header", "labels_header", "flags", "flags_background", "preview", "preview_italic", "preview_lines", "message_zoom", "overview_mode", "override_width", "addresses", "button_extra", "attachments_alt", "thumbnails", "contrast", "hyphenation", "display_font", "monospaced_pre", - "background_color", "text_color", "text_size", "text_font", "text_align", "text_separators", + "list_count", "bundled_fonts", "parse_classes", + "background_color", "text_color", "text_size", "text_font", "text_align", "text_titles", "text_separators", "collapse_quotes", "image_placeholders", "inline_images", "seekbar", "actionbar", "actionbar_color", "group_category", "autoscroll", "swipenav", "reversed", "swipe_close", "swipe_move", "autoexpand", "autoclose", "onclose", @@ -153,7 +154,8 @@ public class FragmentOptions extends FragmentBase { "language_detection", "quick_filter", "quick_scroll", "experiments", "debug", "log_level", "test1", "test2", "test3", "test4", "test5", - "webview_legacy", "browser_zoom", "show_recent", + "webview_legacy", "browser_zoom", "fake_dark", + "show_recent", "biometrics", "default_light" }; diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsBehavior.java b/app/src/main/java/eu/faircode/email/FragmentOptionsBehavior.java index 7f3c920cfd..0994646ba2 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsBehavior.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsBehavior.java @@ -50,6 +50,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.SwitchCompat; +import androidx.lifecycle.Lifecycle; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.preference.PreferenceManager; @@ -57,7 +58,10 @@ import java.util.ArrayList; import java.util.List; public class FragmentOptionsBehavior extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { + private View view; private ImageButton ibHelp; + private SwitchCompat swRestoreOnLaunch; + private TextView tvRestoreOnLaunchHint; private SwitchCompat swSyncOnlaunch; private SwitchCompat swDoubleBack; private SwitchCompat swConversationActions; @@ -100,7 +104,7 @@ public class FragmentOptionsBehavior extends FragmentBase implements SharedPrefe final static int DEFAULT_SWIPE_SENSITIVITY = 7; private final static String[] RESET_OPTIONS = new String[]{ - "sync_on_launch", "double_back", "conversation_actions", "conversation_actions_replies", "language_detection", + "restore_on_launch", "sync_on_launch", "double_back", "conversation_actions", "conversation_actions_replies", "language_detection", "default_snooze", "pull", "autoscroll", "quick_filter", "quick_scroll", "swipe_sensitivity", "foldernav", "doubletap", "swipenav", "volumenav", "reversed", "swipe_close", "swipe_move", @@ -117,11 +121,13 @@ public class FragmentOptionsBehavior extends FragmentBase implements SharedPrefe setSubtitle(R.string.title_setup); setHasOptionsMenu(true); - View view = inflater.inflate(R.layout.fragment_options_behavior, container, false); + view = inflater.inflate(R.layout.fragment_options_behavior, container, false); // Get controls ibHelp = view.findViewById(R.id.ibHelp); + swRestoreOnLaunch = view.findViewById(R.id.swRestoreOnLaunch); + tvRestoreOnLaunchHint = view.findViewById(R.id.tvRestoreOnLaunchHint); swSyncOnlaunch = view.findViewById(R.id.swSyncOnlaunch); swDoubleBack = view.findViewById(R.id.swDoubleBack); swConversationActions = view.findViewById(R.id.swConversationActions); @@ -180,6 +186,14 @@ public class FragmentOptionsBehavior extends FragmentBase implements SharedPrefe } }); + tvRestoreOnLaunchHint.setText(getString(R.string.title_advanced_restore_on_launch_hint, ActivityMain.RESTORE_STATE_INTERVAL)); + swRestoreOnLaunch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("restore_on_launch", checked).apply(); + } + }); + swSyncOnlaunch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -518,11 +532,12 @@ public class FragmentOptionsBehavior extends FragmentBase implements SharedPrefe } private void setOptions() { - if (getContext() == null) + if (view == null || getContext() == null) return; SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + swRestoreOnLaunch.setChecked(prefs.getBoolean("restore_on_launch", false)); swSyncOnlaunch.setChecked(prefs.getBoolean("sync_on_launch", false)); swDoubleBack.setChecked(prefs.getBoolean("double_back", false)); swConversationActions.setChecked(prefs.getBoolean("conversation_actions", Helper.isGoogle())); diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsConnection.java b/app/src/main/java/eu/faircode/email/FragmentOptionsConnection.java index 3c26b2bd68..452590bfd6 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsConnection.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsConnection.java @@ -59,6 +59,7 @@ import androidx.lifecycle.Lifecycle; import androidx.preference.PreferenceManager; public class FragmentOptionsConnection extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { + private View view; private ImageButton ibHelp; private SwitchCompat swMetered; private Spinner spDownload; @@ -76,6 +77,7 @@ public class FragmentOptionsConnection extends FragmentBase implements SharedPre private SwitchCompat swTcpKeepAlive; private TextView tvTcpKeepAliveHint; private SwitchCompat swSslHarden; + private SwitchCompat swSslHardenStrict; private SwitchCompat swCertStrict; private Button btnManage; private TextView tvNetworkMetered; @@ -91,7 +93,7 @@ public class FragmentOptionsConnection extends FragmentBase implements SharedPre "download_headers", "download_eml", "download_plain", "require_validated", "vpn_only", "timeout", "prefer_ip4", "bind_socket", "standalone_vpn", "tcp_keep_alive", - "ssl_harden", "cert_strict" + "ssl_harden", "ssl_harden_strict", "cert_strict" }; @Override @@ -100,7 +102,7 @@ public class FragmentOptionsConnection extends FragmentBase implements SharedPre setSubtitle(R.string.title_setup); setHasOptionsMenu(true); - View view = inflater.inflate(R.layout.fragment_options_connection, container, false); + view = inflater.inflate(R.layout.fragment_options_connection, container, false); // Get controls @@ -121,6 +123,7 @@ public class FragmentOptionsConnection extends FragmentBase implements SharedPre swTcpKeepAlive = view.findViewById(R.id.swTcpKeepAlive); tvTcpKeepAliveHint = view.findViewById(R.id.tvTcpKeepAliveHint); swSslHarden = view.findViewById(R.id.swSslHarden); + swSslHardenStrict = view.findViewById(R.id.swSslHardenStrict); swCertStrict = view.findViewById(R.id.swCertStrict); btnManage = view.findViewById(R.id.btnManage); @@ -283,6 +286,17 @@ public class FragmentOptionsConnection extends FragmentBase implements SharedPre @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { prefs.edit().putBoolean("ssl_harden", checked).apply(); + swSslHardenStrict.setEnabled(checked); + } + }); + + swSslHardenStrict.setVisibility(BuildConfig.PLAY_STORE_RELEASE || + Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + ? View.GONE : View.VISIBLE); + swSslHardenStrict.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("ssl_harden_strict", checked).apply(); } }); @@ -390,7 +404,7 @@ public class FragmentOptionsConnection extends FragmentBase implements SharedPre } private void setOptions() { - if (getContext() == null) + if (view == null || getContext() == null) return; SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); @@ -424,6 +438,8 @@ public class FragmentOptionsConnection extends FragmentBase implements SharedPre swStandaloneVpn.setChecked(prefs.getBoolean("standalone_vpn", false)); swTcpKeepAlive.setChecked(prefs.getBoolean("tcp_keep_alive", false)); swSslHarden.setChecked(prefs.getBoolean("ssl_harden", false)); + swSslHardenStrict.setChecked(prefs.getBoolean("ssl_harden_strict", false)); + swSslHardenStrict.setEnabled(swSslHarden.isChecked()); swCertStrict.setChecked(prefs.getBoolean("cert_strict", !BuildConfig.PLAY_STORE_RELEASE)); } diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsDisplay.java b/app/src/main/java/eu/faircode/email/FragmentOptionsDisplay.java index 070ec9c23e..be83098c89 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsDisplay.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsDisplay.java @@ -52,6 +52,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.widget.SwitchCompat; import androidx.constraintlayout.widget.Group; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.Lifecycle; import androidx.preference.PreferenceManager; import com.flask.colorpicker.ColorPickerView; @@ -63,6 +64,7 @@ import java.util.ArrayList; import java.util.List; public class FragmentOptionsDisplay extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { + private View view; private ImageButton ibHelp; private Button btnTheme; private Spinner spStartup; @@ -170,6 +172,7 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer private SwitchCompat swAttachmentsAlt; private SwitchCompat swThumbnails; + private SwitchCompat swListCount; private SwitchCompat swBundledFonts; private SwitchCompat swParseClasses; private SwitchCompat swBackgroundColor; @@ -177,6 +180,7 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer private SwitchCompat swTextSize; private SwitchCompat swTextFont; private SwitchCompat swTextAlign; + private SwitchCompat swTextTitles; private SwitchCompat swAuthentication; private SwitchCompat swAuthenticationIndicator; @@ -206,8 +210,8 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer "text_separators", "collapse_quotes", "image_placeholders", "inline_images", "button_extra", "unzip", "attachments_alt", "thumbnails", - "bundled_fonts", "parse_classes", - "background_color", "text_color", "text_size", "text_font", "text_align", + "list_count", "bundled_fonts", "parse_classes", + "background_color", "text_color", "text_size", "text_font", "text_align", "text_titles", "authentication", "authentication_indicator" }; @@ -217,7 +221,7 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer setSubtitle(R.string.title_setup); setHasOptionsMenu(true); - View view = inflater.inflate(R.layout.fragment_options_display, container, false); + view = inflater.inflate(R.layout.fragment_options_display, container, false); // Get controls @@ -326,6 +330,7 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer swAttachmentsAlt = view.findViewById(R.id.swAttachmentsAlt); swThumbnails = view.findViewById(R.id.swThumbnails); + swListCount = view.findViewById(R.id.swListCount); swBundledFonts = view.findViewById(R.id.swBundledFonts); swParseClasses = view.findViewById(R.id.swParseClasses); swBackgroundColor = view.findViewById(R.id.swBackgroundColor); @@ -333,6 +338,7 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer swTextSize = view.findViewById(R.id.swTextSize); swTextFont = view.findViewById(R.id.swTextFont); swTextAlign = view.findViewById(R.id.swTextAlign); + swTextTitles = view.findViewById(R.id.swTextTitles); swAuthentication = view.findViewById(R.id.swAuthentication); swAuthenticationIndicator = view.findViewById(R.id.swAuthenticationIndicator); @@ -1198,6 +1204,13 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer } }); + swListCount.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("list_count", checked).apply(); + } + }); + swBundledFonts.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -1249,6 +1262,13 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer } }); + swTextTitles.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("text_titles", checked).apply(); + } + }); + swAuthentication.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -1312,7 +1332,7 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer } private void setOptions() { - if (getContext() == null) + if (view == null || getContext() == null) return; SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); @@ -1490,6 +1510,7 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer swAttachmentsAlt.setChecked(prefs.getBoolean("attachments_alt", false)); swThumbnails.setChecked(prefs.getBoolean("thumbnails", true)); + swListCount.setChecked(prefs.getBoolean("list_count", false)); swBundledFonts.setChecked(prefs.getBoolean("bundled_fonts", true)); swParseClasses.setChecked(prefs.getBoolean("parse_classes", true)); swBackgroundColor.setChecked(prefs.getBoolean("background_color", false)); @@ -1497,6 +1518,7 @@ public class FragmentOptionsDisplay extends FragmentBase implements SharedPrefer swTextSize.setChecked(prefs.getBoolean("text_size", true)); swTextFont.setChecked(prefs.getBoolean("text_font", true)); swTextAlign.setChecked(prefs.getBoolean("text_align", true)); + swTextTitles.setChecked(prefs.getBoolean("text_titles", false)); swAuthentication.setChecked(prefs.getBoolean("authentication", true)); swAuthenticationIndicator.setChecked(prefs.getBoolean("authentication_indicator", false)); swAuthenticationIndicator.setEnabled(swAuthentication.isChecked()); diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsEncryption.java b/app/src/main/java/eu/faircode/email/FragmentOptionsEncryption.java index 1663e061e0..53e49fc616 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsEncryption.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsEncryption.java @@ -81,6 +81,7 @@ import java.util.List; public class FragmentOptionsEncryption extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener, OpenPgpServiceConnection.OnBound { + private View view; private ImageButton ibHelp; private ImageButton ibInfo; private SwitchCompat swSign; @@ -126,7 +127,7 @@ public class FragmentOptionsEncryption extends FragmentBase setHasOptionsMenu(true); PackageManager pm = getContext().getPackageManager(); - View view = inflater.inflate(R.layout.fragment_options_encryption, container, false); + view = inflater.inflate(R.layout.fragment_options_encryption, container, false); // Get controls @@ -360,6 +361,7 @@ public class FragmentOptionsEncryption extends FragmentBase PackageManager pm = context.getPackageManager(); Intent open = new Intent(Intent.ACTION_GET_CONTENT); open.addCategory(Intent.CATEGORY_OPENABLE); + open.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); open.setType("*/*"); if (open.resolveActivity(pm) == null) // system whitelisted ToastEx.makeText(context, R.string.title_no_saf, Toast.LENGTH_LONG).show(); @@ -596,7 +598,7 @@ public class FragmentOptionsEncryption extends FragmentBase } private void setOptions() { - if (getContext() == null) + if (view == null || getContext() == null) return; SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java b/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java index 3b19de6693..8e37437116 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java @@ -37,6 +37,7 @@ import android.graphics.Paint; import android.graphics.Typeface; import android.graphics.fonts.Font; import android.graphics.fonts.SystemFonts; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Debug; @@ -70,6 +71,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.SwitchCompat; import androidx.cardview.widget.CardView; import androidx.constraintlayout.widget.Group; +import androidx.lifecycle.Lifecycle; import androidx.lifecycle.Observer; import androidx.preference.PreferenceManager; @@ -94,6 +96,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private boolean resumed = false; private List> languages = new ArrayList<>(); + private View view; private ImageButton ibHelp; private SwitchCompat swPowerMenu; private SwitchCompat swExternalSearch; @@ -110,6 +113,9 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private TextView tvFtsIndexed; private TextView tvFtsPro; private Spinner spLanguage; + private SwitchCompat swLanguageTool; + private TextView tvLanguageToolPrivacy; + private ImageButton ibLanguageTool; private SwitchCompat swDeepL; private ImageButton ibDeepL; private TextView tvSdcard; @@ -128,6 +134,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private SwitchCompat swWatchdog; private SwitchCompat swExperiments; private TextView tvExperimentsHint; + private SwitchCompat swMainLog; private SwitchCompat swProtocol; private SwitchCompat swLogInfo; private SwitchCompat swDebug; @@ -149,6 +156,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private SwitchCompat swWal; private SwitchCompat swCheckpoints; private SwitchCompat swAnalyze; + private SwitchCompat swAutoVacuum; private TextView tvSqliteCache; private SeekBar sbSqliteCache; private TextView tvChunkSize; @@ -159,6 +167,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private SwitchCompat swUndoManager; private SwitchCompat swWebViewLegacy; private SwitchCompat swBrowserZoom; + private SwitchCompat swFakeDark; private SwitchCompat swShowRecent; private SwitchCompat swModSeq; private SwitchCompat swUid; @@ -169,6 +178,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private SwitchCompat swAuthNtlm; private SwitchCompat swAuthSasl; private SwitchCompat swAuthApop; + private SwitchCompat swUseTop; private SwitchCompat swKeepAlivePoll; private SwitchCompat swEmptyPool; private SwitchCompat swIdleDone; @@ -207,16 +217,18 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private final static String[] RESET_OPTIONS = new String[]{ "sort_answers", "shortcuts", "fts", "classification", "class_min_probability", "class_min_difference", - "language", "deepl_enabled", + "language", "lt_enabled", "deepl_enabled", "updates", "weekly", "show_changelog", "crash_reports", "cleanup_attachments", - "watchdog", "experiments", "protocol", "log_level", "debug", "leak_canary", "test1", + "watchdog", "experiments", "main_log", "protocol", "log_level", "debug", "leak_canary", "test1", "test2", "test3", "test4", "test5", "work_manager", // "external_storage", - "query_threads", "wal", "sqlite_checkpoints", "sqlite_analyze", "sqlite_cache", - "chunk_size", "thread_range", "undo_manager", "webview_legacy", "browser_zoom", "show_recent", + "query_threads", "wal", "sqlite_checkpoints", "sqlite_analyze", "sqlite_auto_vacuum", "sqlite_cache", + "chunk_size", "thread_range", "undo_manager", + "webview_legacy", "browser_zoom", "fake_dark", + "show_recent", "use_modseq", "uid_command", "perform_expunge", "uid_expunge", - "auth_plain", "auth_login", "auth_ntlm", "auth_sasl", "auth_apop", + "auth_plain", "auth_login", "auth_ntlm", "auth_sasl", "auth_apop", "use_top", "keep_alive_poll", "empty_pool", "idle_done", "logarithmic_backoff", "exact_alarms", "infra", "dkim_verify", "dup_msgids", "test_iab" }; @@ -239,7 +251,9 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc "signature_images_hint", "gmail_checked", "eml_auto_confirm", - "open_with_pkg", "open_with_tabs" + "open_with_pkg", "open_with_tabs", + "gmail_checked", "outlook_checked", + "redmi_note" }; @Override @@ -264,7 +278,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc setSubtitle(R.string.title_setup); setHasOptionsMenu(true); - View view = inflater.inflate(R.layout.fragment_options_misc, container, false); + view = inflater.inflate(R.layout.fragment_options_misc, container, false); // Get controls @@ -284,6 +298,9 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc tvFtsIndexed = view.findViewById(R.id.tvFtsIndexed); tvFtsPro = view.findViewById(R.id.tvFtsPro); spLanguage = view.findViewById(R.id.spLanguage); + swLanguageTool = view.findViewById(R.id.swLanguageTool); + tvLanguageToolPrivacy = view.findViewById(R.id.tvLanguageToolPrivacy); + ibLanguageTool = view.findViewById(R.id.ibLanguageTool); swDeepL = view.findViewById(R.id.swDeepL); ibDeepL = view.findViewById(R.id.ibDeepL); tvSdcard = view.findViewById(R.id.tvSdcard); @@ -302,6 +319,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc swWatchdog = view.findViewById(R.id.swWatchdog); swExperiments = view.findViewById(R.id.swExperiments); tvExperimentsHint = view.findViewById(R.id.tvExperimentsHint); + swMainLog = view.findViewById(R.id.swMainLog); swProtocol = view.findViewById(R.id.swProtocol); swLogInfo = view.findViewById(R.id.swLogInfo); swDebug = view.findViewById(R.id.swDebug); @@ -323,6 +341,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc swWal = view.findViewById(R.id.swWal); swCheckpoints = view.findViewById(R.id.swCheckpoints); swAnalyze = view.findViewById(R.id.swAnalyze); + swAutoVacuum = view.findViewById(R.id.swAutoVacuum); tvSqliteCache = view.findViewById(R.id.tvSqliteCache); sbSqliteCache = view.findViewById(R.id.sbSqliteCache); ibSqliteCache = view.findViewById(R.id.ibSqliteCache); @@ -333,6 +352,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc swUndoManager = view.findViewById(R.id.swUndoManager); swWebViewLegacy = view.findViewById(R.id.swWebViewLegacy); swBrowserZoom = view.findViewById(R.id.swBrowserZoom); + swFakeDark = view.findViewById(R.id.swFakeDark); swShowRecent = view.findViewById(R.id.swShowRecent); swModSeq = view.findViewById(R.id.swModSeq); swUid = view.findViewById(R.id.swUid); @@ -343,6 +363,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc swAuthNtlm = view.findViewById(R.id.swAuthNtlm); swAuthSasl = view.findViewById(R.id.swAuthSasl); swAuthApop = view.findViewById(R.id.swAuthApop); + swUseTop = view.findViewById(R.id.swUseTop); swKeepAlivePoll = view.findViewById(R.id.swKeepAlivePoll); swEmptyPool = view.findViewById(R.id.swEmptyPool); swIdleDone = view.findViewById(R.id.swIdleDone); @@ -566,6 +587,28 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc } }); + swLanguageTool.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("lt_enabled", checked).apply(); + } + }); + + tvLanguageToolPrivacy.getPaint().setUnderlineText(true); + tvLanguageToolPrivacy.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Helper.view(v.getContext(), Uri.parse(Helper.LT_PRIVACY_URI), true); + } + }); + + ibLanguageTool.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Helper.viewFAQ(v.getContext(), 180); + } + }); + swDeepL.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -681,6 +724,13 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc } }); + swMainLog.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("main_log", checked).apply(); + } + }); + swProtocol.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -952,6 +1002,17 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc } }); + swAutoVacuum.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton v, boolean checked) { + prefs.edit() + .putBoolean("sqlite_auto_vacuum", checked) + .remove("debug") + .apply(); + ApplicationEx.restart(v.getContext()); + } + }); + sbSqliteCache.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { @@ -1038,6 +1099,13 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc } }); + swFakeDark.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("fake_dark", checked).apply(); + } + }); + swShowRecent.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -1113,6 +1181,13 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc } }); + swUseTop.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("use_top", checked).apply(); + } + }); + swKeepAlivePoll.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -1174,6 +1249,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc public void onClick(View v) { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setType("*/*"); Intent choose = Helper.getChooser(v.getContext(), intent); getActivity().startActivityForResult(choose, ActivitySetup.REQUEST_IMPORT_PROVIDERS); @@ -1610,11 +1686,6 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc editor.remove(key); } - if (BuildConfig.DEBUG) { - editor.remove("gmail_checked"); - editor.remove("outlook_checked"); - } - editor.apply(); ToastEx.makeText(context, R.string.title_setup_done, Toast.LENGTH_LONG).show(); @@ -1661,7 +1732,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc } private void setOptions() { - if (getContext() == null) + if (view == null || getContext() == null) return; SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); @@ -1707,6 +1778,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc if (selected >= 0) spLanguage.setSelection(selected); + swLanguageTool.setChecked(prefs.getBoolean("lt_enabled", false)); swDeepL.setChecked(prefs.getBoolean("deepl_enabled", false)); swUpdates.setChecked(prefs.getBoolean("updates", true)); swCheckWeekly.setChecked(prefs.getBoolean("weekly", Helper.hasPlayStore(getContext()))); @@ -1718,6 +1790,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc swCleanupAttachments.setChecked(prefs.getBoolean("cleanup_attachments", false)); swWatchdog.setChecked(prefs.getBoolean("watchdog", true)); + swMainLog.setChecked(prefs.getBoolean("main_log", true)); swProtocol.setChecked(prefs.getBoolean("protocol", false)); swLogInfo.setChecked(prefs.getInt("log_level", Log.getDefaultLogLevel()) <= android.util.Log.INFO); swDebug.setChecked(prefs.getBoolean("debug", false)); @@ -1737,8 +1810,9 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc sbRoomQueryThreads.setProgress(query_threads); swWal.setChecked(prefs.getBoolean("wal", true)); - swCheckpoints.setChecked(prefs.getBoolean("sqlite_checkpoints", false)); + swCheckpoints.setChecked(prefs.getBoolean("sqlite_checkpoints", true)); swAnalyze.setChecked(prefs.getBoolean("sqlite_analyze", true)); + swAutoVacuum.setChecked(prefs.getBoolean("sqlite_auto_vacuum", !Helper.isRedmiNote())); int sqlite_cache = prefs.getInt("sqlite_cache", DB.DEFAULT_CACHE_SIZE); Integer cache_size = DB.getCacheSizeKb(getContext()); @@ -1761,6 +1835,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc swUndoManager.setChecked(prefs.getBoolean("undo_manager", false)); swWebViewLegacy.setChecked(prefs.getBoolean("webview_legacy", false)); swBrowserZoom.setChecked(prefs.getBoolean("browser_zoom", false)); + swFakeDark.setChecked(prefs.getBoolean("fake_dark", false)); swShowRecent.setChecked(prefs.getBoolean("show_recent", false)); swModSeq.setChecked(prefs.getBoolean("use_modseq", true)); swUid.setChecked(prefs.getBoolean("uid_command", false)); @@ -1771,6 +1846,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc swAuthNtlm.setChecked(prefs.getBoolean("auth_ntlm", true)); swAuthSasl.setChecked(prefs.getBoolean("auth_sasl", true)); swAuthApop.setChecked(prefs.getBoolean("auth_apop", false)); + swUseTop.setChecked(prefs.getBoolean("use_top", true)); swKeepAlivePoll.setChecked(prefs.getBoolean("keep_alive_poll", false)); swEmptyPool.setChecked(prefs.getBoolean("empty_pool", true)); swIdleDone.setChecked(prefs.getBoolean("idle_done", true)); diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsNotifications.java b/app/src/main/java/eu/faircode/email/FragmentOptionsNotifications.java index 37b7b26b73..4e4c008e2f 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsNotifications.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsNotifications.java @@ -23,16 +23,20 @@ import static android.app.Activity.RESULT_OK; import android.app.NotificationChannel; import android.app.NotificationManager; +import android.app.StatusBarManager; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.graphics.Paint; +import android.graphics.drawable.Icon; import android.media.RingtoneManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.Settings; +import android.service.quicksettings.TileService; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -52,11 +56,15 @@ import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.appcompat.widget.SwitchCompat; import androidx.constraintlayout.widget.Group; +import androidx.lifecycle.Lifecycle; import androidx.preference.PreferenceManager; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; public class FragmentOptionsNotifications extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { + private View view; private ImageButton ibHelp; private Button btnManage; private ImageButton ibClear; @@ -67,7 +75,7 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared private ImageButton ibWhy; private TextView tvNotifySeparate; private SwitchCompat swNewestFirst; - private SwitchCompat swBackground; + private SwitchCompat swNotifySummary; private CheckBox cbNotifyActionTrash; private CheckBox cbNotifyActionJunk; @@ -91,8 +99,8 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared private SwitchCompat swNotifyBackgroundOnly; private SwitchCompat swNotifyKnownOnly; private SwitchCompat swNotifySuppressInCall; + private SwitchCompat swNotifySuppressInCar; private TextView tvNotifyKnownPro; - private SwitchCompat swNotifySummary; private SwitchCompat swNotifyRemove; private SwitchCompat swNotifyClear; private SwitchCompat swNotifySubtext; @@ -106,27 +114,34 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared private SwitchCompat swMessagingStyle; private ImageButton ibCar; private SwitchCompat swBiometricsNotify; + private SwitchCompat swBackground; private SwitchCompat swAlertOnce; + private ImageButton ibTileSync; + private ImageButton ibTileUnseen; private TextView tvNoGrouping; private TextView tvNoChannels; private Group grpChannel; private Group grpProperties; private Group grpBackground; + private Group grpTiles; + + private static final ExecutorService executor = + Helper.getBackgroundExecutor(1, "notifications"); private final static String[] RESET_OPTIONS = new String[]{ - "notify_newest_first", + "notify_newest_first", "notify_summary", "notify_trash", "notify_junk", "notify_block_sender", "notify_archive", "notify_move", "notify_reply", "notify_reply_direct", "notify_flag", "notify_seen", "notify_hide", "notify_snooze", "light", "sound", "notify_screen_on", "badge", "unseen_ignored", - "notify_background_only", "notify_known", "notify_suppress_in_call", "notify_summary", "notify_remove", "notify_clear", + "notify_background_only", "notify_known", "notify_suppress_in_call", "notify_suppress_in_car", + "notify_remove", "notify_clear", "notify_subtext", "notify_preview", "notify_preview_all", "notify_preview_only", "notify_transliterate", "wearable_preview", "notify_messaging", - "biometrics_notify", - "background_service", "alert_once" + "biometrics_notify", "background_service", "alert_once" }; @Override @@ -135,7 +150,7 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared setSubtitle(R.string.title_setup); setHasOptionsMenu(true); - View view = inflater.inflate(R.layout.fragment_options_notifications, container, false); + view = inflater.inflate(R.layout.fragment_options_notifications, container, false); // Get controls @@ -149,7 +164,7 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared ibWhy = view.findViewById(R.id.ibWhy); tvNotifySeparate = view.findViewById(R.id.tvNotifySeparate); swNewestFirst = view.findViewById(R.id.swNewestFirst); - swBackground = view.findViewById(R.id.swBackground); + swNotifySummary = view.findViewById(R.id.swNotifySummary); cbNotifyActionTrash = view.findViewById(R.id.cbNotifyActionTrash); cbNotifyActionJunk = view.findViewById(R.id.cbNotifyActionJunk); @@ -173,8 +188,8 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared swNotifyBackgroundOnly = view.findViewById(R.id.swNotifyBackgroundOnly); swNotifyKnownOnly = view.findViewById(R.id.swNotifyKnownOnly); swNotifySuppressInCall = view.findViewById(R.id.swNotifySuppressInCall); + swNotifySuppressInCar = view.findViewById(R.id.swNotifySuppressInCar); tvNotifyKnownPro = view.findViewById(R.id.tvNotifyKnownPro); - swNotifySummary = view.findViewById(R.id.swNotifySummary); swNotifyRemove = view.findViewById(R.id.swNotifyRemove); swNotifyClear = view.findViewById(R.id.swNotifyClear); swNotifySubtext = view.findViewById(R.id.swNotifySubtext); @@ -188,13 +203,17 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared swMessagingStyle = view.findViewById(R.id.swMessagingStyle); ibCar = view.findViewById(R.id.ibCar); swBiometricsNotify = view.findViewById(R.id.swBiometricsNotify); + swBackground = view.findViewById(R.id.swBackground); swAlertOnce = view.findViewById(R.id.swAlertOnce); + ibTileSync = view.findViewById(R.id.ibTileSync); + ibTileUnseen = view.findViewById(R.id.ibTileUnseen); tvNoGrouping = view.findViewById(R.id.tvNoGrouping); tvNoChannels = view.findViewById(R.id.tvNoChannels); grpChannel = view.findViewById(R.id.grpChannel); grpProperties = view.findViewById(R.id.grpProperties); grpBackground = view.findViewById(R.id.grpBackground); + grpTiles = view.findViewById(R.id.grpTiles); setOptions(); @@ -312,11 +331,11 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared } }); - swBackground.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + swNotifySummary.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { - prefs.edit().putBoolean("background_service", checked).apply(); - ServiceSynchronize.eval(compoundButton.getContext(), "background=" + checked); + prefs.edit().putBoolean("notify_summary", checked).apply(); + enableOptions(); } }); @@ -475,14 +494,18 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { prefs.edit().putBoolean("notify_suppress_in_call", checked).apply(); + ServiceSynchronize.restart(compoundButton.getContext()); } }); - swNotifySummary.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + swNotifySuppressInCar.setVisibility( + Build.VERSION.SDK_INT < Build.VERSION_CODES.M || BuildConfig.PLAY_STORE_RELEASE + ? View.GONE : View.VISIBLE); + swNotifySuppressInCar.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { - prefs.edit().putBoolean("notify_summary", checked).apply(); - enableOptions(); + prefs.edit().putBoolean("notify_suppress_in_car", checked).apply(); + ServiceSynchronize.restart(compoundButton.getContext()); } }); @@ -578,6 +601,14 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared } }); + swBackground.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("background_service", checked).apply(); + ServiceSynchronize.eval(compoundButton.getContext(), "background=" + checked); + } + }); + swAlertOnce.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -585,6 +616,22 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared } }); + ibTileSync.setOnClickListener(new View.OnClickListener() { + @Override + @RequiresApi(api = Build.VERSION_CODES.TIRAMISU) + public void onClick(View v) { + addTile(v.getContext(), ServiceTileSynchronize.class, R.string.tile_synchronize, R.drawable.twotone_sync_24); + } + }); + + ibTileUnseen.setOnClickListener(new View.OnClickListener() { + @Override + @RequiresApi(api = Build.VERSION_CODES.TIRAMISU) + public void onClick(View v) { + addTile(v.getContext(), ServiceTileUnseen.class, R.string.tile_unseen, R.drawable.twotone_mail_outline_24); + } + }); + // Initialize FragmentDialogTheme.setBackground(getContext(), view, false); @@ -605,6 +652,9 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared grpBackground.setVisibility( Build.VERSION.SDK_INT < Build.VERSION_CODES.O || BuildConfig.DEBUG ? View.VISIBLE : View.GONE); + grpTiles.setVisibility( + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || !BuildConfig.DEBUG + ? View.GONE : View.VISIBLE); PreferenceManager.getDefaultSharedPreferences(getContext()).registerOnSharedPreferenceChangeListener(this); @@ -659,14 +709,14 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared } private void setOptions() { - if (getContext() == null) + if (view == null || getContext() == null) return; boolean pro = ActivityBilling.isPro(getContext()); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); swNewestFirst.setChecked(prefs.getBoolean("notify_newest_first", false)); - swBackground.setChecked(prefs.getBoolean("background_service", false)); + swNotifySummary.setChecked(prefs.getBoolean("notify_summary", false)); cbNotifyActionTrash.setChecked(prefs.getBoolean("notify_trash", true) || !pro); cbNotifyActionJunk.setChecked(prefs.getBoolean("notify_junk", false) && pro); @@ -687,7 +737,7 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared swNotifyBackgroundOnly.setChecked(prefs.getBoolean("notify_background_only", false)); swNotifyKnownOnly.setChecked(prefs.getBoolean("notify_known", false)); swNotifySuppressInCall.setChecked(prefs.getBoolean("notify_suppress_in_call", false)); - swNotifySummary.setChecked(prefs.getBoolean("notify_summary", false)); + swNotifySuppressInCar.setChecked(prefs.getBoolean("notify_suppress_in_car", false)); swNotifyRemove.setChecked(prefs.getBoolean("notify_remove", true)); swNotifyClear.setChecked(prefs.getBoolean("notify_clear", false)); swNotifySubtext.setChecked(prefs.getBoolean("notify_subtext", true)); @@ -698,6 +748,7 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared swWearablePreview.setChecked(prefs.getBoolean("wearable_preview", false)); swMessagingStyle.setChecked(prefs.getBoolean("notify_messaging", false)); swBiometricsNotify.setChecked(prefs.getBoolean("biometrics_notify", true)); + swBackground.setChecked(prefs.getBoolean("background_service", false)); swAlertOnce.setChecked(!prefs.getBoolean("alert_once", true)); enableOptions(); @@ -759,4 +810,37 @@ public class FragmentOptionsNotifications extends FragmentBase implements Shared prefs.edit().remove("sound").apply(); } } + + @RequiresApi(api = 33) + private void addTile(Context context, Class cls, int title, int icon) { + StatusBarManager sbm = Helper.getSystemService(context, StatusBarManager.class); + sbm.requestAddTileService( + ComponentName.createRelative(context, cls.getName()), + context.getString(title), + Icon.createWithResource(context, icon), + executor, + new Consumer() { + @Override + public void accept(Integer result) { + Log.i("Tile result=" + result + " class=" + cls.getName()); + if (result == null) + return; + switch (result) { + case StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED: + case StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_NOT_ADDED: + break; + case StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED: + getMainHandler().post(new Runnable() { + @Override + public void run() { + ToastEx.makeText(context, R.string.tile_already_added, Toast.LENGTH_LONG).show(); + } + }); + break; + default: + Log.e("Tile result=" + result + " class=" + cls.getName()); + } + } + }); + } } diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsPrivacy.java b/app/src/main/java/eu/faircode/email/FragmentOptionsPrivacy.java index ce5c0f1f6c..afccef03ba 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsPrivacy.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsPrivacy.java @@ -63,6 +63,7 @@ import java.text.SimpleDateFormat; import java.util.Locale; public class FragmentOptionsPrivacy extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { + private View view; private ImageButton ibHelp; private SwitchCompat swConfirmLinks; private SwitchCompat swCheckLinksDbl; @@ -123,7 +124,7 @@ public class FragmentOptionsPrivacy extends FragmentBase implements SharedPrefer setSubtitle(R.string.title_setup); setHasOptionsMenu(true); - View view = inflater.inflate(R.layout.fragment_options_privacy, container, false); + view = inflater.inflate(R.layout.fragment_options_privacy, container, false); // Get controls @@ -183,7 +184,13 @@ public class FragmentOptionsPrivacy extends FragmentBase implements SharedPrefer swConfirmLinks.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { - prefs.edit().putBoolean("confirm_links", checked).apply(); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean("confirm_links", checked); + if (!checked) + for (String key : prefs.getAll().keySet()) + if (key.endsWith(".confirm_link")) + editor.remove(key); + editor.apply(); swCheckLinksDbl.setEnabled(checked); } }); @@ -206,7 +213,13 @@ public class FragmentOptionsPrivacy extends FragmentBase implements SharedPrefer swAskImages.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { - prefs.edit().putBoolean("ask_images", checked).apply(); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean("ask_images", checked); + if (!checked) + for (String key : prefs.getAll().keySet()) + if (key.endsWith(".show_images")) + editor.remove(key); + editor.apply(); } }); @@ -228,7 +241,13 @@ public class FragmentOptionsPrivacy extends FragmentBase implements SharedPrefer swAskHtml.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { - prefs.edit().putBoolean("ask_html", checked).apply(); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean("ask_html", checked); + if (!checked) + for (String key : prefs.getAll().keySet()) + if (key.endsWith(".show_full")) + editor.remove(key); + editor.apply(); } }); @@ -529,7 +548,7 @@ public class FragmentOptionsPrivacy extends FragmentBase implements SharedPrefer } private void setOptions() { - if (getContext() == null) + if (view == null || getContext() == null) return; SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsSend.java b/app/src/main/java/eu/faircode/email/FragmentOptionsSend.java index 0b5f8c0a0f..8c5cbbac3c 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsSend.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsSend.java @@ -46,6 +46,7 @@ import android.widget.Spinner; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.SwitchCompat; +import androidx.lifecycle.Lifecycle; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.preference.PreferenceManager; @@ -54,6 +55,7 @@ import java.util.List; import java.util.Objects; public class FragmentOptionsSend extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { + private View view; private ImageButton ibHelp; private SwitchCompat swKeyboard; private SwitchCompat swKeyboardNoFullscreen; @@ -128,7 +130,7 @@ public class FragmentOptionsSend extends FragmentBase implements SharedPreferenc setSubtitle(R.string.title_setup); setHasOptionsMenu(true); - View view = inflater.inflate(R.layout.fragment_options_send, container, false); + view = inflater.inflate(R.layout.fragment_options_send, container, false); // Get controls @@ -630,7 +632,7 @@ public class FragmentOptionsSend extends FragmentBase implements SharedPreferenc } private void setOptions() { - if (getContext() == null) + if (view == null || getContext() == null) return; SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); @@ -642,7 +644,7 @@ public class FragmentOptionsSend extends FragmentBase implements SharedPreferenc swSuggestReceived.setChecked(prefs.getBoolean("suggest_received", false)); swSuggestFrequently.setChecked(prefs.getBoolean("suggest_frequently", false)); swSuggestFrequently.setEnabled(swSuggestSent.isChecked() || swSuggestReceived.isChecked()); - swAutoIdentity.setChecked(prefs.getBoolean("auto_identity", true)); + swAutoIdentity.setChecked(prefs.getBoolean("auto_identity", false)); swSendChips.setChecked(prefs.getBoolean("send_chips", true)); swSendReminders.setChecked(prefs.getBoolean("send_reminders", true)); swSendPending.setChecked(prefs.getBoolean("send_pending", true)); diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsSynchronize.java b/app/src/main/java/eu/faircode/email/FragmentOptionsSynchronize.java index ed4eec6db2..18c500d3f9 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsSynchronize.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsSynchronize.java @@ -47,6 +47,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.widget.SwitchCompat; import androidx.constraintlayout.widget.Group; import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.Observer; import androidx.localbroadcastmanager.content.LocalBroadcastManager; @@ -63,6 +64,7 @@ import java.util.List; import java.util.Objects; public class FragmentOptionsSynchronize extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { + private View view; private ImageButton ibHelp; private SwitchCompat swEnabled; private SwitchCompat swOptimize; @@ -129,7 +131,7 @@ public class FragmentOptionsSynchronize extends FragmentBase implements SharedPr setSubtitle(R.string.title_setup); setHasOptionsMenu(true); - View view = inflater.inflate(R.layout.fragment_options_synchronize, container, false); + view = inflater.inflate(R.layout.fragment_options_synchronize, container, false); // Get controls @@ -478,6 +480,7 @@ public class FragmentOptionsSynchronize extends FragmentBase implements SharedPr prefs.edit().putBoolean("check_blocklist", checked).apply(); swUseBlocklist.setEnabled(checked); swUseBlocklistPop.setEnabled(checked); + rvBlocklist.setAlpha(checked ? 1.0f : Helper.LOW_LIGHT); } }); @@ -554,7 +557,7 @@ public class FragmentOptionsSynchronize extends FragmentBase implements SharedPr } private void setOptions() { - if (getContext() == null) + if (view == null || getContext() == null) return; SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); @@ -610,6 +613,7 @@ public class FragmentOptionsSynchronize extends FragmentBase implements SharedPr swUseBlocklist.setEnabled(swCheckBlocklist.isChecked()); swUseBlocklistPop.setChecked(prefs.getBoolean("use_blocklist_pop", false)); swUseBlocklistPop.setEnabled(swCheckBlocklist.isChecked()); + rvBlocklist.setAlpha(swCheckBlocklist.isChecked() ? 1.0f : Helper.LOW_LIGHT); } private String formatHour(Context context, int minutes) { diff --git a/app/src/main/java/eu/faircode/email/FragmentOrder.java b/app/src/main/java/eu/faircode/email/FragmentOrder.java index 7d9b3df6b6..cfe667e43e 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOrder.java +++ b/app/src/main/java/eu/faircode/email/FragmentOrder.java @@ -146,7 +146,7 @@ public class FragmentOrder extends FragmentBase { } }.execute(this, new Bundle(), "order:folders"); else - throw new IllegalArgumentException(); + throw new IllegalArgumentException("Unknown class=" + clazz); } @Override @@ -197,19 +197,22 @@ public class FragmentOrder extends FragmentBase { final boolean reset = args.getBoolean("reset"); final long[] ids = args.getLongArray("ids"); - final DB db = DB.getInstance(context); - db.runInTransaction(new Runnable() { - @Override - public void run() { - for (int i = 0; i < ids.length; i++) - if (EntityAccount.class.getName().equals(clazz)) - db.account().setAccountOrder(ids[i], reset ? null : i); - else if (TupleFolderSort.class.getName().equals(clazz)) - db.folder().setFolderOrder(ids[i], reset ? null : i); - else - throw new IllegalArgumentException("Unknown class=" + clazz); - } - }); + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + for (int i = 0; i < ids.length; i++) + if (EntityAccount.class.getName().equals(clazz)) + db.account().setAccountOrder(ids[i], reset ? null : i); + else if (TupleFolderSort.class.getName().equals(clazz)) + db.folder().setFolderOrder(ids[i], reset ? null : i); + else + throw new IllegalArgumentException("Unknown class=" + clazz); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } return null; } diff --git a/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java b/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java index 496908603f..358f89bc4b 100644 --- a/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java +++ b/app/src/main/java/eu/faircode/email/FragmentQuickSetup.java @@ -510,10 +510,7 @@ public class FragmentQuickSetup extends FragmentBase { EntityAccount primary = db.account().getPrimaryAccount(); if (args.getBoolean("update")) { - List accounts = - db.account().getAccounts(user, - EntityAccount.TYPE_IMAP, - new int[]{AUTH_TYPE_PASSWORD}); + List accounts = db.account().getAccounts(user, EntityAccount.TYPE_IMAP); if (accounts != null && accounts.size() == 1) update = accounts.get(0); } @@ -593,9 +590,9 @@ public class FragmentQuickSetup extends FragmentBase { args.putLong("account", update.id); EntityLog.log(context, "Quick setup update account=" + update.name); db.account().setAccountSynchronize(update.id, true); - db.account().setAccountPassword(update.id, password, AUTH_TYPE_PASSWORD); + db.account().setAccountPassword(update.id, password, AUTH_TYPE_PASSWORD, null); db.account().setAccountFingerprint(update.id, imap_fingerprint); - db.identity().setIdentityPassword(update.id, update.user, password, update.auth_type, AUTH_TYPE_PASSWORD); + db.identity().setIdentityPassword(update.id, update.user, password, update.auth_type, AUTH_TYPE_PASSWORD, null); db.identity().setIdentityFingerprint(update.id, smtp_fingerprint); } @@ -604,12 +601,8 @@ public class FragmentQuickSetup extends FragmentBase { db.endTransaction(); } - if (update == null) - ServiceSynchronize.eval(context, "quick setup"); - else { - args.putBoolean("updated", true); - ServiceSynchronize.reload(context, update.id, true, "quick setup"); - } + ServiceSynchronize.eval(context, "quick setup"); + args.putBoolean("updated", update != null); return provider; } catch (Throwable ex) { diff --git a/app/src/main/java/eu/faircode/email/FragmentRule.java b/app/src/main/java/eu/faircode/email/FragmentRule.java index 2cd3bc4227..b085964b80 100644 --- a/app/src/main/java/eu/faircode/email/FragmentRule.java +++ b/app/src/main/java/eu/faircode/email/FragmentRule.java @@ -335,6 +335,7 @@ public class FragmentRule extends FragmentBase { @Override public void onClick(View v) { Intent pick = new Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI); + pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivityForResult(Helper.getChooser(getContext(), pick), REQUEST_SENDER); } }); @@ -352,6 +353,7 @@ public class FragmentRule extends FragmentBase { @Override public void onClick(View v) { Intent pick = new Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI); + pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivityForResult(Helper.getChooser(getContext(), pick), REQUEST_RECIPIENT); } }); @@ -646,6 +648,7 @@ public class FragmentRule extends FragmentBase { @Override public void onClick(View v) { Intent pick = new Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI); + pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivityForResult(Helper.getChooser(getContext(), pick), REQUEST_TO); } }); @@ -912,7 +915,15 @@ public class FragmentRule extends FragmentBase { et.setText(cursor.getString(0)); } catch (Throwable ex) { Log.e(ex); - Log.unexpectedError(getParentFragmentManager(), ex); + if (ex instanceof SecurityException) + try { + String permission = android.Manifest.permission.READ_CONTACTS; + requestPermissions(new String[]{permission}, REQUEST_PERMISSIONS); + } catch (Throwable ex1) { + Log.unexpectedError(getParentFragmentManager(), ex1); + } + else + Log.unexpectedError(getParentFragmentManager(), ex); } } @@ -1724,6 +1735,7 @@ public class FragmentRule extends FragmentBase { rule.folder = args.getLong("folder"); rule.condition = args.getString("condition"); rule.action = args.getString("action"); + rule.validate(context); List matching = new ArrayList<>(); diff --git a/app/src/main/java/eu/faircode/email/FragmentRules.java b/app/src/main/java/eu/faircode/email/FragmentRules.java index 69ef477c7b..68dec51d73 100644 --- a/app/src/main/java/eu/faircode/email/FragmentRules.java +++ b/app/src/main/java/eu/faircode/email/FragmentRules.java @@ -297,6 +297,7 @@ public class FragmentRules extends FragmentBase { Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); intent.setType("*/*"); intent.putExtra(Intent.EXTRA_TITLE, "fairemail_" + new SimpleDateFormat("yyyyMMdd").format(new Date().getTime()) + ".rules"); @@ -307,6 +308,7 @@ public class FragmentRules extends FragmentBase { private void onMenuImport() { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setType("*/*"); startActivityForResult(intent, REQUEST_IMPORT); } @@ -361,11 +363,29 @@ public class FragmentRules extends FragmentBase { JSONObject jaction = new JSONObject(rule.action); int type = jaction.getInt("type"); - if (type == EntityRule.TYPE_MOVE || type == EntityRule.TYPE_COPY) { - long target = jaction.optLong("target", -1); - EntityFolder f = db.folder().getFolder(target); - if (f != null) - jaction.put("folderType", f.type); + switch (type) { + case EntityRule.TYPE_MOVE: + case EntityRule.TYPE_COPY: + long target = jaction.optLong("target", -1); + EntityFolder f = db.folder().getFolder(target); + EntityAccount a = (f == null ? null : db.account().getAccount(f.account)); + if (a != null) + jaction.put("targetAccountUuid", a.uuid); + if (f != null) { + jaction.put("targetFolderType", f.type); + jaction.put("targetFolderName", f.name); + } + break; + case EntityRule.TYPE_ANSWER: + long identity = jaction.getLong("identity"); + long answer = jaction.getLong("answer"); + EntityIdentity i = db.identity().getIdentity(identity); + EntityAnswer t = db.answer().getAnswer(answer); + if (i != null) + jaction.put("identityUuid", i.uuid); + if (t != null) + jaction.put("answerUuid", t.uuid); + break; } rule.action = jaction.toString(); @@ -450,21 +470,52 @@ public class FragmentRules extends FragmentBase { if (folder == null) return null; - for (int i = 0; i < jrules.length(); i++) { - JSONObject jrule = jrules.getJSONObject(i); + for (int r = 0; r < jrules.length(); r++) { + JSONObject jrule = jrules.getJSONObject(r); EntityRule rule = EntityRule.fromJSON(jrule); JSONObject jaction = new JSONObject(rule.action); int type = jaction.getInt("type"); - if (type == EntityRule.TYPE_MOVE || type == EntityRule.TYPE_COPY) { - String folderType = jaction.optString("folderType"); - if (!EntityFolder.SYSTEM.equals(folderType) && - !EntityFolder.USER.equals(folderType)) { - EntityFolder f = db.folder().getFolderByType(folder.account, folderType); - if (f != null) - jaction.put("target", f.id); - } + switch (type) { + case EntityRule.TYPE_MOVE: + case EntityRule.TYPE_COPY: + String targetAccountUuid = jaction.optString("targetAccountUuid"); + String targetFolderName = jaction.optString("targetFolderName"); + if (!TextUtils.isEmpty(targetAccountUuid) && !TextUtils.isEmpty(targetFolderName)) { + EntityAccount a = db.account().getAccountByUUID(targetAccountUuid); + if (a != null) { + EntityFolder f = db.folder().getFolderByName(a.id, targetFolderName); + if (f != null) { + jaction.put("target", f.id); + break; + } + } + } + + String folderType = jaction.optString("targetFolderType"); + if (TextUtils.isEmpty(folderType)) + folderType = jaction.optString("folderType"); // legacy + if (!EntityFolder.SYSTEM.equals(folderType) && + !EntityFolder.USER.equals(folderType)) { + EntityFolder f = db.folder().getFolderByType(folder.account, folderType); + if (f != null) + jaction.put("target", f.id); + } + break; + + case EntityRule.TYPE_ANSWER: + String identityUuid = jaction.optString("identityUuid"); + String answerUuid = jaction.optString("answerUuid"); + if (!TextUtils.isEmpty(identityUuid) && !TextUtils.isEmpty(answerUuid)) { + EntityIdentity i = db.identity().getIdentityByUUID(identityUuid); + EntityAnswer a = db.answer().getAnswerByUUID(answerUuid); + if (i != null && a != null) { + jaction.put("identity", i.id); + jaction.put("answer", a.id); + break; + } + } } rule.action = jaction.toString(); diff --git a/app/src/main/java/eu/faircode/email/FragmentSetup.java b/app/src/main/java/eu/faircode/email/FragmentSetup.java index 2119972254..cde231bbbf 100644 --- a/app/src/main/java/eu/faircode/email/FragmentSetup.java +++ b/app/src/main/java/eu/faircode/email/FragmentSetup.java @@ -47,7 +47,6 @@ import android.text.style.RelativeSizeSpan; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; -import android.view.TouchDelegate; import android.view.View; import android.view.ViewGroup; import android.widget.Button; @@ -84,6 +83,7 @@ public class FragmentSetup extends FragmentBase { private TextView tvNoInternet; private ImageButton ibHelp; private Button btnQuick; + private TextView tvTutorials; private TextView tvQuickNew; private CardView cardManual; @@ -120,6 +120,9 @@ public class FragmentSetup extends FragmentBase { private CardView cardExtra; private TextView tvExtra; private Button btnNotification; + private Button btnSignature; + private Button btnReorderAccounts; + private Button btnReorderFolders; private Button btnDelete; private Button btnApp; private Button btnMore; @@ -162,6 +165,7 @@ public class FragmentSetup extends FragmentBase { tvNoInternet = view.findViewById(R.id.tvNoInternet); ibHelp = view.findViewById(R.id.ibHelp); btnQuick = view.findViewById(R.id.btnQuick); + tvTutorials = view.findViewById(R.id.tvTutorials); tvQuickNew = view.findViewById(R.id.tvQuickNew); cardManual = view.findViewById(R.id.cardManual); @@ -198,6 +202,9 @@ public class FragmentSetup extends FragmentBase { cardExtra = view.findViewById(R.id.cardExtra); tvExtra = view.findViewById(R.id.tvExtra); btnNotification = view.findViewById(R.id.btnNotification); + btnSignature = view.findViewById(R.id.btnSignature); + btnReorderAccounts = view.findViewById(R.id.btnReorderAccounts); + btnReorderFolders = view.findViewById(R.id.btnReorderFolders); btnDelete = view.findViewById(R.id.btnDelete); btnApp = view.findViewById(R.id.btnApp); btnMore = view.findViewById(R.id.btnMore); @@ -270,20 +277,35 @@ public class FragmentSetup extends FragmentBase { Resources res = context.getResources(); String pkg = context.getPackageName(); + List providers = EmailProvider.loadProfiles(context); + + boolean web = BuildConfig.DEBUG; + for (EmailProvider provider : providers) + if ("gmail".equals(provider.id) && + provider.oauth != null && + provider.oauth.enabled) { + web = true; + break; + } int order = 1; - String gmail = getString(R.string.title_setup_oauth, getString(R.string.title_setup_gmail)); + + // Gmail / account manager + String gmail = getString(web ? R.string.title_setup_android : R.string.title_setup_oauth, + getString(R.string.title_setup_gmail)); MenuItem item = menu.add(Menu.FIRST, R.string.title_setup_gmail, order++, gmail); int resid = res.getIdentifier("provider_gmail", "drawable", pkg); if (resid != 0) item.setIcon(resid); - for (EmailProvider provider : EmailProvider.loadProfiles(context)) + // OAuth + for (EmailProvider provider : providers) if (provider.oauth != null && (provider.oauth.enabled || BuildConfig.DEBUG) && !TextUtils.isEmpty(provider.oauth.clientId)) { + String title = getString(R.string.title_setup_oauth, provider.description); item = menu - .add(Menu.FIRST, -1, order++, getString(R.string.title_setup_oauth, provider.description)) + .add(Menu.FIRST, -1, order++, title) .setIntent(new Intent(ActivitySetup.ACTION_QUICK_OAUTH) .putExtra("id", provider.id) .putExtra("name", provider.description) @@ -291,6 +313,7 @@ public class FragmentSetup extends FragmentBase { .putExtra("askAccount", provider.oauth.askAccount) .putExtra("askTenant", provider.oauth.askTenant()) .putExtra("pop", provider.pop != null)); + // https://developers.google.com/identity/branding-guidelines resid = res.getIdentifier("provider_" + provider.id, "drawable", pkg); if (resid != 0) item.setIcon(resid); @@ -392,6 +415,14 @@ public class FragmentSetup extends FragmentBase { } }); + tvTutorials.setPaintFlags(tvTutorials.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); + tvTutorials.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Helper.view(v.getContext(), Uri.parse(Helper.TUTORIALS_URI), false); + } + }); + tvQuickNew.setPaintFlags(tvQuickNew.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); tvQuickNew.setOnClickListener(new View.OnClickListener() { @Override @@ -626,6 +657,35 @@ public class FragmentSetup extends FragmentBase { } }); + btnSignature.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + FragmentDialogSelectIdentity fragment = new FragmentDialogSelectIdentity(); + fragment.setArguments(new Bundle()); + fragment.setTargetFragment(FragmentSetup.this, ActivitySetup.REQUEST_SELECT_IDENTITY); + fragment.show(getParentFragmentManager(), "select:identity"); + } + }); + + btnReorderAccounts.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(v.getContext()); + lbm.sendBroadcast(new Intent(ActivitySetup.ACTION_SETUP_REORDER) + .putExtra("className", EntityAccount.class.getName())); + } + }); + + btnReorderFolders.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(v.getContext()); + lbm.sendBroadcast(new Intent(ActivitySetup.ACTION_SETUP_REORDER) + .putExtra("className", TupleFolderSort.class.getName())); + } + }); + + btnDelete.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { @@ -856,28 +916,6 @@ public class FragmentSetup extends FragmentBase { boolean setup_welcome = prefs.getBoolean("setup_welcome", true); ibWelcome.setImageLevel(setup_welcome ? 0 /* less */ : 1 /* more */); grpWelcome.setVisibility(setup_welcome ? View.VISIBLE : View.GONE); - - ViewGroup vwWelcome = (ViewGroup) ibWelcome.getParent(); - if (vwWelcome == null) - return; - - vwWelcome.post(new Runnable() { - @Override - public void run() { - try { - if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) - return; - Rect rect = new Rect( - vwWelcome.getLeft(), - ibWelcome.getTop(), - vwWelcome.getRight(), - ibWelcome.getBottom()); - vwWelcome.setTouchDelegate(new TouchDelegate(rect, ibWelcome)); - } catch (Throwable ex) { - Log.e(ex); - } - } - }); } private void updateManual() { @@ -905,28 +943,6 @@ public class FragmentSetup extends FragmentBase { ? View.VISIBLE : View.GONE); grpExtra.setVisibility(setup_extra ? View.VISIBLE : View.GONE); - - ViewGroup vwExtra = (ViewGroup) ibExtra.getParent(); - if (vwExtra == null) - return; - - vwExtra.post(new Runnable() { - @Override - public void run() { - try { - if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) - return; - Rect rect = new Rect( - vwExtra.getLeft(), - ibExtra.getTop(), - vwExtra.getRight(), - ibExtra.getBottom()); - vwExtra.setTouchDelegate(new TouchDelegate(rect, ibExtra)); - } catch (Throwable ex) { - Log.e(ex); - } - } - }); } private void ensureVisible(View child) { @@ -958,6 +974,14 @@ public class FragmentSetup extends FragmentBase { try { switch (requestCode) { + case ActivitySetup.REQUEST_SELECT_IDENTITY: + if (resultCode == RESULT_OK && data != null) + onSelectIdentity(data.getBundleExtra("args")); + break; + case ActivitySetup.REQUEST_EDIT_SIGNATURE: + if (resultCode == RESULT_OK && data != null) + onEditIdentity(data.getExtras()); + break; case ActivitySetup.REQUEST_DELETE_ACCOUNT: if (resultCode == RESULT_OK && data != null) onDeleteAccount(data.getBundleExtra("args")); @@ -1018,6 +1042,33 @@ public class FragmentSetup extends FragmentBase { btnPermissions.setEnabled(!all); } + private void onSelectIdentity(Bundle args) { + Intent intent = new Intent(getContext(), ActivitySignature.class); + intent.putExtra("id", args.getLong("id")); + intent.putExtra("html", args.getString("html")); + startActivityForResult(intent, ActivitySetup.REQUEST_EDIT_SIGNATURE); + } + + private void onEditIdentity(Bundle args) { + new SimpleTask() { + @Override + protected Void onExecute(Context context, Bundle args) throws Throwable { + long id = args.getLong("id"); + String html = args.getString("html"); + + DB db = DB.getInstance(context); + db.identity().setIdentitySignature(id, html); + + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex); + } + }.execute(this, args, "set:signature"); + } + private void onDeleteAccount(Bundle args) { long account = args.getLong("account"); String name = args.getString("name"); diff --git a/app/src/main/java/eu/faircode/email/GmailState.java b/app/src/main/java/eu/faircode/email/GmailState.java index 1fef8e0c90..a07e1341d8 100644 --- a/app/src/main/java/eu/faircode/email/GmailState.java +++ b/app/src/main/java/eu/faircode/email/GmailState.java @@ -39,7 +39,7 @@ public class GmailState { private long acquired; static final String TYPE_GOOGLE = "com.google"; - private static final long TOKEN_LIFETIME = 45 * 60 * 1000L; // milliseconds + private static final long TOKEN_LIFETIME = 60 * 60 * 1000L; // milliseconds private GmailState(String token, long acquired) { this.token = token; @@ -60,20 +60,27 @@ public class GmailState { return acquired + TOKEN_LIFETIME; } - void refresh(@NonNull Context context, @NonNull String user, boolean expire, long keep_alive) + void refresh(@NonNull Context context, String id, @NonNull String user, boolean forceRefresh) throws AuthenticatorException, OperationCanceledException, IOException { + long now = new Date().getTime(); Long expiration = getAccessTokenExpirationTime(); - if (expiration != null && expiration - keep_alive < new Date().getTime()) { - EntityLog.log(context, "Force invalidation" + - " expiration=" + new Date(expiration) + - " keep-alive=" + (keep_alive / 60 / 1000) + "m"); - expire = true; - } + boolean needsRefresh = (expiration != null && expiration < now); + + if (!needsRefresh && forceRefresh && + expiration != null && + expiration - ServiceAuthenticator.MIN_FORCE_REFRESH_INTERVAL < now) + needsRefresh = true; + + + EntityLog.log(context, EntityLog.Type.Debug, "Token user=" + id + ":" + user + + " expiration=" + (expiration == null ? null : new Date(expiration)) + + " need=" + needsRefresh + + " force=" + forceRefresh); - if (expire) + if (needsRefresh) try { if (token != null) { - EntityLog.log(context, "Invalidating token user=" + user); + EntityLog.log(context, "Invalidating token user=" + id + ":" + user); AccountManager am = AccountManager.get(context); am.invalidateAuthToken(TYPE_GOOGLE, token); } @@ -85,9 +92,9 @@ public class GmailState { Account account = getAccount(context, user.replace("recent:", "")); if (account == null) - throw new AuthenticatorException("Account not found for " + user); + throw new AuthenticatorException("Account not found for " + id + ":" + user); - EntityLog.log(context, "Getting token user=" + user); + EntityLog.log(context, "Getting token user=" + id + ":" + user); AccountManager am = AccountManager.get(context); String newToken = am.blockingGetAuthToken( account, @@ -100,7 +107,7 @@ public class GmailState { } if (token == null) - throw new AuthenticatorException("No token for " + user); + throw new AuthenticatorException("No token for " + id + ":" + user); } static Account getAccount(Context context, String user) { diff --git a/app/src/main/java/eu/faircode/email/Helper.java b/app/src/main/java/eu/faircode/email/Helper.java index 3c131d8059..2f4c77f5ee 100644 --- a/app/src/main/java/eu/faircode/email/Helper.java +++ b/app/src/main/java/eu/faircode/email/Helper.java @@ -187,11 +187,13 @@ public class Helper { static final String PACKAGE_CHROME = "com.android.chrome"; static final String PACKAGE_WEBVIEW = "https://play.google.com/store/apps/details?id=com.google.android.webview"; - static final String PRIVACY_URI = "https://email.faircode.eu/privacy/"; + static final String PRIVACY_URI = "https://github.com/M66B/FairEmail/blob/master/PRIVACY.md"; + static final String TUTORIALS_URI = "https://github.com/M66B/FairEmail/tree/master/tutorials#main"; static final String XDA_URI = "https://forum.xda-developers.com/showthread.php?t=3824168"; static final String SUPPORT_URI = "https://contact.faircode.eu/"; static final String TEST_URI = "https://play.google.com/apps/testing/" + BuildConfig.APPLICATION_ID; static final String BIMI_PRIVACY_URI = "https://datatracker.ietf.org/doc/html/draft-brotman-ietf-bimi-guidance-03#section-7.4"; + static final String LT_PRIVACY_URI = "https://languagetool.org/legal/privacy"; static final String ID_COMMAND_URI = "https://datatracker.ietf.org/doc/html/rfc2971#section-3.1"; static final String AUTH_RESULTS_URI = "https://datatracker.ietf.org/doc/html/rfc7601"; static final String FAVICON_PRIVACY_URI = "https://en.wikipedia.org/wiki/Favicon"; @@ -417,7 +419,8 @@ public class Helper { PackageManager pm = context.getPackageManager(); Intent view = new Intent(Intent.ACTION_VIEW, uri); - List ris = pm.queryIntentActivities(view, 0); // action whitelisted + int flags = (Build.VERSION.SDK_INT < Build.VERSION_CODES.M ? 0 : PackageManager.MATCH_ALL); + List ris = pm.queryIntentActivities(view, flags); // action whitelisted for (ResolveInfo info : ris) { Intent intent = new Intent(); intent.setAction(ACTION_CUSTOM_TABS_CONNECTION); @@ -828,7 +831,8 @@ public class Helper { List ris = null; try { PackageManager pm = context.getPackageManager(); - ris = pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + int flags = (Build.VERSION.SDK_INT < Build.VERSION_CODES.M ? 0 : PackageManager.MATCH_ALL); + ris = pm.queryIntentActivities(intent, flags); for (ResolveInfo ri : ris) { Log.i("Target=" + ri); context.grantUriPermission(ri.activityInfo.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); @@ -898,34 +902,59 @@ public class Helper { String open_with_pkg = prefs.getString("open_with_pkg", null); boolean open_with_tabs = prefs.getBoolean("open_with_tabs", true); + Log.i("View=" + uri + + " browse=" + browse + + " task=" + task + + " pkg=" + open_with_pkg + ":" + open_with_tabs + + " isHyperLink=" + UriHelper.isHyperLink(uri) + + " isInstalled=" + isInstalled(context, open_with_pkg) + + " hasCustomTabs=" + hasCustomTabs(context, uri, open_with_pkg)); + if (!UriHelper.isHyperLink(uri)) { open_with_pkg = null; open_with_tabs = false; } - if (open_with_pkg != null && !isInstalled(context, open_with_pkg)) { - open_with_pkg = null; - open_with_tabs = false; - } + if (!"chooser".equals(open_with_pkg)) { + if (open_with_pkg != null && !isInstalled(context, open_with_pkg)) + open_with_pkg = null; - if (open_with_tabs && !hasCustomTabs(context, uri, open_with_pkg)) - open_with_tabs = false; + if (open_with_tabs && !hasCustomTabs(context, uri, open_with_pkg)) + open_with_tabs = false; + } - Log.i("View=" + uri + - " browse=" + browse + - " task=" + task + - " pkg=" + open_with_pkg + ":" + open_with_tabs); + Intent view = new Intent(Intent.ACTION_VIEW); + if (mimeType == null) + view.setData(uri); + else + view.setDataAndType(uri, mimeType); + if (task) + view.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - if (browse || !open_with_tabs) { + if ("chooser".equals(open_with_pkg)) { + try { + if (open_with_tabs) { + EntityLog.log(context, "Launching direct uri=" + uri + + " intent=" + view + + " extras=" + TextUtils.join(", ", Log.getExtras(view.getExtras()))); + context.startActivity(view); + } else { + EntityLog.log(context, "Launching chooser uri=" + uri + + " intent=" + view + + " extras=" + TextUtils.join(", ", Log.getExtras(view.getExtras()))); + Intent chooser = Intent.createChooser(view, context.getString(R.string.title_select_app)); + context.startActivity(chooser); + } + } catch (ActivityNotFoundException ex) { + Log.w(ex); + reportNoViewer(context, uri, ex); + } + } else if (browse || !open_with_tabs) { try { - Intent view = new Intent(Intent.ACTION_VIEW); - if (mimeType == null) - view.setData(uri); - else - view.setDataAndType(uri, mimeType); - if (task) - view.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); view.setPackage(open_with_pkg); + EntityLog.log(context, "Launching view uri=" + uri + + " intent=" + view + + " extras=" + TextUtils.join(", ", Log.getExtras(view.getExtras()))); context.startActivity(view); } catch (Throwable ex) { reportNoViewer(context, uri, ex); @@ -965,7 +994,6 @@ public class Helper { languages.add(slocale.getLanguage() + ";q=0.7"); } languages.add("*;q=0.5"); - Log.i("MMM " + TextUtils.join(", ", languages)); Bundle headers = new Bundle(); // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language @@ -976,6 +1004,9 @@ public class Helper { customTabsIntent.intent.setPackage(open_with_pkg); try { + EntityLog.log(context, "Launching tab uri=" + uri + + " intent=" + customTabsIntent.intent + + " extras=" + TextUtils.join(", ", Log.getExtras(customTabsIntent.intent.getExtras()))); customTabsIntent.launchUrl(context, uri); } catch (Throwable ex) { reportNoViewer(context, uri, ex); @@ -1124,6 +1155,18 @@ public class Helper { return 0; } + static long getUpdateTime(Context context) { + try { + PackageManager pm = context.getPackageManager(); + PackageInfo pi = pm.getPackageInfo(BuildConfig.APPLICATION_ID, 0); + if (pi != null) + return pi.lastUpdateTime; + } catch (Throwable ex) { + Log.e(ex); + } + return 0; + } + static int getTargetSdk(Context context) { if (targetSdk == null) try { @@ -1203,6 +1246,16 @@ public class Helper { return "Xiaomi".equalsIgnoreCase(Build.MANUFACTURER); } + static boolean isRedmiNote() { + // Manufacturer: Xiaomi + // Model: Redmi Note 8 Pro + // Model: Redmi Note 10S + return isXiaomi() && + !TextUtils.isEmpty(Build.MODEL) && + Build.MODEL.toLowerCase(Locale.ROOT).contains("redmi") && + Build.MODEL.toLowerCase(Locale.ROOT).contains("note"); + } + static boolean isMeizu() { return "Meizu".equalsIgnoreCase(Build.MANUFACTURER); } @@ -2051,6 +2104,12 @@ public class Helper { // Files + static { + System.loadLibrary("fairemail"); + } + + public static native void sync(); + static String sanitizeFilename(String name) { if (name == null) return null; diff --git a/app/src/main/java/eu/faircode/email/HtmlEx.java b/app/src/main/java/eu/faircode/email/HtmlEx.java index 2ac55e8513..c94a0db524 100644 --- a/app/src/main/java/eu/faircode/email/HtmlEx.java +++ b/app/src/main/java/eu/faircode/email/HtmlEx.java @@ -42,6 +42,7 @@ import android.text.style.SuperscriptSpan; import android.text.style.TypefaceSpan; import android.text.style.URLSpan; import android.text.style.UnderlineSpan; +import android.util.StringBuilderPrinter; import java.util.ArrayList; import java.util.List; @@ -140,6 +141,15 @@ public class HtmlEx { int n1 = text.nextSpanTransition(i, end, QuoteSpan.class); int n2 = text.nextSpanTransition(i, end, eu.faircode.email.IndentSpan.class); next = Math.min(n1, n2); + if (next > end) { + StringBuilder sb = new StringBuilder(); + TextUtils.dumpSpans(text, new StringBuilderPrinter(sb), "withinDiv "); + sb.append(" next=").append(next); + sb.append(" start=").append(start); + sb.append(" end=").append(end); + eu.faircode.email.Log.e(sb.toString()); + next = end; + } List spans = new ArrayList<>(); for (Object span : getSpans(text, i, next, LeadingMarginSpan.class)) if (span instanceof QuoteSpan || diff --git a/app/src/main/java/eu/faircode/email/HtmlHelper.java b/app/src/main/java/eu/faircode/email/HtmlHelper.java index 0547b46b63..7b9926cc9c 100644 --- a/app/src/main/java/eu/faircode/email/HtmlHelper.java +++ b/app/src/main/java/eu/faircode/email/HtmlHelper.java @@ -55,6 +55,7 @@ import android.text.style.StrikethroughSpan; import android.text.style.StyleSpan; import android.text.style.SubscriptSpan; import android.text.style.SuperscriptSpan; +import android.text.style.TypefaceSpan; import android.text.style.URLSpan; import android.text.style.UnderlineSpan; import android.util.Base64; @@ -64,6 +65,7 @@ import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.FileProvider; +import androidx.core.content.res.ResourcesCompat; import androidx.core.graphics.ColorUtils; import androidx.core.util.PatternsCompat; import androidx.preference.PreferenceManager; @@ -140,12 +142,12 @@ public class HtmlHelper { private static final int DEFAULT_FONT_SIZE_PT = 12; // points private static final int GRAY_THRESHOLD = Math.round(255 * 0.2f); private static final int COLOR_THRESHOLD = Math.round(255 * 0.1f); - private static final float MIN_LUMINANCE = 0.7f; - private static final float MIN_LUMINANCE_DARK = 0.1f; + private static final float MIN_LUMINANCE_VIEW = 0.7f; + private static final float MIN_LUMINANCE_COMPOSE = 0.85f; private static final int TAB_SIZE = 4; private static final int MAX_ALT = 250; private static final int MAX_AUTO_LINK = 250; - private static final int MAX_FORMAT_TEXT_SIZE = 200 * 1024; // characters + private static final int MAX_FORMAT_TEXT_SIZE = 100 * 1024; // characters private static final int SMALL_IMAGE_SIZE = 5; // pixels private static final int TRACKING_PIXEL_SURFACE = 25; // pixels private static final float[] HEADING_SIZES = {1.5f, 1.4f, 1.3f, 1.2f, 1.1f, 1f}; @@ -342,42 +344,72 @@ public class HtmlHelper { x11ColorMap.put("yellowgreen", 0x9ACD32); } - // http://www.alanwood.net/demos/wingdings.html - static int[] WINGDING_TO_UNICODE = { - 0x0020, 0x1F589, 0x2702, 0x2701, 0x1F453, 0x1F56D, 0x1F56E, 0x1F56F, - 0x1F57F, 0x2706, 0x1F582, 0x1F583, 0x1F4EA, 0x1F4EB, 0x1F4EC, 0x1F4ED, - 0x1F4C1, 0x1F4C2, 0x1F4C4, 0x1F5CF, 0x1F5D0, 0x1F5C4, 0x231B, 0x1F5AE, - 0x1F5B0, 0x1F5B2, 0x1F5B3, 0x1F5B4, 0x1F5AB, 0x1F5AC, 0x2707, 0x270D, - 0x1F58E, 0x270C, 0x1F44C, 0x1F44D, 0x1F44E, 0x261C, 0x261E, 0x261D, - 0x261F, 0x1F590, 0x263A, 0x1F610, 0x2639, 0x1F4A3, 0x2620, 0x1F3F3, - 0x1F3F1, 0x2708, 0x263C, 0x1F4A7, 0x2744, 0x1F546, 0x271E, 0x1F548, - 0x2720, 0x2721, 0x262A, 0x262F, 0x0950, 0x2638, 0x2648, 0x2649, - 0x264A, 0x264B, 0x264C, 0x264D, 0x264E, 0x264F, 0x2650, 0x2651, - 0x2652, 0x2653, 0x1F670, 0x1F675, 0x25CF, 0x1F53E, 0x25A0, 0x25A1, - 0x1F790, 0x2751, 0x2752, 0x2B27, 0x29EB, 0x25C6, 0x2756, 0x2B25, - 0x2327, 0x2BB9, 0x2318, 0x1F3F5, 0x1F3F6, 0x1F676, 0x1F677, 0x003F, - 0x24EA, 0x2460, 0x2461, 0x2462, 0x2463, 0x2464, 0x2465, 0x2466, - 0x2467, 0x2468, 0x2469, 0x24FF, 0x2776, 0x2777, 0x2778, 0x2779, - 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, 0x1F662, 0x1F660, - 0x1F661, 0x1F663, 0x1F65E, 0x1F65C, 0x1F65D, 0x1F65F, 0x00B7, 0x2022, - 0x25AA, 0x26AA, 0x1F786, 0x1F788, 0x25C9, 0x25CE, 0x1F53F, 0x25AA, - 0x25FB, 0x1F7C2, 0x2726, 0x2605, 0x2736, 0x2734, 0x2739, 0x2735, - 0x2BD0, 0x2316, 0x27E1, 0x2311, 0x2BD1, 0x272A, 0x2730, 0x1F550, - 0x1F551, 0x1F552, 0x1F553, 0x1F554, 0x1F555, 0x1F556, 0x1F557, 0x1F558, - 0x1F559, 0x1F55A, 0x1F55B, 0x2BB0, 0x2BB1, 0x2BB2, 0x2BB3, 0x2BB4, - 0x2BB5, 0x2BB6, 0x2BB7, 0x1F66A, 0x1F66B, 0x1F655, 0x1F654, 0x1F657, - 0x1F656, 0x1F650, 0x1F651, 0x1F652, 0x1F653, 0x232B, 0x2326, 0x2B98, - 0x2B9A, 0x2B99, 0x2B9B, 0x2B88, 0x2B8A, 0x2B89, 0x2B8B, 0x1F868, - 0x1F86A, 0x1F869, 0x1F86B, 0x1F86C, 0x1F86D, 0x1F86F, 0x1F86E, 0x1F878, - 0x1F87A, 0x1F879, 0x1F87B, 0x1F87C, 0x1F87D, 0x1F87F, 0x1F87E, 0x21E6, - 0x21E8, 0x21E7, 0x21E9, 0x2B04, 0x21F3, 0x2B00, 0x2B01, 0x2B03, - 0x2B02, 0x1F8AC, 0x1F8AD, 0x1F5F6, 0x2714, 0x1F5F7, 0x1F5F9, 0x0077 - }; - private static final List TRACKING_HOSTS = Collections.unmodifiableList(Arrays.asList( "www.google-analytics.com" )); + static Map MAP_WINGDINGS; + + static { + // http://www.alanwood.net/demos/wingdings.html + // https://unicode.org/L2/L2011/11052r-wingding.pdf + Map map = new HashMap<>(); + map.put(37, 0x1F514); // Bell + map.put(39, 0x1F56F); // Candle + map.put(44, 0x1F4EA); // Closed mailbox with lowered flag + map.put(45, 0x1F4EB); // Closed mailbox with raised flag + map.put(46, 0x1F4EC); // Open mailbox with raised flag + map.put(47, 0x1F4ED); // Open mailbox with lowered flag + map.put(48, 0x1F4C1); // Folder + map.put(49, 0x1F4C2); // Open folder + map.put(53, 0x1F5C4); // File cabinet + map.put(54, 0x231B); // Hourglass + map.put(57, 0x1F5B2); // Trackball + map.put(58, 0x1F5A5); // Computer + map.put(65, 0x270C); // Victory hand + map.put(66, 0x1F44C); // OK hand + map.put(67, 0x1F44D); // Thumb up + map.put(68, 0x1F44E); // Thumb down + map.put(69, 0x1F448); // Pointing left + map.put(70, 0x1F449); // Pointing right + map.put(71, 0x261D); // Pointing up + map.put(72, 0x1F447); // Pointing down + map.put(73, 0x1F590); // Raised hand + map.put(74, 0x1F642); // Smiling face + map.put(75, 0x1F610); // Neutral face + map.put(76, 0x1F641); // Frowning face + map.put(77, 0x1F4A3); // Bomb + map.put(83, 0x1F4A7); // Droplet + map.put(84, 0x2744); // Snowflake + map.put(94, 0x2648); // Aries + map.put(95, 0x2649); // Taurus + map.put(96, 0x264A); // Gemini + map.put(97, 0x264B); // Cancer + map.put(98, 0x264C); // Leo + map.put(99, 0x264D); // Virgo + map.put(100, 0x264E); // Libra + map.put(101, 0x264F); // Scorpio + map.put(102, 0x2650); // Sagittarius + map.put(103, 0x2651); // Capricorn + map.put(104, 0x2652); // Aquarius + map.put(105, 0x2653); // Pisces + map.put(183, 0x1F550); // Clock 1 + map.put(184, 0x1F551); // Clock 2 + map.put(185, 0x1F552); // Clock 3 + map.put(186, 0x1F553); // Clock 4 + map.put(187, 0x1F554); // Clock 5 + map.put(188, 0x1F555); // Clock 6 + map.put(189, 0x1F556); // Clock 7 + map.put(190, 0x1F557); // Clock 8 + map.put(191, 0x1F558); // Clock 9 + map.put(192, 0x1F559); // Clock 10 + map.put(193, 0x1F55A); // Clock 11 + map.put(194, 0x1F55B); // Clock 12 + map.put(251, 0x274C); // Red cross + map.put(252, 0x2705); // Green check + MAP_WINGDINGS = Collections.unmodifiableMap(map); + } + static Document sanitizeCompose(Context context, String html, boolean show_images) { try { return sanitize(context, JsoupEx.parse(html), false, show_images); @@ -424,6 +456,7 @@ public class HtmlHelper { boolean text_size = (!view || prefs.getBoolean("text_size", true)); boolean text_font = (!view || prefs.getBoolean("text_font", true)); boolean text_align = prefs.getBoolean("text_align", true); + boolean text_titles = prefs.getBoolean("text_titles", false); boolean display_hidden = prefs.getBoolean("display_hidden", false); boolean disable_tracking = prefs.getBoolean("disable_tracking", true); boolean parse_classes = prefs.getBoolean("parse_classes", true); @@ -509,6 +542,7 @@ public class HtmlHelper { .addAttributes("td", "height") .addAttributes("tr", "width") .addAttributes("tr", "height") + .addAttributes(":all", "title") .removeAttributes("td", "colspan", "rowspan", "width") .removeAttributes("th", "colspan", "rowspan", "width") .addProtocols("img", "src", "cid") @@ -711,12 +745,12 @@ public class HtmlHelper { e = e.parent(); } - if (!view && dark && + if (!view && color != null && (bg == null || bg == Color.TRANSPARENT)) { // Special case: - // external draft / dark background / very dark/light font + // external draft: very dark/light font double lum = ColorUtils.calculateLuminance(color); - if (lum < MIN_LUMINANCE_DARK || lum > 1 - MIN_LUMINANCE_DARK) + if (lum < MIN_LUMINANCE_COMPOSE || lum > 1 - MIN_LUMINANCE_COMPOSE) color = null; } @@ -727,7 +761,7 @@ public class HtmlHelper { // Background color was suppressed because "no color" if (color != null) { double lum = ColorUtils.calculateLuminance(color); - if (dark ? lum < MIN_LUMINANCE : lum > 1 - MIN_LUMINANCE) + if (dark ? lum < MIN_LUMINANCE_VIEW : lum > 1 - MIN_LUMINANCE_VIEW) color = textColorPrimary; } } @@ -1031,6 +1065,18 @@ public class HtmlHelper { element.attr("x-block", "true"); } + // Insert titles + if (text_titles) + for (Element e : document.select("[title]")) { + String title = e.attr("title"); + if (TextUtils.isEmpty(title)) + continue; + if ("img".equals(e.tagName()) && + title.equals(e.attr("alt"))) + continue; + e.prependChild(document.createElement("span").text("{" + title + "}")); + } + // Replace headings Elements hs = document.select("h1,h2,h3,h4,h5,h6"); hs.attr("x-line-before", "true"); @@ -2046,7 +2092,7 @@ public class HtmlHelper { if (r == g && r == b && (dark ? 255 - r : r) * a < GRAY_THRESHOLD) color = textColorPrimary; - return adjustLuminance(color, dark, MIN_LUMINANCE); + return adjustLuminance(color, dark, MIN_LUMINANCE_VIEW); } static int adjustLuminance(int color, boolean dark, float min) { @@ -2372,6 +2418,43 @@ public class HtmlHelper { document.select("head").append(sb.toString()); } + static boolean hasColorScheme(Document document, String name) { + List sheets = parseStyles(document.head().select("style")); + for (CSSStyleSheet sheet : sheets) + if (sheet.getCssRules() != null) { + for (int i = 0; i < sheet.getCssRules().getLength(); i++) { + CSSRule rule = sheet.getCssRules().item(i); + if (rule instanceof CSSMediaRuleImpl) { + MediaList list = ((CSSMediaRuleImpl) rule).getMedia(); + String media = (list == null ? null : list.getMediaText()); + if (media != null && + media.toLowerCase(Locale.ROOT).contains("prefers-color-scheme") && + media.toLowerCase(Locale.ROOT).contains(name)) { + Log.i("@media=" + media); + return true; + } + } + } + } + + return false; + } + + static void fakeDark(Document document) { + // https://issuetracker.google.com/issues/237785596 + // https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/invert + // https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/hue-rotate + // https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme + // https://developer.android.com/reference/android/webkit/WebSettings#setAlgorithmicDarkeningAllowed(boolean) + + if (true || hasColorScheme(document, "dark")) + return; + + document.head().appendElement("style").html( + "body { filter: invert(100%) hue-rotate(180deg) !important; background: black !important; }" + + "img { filter: invert(100%) hue-rotate(180deg) !important; }"); + } + static String getLanguage(Context context, String subject, String text) { try { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); @@ -3100,6 +3183,7 @@ public class HtmlHelper { NodeTraversor.traverse(new NodeVisitor() { private Element element; private TextNode tnode; + private Typeface wingdings = null; @Override public void head(Node node, int depth) { @@ -3174,15 +3258,29 @@ public class HtmlHelper { break; case "font-family": if ("wingdings".equalsIgnoreCase(value)) { + if (wingdings == null) + wingdings = ResourcesCompat.getFont(context.getApplicationContext(), R.font.wingdings); + + int from = start; for (int i = start; i < ssb.length(); i++) { int kar = ssb.charAt(i); - if (kar >= 0x20 && kar < 0x20 + WINGDING_TO_UNICODE.length) { - int codepoint = WINGDING_TO_UNICODE[kar - 0x20]; + if (MAP_WINGDINGS.containsKey(kar)) { + if (from < i) { + TypefaceSpan span = new CustomTypefaceSpan("wingdings", wingdings); + setSpan(ssb, span, from, i); + } + int codepoint = MAP_WINGDINGS.get(kar); String replacement = new String(Character.toChars(codepoint)); - if (replacement.length() == 1) - ssb.replace(i, i + 1, replacement); + ssb.replace(i, i + 1, replacement); + i += replacement.length() - 1; + from = i + 1; } } + + if (from < ssb.length()) { + TypefaceSpan span = new CustomTypefaceSpan("wingdings", wingdings); + setSpan(ssb, span, from, ssb.length()); + } } else setSpan(ssb, StyleHelper.getTypefaceSpan(value, context), start, ssb.length()); break; diff --git a/app/src/main/java/eu/faircode/email/IPInfo.java b/app/src/main/java/eu/faircode/email/IPInfo.java index cb1a2e1eda..124f35d84c 100644 --- a/app/src/main/java/eu/faircode/email/IPInfo.java +++ b/app/src/main/java/eu/faircode/email/IPInfo.java @@ -29,6 +29,7 @@ import androidx.core.net.MailTo; import java.io.FileNotFoundException; import java.io.IOException; +import java.net.IDN; import java.net.InetAddress; import java.net.URL; import java.net.UnknownHostException; @@ -57,6 +58,11 @@ public class IPInfo { String host = uri.getHost(); if (host == null) throw new UnknownHostException(); + try { + host = IDN.toASCII(host, IDN.ALLOW_UNASSIGNED); + } catch (Throwable ex) { + Log.i(ex); + } InetAddress address = InetAddress.getByName(host); return new Pair<>(address, getOrganization(address, context)); } diff --git a/app/src/main/java/eu/faircode/email/ImageHelper.java b/app/src/main/java/eu/faircode/email/ImageHelper.java index b320ff430c..d39bd02c3c 100644 --- a/app/src/main/java/eu/faircode/email/ImageHelper.java +++ b/app/src/main/java/eu/faircode/email/ImageHelper.java @@ -285,6 +285,11 @@ class ImageHelper { @NonNull static Bitmap renderSvg(InputStream is, int fillColor, int scaleToPixels) throws IOException { try { + // https://bugzilla.mozilla.org/show_bug.cgi?id=455100 + // https://bug1105796.bmoattachments.org/attachment.cgi?id=8529795 + // https://github.com/BigBadaboom/androidsvg/issues/122#issuecomment-361902061 + SVG.setInternalEntitiesEnabled(false); + SVG svg = SVG.getFromInputStream(is); float w = svg.getDocumentWidth(); float h = svg.getDocumentHeight(); diff --git a/app/src/main/java/eu/faircode/email/LanguageTool.java b/app/src/main/java/eu/faircode/email/LanguageTool.java index a841d8b099..1c6bd8ea24 100644 --- a/app/src/main/java/eu/faircode/email/LanguageTool.java +++ b/app/src/main/java/eu/faircode/email/LanguageTool.java @@ -20,6 +20,9 @@ package eu.faircode.email; */ import android.content.Context; +import android.content.SharedPreferences; + +import androidx.preference.PreferenceManager; import org.json.JSONArray; import org.json.JSONException; @@ -41,6 +44,11 @@ public class LanguageTool { private static final String LT_URI = "https://api.languagetool.org/v2/"; private static final int LT_TIMEOUT = 20; // seconds + static boolean isEnabled(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getBoolean("lt_enabled", false); + } + static List getSuggestions(Context context, CharSequence text) throws IOException, JSONException { // https://languagetool.org/http-api/swagger-ui/#!/default/post_check String request = diff --git a/app/src/main/java/eu/faircode/email/Log.java b/app/src/main/java/eu/faircode/email/Log.java index f115c327f0..3891198656 100644 --- a/app/src/main/java/eu/faircode/email/Log.java +++ b/app/src/main/java/eu/faircode/email/Log.java @@ -81,6 +81,7 @@ import androidx.annotation.RequiresApi; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentManager; import androidx.preference.PreferenceManager; +import androidx.webkit.WebViewCompat; import androidx.webkit.WebViewFeature; import com.bugsnag.android.BreadcrumbType; @@ -370,6 +371,7 @@ public class Log { // https://docs.bugsnag.com/platforms/android/sdk/ com.bugsnag.android.Configuration config = new com.bugsnag.android.Configuration("9d2d57476a0614974449a3ec33f2604a"); + config.setTelemetry(Collections.emptySet()); if (BuildConfig.DEBUG) config.setReleaseStage("debug"); @@ -1439,7 +1441,38 @@ public class Log { for (StackTraceElement ste : stack) { String clazz = ste.getClassName(); - if (clazz != null && clazz.startsWith("org.chromium.net.")) + if (clazz != null && clazz.startsWith("org.chromium.")) + /* + android.content.res.Resources$NotFoundException: + at android.content.res.ResourcesImpl.getValue (ResourcesImpl.java:225) + at android.content.res.Resources.getInteger (Resources.java:1192) + at org.chromium.ui.base.DeviceFormFactor.a (chromium-TrichromeWebViewGoogle6432.aab-stable-500512534:105) + at y8.onCreateActionMode (chromium-TrichromeWebViewGoogle6432.aab-stable-500512534:744) + at px.onCreateActionMode (chromium-TrichromeWebViewGoogle6432.aab-stable-500512534:36) + at com.android.internal.policy.DecorView$ActionModeCallback2Wrapper.onCreateActionMode (DecorView.java:2722) + at com.android.internal.policy.DecorView.startActionMode (DecorView.java:926) + at com.android.internal.policy.DecorView.startActionModeForChild (DecorView.java:882) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.ViewGroup.startActionModeForChild (ViewGroup.java:1035) + at android.view.View.startActionMode (View.java:7654) + at org.chromium.content.browser.selection.SelectionPopupControllerImpl.B (chromium-TrichromeWebViewGoogle6432.aab-stable-500512534:31) + at uh0.a (chromium-TrichromeWebViewGoogle6432.aab-stable-500512534:1605) + at Kk0.i (chromium-TrichromeWebViewGoogle6432.aab-stable-500512534:259) + at B6.run (chromium-TrichromeWebViewGoogle6432.aab-stable-500512534:454) + at android.os.Handler.handleCallback (Handler.java:938) + */ return false; } @@ -1811,6 +1844,9 @@ public class Log { ContentResolver resolver = context.getContentResolver(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + boolean main_log = prefs.getBoolean("main_log", true); + boolean protocol = prefs.getBoolean("protocol", false); + int level = prefs.getInt("log_level", Log.getDefaultLogLevel()); long last_cleanup = prefs.getLong("last_cleanup", 0); PackageManager pm = context.getPackageManager(); @@ -1837,6 +1873,7 @@ public class Log { sb.append(String.format("Installer: %s\r\n", installer)); sb.append(String.format("Installed: %s\r\n", new Date(Helper.getInstallTime(context)))); + sb.append(String.format("Updated: %s\r\n", new Date(Helper.getUpdateTime(context)))); sb.append(String.format("Last cleanup: %s\r\n", new Date(last_cleanup))); sb.append(String.format("Now: %s\r\n", new Date())); @@ -1863,6 +1900,8 @@ public class Log { sb.append(String.format("SoC: %s/%s\r\n", Build.SOC_MANUFACTURER, Build.SOC_MODEL)); sb.append(String.format("OS version: %s\r\n", osVersion)); sb.append(String.format("uid: %d\r\n", android.os.Process.myUid())); + sb.append(String.format("Log main: %b protocol: %b level: %d=%b\r\n", + main_log, protocol, level, level <= android.util.Log.INFO)); sb.append("\r\n"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -2011,6 +2050,15 @@ public class Log { sb.append(String.format("Darken support: %b\r\n", WebViewEx.isFeatureSupported(context, WebViewFeature.ALGORITHMIC_DARKENING))); + try { + PackageInfo pkg = WebViewCompat.getCurrentWebViewPackage(context); + sb.append(String.format("WebView %d/%s %s\r\n", + pkg == null ? -1 : pkg.versionCode, + pkg == null ? null : pkg.versionName, + pkg == null || pkg.versionCode / 100000 < 5005 ? "!!!" : "")); + } catch (Throwable ex) { + sb.append(ex).append("\r\n"); + } sb.append("\r\n"); diff --git a/app/src/main/java/eu/faircode/email/MediaPlayerHelper.java b/app/src/main/java/eu/faircode/email/MediaPlayerHelper.java index cf864e53e3..fe5711025c 100644 --- a/app/src/main/java/eu/faircode/email/MediaPlayerHelper.java +++ b/app/src/main/java/eu/faircode/email/MediaPlayerHelper.java @@ -5,6 +5,12 @@ import android.media.AudioAttributes; import android.media.AudioManager; import android.media.MediaPlayer; import android.net.Uri; +import android.os.Build; + +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.OnLifecycleEvent; import java.io.IOException; import java.util.concurrent.ExecutorService; @@ -79,6 +85,51 @@ public class MediaPlayerHelper { } } + static void liveInCall(Context context, LifecycleOwner owner, IInCall intf) { + AudioManager am = Helper.getSystemService(context, AudioManager.class); + if (am == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + intf.onChanged(false); + Log.i("Audio mode legacy"); + } else { + AudioManager.OnModeChangedListener listener = new AudioManager.OnModeChangedListener() { + @Override + public void onModeChanged(int mode) { + ApplicationEx.getMainHandler().post(new RunnableEx("AudioMode") { + @Override + public void delegate() { + intf.onChanged(isInCall(mode)); + } + }); + } + }; + listener.onModeChanged(am.getMode()); // Init + + owner.getLifecycle().addObserver(new LifecycleObserver() { + private boolean registered = false; + + @OnLifecycleEvent(Lifecycle.Event.ON_ANY) + public void onStateChanged() { + try { + if (owner.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) { + if (!registered) { + am.addOnModeChangedListener(executor, listener); + registered = true; + } + } else { + if (registered) { + am.removeOnModeChangedListener(listener); + registered = false; + } + } + Log.i("Audio mode registered=" + registered); + } catch (Throwable ex) { + Log.e(ex); + } + } + }); + } + } + static boolean isInCall(Context context) { AudioManager am = Helper.getSystemService(context, AudioManager.class); if (am == null) @@ -100,4 +151,8 @@ public class MediaPlayerHelper { mode == AudioManager.MODE_IN_CALL || mode == AudioManager.MODE_IN_COMMUNICATION); } + + interface IInCall { + void onChanged(boolean inCall); + } } diff --git a/app/src/main/java/eu/faircode/email/MessageHelper.java b/app/src/main/java/eu/faircode/email/MessageHelper.java index 907656c0b3..29c56c308d 100644 --- a/app/src/main/java/eu/faircode/email/MessageHelper.java +++ b/app/src/main/java/eu/faircode/email/MessageHelper.java @@ -107,6 +107,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.activation.DataHandler; +import javax.activation.DataSource; import javax.activation.FileDataSource; import javax.activation.FileTypeMap; import javax.mail.Address; @@ -198,6 +199,7 @@ public class MessageHelper { static final String FLAG_NOT_DELIVERED = "$NotDelivered"; static final String FLAG_DISPLAYED = "$Displayed"; static final String FLAG_NOT_DISPLAYED = "$NotDisplayed"; + static final String FLAG_COMPLAINT = "Complaint"; static final String FLAG_LOW_IMPORTANCE = "$LowImportance"; static final String FLAG_HIGH_IMPORTANCE = "$HighImportance"; @@ -1265,22 +1267,20 @@ public class MessageHelper { List result = new ArrayList<>(); String refs = imessage.getHeader("References", null); - if (refs != null) - result.addAll(Arrays.asList(getReferences(refs))); + result.addAll(getReferences(refs)); // Merge references of reported message for threading InternetHeaders iheaders = getReportHeaders(); if (iheaders != null) { String arefs = iheaders.getHeader("References", null); - if (arefs != null) - for (String ref : getReferences(arefs)) - if (!result.contains(ref)) { - Log.i("rfc822 ref=" + ref); - result.add(ref); - } + for (String ref : getReferences(arefs)) + if (!result.contains(ref)) { + Log.i("rfc822 ref=" + ref); + result.add(ref); + } String amsgid = iheaders.getHeader("Message-Id", null); - if (amsgid != null) { + if (!TextUtils.isEmpty(amsgid)) { String msgid = MimeUtility.unfold(amsgid); if (!result.contains(msgid)) { Log.i("rfc822 id=" + msgid); @@ -1292,8 +1292,17 @@ public class MessageHelper { return result.toArray(new String[0]); } - private String[] getReferences(String header) { - return MimeUtility.unfold(header).split("[,\\s]+"); + private List getReferences(String header) { + List result = new ArrayList<>(); + if (header == null) + return result; + header = MimeUtility.unfold(header); + if (TextUtils.isEmpty(header)) + return result; + for (String ref : header.split("[,\\s]+")) + if (!result.contains(ref)) + result.add(ref); + return result; } String getDeliveredTo() throws MessagingException { @@ -1314,7 +1323,7 @@ public class MessageHelper { String getInReplyTo() throws MessagingException { String[] a = getInReplyTos(); - return (a.length == 0 ? null : TextUtils.join(" ", a)); + return (a.length < 1 ? null : a[0]); } String[] getInReplyTos() throws MessagingException { @@ -1323,15 +1332,14 @@ public class MessageHelper { List result = new ArrayList<>(); String header = imessage.getHeader("In-Reply-To", null); - if (header != null) - result.addAll(Arrays.asList(getReferences(header))); + result.addAll(getReferences(header)); if (result.size() == 0) { // Use reported message ID as synthetic in-reply-to InternetHeaders iheaders = getReportHeaders(); if (iheaders != null) { header = iheaders.getHeader("Message-Id", null); - if (header != null) { + if (!TextUtils.isEmpty(header)) { result.add(header); Log.i("rfc822 id=" + header); } @@ -1349,7 +1357,8 @@ public class MessageHelper { ContentType ct = new ContentType(imessage.getContentType()); String reportType = ct.getParameter("report-type"); if ("delivery-status".equalsIgnoreCase(reportType) || - "disposition-notification".equalsIgnoreCase(reportType)) { + "disposition-notification".equalsIgnoreCase(reportType) || + "feedback-report".equalsIgnoreCase(reportType)) { MessageParts parts = new MessageParts(); getMessageParts(null, imessage, parts, null); for (AttachmentPart apart : parts.attachments) @@ -1530,7 +1539,8 @@ public class MessageHelper { DB db = DB.getInstance(context); List all = new ArrayList<>(refs); - all.add(msgid); + if (!TextUtils.isEmpty(msgid)) + all.add(msgid); int thread_range = prefs.getInt("thread_range", MessageHelper.DEFAULT_THREAD_RANGE); int range = (int) Math.pow(2, thread_range); @@ -2718,7 +2728,7 @@ public class MessageHelper { } } - enum AddressFormat {NAME_ONLY, EMAIL_ONLY, NAME_EMAIL} + enum AddressFormat {NAME_ONLY, EMAIL_ONLY, NAME_EMAIL, EMAIL_NAME} static AddressFormat getAddressFormat(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); @@ -2789,6 +2799,8 @@ public class MessageHelper { if (format == AddressFormat.NAME_EMAIL && !TextUtils.isEmpty(email)) formatted.add(personal + " <" + email + ">"); + else if (format == AddressFormat.EMAIL_NAME && !TextUtils.isEmpty(email)) + formatted.add("<" + email + "> " + personal); else formatted.add(personal); } @@ -2877,6 +2889,11 @@ public class MessageHelper { try { byte[] b1 = decodeWord(p1.text, p1.encoding, p1.charset); byte[] b2 = decodeWord(p2.text, p2.encoding, p2.charset); + if (CharsetHelper.isValid(b1, p1.charset) && CharsetHelper.isValid(b2, p2.charset)) { + p++; + continue; + } + byte[] b = new byte[b1.length + b2.length]; System.arraycopy(b1, 0, b, 0, b1.length); System.arraycopy(b2, 0, b, b1.length, b2.length); @@ -2979,7 +2996,9 @@ public class MessageHelper { boolean isReport() { String ct = contentType.getBaseType(); - return Report.isDeliveryStatus(ct) || Report.isDispositionNotification(ct); + return (Report.isDeliveryStatus(ct) || + Report.isDispositionNotification(ct) || + Report.isFeedbackReport(ct)); } } @@ -3167,8 +3186,38 @@ public class MessageHelper { result = Helper.readStream((InputStream) content, cs == null ? StandardCharsets.ISO_8859_1 : cs); } else { - Log.e(content.getClass().getName()); - result = content.toString(); + result = null; + + StringBuilder m = new StringBuilder(); + if (content instanceof Multipart) { + m.append("multipart"); + Multipart mp = (Multipart) content; + for (int i = 0; i < mp.getCount(); i++) { + BodyPart bp = mp.getBodyPart(i); + try { + ContentType ct = new ContentType(bp.getContentType()); + if (h.contentType.match(ct)) { + String _charset = ct.getParameter("charset"); + Charset _cs = (TextUtils.isEmpty(_charset) + ? StandardCharsets.ISO_8859_1 : + Charset.forName(_charset)); + result = Helper.readStream(bp.getInputStream(), _cs); + } + } catch (Throwable ex) { + Log.w(ex); + } + m.append(" [").append(bp.getContentType()).append("]"); + } + } else + m.append(content.getClass().getName()); + + String msg = "Expected " + h.contentType + " got " + m + " result=" + (result != null); + Log.e(msg); + warnings.add(msg); + + if (result == null) + result = Helper.readStream(h.part.getInputStream(), + cs == null ? StandardCharsets.ISO_8859_1 : cs); } } catch (DecodingException ex) { Log.e(ex); @@ -3362,6 +3411,11 @@ public class MessageHelper { w.append(report.disposition); } + if (report.isFeedbackReport()) { + if (!TextUtils.isEmpty(report.feedback)) + w.append(report.feedback); + } + if (w.length() > 0) warnings.add(w.toString()); } else @@ -4042,6 +4096,10 @@ public class MessageHelper { if (part.isMimeType("multipart/mixed")) { Object content = part.getContent(); + + if (content instanceof String) + content = tryParseMultipart((String) content, part.getContentType()); + if (content instanceof Multipart) { Multipart mp = (Multipart) content; for (int i = 0; i < mp.getCount(); i++) { @@ -4065,6 +4123,10 @@ public class MessageHelper { "application/pkcs7-signature".equals(protocol) || "application/x-pkcs7-signature".equals(protocol)) { Object content = part.getContent(); + + if (content instanceof String) + content = tryParseMultipart((String) content, part.getContentType()); + if (content instanceof Multipart) { Multipart multipart = (Multipart) content; if (multipart.getCount() == 2) { @@ -4111,6 +4173,10 @@ public class MessageHelper { String protocol = ct.getParameter("protocol"); if ("application/pgp-encrypted".equals(protocol) || protocol == null) { Object content = part.getContent(); + + if (content instanceof String) + content = tryParseMultipart((String) content, part.getContentType()); + if (content instanceof Multipart) { Multipart multipart = (Multipart) content; if (multipart.getCount() == 2) { @@ -4192,9 +4258,10 @@ public class MessageHelper { try { Log.d("Part class=" + part.getClass() + " type=" + part.getContentType()); - // https://github.com/autocrypt/protected-headers try { ContentType ct = new ContentType(part.getContentType()); + + // https://github.com/autocrypt/protected-headers if ("v1".equals(ct.getParameter("protected-headers"))) { String[] subject = part.getHeader("subject"); if (subject != null && subject.length != 0) { @@ -4202,6 +4269,17 @@ public class MessageHelper { parts.protected_subject = decodeMime(subject[0]); } } + + // https://en.wikipedia.org/wiki/MIME#Multipart_subtypes + if ("multipart".equals(ct.getPrimaryType()) && + !("mixed".equalsIgnoreCase(ct.getSubType()) || + "alternative".equalsIgnoreCase(ct.getSubType()) || + "related".equalsIgnoreCase(ct.getSubType()) || + "report".equalsIgnoreCase(ct.getSubType()) || + "parallel".equalsIgnoreCase(ct.getSubType()) || + "digest".equalsIgnoreCase(ct.getSubType()) || + "appledouble".equalsIgnoreCase(ct.getSubType()))) + Log.e(part.getContentType()); } catch (Throwable ex) { Log.e(ex); } @@ -4209,8 +4287,12 @@ public class MessageHelper { if (part.isMimeType("multipart/*")) { Multipart multipart; Object content = part.getContent(); // Should always be Multipart + + if (content instanceof String) + content = tryParseMultipart((String) content, part.getContentType()); + if (content instanceof Multipart) { - multipart = (Multipart) part.getContent(); + multipart = (Multipart) content; int count = multipart.getCount(); for (int i = 0; i < count; i++) try { @@ -4294,7 +4376,9 @@ public class MessageHelper { !Part.ATTACHMENT.equalsIgnoreCase(disposition) && TextUtils.isEmpty(filename)) { parts.text.add(new PartHolder(part, contentType)); } else { - if (Report.isDeliveryStatus(ct) || Report.isDispositionNotification(ct)) + if (Report.isDeliveryStatus(ct) || + Report.isDispositionNotification(ct) || + Report.isFeedbackReport(ct)) parts.extra.add(new PartHolder(part, contentType)); AttachmentPart apart = new AttachmentPart(); @@ -4363,6 +4447,35 @@ public class MessageHelper { } } + private Object tryParseMultipart(String text, String contentType) { + try { + return new MimeMultipart(new DataSource() { + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(text.getBytes(StandardCharsets.ISO_8859_1)); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return null; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public String getName() { + return "String"; + } + }); + } catch (MessagingException ex) { + Log.e(ex); + return text; + } + } + private void ensureEnvelope() throws MessagingException { _ensureMessage(false, false); } @@ -4703,6 +4816,7 @@ public class MessageHelper { String diagnostic; String disposition; String refid; + String feedback; String html; Report(String type, String content) { @@ -4762,6 +4876,15 @@ public class MessageHelper { this.refid = value; break; } + } else if (isFeedbackReport(type)) { + // https://datatracker.ietf.org/doc/html/rfc5965 + feedback = "complaint"; + switch (name) { + case "Feedback-Type": + // abuse, fraud, other, virus + feedback = value; + break; + } } } } catch (Throwable ex) { @@ -4780,6 +4903,10 @@ public class MessageHelper { return isDispositionNotification(type); } + boolean isFeedbackReport() { + return isFeedbackReport(type); + } + boolean isDelivered() { return ("delivered".equals(action) || "relayed".equals(action) || "expanded".equals(action)); } @@ -4839,5 +4966,9 @@ public class MessageHelper { static boolean isDispositionNotification(String type) { return "message/disposition-notification".equalsIgnoreCase(type); } + + static boolean isFeedbackReport(String type) { + return "message/feedback-report".equalsIgnoreCase(type); + } } } diff --git a/app/src/main/java/eu/faircode/email/PopupMenuLifecycle.java b/app/src/main/java/eu/faircode/email/PopupMenuLifecycle.java index 195bb96a99..d09e743b3a 100644 --- a/app/src/main/java/eu/faircode/email/PopupMenuLifecycle.java +++ b/app/src/main/java/eu/faircode/email/PopupMenuLifecycle.java @@ -20,10 +20,12 @@ package eu.faircode.email; */ import android.content.Context; +import android.content.Intent; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.text.SpannableStringBuilder; +import android.text.style.BackgroundColorSpan; import android.text.style.ImageSpan; import android.view.Gravity; import android.view.Menu; @@ -117,7 +119,6 @@ public class PopupMenuLifecycle extends PopupMenu { static void insertIcon(Context context, MenuItem menuItem, boolean submenu) { Drawable icon = menuItem.getIcon(); - if (icon == null) icon = new ColorDrawable(Color.TRANSPARENT); else { diff --git a/app/src/main/java/eu/faircode/email/ServiceAuthenticator.java b/app/src/main/java/eu/faircode/email/ServiceAuthenticator.java index 6cfcb269f1..1b565d17eb 100644 --- a/app/src/main/java/eu/faircode/email/ServiceAuthenticator.java +++ b/app/src/main/java/eu/faircode/email/ServiceAuthenticator.java @@ -47,7 +47,6 @@ public class ServiceAuthenticator extends Authenticator { private Context context; private int auth; private String provider; - private long keep_alive; private String user; private String password; private IAuthenticated intf; @@ -56,15 +55,16 @@ public class ServiceAuthenticator extends Authenticator { static final int AUTH_TYPE_GMAIL = 2; static final int AUTH_TYPE_OAUTH = 3; + static final long MIN_FORCE_REFRESH_INTERVAL = 15 * 60 * 1000L; + ServiceAuthenticator( Context context, - int auth, String provider, int keep_alive, + int auth, String provider, String user, String password, IAuthenticated intf) { this.context = context.getApplicationContext(); this.auth = auth; this.provider = provider; - this.keep_alive = keep_alive * 60 * 1000L; this.user = user; this.password = password; this.intf = intf; @@ -82,14 +82,16 @@ public class ServiceAuthenticator extends Authenticator { Log.e(ex); } - Log.i(user + " returning " + (auth == AUTH_TYPE_PASSWORD ? "password" : "token")); + Log.i(user + " returning " + + (auth == AUTH_TYPE_PASSWORD ? "password" : "token") + + (BuildConfig.DEBUG ? "=" + token : "")); return new PasswordAuthentication(user, token); } - String refreshToken(boolean expire) throws AuthenticatorException, OperationCanceledException, IOException, JSONException, MessagingException { + String refreshToken(boolean forceRefresh) throws AuthenticatorException, OperationCanceledException, IOException, JSONException, MessagingException { if (auth == AUTH_TYPE_GMAIL) { GmailState authState = GmailState.jsonDeserialize(password); - authState.refresh(context, user, expire, keep_alive); + authState.refresh(context, "android", user, forceRefresh); Long expiration = authState.getAccessTokenExpirationTime(); if (expiration != null) EntityLog.log(context, user + " token expiration=" + new Date(expiration)); @@ -104,7 +106,7 @@ public class ServiceAuthenticator extends Authenticator { return authState.getAccessToken(); } else if (auth == AUTH_TYPE_OAUTH && provider != null) { AuthState authState = AuthState.jsonDeserialize(password); - OAuthRefresh(context, provider, authState, expire, keep_alive); + OAuthRefresh(context, provider, user, authState, forceRefresh); Long expiration = authState.getAccessTokenExpirationTime(); if (expiration != null) EntityLog.log(context, user + " token expiration=" + new Date(expiration)); @@ -121,44 +123,44 @@ public class ServiceAuthenticator extends Authenticator { return password; } - void checkToken() { - Long expiration = null; - + Long getAccessTokenExpirationTime() { try { if (auth == AUTH_TYPE_GMAIL) { GmailState authState = GmailState.jsonDeserialize(password); - expiration = authState.getAccessTokenExpirationTime(); + return authState.getAccessTokenExpirationTime(); } else if (auth == AUTH_TYPE_OAUTH) { AuthState authState = AuthState.jsonDeserialize(password); - expiration = authState.getAccessTokenExpirationTime(); + return authState.getAccessTokenExpirationTime(); } } catch (JSONException ex) { Log.e(ex); } - - long slack = Math.min(keep_alive, 30 * 60 * 1000L); - if (expiration != null && expiration - slack < new Date().getTime()) - throw new IllegalStateException(Log.TOKEN_REFRESH_REQUIRED); + return null; } interface IAuthenticated { void onPasswordChanged(Context context, String newPassword); } - private static void OAuthRefresh(Context context, String id, AuthState authState, boolean expire, long keep_alive) + private static void OAuthRefresh(Context context, String id, String user, AuthState authState, boolean forceRefresh) throws MessagingException { try { + long now = new Date().getTime(); Long expiration = authState.getAccessTokenExpirationTime(); - if (expiration != null && expiration - keep_alive < new Date().getTime()) { - EntityLog.log(context, "OAuth force refresh" + - " expiration=" + new Date(expiration) + - " keep_alive=" + (keep_alive / 60 / 1000) + "m"); + boolean needsRefresh = (expiration != null && expiration < now); + if (needsRefresh) authState.setNeedsTokenRefresh(true); - } - if (expire) + if (!needsRefresh && forceRefresh && + expiration != null && + expiration - ServiceAuthenticator.MIN_FORCE_REFRESH_INTERVAL < now) authState.setNeedsTokenRefresh(true); + EntityLog.log(context, EntityLog.Type.Debug, "Token user=" + id + ":" + user + + " expiration=" + (expiration == null ? null : new Date(expiration)) + + " need=" + needsRefresh + "/" + authState.getNeedsTokenRefresh() + + " force=" + forceRefresh); + ClientAuthentication clientAuth; EmailProvider provider = EmailProvider.getProvider(context, id); if (provider.oauth.clientSecret == null) @@ -169,7 +171,7 @@ public class ServiceAuthenticator extends Authenticator { ErrorHolder holder = new ErrorHolder(); Semaphore semaphore = new Semaphore(0); - Log.i("OAuth refresh id=" + id); + Log.i("OAuth refresh user=" + id + ":" + user); AuthorizationService authService = new AuthorizationService(context); authState.performActionWithFreshTokens( authService, @@ -184,12 +186,12 @@ public class ServiceAuthenticator extends Authenticator { }); semaphore.acquire(); - Log.i("OAuth refreshed id=" + id); + Log.i("OAuth refreshed user=" + id + ":" + user); if (holder.error != null) throw holder.error; } catch (Exception ex) { - throw new MessagingException("OAuth refresh id=" + id, ex); + throw new MessagingException("OAuth refresh id=" + id + ":" + user, ex); } } diff --git a/app/src/main/java/eu/faircode/email/ServiceSend.java b/app/src/main/java/eu/faircode/email/ServiceSend.java index e88fac9285..7931915f65 100644 --- a/app/src/main/java/eu/faircode/email/ServiceSend.java +++ b/app/src/main/java/eu/faircode/email/ServiceSend.java @@ -272,7 +272,7 @@ public class ServiceSend extends ServiceBase implements SharedPreferences.OnShar private static PendingIntent getPendingIntent(Context context) { Intent intent = new Intent(context, ActivityView.class); intent.setAction("outbox"); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); return PendingIntentCompat.getActivity( context, ActivityView.PI_OUTBOX, intent, PendingIntent.FLAG_UPDATE_CURRENT); } @@ -500,6 +500,8 @@ public class ServiceSend extends ServiceBase implements SharedPreferences.OnShar } db.setTransactionSuccessful(); + } catch (IllegalArgumentException ex) { + Log.w(ex); } finally { db.endTransaction(); } @@ -566,24 +568,21 @@ public class ServiceSend extends ServiceBase implements SharedPreferences.OnShar Long sid = null; EntityFolder sent = null; - if (reply_move && !TextUtils.isEmpty(message.inreplyto)) - for (String inreplyto : message.inreplyto.split(" ")) { - List replied = db.message().getMessagesByMsgId(message.account, inreplyto); - if (replied != null) - for (EntityMessage m : replied) - if (!m.ui_hide) { - EntityFolder folder = db.folder().getFolder(m.folder); - if (folder != null && - (EntityFolder.INBOX.equals(folder.type) || - EntityFolder.ARCHIVE.equals(folder.type) || - EntityFolder.USER.equals(folder.type))) { - sent = folder; - break; - } + if (reply_move && !TextUtils.isEmpty(message.inreplyto)) { + List replied = db.message().getMessagesByMsgId(message.account, message.inreplyto); + if (replied != null) + for (EntityMessage m : replied) + if (!m.ui_hide) { + EntityFolder folder = db.folder().getFolder(m.folder); + if (folder != null && + (EntityFolder.INBOX.equals(folder.type) || + EntityFolder.ARCHIVE.equals(folder.type) || + EntityFolder.USER.equals(folder.type))) { + sent = folder; + break; } - if (sent != null) - break; - } + } + } if (sent == null) sent = db.folder().getFolderByType(message.account, EntityFolder.SENT); @@ -808,12 +807,11 @@ public class ServiceSend extends ServiceBase implements SharedPreferences.OnShar } // Mark replied - if (message.inreplyto != null) - for (String inreplyto : message.inreplyto.split(" ")) { - List replieds = db.message().getMessagesByMsgId(message.account, inreplyto); - for (EntityMessage replied : replieds) - EntityOperation.queue(this, replied, EntityOperation.ANSWERED, true); - } + if (message.inreplyto != null) { + List replieds = db.message().getMessagesByMsgId(message.account, message.inreplyto); + for (EntityMessage replied : replieds) + EntityOperation.queue(this, replied, EntityOperation.ANSWERED, true); + } // Mark forwarded if (message.wasforwardedfrom != null) { diff --git a/app/src/main/java/eu/faircode/email/ServiceSynchronize.java b/app/src/main/java/eu/faircode/email/ServiceSynchronize.java index 602f9abfb2..8755811b4e 100644 --- a/app/src/main/java/eu/faircode/email/ServiceSynchronize.java +++ b/app/src/main/java/eu/faircode/email/ServiceSynchronize.java @@ -31,7 +31,6 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; -import android.media.AudioManager; import android.net.ConnectivityManager; import android.net.LinkProperties; import android.net.Network; @@ -46,6 +45,7 @@ import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; +import androidx.car.app.connection.CarConnection; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; import androidx.lifecycle.Lifecycle; @@ -64,6 +64,8 @@ import com.sun.mail.imap.IMAPStore; import com.sun.mail.imap.protocol.IMAPProtocol; import com.sun.mail.imap.protocol.IMAPResponse; +import net.openid.appauth.AuthState; + import org.json.JSONObject; import java.io.File; @@ -116,6 +118,8 @@ public class ServiceSynchronize extends ServiceBase implements SharedPreferences private int lastAccounts = 0; private int lastOperations = 0; private ConnectionHelper.NetworkState lastNetworkState = null; + private boolean isInCall = false; + private boolean isInCar = false; private boolean foreground = false; private final Map coreStates = new Hashtable<>(); @@ -161,7 +165,7 @@ public class ServiceSynchronize extends ServiceBase implements SharedPreferences "sync_folders", "sync_shared_folders", "download_headers", "download_eml", - "prefer_ip4", "bind_socket", "standalone_vpn", "tcp_keep_alive", "ssl_harden", "cert_strict", // force reconnect + "prefer_ip4", "bind_socket", "standalone_vpn", "tcp_keep_alive", "ssl_harden", "ssl_harden_strict", "cert_strict", // force reconnect "experiments", "debug", "protocol", // force reconnect "auth_plain", "auth_login", "auth_ntlm", "auth_sasl", "auth_apop", // force reconnect "keep_alive_poll", "empty_pool", "idle_done", // force reconnect @@ -768,58 +772,45 @@ public class ServiceSynchronize extends ServiceBase implements SharedPreferences }); final TwoStateOwner mowner = new TwoStateOwner(this, "mutableUnseenNotify"); + mowner.getLifecycle().addObserver(new LifecycleObserver() { + @OnLifecycleEvent(Lifecycle.Event.ON_ANY) + public void onStateChanged() { + Lifecycle.State state = mowner.getLifecycle().getCurrentState(); + EntityLog.log(ServiceSynchronize.this, EntityLog.Type.Debug, "Owner state=" + state); + if (state.equals(Lifecycle.State.DESTROYED)) + mowner.getLifecycle().removeObserver(this); + } + }); - AudioManager am = Helper.getSystemService(this, AudioManager.class); - if (am == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - mowner.start(); - Log.i("Audio mode legacy"); - } else { - AudioManager.OnModeChangedListener listener = new AudioManager.OnModeChangedListener() { - @Override - public void onModeChanged(int mode) { - getMainHandler().post(new RunnableEx("AudioMode") { - @Override - public void delegate() { - boolean incall = MediaPlayerHelper.isInCall(mode); - boolean suppress = prefs.getBoolean("notify_suppress_in_call", false); - boolean start = (!suppress || !incall); - Log.i("Audio mode start=" + start + - " incall=" + incall + - " suppress=" + suppress); - if (start) - mowner.start(); - else - mowner.stop(); - } - }); - } - }; - listener.onModeChanged(am.getMode()); // Init - - getLifecycle().addObserver(new LifecycleObserver() { - private boolean registered = false; + MediaPlayerHelper.liveInCall(this, this, new MediaPlayerHelper.IInCall() { + @Override + public void onChanged(boolean inCall) { + boolean suppress = prefs.getBoolean("notify_suppress_in_call", false); + EntityLog.log(ServiceSynchronize.this, EntityLog.Type.Debug, + "In call=" + inCall + " suppress=" + suppress); + isInCall = (inCall && suppress); + if (isInCall || isInCar) + mowner.stop(); + else + mowner.start(); + } + }); - @OnLifecycleEvent(Lifecycle.Event.ON_ANY) - public void onStateChanged() { - try { - if (ServiceSynchronize.this.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) { - if (!registered) { - am.addOnModeChangedListener(executor, listener); - registered = true; - } - } else { - if (registered) { - am.removeOnModeChangedListener(listener); - registered = false; - } - } - Log.i("Audio mode registered=" + registered); - } catch (Throwable ex) { - Log.e(ex); - } - } - }); - } + new CarConnection(this).getType().observe(this, new Observer() { + @Override + public void onChanged(Integer connectionState) { + boolean projection = (connectionState != null && + connectionState == CarConnection.CONNECTION_TYPE_PROJECTION); + boolean suppress = prefs.getBoolean("notify_suppress_in_car", false); + EntityLog.log(ServiceSynchronize.this, EntityLog.Type.Debug, + "Projection=" + projection + " state=" + connectionState + " suppress=" + suppress); + isInCar = (projection && suppress); + if (isInCall || isInCar) + mowner.stop(); + else + mowner.start(); + } + }); mutableUnseenNotify.observe(mowner, new Observer>() { private final ExecutorService executor = @@ -1393,7 +1384,7 @@ public class ServiceSynchronize extends ServiceBase implements SharedPreferences // Build pending intent Intent why = new Intent(this, ActivityView.class); why.setAction("why"); - why.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + why.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent piWhy = PendingIntentCompat.getActivity( this, ActivityView.PI_WHY, why, PendingIntent.FLAG_UPDATE_CURRENT); @@ -1443,7 +1434,7 @@ public class ServiceSynchronize extends ServiceBase implements SharedPreferences intent.putExtra("protocol", account.protocol); intent.putExtra("auth_type", account.auth_type); intent.putExtra("faq", 23); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent piAlert = PendingIntentCompat.getActivity( this, ActivityError.PI_ALERT, intent, PendingIntent.FLAG_UPDATE_CURRENT); @@ -2251,8 +2242,15 @@ public class ServiceSynchronize extends ServiceBase implements SharedPreferences } // Check token expiration - if (!account.isTransient(this)) - iservice.check(); + if (!account.isTransient(this)) { + Long expirationTime = iservice.getAccessTokenExpirationTime(); + if (expirationTime != null && expirationTime < new Date().getTime()) { + EntityLog.log(this, EntityLog.Type.Debug, "Token" + + " expired=" + new Date(expirationTime) + + " user=" + account.provider + ":" + account.user); + throw new IllegalStateException(Log.TOKEN_REFRESH_REQUIRED); + } + } // Sends store NOOP if (EmailService.SEPARATE_STORE_CONNECTION) { @@ -2368,6 +2366,19 @@ public class ServiceSynchronize extends ServiceBase implements SharedPreferences try { long duration = account.poll_interval * 60 * 1000L; long trigger = System.currentTimeMillis() + duration; + + if (!account.isTransient(this)) { + Long expirationTime = iservice.getAccessTokenExpirationTime(); + if (expirationTime != null && + expirationTime < trigger && + expirationTime > new Date().getTime()) { + expirationTime += AuthState.EXPIRY_TIME_TOLERANCE_MS; + EntityLog.log(this, EntityLog.Type.Debug, "Expedite keep alive" + + " from " + new Date(trigger) + " to " + new Date(expirationTime)); + trigger = expirationTime; + } + } + EntityLog.log(this, EntityLog.Type.Account, account, "### " + account.name + " keep alive" + " wait=" + account.poll_interval + " until=" + new Date(trigger)); @@ -3002,6 +3013,8 @@ public class ServiceSynchronize extends ServiceBase implements SharedPreferences EntityMessage.snooze(context, message.id, message.ui_snoozed); db.setTransactionSuccessful(); + } catch (IllegalArgumentException ex) { + Log.w(ex); } finally { db.endTransaction(); } diff --git a/app/src/main/java/eu/faircode/email/Shortcuts.java b/app/src/main/java/eu/faircode/email/Shortcuts.java index e64e35c7e7..af14f1babc 100644 --- a/app/src/main/java/eu/faircode/email/Shortcuts.java +++ b/app/src/main/java/eu/faircode/email/Shortcuts.java @@ -280,7 +280,7 @@ class Shortcuts { static ShortcutInfoCompat.Builder getShortcut(Context context, EntityMessage message, ContactInfo[] contactInfo) { Intent thread = new Intent(context, ActivityView.class); thread.setAction("thread:" + message.id); - thread.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + thread.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); thread.putExtra("account", message.account); thread.putExtra("folder", message.folder); thread.putExtra("thread", message.thread); @@ -323,7 +323,7 @@ class Shortcuts { view.setAction("folder:" + folder.id); view.putExtra("account", folder.account); view.putExtra("type", folder.type); - view.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + view.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); int resid = EntityFolder.getIcon(folder.type); Drawable d = context.getDrawable(resid); diff --git a/app/src/main/java/eu/faircode/email/SimpleTask.java b/app/src/main/java/eu/faircode/email/SimpleTask.java index 6b325cfae0..4c88c45bc1 100644 --- a/app/src/main/java/eu/faircode/email/SimpleTask.java +++ b/app/src/main/java/eu/faircode/email/SimpleTask.java @@ -52,6 +52,7 @@ import java.util.concurrent.Future; public abstract class SimpleTask implements LifecycleObserver { private boolean log = true; private boolean count = true; + private boolean keepawake = false; private String name; private long started; @@ -85,6 +86,11 @@ public abstract class SimpleTask implements LifecycleObserver { return this; } + public SimpleTask setKeepAwake(boolean value) { + this.keepawake = value; + return this; + } + public SimpleTask setExecutor(ExecutorService executor) { this.localExecutor = executor; return this; @@ -183,7 +189,10 @@ public abstract class SimpleTask implements LifecycleObserver { public void run() { // Run in background thread try { - wl.acquire(MAX_WAKELOCK); + if (keepawake) + wl.acquire(); + else + wl.acquire(MAX_WAKELOCK); if (log) Log.i("Executing task=" + name); diff --git a/app/src/main/java/eu/faircode/email/StyleHelper.java b/app/src/main/java/eu/faircode/email/StyleHelper.java index dbbad5f82c..1a588289ed 100644 --- a/app/src/main/java/eu/faircode/email/StyleHelper.java +++ b/app/src/main/java/eu/faircode/email/StyleHelper.java @@ -198,6 +198,7 @@ public class StyleHelper { IndentSpan[] indents = edit.getSpans(start, end, IndentSpan.class); popupMenu.getMenu().findItem(R.id.menu_style_indentation_decrease).setEnabled(indents.length > 0); + popupMenu.getMenu().findItem(R.id.menu_style_parenthesis).setEnabled(BuildConfig.DEBUG); popupMenu.getMenu().findItem(R.id.menu_style_code).setEnabled(BuildConfig.DEBUG); popupMenu.insertIcons(context); @@ -233,6 +234,8 @@ public class StyleHelper { return setMark(item); } else if (groupId == R.id.group_style_strikethrough) { return setStrikeThrough(item); + } else if (groupId == R.id.group_style_parenthesis) { + return setParenthesis(item); } else if (groupId == R.id.group_style_code) { return setCode(item); } else if (groupId == R.id.group_style_clear) { @@ -662,7 +665,15 @@ public class StyleHelper { return true; } + private boolean setParenthesis(MenuItem item) { + Log.breadcrumb("style", "action", "parenthesis"); + edit.insert(end, ")"); + edit.insert(start, "("); + return true; + } + private boolean setCode(MenuItem item) { + Log.breadcrumb("style", "action", "code"); _setSize(HtmlHelper.FONT_SMALL); _setFont("monospace"); return true; diff --git a/app/src/main/java/eu/faircode/email/TupleAccountState.java b/app/src/main/java/eu/faircode/email/TupleAccountState.java index 2cb5a1b341..110ab11e05 100644 --- a/app/src/main/java/eu/faircode/email/TupleAccountState.java +++ b/app/src/main/java/eu/faircode/email/TupleAccountState.java @@ -37,7 +37,8 @@ public class TupleAccountState extends EntityAccount { this.insecure.equals(other.insecure) && this.port.equals(other.port) && this.user.equals(other.user) && - (auth_type != AUTH_TYPE_PASSWORD || this.password.equals(other.password)) && + Objects.equals(this.auth_type, other.auth_type) && + this.password.equals(other.password) && Objects.equals(this.certificate_alias, other.certificate_alias) && Objects.equals(this.realm, other.realm) && Objects.equals(this.fingerprint, other.fingerprint) && diff --git a/app/src/main/java/eu/faircode/email/TupleThreadInfo.java b/app/src/main/java/eu/faircode/email/TupleThreadInfo.java index be71903868..ed7212b346 100644 --- a/app/src/main/java/eu/faircode/email/TupleThreadInfo.java +++ b/app/src/main/java/eu/faircode/email/TupleThreadInfo.java @@ -36,6 +36,6 @@ public class TupleThreadInfo { } public boolean isReferencing(String msgid) { - return !isSelf(msgid) && !isReferenced(msgid); + return !TextUtils.isEmpty(msgid) && !isSelf(msgid) && !isReferenced(msgid); } } diff --git a/app/src/main/java/eu/faircode/email/UriHelper.java b/app/src/main/java/eu/faircode/email/UriHelper.java index 4efb79c505..2d14e9a198 100644 --- a/app/src/main/java/eu/faircode/email/UriHelper.java +++ b/app/src/main/java/eu/faircode/email/UriHelper.java @@ -297,10 +297,22 @@ public class UriHelper { changed = (result != null); url = (result == null ? uri : result); + } else if (uri.getQueryParameter("redirectUrl") != null) { + // https://.../link-tracker?redirectUrl=&sig=...&iat=...&a=...&account=...&email=...&s=...&i=... + try { + byte[] bytes = Base64.decode(uri.getQueryParameter("redirectUrl"), 0); + String u = URLDecoder.decode(new String(bytes), StandardCharsets.UTF_8.name()); + Uri result = Uri.parse(u); + changed = (result != null); + url = (result == null ? uri : result); + } catch (Throwable ex) { + Log.i(ex); + url = uri; + } } else url = uri; - if (url.isOpaque()) + if (url.isOpaque() || !UriHelper.isHyperLink(url)) return uri; Uri.Builder builder = url.buildUpon(); diff --git a/app/src/main/java/eu/faircode/email/WebViewEx.java b/app/src/main/java/eu/faircode/email/WebViewEx.java index 5de6328d8f..f54a49758b 100644 --- a/app/src/main/java/eu/faircode/email/WebViewEx.java +++ b/app/src/main/java/eu/faircode/email/WebViewEx.java @@ -43,12 +43,15 @@ import androidx.webkit.WebSettingsCompat; import androidx.webkit.WebViewCompat; import androidx.webkit.WebViewFeature; +import java.util.Objects; + public class WebViewEx extends WebView implements DownloadListener, View.OnLongClickListener { private int height; private int maxHeight; private boolean legacy; private IWebView intf; private Runnable onPageLoaded; + private String hash; private static String userAgent = null; @@ -121,7 +124,7 @@ public class WebViewEx extends WebView implements DownloadListener, View.OnLongC // https://developer.android.com/reference/android/webkit/WebSettings#setAlgorithmicDarkeningAllowed(boolean) // https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme boolean canDarken = WebViewEx.isFeatureSupported(context, WebViewFeature.ALGORITHMIC_DARKENING); - if (canDarken) + if (canDarken && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, dark && !force_light); setBackgroundColor(canDarken && dark && !force_light ? Color.TRANSPARENT : Color.WHITE); @@ -219,6 +222,21 @@ public class WebViewEx extends WebView implements DownloadListener, View.OnLongC settings.setBlockNetworkImage(!show_images); } + @Override + public void loadDataWithBaseURL(String baseUrl, String data, String mimeType, String encoding, String historyUrl) { + try { + // Prevent flickering + String h = (data == null ? null : Helper.md5(data.getBytes())); + if (Objects.equals(hash, h)) + return; + this.hash = h; + } catch (Throwable ex) { + Log.w(ex); + } + + super.loadDataWithBaseURL(baseUrl, data, mimeType, encoding, historyUrl); + } + @Override public void setMinimumHeight(int minHeight) { super.setMinimumHeight(minHeight); @@ -295,7 +313,20 @@ public class WebViewEx extends WebView implements DownloadListener, View.OnLongC clampedY = true; } - Log.i("onOverScrolled clamped=" + clampedY + " new=" + newScrollY + " dy=" + deltaY); + Log.i("onOverScrolled" + + " clampedY=" + clampedY + + " scrollY=" + scrollY + + " deltaY=" + deltaY + + " RangeY=" + scrollRangeY + + " maxY=" + maxOverScrollY + + " newY=" + (scrollY + deltaY) + "/" + newScrollY + + " dy=" + deltaY + + " top=" + top + + " bottom=" + bottom); + + if (Math.abs(deltaY) > bottom - top) + deltaY = (deltaY > 0 ? 1 : -1) * (bottom - top); + intf.onOverScrolled(scrollX, scrollY, deltaX, deltaY, clampedX, clampedY); return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent); @@ -355,7 +386,16 @@ public class WebViewEx extends WebView implements DownloadListener, View.OnLongC } public static boolean isFeatureSupported(Context context, String feature) { - if (WebViewFeature.ALGORITHMIC_DARKENING.equals(feature)) + if (WebViewFeature.ALGORITHMIC_DARKENING.equals(feature)) { + if (BuildConfig.DEBUG) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + boolean fake_dark = prefs.getBoolean("fake_dark", false); + if (fake_dark) + return false; + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + return false; + try { PackageInfo pkg = WebViewCompat.getCurrentWebViewPackage(context); if (pkg != null && pkg.versionCode / 100000 < 5005) // Version 102.* @@ -363,6 +403,7 @@ public class WebViewEx extends WebView implements DownloadListener, View.OnLongC } catch (Throwable ex) { Log.e(ex); } + } try { return WebViewFeature.isFeatureSupported(feature); diff --git a/app/src/main/java/eu/faircode/email/Widget.java b/app/src/main/java/eu/faircode/email/Widget.java index db248691d7..f51b4ac4fe 100644 --- a/app/src/main/java/eu/faircode/email/Widget.java +++ b/app/src/main/java/eu/faircode/email/Widget.java @@ -80,7 +80,7 @@ public class Widget extends AppWidgetProvider { view.putExtra("type", folders.get(0).type); view.putExtra("refresh", true); view.putExtra("version", version); - view.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + view.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); pi = PendingIntentCompat.getActivity( context, appWidgetId, view, PendingIntent.FLAG_UPDATE_CURRENT); } else { @@ -89,7 +89,7 @@ public class Widget extends AppWidgetProvider { view.setAction("unified"); view.putExtra("refresh", true); view.putExtra("version", version); - view.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + view.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); pi = PendingIntentCompat.getActivity( context, ActivityView.PI_UNIFIED, view, PendingIntent.FLAG_UPDATE_CURRENT); } else { @@ -97,7 +97,7 @@ public class Widget extends AppWidgetProvider { view.setAction("folders:" + account); view.putExtra("refresh", true); view.putExtra("version", version); - view.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + view.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); pi = PendingIntentCompat.getActivity( context, appWidgetId, view, PendingIntent.FLAG_UPDATE_CURRENT); } diff --git a/app/src/main/java/eu/faircode/email/WidgetUnified.java b/app/src/main/java/eu/faircode/email/WidgetUnified.java index e8a6a0437c..98803adeb5 100644 --- a/app/src/main/java/eu/faircode/email/WidgetUnified.java +++ b/app/src/main/java/eu/faircode/email/WidgetUnified.java @@ -73,7 +73,7 @@ public class WidgetUnified extends AppWidgetProvider { view.putExtra("type", type); view.putExtra("refresh", true); view.putExtra("version", version); - view.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + view.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); PendingIntent pi = PendingIntentCompat.getActivity( context, appWidgetId, view, PendingIntent.FLAG_UPDATE_CURRENT); @@ -88,7 +88,7 @@ public class WidgetUnified extends AppWidgetProvider { edit.setAction("widget:" + appWidgetId); edit.putExtra("action", "new"); edit.putExtra("account", account); - edit.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + edit.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); PendingIntent piCompose = PendingIntentCompat.getActivity( context, appWidgetId, edit, PendingIntent.FLAG_UPDATE_CURRENT); @@ -121,12 +121,12 @@ public class WidgetUnified extends AppWidgetProvider { views.setRemoteAdapter(R.id.lv, service); Intent thread = new Intent(context, ActivityView.class); - thread.setAction("widget"); + thread.setAction("widget:" + appWidgetId); thread.putExtra("widget_account", account); thread.putExtra("widget_folder", folder); thread.putExtra("widget_type", type); thread.putExtra("filter_archive", !EntityFolder.ARCHIVE.equals(type)); - thread.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + thread.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent piItem = PendingIntentCompat.getActivity( context, ActivityView.PI_WIDGET, thread, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); diff --git a/app/src/main/java/eu/faircode/email/WorkerCleanup.java b/app/src/main/java/eu/faircode/email/WorkerCleanup.java index a6a8e9cc3a..bcb6c0bc7f 100644 --- a/app/src/main/java/eu/faircode/email/WorkerCleanup.java +++ b/app/src/main/java/eu/faircode/email/WorkerCleanup.java @@ -154,8 +154,12 @@ public class WorkerCleanup extends Worker { } // Restore alarms - for (EntityMessage message : db.message().getSnoozed(null)) - EntityMessage.snooze(context, message.id, message.ui_snoozed); + try { + for (EntityMessage message : db.message().getSnoozed(null)) + EntityMessage.snooze(context, message.id, message.ui_snoozed); + } catch (IllegalArgumentException ex) { + Log.w(ex); + } ServiceSynchronize.reschedule(context); diff --git a/app/src/main/java/eu/faircode/email/WorkerFts.java b/app/src/main/java/eu/faircode/email/WorkerFts.java index 9890c42923..d2e6c193b0 100644 --- a/app/src/main/java/eu/faircode/email/WorkerFts.java +++ b/app/src/main/java/eu/faircode/email/WorkerFts.java @@ -59,7 +59,7 @@ public class WorkerFts extends Worker { Context context = getApplicationContext(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - boolean checkpoints = prefs.getBoolean("sqlite_checkpoints", false); + boolean checkpoints = prefs.getBoolean("sqlite_checkpoints", true); int indexed = 0; List ids = new ArrayList<>(INDEX_BATCH_SIZE); @@ -109,8 +109,10 @@ public class WorkerFts extends Worker { markIndexed(db, ids); - if (checkpoints) + if (checkpoints) { DB.checkpoint(context); + Helper.sync(); + } Log.i("FTS indexed=" + indexed); return Result.success(); diff --git a/app/src/main/java/javax/activation/DataHandler.java b/app/src/main/java/javax/activation/DataHandler.java index 5f9285a76b..81f4187c33 100644 --- a/app/src/main/java/javax/activation/DataHandler.java +++ b/app/src/main/java/javax/activation/DataHandler.java @@ -253,7 +253,8 @@ public class DataHandler /*implements Transferable*/ { throw new IOException(ex); } - eu.faircode.email.Log.w("DataHandler" + + // com.sun.mail.smtp.SMTPTransport.convertTo8Bit + eu.faircode.email.Log.i("DataHandler" + " object=" + (object == null ? null : object.getClass().getName()) + " dch=" + dch.getClass().getName() + " type=" + getContentType()); @@ -274,11 +275,13 @@ public class DataHandler /*implements Transferable*/ { try { fdch.writeTo(object, objectMimeType, pos); } catch (IOException e) { - + eu.faircode.email.Log.e(e); } finally { try { pos.close(); - } catch (IOException ie) { } + } catch (IOException ie) { + eu.faircode.email.Log.e(ie); + } } } }, diff --git a/app/src/main/jni/fairemail.cc b/app/src/main/jni/fairemail.cc index 9af3ec9d26..f6e6849b4c 100644 --- a/app/src/main/jni/fairemail.cc +++ b/app/src/main/jni/fairemail.cc @@ -7,6 +7,7 @@ #include #include #include +#include #include "compact_enc_det/compact_enc_det.h" #include "cld_3/src/nnet_language_identifier.h" @@ -200,3 +201,11 @@ Java_eu_faircode_email_ConnectionHelper_jni_1is_1numeric_1address( env->ReleaseStringUTFChars(_ip, ip); return numeric; } + +extern "C" +JNIEXPORT void JNICALL +Java_eu_faircode_email_Helper_sync(JNIEnv *env, jclass clazz) { + log_android(ANDROID_LOG_DEBUG, "sync"); + sync(); + log_android(ANDROID_LOG_DEBUG, "synced"); +} diff --git a/app/src/main/res/drawable/attachment_disposition.xml b/app/src/main/res/drawable/attachment_disposition.xml index 753634757a..2a72d34409 100644 --- a/app/src/main/res/drawable/attachment_disposition.xml +++ b/app/src/main/res/drawable/attachment_disposition.xml @@ -4,6 +4,6 @@ android:drawable="@drawable/twotone_attachment_24" android:maxLevel="0" /> diff --git a/app/src/main/res/drawable/google_logo.xml b/app/src/main/res/drawable/google_logo.xml new file mode 100644 index 0000000000..9d07b693d7 --- /dev/null +++ b/app/src/main/res/drawable/google_logo.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/google_logo_disabled.xml b/app/src/main/res/drawable/google_logo_disabled.xml new file mode 100644 index 0000000000..57efa0dd12 --- /dev/null +++ b/app/src/main/res/drawable/google_logo_disabled.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/google_logo_enabled.xml b/app/src/main/res/drawable/google_logo_enabled.xml new file mode 100644 index 0000000000..0bfdcfde6e --- /dev/null +++ b/app/src/main/res/drawable/google_logo_enabled.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/google_signin_background_dark.xml b/app/src/main/res/drawable/google_signin_background_dark.xml new file mode 100644 index 0000000000..9acaff4b46 --- /dev/null +++ b/app/src/main/res/drawable/google_signin_background_dark.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/google_signin_background_light.xml b/app/src/main/res/drawable/google_signin_background_light.xml new file mode 100644 index 0000000000..497e1a158f --- /dev/null +++ b/app/src/main/res/drawable/google_signin_background_light.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/baseline_code_24.xml b/app/src/main/res/drawable/twotone_code_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_code_24.xml rename to app/src/main/res/drawable/twotone_code_24.xml diff --git a/app/src/main/res/drawable/twotone_data_object_24.xml b/app/src/main/res/drawable/twotone_data_object_24.xml new file mode 100644 index 0000000000..a924fe6090 --- /dev/null +++ b/app/src/main/res/drawable/twotone_data_object_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/twotone_menu_book_24.xml b/app/src/main/res/drawable/twotone_menu_book_24.xml new file mode 100644 index 0000000000..36e45125c0 --- /dev/null +++ b/app/src/main/res/drawable/twotone_menu_book_24.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/app/src/main/res/font/materialdings.ttf b/app/src/main/res/font/materialdings.ttf new file mode 100644 index 0000000000..1ff090a2a6 Binary files /dev/null and b/app/src/main/res/font/materialdings.ttf differ diff --git a/app/src/main/res/font/wingdings.xml b/app/src/main/res/font/wingdings.xml new file mode 100644 index 0000000000..2ce51f0563 --- /dev/null +++ b/app/src/main/res/font/wingdings.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/layout/action_bar.xml b/app/src/main/res/layout/action_bar.xml index c682163876..6cb13e25d8 100644 --- a/app/src/main/res/layout/action_bar.xml +++ b/app/src/main/res/layout/action_bar.xml @@ -12,10 +12,24 @@ android:singleLine="true" android:text="Title" android:textAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Title" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@+id/count" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + app:srcCompat="@drawable/twotone_code_24" /> diff --git a/app/src/main/res/layout/dialog_block_sender.xml b/app/src/main/res/layout/dialog_block_sender.xml index 4838fe37b2..5ffb9f295c 100644 --- a/app/src/main/res/layout/dialog_block_sender.xml +++ b/app/src/main/res/layout/dialog_block_sender.xml @@ -45,7 +45,8 @@ android:layout_marginTop="12dp" android:text="@string/title_junk_pop_hint" android:textAppearance="@style/TextAppearance.AppCompat.Small" - android:textStyle="bold" + android:textColor="?attr/colorWarning" + android:textStyle="italic" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/tvJunkHint" /> diff --git a/app/src/main/res/layout/dialog_buttons.xml b/app/src/main/res/layout/dialog_buttons.xml index 7b08d89856..5af53e1bd4 100644 --- a/app/src/main/res/layout/dialog_buttons.xml +++ b/app/src/main/res/layout/dialog_buttons.xml @@ -31,45 +31,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tvTitle" /> - - - - - - + app:layout_constraintTop_toBottomOf="@id/tvHint" /> + app:layout_constraintTop_toBottomOf="@id/cbKeywords" /> + app:layout_constraintTop_toBottomOf="@id/cbNotes" /> + app:layout_constraintTop_toBottomOf="@id/cbSeen" /> + app:layout_constraintTop_toBottomOf="@id/cbHide" /> + app:layout_constraintTop_toBottomOf="@id/cbImportance" /> + + + + + app:layout_constraintTop_toBottomOf="@id/cbSearchText" /> + + + app:layout_constraintTop_toBottomOf="@id/cbRaw" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_dark.xml b/app/src/main/res/layout/dialog_dark.xml new file mode 100644 index 0000000000..0a733d61c4 --- /dev/null +++ b/app/src/main/res/layout/dialog_dark.xml @@ -0,0 +1,32 @@ + + + + + + + +