diff --git a/app/schemas/1.1926-1.1940.diff b/app/schemas/1.1926-1.1940.diff new file mode 100644 index 0000000000..f14fe15d4b --- /dev/null +++ b/app/schemas/1.1926-1.1940.diff @@ -0,0 +1,167198 @@ +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 @@ ++ ++ ++ ++ ++ ++ ++ ++