From 3cef7c5092cf03d7f1fe25c11141ece3fddd98d9 Mon Sep 17 00:00:00 2001 From: Fr4nz D13trich Date: Mon, 24 Nov 2025 18:55:42 +0100 Subject: [PATCH] Repo created --- .editorconfig | 15 + .gitattributes | 4 + .gitignore | 32 + .tx/config | 8 + LICENSE | 202 + NOTICE | 3 + README.md | 69 +- app-ui-catalog/README.md | 7 + app-ui-catalog/build.gradle.kts | 21 + app-ui-catalog/proguard-rules.pro | 21 + app-ui-catalog/src/main/AndroidManifest.xml | 23 + .../src/main/ic_launcher-playstore.png | Bin 0 -> 21464 bytes .../app/k9mail/ui/catalog/CatalogActivity.kt | 18 + .../app/k9mail/ui/catalog/CatalogContent.kt | 89 + .../app/k9mail/ui/catalog/CatalogScreen.kt | 50 + .../app/k9mail/ui/catalog/CatalogTheme.kt | 13 + .../k9mail/ui/catalog/CatalogThemeSelector.kt | 29 + .../k9mail/ui/catalog/CatalogThemeSwitch.kt | 26 + .../k9mail/ui/catalog/CatalogThemeVariant.kt | 5 + .../ui/catalog/CatalogThemeVariantSelector.kt | 29 + .../k9mail/ui/catalog/items/ButtonItems.kt | 40 + .../app/k9mail/ui/catalog/items/ColorItems.kt | 78 + .../app/k9mail/ui/catalog/items/ImageItems.kt | 13 + .../ui/catalog/items/SectionHeaderItem.kt | 16 + .../ui/catalog/items/SectionSubtitleItem.kt | 16 + .../ui/catalog/items/SelectionControlItems.kt | 39 + .../k9mail/ui/catalog/items/TextFieldItems.kt | 90 + .../ui/catalog/items/ThemeHeaderItem.kt | 16 + .../ui/catalog/items/ThemeSelectorItems.kt | 31 + .../ui/catalog/items/TypographyItems.kt | 33 + .../drawable-v24/ic_launcher_foreground.xml | 149 + .../res/drawable/ic_launcher_foreground.xml | 104 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1871 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 2767 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3911 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1241 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 1758 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2410 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 2586 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 3856 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 5533 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 4094 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 6185 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 8799 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 5680 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 9320 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 12843 bytes app-ui-catalog/src/main/res/values/colors.xml | 4 + .../src/main/res/values/strings.xml | 3 + app-ui-catalog/src/main/res/values/themes.xml | 4 + app/autodiscovery/api/build.gradle.kts | 9 + .../api/ConnectionSettingsDiscovery.kt | 19 + .../providersxml/build.gradle.kts | 20 + .../autodiscovery/providersxml/KoinModule.kt | 8 + .../providersxml/ProvidersXmlDiscovery.kt | 156 + .../providersxml/ProvidersXmlProvider.kt | 10 + .../src/main/res/xml/providers.xml | 774 +++ .../providersxml/ProvidersXmlDiscoveryTest.kt | 64 + app/autodiscovery/srvrecords/build.gradle.kts | 11 + .../srvrecords/MiniDnsSrvResolver.kt | 29 + .../autodiscovery/srvrecords/SrvResolver.kt | 5 + .../srvrecords/SrvServiceDiscovery.kt | 56 + .../srvrecords/SrvServiceDiscoveryTest.kt | 178 + .../thunderbird/build.gradle.kts | 15 + .../ThunderbirdAutoconfigFetcher.kt | 28 + .../ThunderbirdAutoconfigParser.kt | 102 + .../ThunderbirdAutoconfigUrlProvider.kt | 47 + .../thunderbird/ThunderbirdDiscovery.kt | 28 + .../ThunderbirdAutoconfigFetcherTest.kt | 44 + .../thunderbird/ThunderbirdAutoconfigTest.kt | 174 + .../ThunderbirdAutoconfigUrlProviderTest.kt | 21 + app/core/build.gradle.kts | 51 + app/core/src/main/AndroidManifest.xml | 7 + app/core/src/main/java/com/fsck/k9/Account.kt | 696 +++ .../fsck/k9/AccountPreferenceSerializer.kt | 648 +++ .../com/fsck/k9/AccountRemovedListener.kt | 5 + .../com/fsck/k9/AccountsChangeListener.java | 6 + .../java/com/fsck/k9/ActivityExtensions.kt | 11 + .../src/main/java/com/fsck/k9/AppConfig.kt | 5 + .../src/main/java/com/fsck/k9/BaseAccount.kt | 7 + app/core/src/main/java/com/fsck/k9/Core.kt | 89 + .../main/java/com/fsck/k9/CoreKoinModules.kt | 36 + .../java/com/fsck/k9/CoreResourceProvider.kt | 35 + app/core/src/main/java/com/fsck/k9/DI.kt | 44 + .../java/com/fsck/k9/EmailAddressValidator.kt | 27 + .../src/main/java/com/fsck/k9/FontSizes.java | 197 + .../src/main/java/com/fsck/k9/Identity.kt | 20 + app/core/src/main/java/com/fsck/k9/K9.kt | 535 ++ .../src/main/java/com/fsck/k9/KoinModule.kt | 40 + .../java/com/fsck/k9/LocalKeyStoreManager.kt | 59 + .../java/com/fsck/k9/NotificationLight.kt | 33 + .../java/com/fsck/k9/NotificationSettings.kt | 11 + .../java/com/fsck/k9/NotificationVibration.kt | 62 + .../src/main/java/com/fsck/k9/Preferences.kt | 300 ++ .../java/com/fsck/k9/QuietTimeChecker.java | 46 + .../com/fsck/k9/ServerSettingsSerializer.kt | 122 + .../src/main/java/com/fsck/k9/StrictMode.kt | 44 + .../src/main/java/com/fsck/k9/SwipeAction.kt | 12 + .../src/main/java/com/fsck/k9/TimberLogger.kt | 121 + .../src/main/java/com/fsck/k9/UiDensity.kt | 7 + .../k9/autocrypt/AutocryptDraftStateHeader.kt | 70 + .../AutocryptDraftStateHeaderParser.kt | 40 + .../k9/autocrypt/AutocryptGossipHeader.java | 60 + .../AutocryptGossipHeaderParser.java | 95 + .../fsck/k9/autocrypt/AutocryptHeader.java | 102 + .../k9/autocrypt/AutocryptHeaderParser.java | 99 + .../AutocryptOpenPgpApiInteractor.java | 44 + .../k9/autocrypt/AutocryptOperations.java | 164 + .../k9/autocrypt/AutocryptStringProvider.kt | 6 + .../AutocryptTransferMessageCreator.kt | 50 + .../java/com/fsck/k9/autocrypt/KoinModule.kt | 8 + .../com/fsck/k9/backend/BackendFactory.kt | 8 + .../com/fsck/k9/backend/BackendManager.kt | 75 + .../fsck/k9/contact/ContactIntentHelper.kt | 47 + .../fsck/k9/controller/ControllerExtension.kt | 12 + .../com/fsck/k9/controller/DraftOperations.kt | 177 + .../java/com/fsck/k9/controller/KoinModule.kt | 36 + .../MemorizingMessagingListener.java | 124 + .../k9/controller/MessageCountsProvider.kt | 76 + .../fsck/k9/controller/MessageReference.kt | 52 + .../k9/controller/MessageReferenceHelper.java | 34 + .../k9/controller/MessagingController.java | 2691 ++++++++++ .../MessagingControllerCommands.java | 269 + .../fsck/k9/controller/MessagingListener.java | 48 + .../k9/controller/NotificationOperations.kt | 63 + .../fsck/k9/controller/NotificationState.kt | 6 + .../controller/PendingCommandSerializer.java | 77 + .../com/fsck/k9/controller/Preconditions.kt | 29 + .../k9/controller/ProgressBodyFactory.java | 42 + .../controller/SimpleMessagingListener.java | 108 + .../k9/controller/UidReverseComparator.java | 34 + .../controller/push/AccountPushController.kt | 103 + .../push/AccountPushControllerFactory.kt | 24 + .../k9/controller/push/AutoSyncManager.kt | 57 + .../controller/push/BootCompleteReceiver.kt | 46 + .../com/fsck/k9/controller/push/KoinModule.kt | 30 + .../fsck/k9/controller/push/PushController.kt | 252 + .../fsck/k9/controller/push/PushService.kt | 58 + .../k9/controller/push/PushServiceManager.kt | 54 + .../com/fsck/k9/crypto/EncryptionExtractor.kt | 17 + .../java/com/fsck/k9/crypto/KoinModule.kt | 11 + .../MessageCryptoStructureDetector.java | 303 ++ .../com/fsck/k9/crypto/OpenPgpApiHelper.java | 28 + .../com/fsck/k9/helper/AlarmManagerCompat.kt | 19 + .../AndroidKeyStoreDirectoryProvider.kt | 11 + .../com/fsck/k9/helper/ClipboardManager.kt | 22 + .../fsck/k9/helper/CollectionExtensions.kt | 40 + .../com/fsck/k9/helper/ContactNameProvider.kt | 16 + .../main/java/com/fsck/k9/helper/Contacts.kt | 16 + .../java/com/fsck/k9/helper/CrLfConverter.kt | 9 + .../helper/DefaultTrustedSocketFactory.java | 167 + .../java/com/fsck/k9/helper/FileHelper.java | 147 + .../java/com/fsck/k9/helper/IdentityHelper.kt | 40 + .../fsck/k9/helper/KeyChainKeyManager.java | 190 + .../java/com/fsck/k9/helper/KoinModule.kt | 15 + .../java/com/fsck/k9/helper/ListHeaders.java | 70 + .../fsck/k9/helper/ListUnsubscribeHelper.kt | 55 + .../main/java/com/fsck/k9/helper/MailTo.java | 165 + .../java/com/fsck/k9/helper/MessageHelper.kt | 131 + .../java/com/fsck/k9/helper/MimeTypeUtil.java | 925 ++++ .../java/com/fsck/k9/helper/MutableBoolean.kt | 3 + .../com/fsck/k9/helper/NamedThreadFactory.kt | 9 + .../com/fsck/k9/helper/ParcelableUtil.java | 31 + .../com/fsck/k9/helper/PendingIntentCompat.kt | 12 + .../com/fsck/k9/helper/ReplyToParser.java | 90 + .../com/fsck/k9/helper/RetainFragment.java | 63 + .../com/fsck/k9/helper/SimpleTextWatcher.java | 22 + .../com/fsck/k9/helper/SingleLiveEvent.java | 76 + .../java/com/fsck/k9/helper/StringHelper.kt | 5 + .../main/java/com/fsck/k9/helper/Timing.kt | 22 + .../java/com/fsck/k9/helper/UnsubscribeUri.kt | 10 + .../com/fsck/k9/helper/UrlEncodingHelper.java | 21 + .../main/java/com/fsck/k9/helper/Utility.java | 298 ++ .../helper/jsoup/AdvancedNodeTraversor.java | 139 + .../com/fsck/k9/helper/jsoup/NodeFilter.java | 111 + .../main/java/com/fsck/k9/job/K9JobManager.kt | 35 + .../java/com/fsck/k9/job/K9WorkerFactory.kt | 19 + .../main/java/com/fsck/k9/job/KoinModule.kt | 18 + .../java/com/fsck/k9/job/MailSyncWorker.kt | 72 + .../com/fsck/k9/job/MailSyncWorkerManager.kt | 90 + .../job/WorkManagerConfigurationProvider.kt | 12 + .../java/com/fsck/k9/logging/KoinModule.kt | 8 + .../java/com/fsck/k9/logging/LogFileWriter.kt | 38 + .../com/fsck/k9/logging/ProcessExecutor.kt | 14 + .../com/fsck/k9/mail/MailServerDirection.kt | 6 + .../fsck/k9/mailstore/AttachmentResolver.java | 84 + .../fsck/k9/mailstore/AttachmentViewInfo.java | 55 + ...pandFolderBackendFoldersRefreshListener.kt | 56 + .../BackendFoldersRefreshListener.kt | 6 + .../k9/mailstore/BinaryAttachmentBody.java | 54 + .../k9/mailstore/CacheAwareMessageMapper.kt | 53 + .../com/fsck/k9/mailstore/CreateFolderInfo.kt | 10 + .../k9/mailstore/CryptoResultAnnotation.java | 193 + .../k9/mailstore/DatabasePreviewType.java | 52 + .../fsck/k9/mailstore/DeferredFileBody.java | 138 + .../com/fsck/k9/mailstore/FileBackedBody.java | 60 + .../com/fsck/k9/mailstore/FolderMapper.kt | 29 + .../k9/mailstore/FolderNotFoundException.kt | 3 + .../com/fsck/k9/mailstore/FolderRepository.kt | 334 ++ .../com/fsck/k9/mailstore/FolderSettings.kt | 13 + .../k9/mailstore/FolderSettingsProvider.kt | 44 + .../fsck/k9/mailstore/FolderTypeConverter.kt | 33 + .../com/fsck/k9/mailstore/K9BackendFolder.kt | 129 + .../com/fsck/k9/mailstore/K9BackendStorage.kt | 76 + .../k9/mailstore/K9BackendStorageFactory.kt | 27 + .../java/com/fsck/k9/mailstore/KoinModule.kt | 38 + .../k9/mailstore/ListenableMessageStore.kt | 66 + .../com/fsck/k9/mailstore/LocalBodyPart.java | 42 + .../com/fsck/k9/mailstore/LocalFolder.java | 1260 +++++ .../com/fsck/k9/mailstore/LocalMessage.java | 454 ++ .../fsck/k9/mailstore/LocalMimeMessage.java | 33 + .../java/com/fsck/k9/mailstore/LocalPart.java | 9 + .../com/fsck/k9/mailstore/LocalStore.java | 1067 ++++ .../fsck/k9/mailstore/LocalStoreProvider.kt | 30 + .../fsck/k9/mailstore/LockableDatabase.java | 380 ++ .../com/fsck/k9/mailstore/MessageColumns.kt | 24 + .../mailstore/MessageCryptoAnnotations.java | 36 + .../com/fsck/k9/mailstore/MessageDetails.kt | 22 + .../com/fsck/k9/mailstore/MessageHelper.java | 47 + .../com/fsck/k9/mailstore/MessageListCache.kt | 147 + .../k9/mailstore/MessageListRepository.kt | 93 + .../com/fsck/k9/mailstore/MessageMapper.kt | 28 + .../k9/mailstore/MessageNotFoundException.kt | 4 + .../fsck/k9/mailstore/MessageRepository.kt | 69 + .../com/fsck/k9/mailstore/MessageStore.kt | 356 ++ .../fsck/k9/mailstore/MessageStoreFactory.kt | 7 + .../fsck/k9/mailstore/MessageStoreManager.kt | 28 + .../fsck/k9/mailstore/MessageViewInfo.java | 95 + .../mailstore/MessageViewInfoExtractor.java | 560 ++ .../MessageViewInfoExtractorFactory.kt | 17 + .../fsck/k9/mailstore/MigrationsHelper.java | 13 + .../k9/mailstore/MimePartStreamParser.java | 211 + .../com/fsck/k9/mailstore/MoreMessages.java | 28 + .../fsck/k9/mailstore/NotificationMessage.kt | 7 + .../fsck/k9/mailstore/NotifierMessageStore.kt | 69 + .../java/com/fsck/k9/mailstore/OutboxState.kt | 8 + .../k9/mailstore/OutboxStateRepository.kt | 124 + .../com/fsck/k9/mailstore/SaveMessageData.kt | 17 + .../k9/mailstore/SaveMessageDataCreator.kt | 53 + .../k9/mailstore/SchemaDefinitionFactory.kt | 9 + .../fsck/k9/mailstore/SearchStatusManager.kt | 5 + .../java/com/fsck/k9/mailstore/SendState.kt | 15 + ...cialFolderBackendFoldersRefreshListener.kt | 15 + .../SpecialFolderSelectionStrategy.kt | 10 + .../fsck/k9/mailstore/SpecialFolderUpdater.kt | 138 + .../mailstore/SpecialLocalFoldersCreator.kt | 73 + .../com/fsck/k9/mailstore/StorageManager.java | 294 ++ .../com/fsck/k9/mailstore/TempFileBody.java | 35 + .../com/fsck/k9/mailstore/ThreadInfo.java | 17 + .../util/DeferredFileOutputStream.java | 72 + .../fsck/k9/mailstore/util/FileFactory.java | 10 + .../java/com/fsck/k9/message/Attachment.kt | 17 + .../k9/message/AutocryptStatusInteractor.java | 135 + .../ComposePgpEnableByDefaultDecider.java | 20 + .../k9/message/ComposePgpInlineDecider.java | 21 + .../java/com/fsck/k9/message/CryptoStatus.kt | 17 + .../com/fsck/k9/message/IdentityField.java | 49 + .../k9/message/IdentityHeaderBuilder.java | 206 + .../fsck/k9/message/IdentityHeaderParser.java | 94 + .../com/fsck/k9/message/MessageBuilder.java | 642 +++ .../fsck/k9/message/PgpMessageBuilder.java | 444 ++ .../com/fsck/k9/message/QuotedTextMode.java | 8 + .../fsck/k9/message/ReplyActionStrategy.kt | 36 + .../fsck/k9/message/SimpleMessageBuilder.java | 44 + .../fsck/k9/message/SimpleMessageFormat.java | 7 + .../com/fsck/k9/message/TextBodyBuilder.java | 247 + .../message/extractors/AttachmentCounter.java | 25 + .../extractors/AttachmentInfoExtractor.java | 159 + .../extractors/BasicPartInfoExtractor.kt | 51 + .../message/extractors/BodyTextExtractor.java | 61 + .../fsck/k9/message/extractors/KoinModule.kt | 9 + .../extractors/MessageFulltextCreator.java | 48 + .../extractors/MessagePreviewCreator.java | 49 + .../PreviewExtractionException.java | 8 + .../k9/message/extractors/PreviewResult.java | 56 + .../extractors/PreviewTextExtractor.kt | 127 + .../k9/message/extractors/TextPartFinder.kt | 68 + .../com/fsck/k9/message/html/DisplayHtml.kt | 58 + .../k9/message/html/DisplayHtmlFactory.kt | 7 + .../fsck/k9/message/html/DividerReplacer.kt | 31 + .../com/fsck/k9/message/html/EmailSection.kt | 126 + .../k9/message/html/EmailSectionExtractor.kt | 126 + .../fsck/k9/message/html/EmailTextToHtml.kt | 69 + .../fsck/k9/message/html/GenericUriParser.kt | 34 + .../com/fsck/k9/message/html/HtmlConverter.kt | 52 + .../fsck/k9/message/html/HtmlModification.kt | 12 + .../k9/message/html/HtmlProcessorFactory.kt | 12 + .../com/fsck/k9/message/html/HtmlSettings.kt | 6 + .../fsck/k9/message/html/HtmlToPlainText.kt | 111 + .../com/fsck/k9/message/html/HttpUriParser.kt | 273 + .../com/fsck/k9/message/html/KoinModule.kt | 8 + .../fsck/k9/message/html/SignatureWrapper.kt | 24 + .../com/fsck/k9/message/html/TextToHtml.kt | 164 + .../com/fsck/k9/message/html/UriLinkifier.kt | 30 + .../java/com/fsck/k9/message/html/UriMatch.kt | 7 + .../com/fsck/k9/message/html/UriMatcher.kt | 34 + .../com/fsck/k9/message/html/UriParser.kt | 13 + .../k9/message/quote/HtmlQuoteCreator.java | 216 + .../message/quote/InsertableHtmlContent.java | 161 + .../com/fsck/k9/message/quote/KoinModule.kt | 8 + .../k9/message/quote/QuoteDateFormatter.kt | 34 + .../fsck/k9/message/quote/TextQuoteCreator.kt | 101 + .../message/signature/HtmlSignatureRemover.kt | 142 + .../signature/TextSignatureRemover.java | 17 + .../fsck/k9/network/ConnectivityManager.kt | 25 + .../k9/network/ConnectivityManagerApi21.kt | 66 + .../k9/network/ConnectivityManagerApi23.kt | 73 + .../k9/network/ConnectivityManagerApi24.kt | 65 + .../k9/network/ConnectivityManagerBase.kt | 31 + .../java/com/fsck/k9/network/KointModule.kt | 10 + .../k9/notification/AddNotificationResult.kt | 42 + ...thenticationErrorNotificationController.kt | 63 + .../BaseNotificationDataCreator.kt | 48 + .../CertificateErrorNotificationController.kt | 63 + .../fsck/k9/notification/CoreKoinModule.kt | 128 + .../LockScreenNotificationCreator.kt | 60 + .../NewMailNotificationController.kt | 92 + .../notification/NewMailNotificationData.kt | 87 + .../NewMailNotificationManager.kt | 134 + .../notification/NotificationActionCreator.kt | 39 + .../notification/NotificationActionService.kt | 264 + .../NotificationChannelManager.kt | 257 + .../NotificationConfigurationConverter.kt | 32 + .../k9/notification/NotificationContent.kt | 11 + .../NotificationContentCreator.kt | 101 + .../k9/notification/NotificationController.kt | 94 + .../fsck/k9/notification/NotificationData.kt | 42 + .../k9/notification/NotificationDataStore.kt | 234 + .../k9/notification/NotificationGroupKeys.kt | 11 + .../k9/notification/NotificationHelper.kt | 132 + .../k9/notification/NotificationHolder.kt | 12 + .../fsck/k9/notification/NotificationIds.kt | 64 + .../notification/NotificationLightDecoder.kt | 27 + .../k9/notification/NotificationRepository.kt | 133 + .../NotificationResourceProvider.kt | 57 + .../NotificationSettingsUpdater.kt | 36 + .../fsck/k9/notification/NotificationStore.kt | 6 + .../NotificationStoreOperation.kt | 20 + .../notification/NotificationStoreProvider.kt | 7 + .../k9/notification/NotificationStrategy.kt | 15 + .../NotificationVibrationDecoder.kt | 26 + .../notification/PushNotificationManager.kt | 79 + .../notification/RemoveNotificationsResult.kt | 8 + .../SendFailedNotificationController.kt | 63 + .../SingleMessageNotificationCreator.kt | 173 + .../SingleMessageNotificationDataCreator.kt | 87 + .../SummaryNotificationCreator.kt | 194 + .../SummaryNotificationDataCreator.kt | 92 + .../SyncNotificationController.kt | 118 + .../com/fsck/k9/oauth/OAuthConfiguration.kt | 9 + .../k9/oauth/OAuthConfigurationProvider.kt | 22 + .../com/fsck/k9/power/AndroidPowerManager.kt | 93 + .../main/java/com/fsck/k9/power/KoinModule.kt | 10 + .../com/fsck/k9/preferences/AccountManager.kt | 17 + .../AccountSettingsDescriptions.java | 574 +++ .../FolderSettingsDescriptions.java | 81 + .../k9/preferences/FolderSettingsProvider.kt | 52 + .../fsck/k9/preferences/GeneralSettings.kt | 38 + .../GeneralSettingsDescriptions.java | 635 +++ .../k9/preferences/GeneralSettingsManager.kt | 19 + .../IdentitySettingsDescriptions.java | 130 + .../com/fsck/k9/preferences/KoinModule.kt | 26 + .../java/com/fsck/k9/preferences/Protocols.kt | 8 + .../preferences/RealGeneralSettingsManager.kt | 176 + .../k9/preferences/ServerTypeConverter.kt | 15 + .../com/fsck/k9/preferences/Settings.java | 580 +++ .../fsck/k9/preferences/SettingsExporter.kt | 524 ++ .../SettingsImportExportException.java | 22 + .../fsck/k9/preferences/SettingsImporter.java | 1154 +++++ .../java/com/fsck/k9/preferences/Storage.java | 69 + .../com/fsck/k9/preferences/StorageEditor.kt | 12 + .../fsck/k9/preferences/StoragePersister.kt | 11 + .../fsck/k9/provider/AttachmentProvider.java | 189 + .../provider/AttachmentTempFileProvider.java | 211 + .../k9/provider/DecryptedFileProvider.java | 217 + .../fsck/k9/provider/RawMessageProvider.java | 204 + .../fsck/k9/search/AccountSearchConditions.kt | 77 + .../fsck/k9/search/ConditionsTreeNode.java | 421 ++ .../java/com/fsck/k9/search/LocalSearch.java | 360 ++ .../fsck/k9/search/LocalSearchExtensions.kt | 33 + .../java/com/fsck/k9/search/SearchAccount.kt | 53 + .../fsck/k9/search/SearchSpecification.java | 164 + .../com/fsck/k9/search/SqlQueryBuilder.java | 233 + .../k9/service/DatabaseUpgradeService.java | 223 + .../com/fsck/k9/setup/ServerNameSuggester.kt | 13 + .../values/arrays_account_settings_values.xml | 221 + .../src/main/res/values/arrays_drawer.xml | 27 + .../values/arrays_general_settings_values.xml | 224 + .../src/main/res/values/material_colors.xml | 211 + .../res/xml/decrypted_file_provider_paths.xml | 3 + .../main/res/xml/temp_file_provider_paths.xml | 3 + .../com/fsck/k9/EmailAddressValidatorTest.kt | 36 + .../java/com/fsck/k9/K9RobolectricTest.kt | 16 + .../java/com/fsck/k9/QuietTimeCheckerTest.kt | 118 + .../fsck/k9/ServerSettingsSerializerTest.kt | 76 + app/core/src/test/java/com/fsck/k9/TestApp.kt | 41 + .../com/fsck/k9/TestCoreResourceProvider.kt | 42 + .../AutocryptDraftStateHeaderParserTest.kt | 57 + .../AutocryptGossipHeaderParserTest.kt | 110 + .../autocrypt/AutocryptHeaderParserTest.java | 122 + .../k9/autocrypt/AutocryptHeaderTest.java | 43 + .../DefaultMessageCountsProviderTest.kt | 48 + .../k9/controller/MessageReferenceTest.kt | 64 + .../controller/MessagingControllerTest.java | 413 ++ .../PendingCommandSerializerTest.java | 80 + .../controller/UidReverseComparatorTest.java | 209 + .../MessageCryptoStructureDetectorTest.java | 558 ++ .../fsck/k9/crypto/OpenPgpApiHelperTest.kt | 31 + .../com/fsck/k9/helper/EmailHelperTest.java | 66 + .../com/fsck/k9/helper/IdentityHelperTest.kt | 162 + .../com/fsck/k9/helper/ListHeadersTest.java | 123 + .../k9/helper/ListUnsubscribeHelperTest.kt | 96 + .../java/com/fsck/k9/helper/MailToTest.java | 235 + .../com/fsck/k9/helper/MessageHelperTest.kt | 147 + .../com/fsck/k9/helper/ReplyToParserTest.java | 182 + .../java/com/fsck/k9/helper/UtilityTest.java | 82 + .../k9/logging/LogcatLogFileWriterTest.kt | 86 + .../k9/mailstore/AttachmentResolverTest.java | 99 + .../k9/mailstore/DeferredFileBodyTest.java | 141 + .../fsck/k9/mailstore/K9BackendFolderTest.kt | 159 + .../fsck/k9/mailstore/K9BackendStorageTest.kt | 92 + .../com/fsck/k9/mailstore/LocalStoreTest.java | 82 + .../fsck/k9/mailstore/MessageListCacheTest.kt | 147 + .../k9/mailstore/MessageListRepositoryTest.kt | 435 ++ .../k9/mailstore/MessageStoreManagerTest.kt | 51 + .../MessageViewInfoExtractorTest.java | 662 +++ .../k9/mailstore/MimePartStreamParserTest.kt | 83 + .../fsck/k9/mailstore/MoreMessagesTest.java | 28 + .../k9/message/IdentityHeaderBuilderTest.kt | 57 + .../k9/message/IdentityHeaderParserTest.kt | 34 + .../fsck/k9/message/MessageBuilderTest.java | 480 ++ .../k9/message/MessageCreationHelper.java | 69 + .../k9/message/ReplyActionStrategyTest.kt | 115 + .../fsck/k9/message/TextBodyBuilderTest.kt | 223 + .../AttachmentInfoExtractorTest.java | 222 + .../extractors/BasicPartInfoExtractorTest.kt | 122 + .../extractors/MessagePreviewCreatorTest.java | 102 + .../extractors/PreviewTextExtractorTest.kt | 219 + .../message/extractors/TextPartFinderTest.kt | 256 + .../fsck/k9/message/html/DisplayHtmlTest.kt | 58 + .../message/html/EmailSectionExtractorTest.kt | 189 + .../fsck/k9/message/html/EmailSectionTest.kt | 93 + .../k9/message/html/GenericUriParserTest.kt | 72 + .../fsck/k9/message/html/HtmlConverterTest.kt | 612 +++ .../com/fsck/k9/message/html/HtmlHelper.kt | 11 + .../k9/message/html/HttpUriParserTest.java | 312 ++ .../fsck/k9/message/html/UriMatcherTest.kt | 106 + .../message/quote/QuoteDateFormatterTest.kt | 73 + .../k9/message/quote/TextQuoteCreatorTest.kt | 114 + .../signature/HtmlSignatureRemoverTest.kt | 203 + .../signature/TextSignatureRemoverTest.java | 21 + ...ticationErrorNotificationControllerTest.kt | 121 + .../BaseNotificationDataCreatorTest.kt | 211 + ...tificateErrorNotificationControllerTest.kt | 124 + .../LockScreenNotificationCreatorTest.kt | 116 + .../NewMailNotificationManagerTest.kt | 478 ++ .../NotificationContentCreatorTest.kt | 166 + .../notification/NotificationDataStoreTest.kt | 253 + .../k9/notification/NotificationIdsTest.kt | 125 + .../SendFailedNotificationControllerTest.kt | 94 + ...ingleMessageNotificationDataCreatorTest.kt | 287 ++ .../SummaryNotificationDataCreatorTest.kt | 283 + .../SyncNotificationControllerTest.kt | 152 + .../TestNotificationResourceProvider.kt | 88 + .../k9/preferences/SettingsExporterTest.kt | 75 + .../k9/preferences/SettingsImporterTest.java | 229 + .../java/com/fsck/k9/sasl/OAuthBearerTest.kt | 32 + .../k9/setup/ServerNameSuggesterTest.java | 59 + .../test/resources/autocrypt/no_autocrypt.eml | 11 + .../autocrypt/rsa2048-broken-base64.eml | 35 + .../autocrypt/rsa2048-explicit-type.eml | 40 + .../autocrypt/rsa2048-simple-to-bot.eml | 34 + .../resources/autocrypt/rsa2048-simple.eml | 34 + .../autocrypt/rsa2048-unknown-critical.eml | 35 + .../rsa2048-unknown-non-critical.eml | 36 + .../test/resources/autocrypt/unknown-type.eml | 36 + app/crypto-openpgp/build.gradle.kts | 11 + .../k9/crypto/openpgp/EncryptionDetector.java | 67 + .../openpgp/OpenPgpEncryptionExtractor.kt | 30 + .../openpgp/EncryptionDetectorTest.java | 109 + .../crypto/openpgp/MessageCreationHelper.java | 51 + app/html-cleaner/build.gradle.kts | 9 + .../app/k9mail/html/cleaner/BodyCleaner.kt | 78 + .../app/k9mail/html/cleaner/HeadCleaner.kt | 75 + .../k9mail/html/cleaner/HtmlHeadProvider.kt | 5 + .../app/k9mail/html/cleaner/HtmlProcessor.kt | 25 + .../app/k9mail/html/cleaner/HtmlSanitizer.kt | 16 + .../k9mail/html/cleaner/HtmlSanitizerTest.kt | 516 ++ app/k9mail/build.gradle.kts | 170 + app/k9mail/proguard-rules.pro | 55 + .../debug/java/app/k9mail/dev/DebugConfig.kt | 12 + .../java/app/k9mail/dev/DemoBackendFactory.kt | 14 + app/k9mail/src/debug/res/mipmap-hdpi/icon.png | Bin 0 -> 1780 bytes .../src/debug/res/mipmap-hdpi/icon_round.png | Bin 0 -> 3812 bytes app/k9mail/src/debug/res/mipmap-mdpi/icon.png | Bin 0 -> 1209 bytes .../src/debug/res/mipmap-mdpi/icon_round.png | Bin 0 -> 2357 bytes .../src/debug/res/mipmap-xhdpi/icon.png | Bin 0 -> 2554 bytes .../src/debug/res/mipmap-xhdpi/icon_round.png | Bin 0 -> 5459 bytes .../src/debug/res/mipmap-xxhdpi/icon.png | Bin 0 -> 4031 bytes .../debug/res/mipmap-xxhdpi/icon_round.png | Bin 0 -> 8785 bytes .../src/debug/res/mipmap-xxxhdpi/icon.png | Bin 0 -> 5615 bytes .../debug/res/mipmap-xxxhdpi/icon_round.png | Bin 0 -> 12687 bytes app/k9mail/src/main/AndroidManifest.xml | 428 ++ app/k9mail/src/main/icon-playstore.png | Bin 0 -> 21464 bytes app/k9mail/src/main/java/com/fsck/k9/App.kt | 145 + .../src/main/java/com/fsck/k9/Dependencies.kt | 44 + .../com/fsck/k9/MessagingListenerProvider.kt | 5 + .../com/fsck/k9/auth/OAuthConfigurations.kt | 46 + .../fsck/k9/backends/AndroidAlarmManager.kt | 71 + .../fsck/k9/backends/ImapBackendFactory.kt | 97 + .../java/com/fsck/k9/backends/KoinModule.kt | 51 + .../fsck/k9/backends/Pop3BackendFactory.kt | 35 + .../k9/backends/RealOAuth2TokenProvider.kt | 76 + .../fsck/k9/backends/WebDavBackendFactory.kt | 35 + .../com/fsck/k9/glide/K9AppGlideModule.java | 8 + .../K9NotificationActionCreator.kt | 264 + .../K9NotificationResourceProvider.kt | 100 + .../k9/notification/K9NotificationStrategy.kt | 102 + .../com/fsck/k9/notification/KoinModule.kt | 11 + .../fsck/k9/provider/UnreadWidgetProvider.kt | 103 + .../k9/resources/K9AutocryptStringProvider.kt | 10 + .../k9/resources/K9CoreResourceProvider.kt | 52 + .../java/com/fsck/k9/resources/KoinModule.kt | 10 + .../com/fsck/k9/widget/list/KoinModule.kt | 8 + .../widget/list/MessageListWidgetProvider.kt | 9 + .../com/fsck/k9/widget/unread/KoinModule.kt | 20 + .../UnreadWidgetConfigurationActivity.kt | 39 + .../UnreadWidgetConfigurationFragment.kt | 226 + .../fsck/k9/widget/unread/UnreadWidgetData.kt | 10 + .../widget/unread/UnreadWidgetDataProvider.kt | 98 + .../widget/unread/UnreadWidgetMigrations.kt | 44 + .../widget/unread/UnreadWidgetRepository.kt | 62 + .../unread/UnreadWidgetUpdateListener.kt | 29 + .../k9/widget/unread/UnreadWidgetUpdater.kt | 30 + .../res/drawable-hdpi/ic_unread_widget.png | Bin 0 -> 2893 bytes .../ic_unread_widget_selected.png | Bin 0 -> 1522 bytes .../res/drawable-mdpi/ic_unread_widget.png | Bin 0 -> 1668 bytes .../ic_unread_widget_selected.png | Bin 0 -> 1011 bytes .../res/drawable-xhdpi/ic_unread_widget.png | Bin 0 -> 4278 bytes .../ic_unread_widget_selected.png | Bin 0 -> 1899 bytes .../res/drawable/ic_launcher_foreground.xml | 17 + .../res/drawable/unread_count_background.xml | 9 + .../res/drawable/unread_widget_background.xml | 15 + .../main/res/drawable/unread_widget_icon.xml | 6 + .../activity_unread_widget_configuration.xml | 17 + .../main/res/layout/unread_widget_layout.xml | 55 + .../main/res/menu/unread_widget_option.xml | 10 + .../src/main/res/mipmap-anydpi-v26/icon.xml | 5 + .../main/res/mipmap-anydpi-v26/icon_round.xml | 5 + app/k9mail/src/main/res/mipmap-hdpi/icon.png | Bin 0 -> 1780 bytes .../main/res/mipmap-hdpi/icon_foreground.png | Bin 0 -> 2767 bytes .../src/main/res/mipmap-hdpi/icon_round.png | Bin 0 -> 3812 bytes app/k9mail/src/main/res/mipmap-mdpi/icon.png | Bin 0 -> 1209 bytes .../main/res/mipmap-mdpi/icon_foreground.png | Bin 0 -> 1758 bytes .../src/main/res/mipmap-mdpi/icon_round.png | Bin 0 -> 2357 bytes app/k9mail/src/main/res/mipmap-xhdpi/icon.png | Bin 0 -> 2554 bytes .../main/res/mipmap-xhdpi/icon_foreground.png | Bin 0 -> 3856 bytes .../src/main/res/mipmap-xhdpi/icon_round.png | Bin 0 -> 5459 bytes .../src/main/res/mipmap-xxhdpi/icon.png | Bin 0 -> 4031 bytes .../res/mipmap-xxhdpi/icon_foreground.png | Bin 0 -> 6185 bytes .../src/main/res/mipmap-xxhdpi/icon_round.png | Bin 0 -> 8785 bytes .../src/main/res/mipmap-xxxhdpi/icon.png | Bin 0 -> 5615 bytes .../res/mipmap-xxxhdpi/icon_foreground.png | Bin 0 -> 9320 bytes .../main/res/mipmap-xxxhdpi/icon_round.png | Bin 0 -> 12687 bytes .../res/values-land/unread_widget_styles.xml | 23 + .../unread_widget_styles.xml | 13 + .../unread_widget_styles.xml | 21 + .../main/res/values-v31/manifest_values.xml | 5 + .../res/values/ic_launcher_background.xml | 4 + .../src/main/res/values/icon_background.xml | 4 + .../src/main/res/values/manifest_values.xml | 9 + .../main/res/values/unread_widget_styles.xml | 22 + .../main/res/xml/network_security_config.xml | 12 + .../res/xml/unread_widget_configuration.xml | 25 + .../src/main/res/xml/unread_widget_info.xml | 9 + .../java/app/k9mail/dev/ReleaseConfig.kt | 9 + .../java/com/fsck/k9/AppRobolectricTest.kt | 14 + .../com/fsck/k9/DependencyInjectionTest.kt | 55 + .../unread/UnreadWidgetDataProviderTest.kt | 145 + app/storage/build.gradle.kts | 23 + .../fsck/k9/preferences/K9StorageEditor.java | 114 + .../k9/preferences/K9StoragePersister.java | 251 + .../migrations/StorageMigrationTo10.kt | 37 + .../migrations/StorageMigrationTo11.kt | 26 + .../migrations/StorageMigrationTo12.kt | 65 + .../migrations/StorageMigrationTo13.kt | 18 + .../migrations/StorageMigrationTo14.kt | 35 + .../migrations/StorageMigrationTo15.kt | 36 + .../migrations/StorageMigrationTo16.kt | 18 + .../migrations/StorageMigrationTo17.kt | 54 + .../migrations/StorageMigrationTo18.kt | 36 + .../migrations/StorageMigrationTo19.kt | 54 + .../migrations/StorageMigrationTo2.java | 100 + .../migrations/StorageMigrationTo3.kt | 44 + .../migrations/StorageMigrationTo4.kt | 31 + .../migrations/StorageMigrationTo5.kt | 36 + .../migrations/StorageMigrationTo6.kt | 54 + .../migrations/StorageMigrationTo7.kt | 54 + .../migrations/StorageMigrationTo8.kt | 23 + .../migrations/StorageMigrations.kt | 29 + .../migrations/StorageMigrationsHelper.kt | 10 + .../migration12/ImapStoreUriDecoder.java | 144 + .../migration12/Pop3StoreUriDecoder.java | 106 + .../migration12/SmtpTransportUriDecoder.java | 108 + .../migration12/WebDavStoreUriDecoder.java | 120 + .../k9/storage/K9SchemaDefinitionFactory.kt | 13 + .../java/com/fsck/k9/storage/KoinModule.kt | 18 + .../k9/storage/StoreSchemaDefinition.java | 273 + .../storage/messages/AttachmentFileManager.kt | 36 + .../storage/messages/CheckFolderOperations.kt | 34 + .../messages/ChunkedDatabaseOperations.kt | 21 + .../storage/messages/CopyMessageOperations.kt | 315 ++ .../messages/CreateFolderOperations.kt | 31 + .../fsck/k9/storage/messages/DataLocation.kt | 3 + .../k9/storage/messages/DatabaseOperations.kt | 40 + .../messages/DeleteFolderOperations.kt | 44 + .../messages/DeleteMessageOperations.kt | 170 + .../storage/messages/FlagMessageOperations.kt | 111 + .../k9/storage/messages/K9MessageStore.kt | 287 ++ .../storage/messages/K9MessageStoreFactory.kt | 22 + .../messages/KeyValueStoreOperations.kt | 131 + .../storage/messages/MoveMessageOperations.kt | 132 + .../messages/RetrieveFolderOperations.kt | 284 + .../messages/RetrieveMessageListOperations.kt | 242 + .../messages/RetrieveMessageOperations.kt | 197 + .../storage/messages/SaveMessageOperations.kt | 539 ++ .../messages/ThreadMessageOperations.kt | 223 + .../messages/UpdateFolderOperations.kt | 105 + .../messages/UpdateMessageOperations.kt | 28 + .../migrations/LegacyPendingAppend.java | 6 + .../migrations/LegacyPendingCommand.java | 4 + .../migrations/LegacyPendingDelete.java | 24 + .../migrations/LegacyPendingExpunge.java | 5 + .../LegacyPendingMarkAllAsRead.java | 5 + .../LegacyPendingMoveAndMarkAsRead.java | 9 + .../migrations/LegacyPendingMoveOrCopy.java | 10 + .../migrations/LegacyPendingSetFlag.java | 12 + .../k9/storage/migrations/MigrationTo62.java | 15 + .../k9/storage/migrations/MigrationTo64.kt | 34 + .../k9/storage/migrations/MigrationTo65.kt | 22 + .../k9/storage/migrations/MigrationTo66.kt | 12 + .../k9/storage/migrations/MigrationTo67.kt | 26 + .../k9/storage/migrations/MigrationTo68.kt | 33 + .../k9/storage/migrations/MigrationTo69.kt | 39 + .../k9/storage/migrations/MigrationTo70.kt | 93 + .../k9/storage/migrations/MigrationTo71.kt | 24 + .../k9/storage/migrations/MigrationTo72.kt | 10 + .../k9/storage/migrations/MigrationTo73.kt | 169 + .../k9/storage/migrations/MigrationTo74.kt | 124 + .../k9/storage/migrations/MigrationTo75.kt | 36 + .../k9/storage/migrations/MigrationTo76.kt | 131 + .../k9/storage/migrations/MigrationTo78.kt | 12 + .../k9/storage/migrations/MigrationTo79.kt | 21 + .../k9/storage/migrations/MigrationTo80.kt | 12 + .../k9/storage/migrations/MigrationTo81.kt | 22 + .../k9/storage/migrations/MigrationTo82.kt | 27 + .../k9/storage/migrations/MigrationTo83.kt | 42 + .../k9/storage/migrations/MigrationTo84.kt | 59 + .../fsck/k9/storage/migrations/Migrations.kt | 34 + .../notifications/K9NotificationStore.kt | 72 + .../K9NotificationStoreProvider.kt | 13 + .../fsck/k9/preferences/StorageEditorTest.kt | 207 + .../k9/preferences/StoragePersisterTest.kt | 120 + .../com/fsck/k9/storage/K9RobolectricTest.kt | 16 + .../com/fsck/k9/storage/RobolectricTest.kt | 15 + .../k9/storage/StoreSchemaDefinitionTest.java | 424 ++ .../test/java/com/fsck/k9/storage/TestApp.kt | 35 + .../messages/CheckFolderOperationsTest.kt | 65 + .../messages/ChunkedDatabaseOperationsTest.kt | 114 + .../messages/CopyMessageOperationsTest.kt | 252 + .../messages/CreateFolderOperationsTest.kt | 91 + .../messages/DeleteFolderOperationsTest.kt | 62 + .../messages/DeleteMessageOperationsTest.kt | 196 + .../fsck/k9/storage/messages/FileHelpers.kt | 10 + .../messages/FlagMessageOperationsTest.kt | 100 + .../fsck/k9/storage/messages/FolderHelpers.kt | 95 + .../k9/storage/messages/KeyValueHelpers.kt | 77 + .../messages/KeyValueStoreOperationsTest.kt | 210 + .../messages/MessageDatabaseHelpers.kt | 213 + .../messages/MessagePartDatabaseHelpers.kt | 182 + .../messages/MoveMessageOperationsTest.kt | 216 + .../messages/RetrieveFolderOperationsTest.kt | 522 ++ .../RetrieveMessageListOperationsTest.kt | 491 ++ .../messages/RetrieveMessageOperationsTest.kt | 222 + .../messages/SaveMessageOperationsTest.kt | 556 ++ .../storage/messages/ThreadDatabaseHelpers.kt | 40 + .../messages/ThreadMessageOperationsTest.kt | 266 + .../messages/UpdateFolderOperationsTest.kt | 167 + .../messages/UpdateMessageOperationsTest.kt | 54 + .../notifications/K9NotificationStoreTest.kt | 107 + .../NotificationsTableHelpers.kt | 40 + .../src/test/resources/attach/k9small.png | Bin 0 -> 2250 bytes app/testing/build.gradle.kts | 17 + .../main/java/com/fsck/k9/RobolectricTest.kt | 17 + .../preferences/InMemoryStoragePersister.kt | 61 + .../java/com/fsck/k9/testing/MockHelper.kt | 23 + .../com/fsck/k9/testing/StringExtensions.kt | 3 + app/ui/base/build.gradle.kts | 23 + app/ui/base/src/main/AndroidManifest.xml | 20 + .../com/fsck/k9/ui/base/AppLanguageManager.kt | 98 + .../java/com/fsck/k9/ui/base/K9Activity.kt | 87 + .../java/com/fsck/k9/ui/base/KoinModule.kt | 18 + .../fsck/k9/ui/base/LocaleContextWrapper.kt | 17 + .../java/com/fsck/k9/ui/base/ThemeManager.kt | 117 + .../java/com/fsck/k9/ui/base/ThemeProvider.kt | 20 + .../extensions/ConfigurationExtensions.kt | 41 + .../base/extensions/NavigationExtensions.kt | 11 + .../extensions/TextInputLayoutExtensions.kt | 134 + .../fsck/k9/ui/base/loader/LiveDataLoader.kt | 56 + .../k9/ui/base/loader/LoaderStateObserver.kt | 50 + .../ui/base/locale/LocaleBroadcastReceiver.kt | 17 + .../k9/ui/base/locale/SystemLocaleManager.kt | 68 + app/ui/base/src/main/res/layout/toolbar.xml | 6 + app/ui/legacy/build.gradle.kts | 83 + app/ui/legacy/sampledata/accounts.json | 19 + app/ui/legacy/sampledata/folders.json | 48 + .../k9/ui/settings/ExtraAccountDiscovery.kt | 27 + app/ui/legacy/src/main/AndroidManifest.xml | 48 + app/ui/legacy/src/main/icon-playstore.png | Bin 0 -> 21464 bytes .../main/java/com/fsck/k9/UiKoinModules.kt | 41 + .../com/fsck/k9/account/AccountCreator.kt | 69 + .../com/fsck/k9/account/AccountRemover.kt | 73 + .../fsck/k9/account/AccountRemoverService.kt | 34 + .../k9/account/BackgroundAccountRemover.kt | 14 + .../java/com/fsck/k9/account/KoinModule.kt | 17 + .../com/fsck/k9/activity/AccountList.java | 161 + .../activity/AlternateRecipientAdapter.java | 274 + .../com/fsck/k9/activity/ChooseAccount.java | 27 + .../com/fsck/k9/activity/ChooseIdentity.java | 91 + .../java/com/fsck/k9/activity/EditIdentity.kt | 126 + .../com/fsck/k9/activity/FolderInfoHolder.kt | 48 + .../com/fsck/k9/activity/K9ListActivity.java | 33 + .../java/com/fsck/k9/activity/KoinModule.kt | 10 + .../fsck/k9/activity/LauncherShortcuts.java | 50 + .../fsck/k9/activity/ManageIdentities.java | 146 + .../com/fsck/k9/activity/MessageCompose.java | 1974 +++++++ .../java/com/fsck/k9/activity/MessageList.kt | 1551 ++++++ .../k9/activity/MessageListActivityConfig.kt | 74 + .../fsck/k9/activity/MessageLoaderHelper.java | 534 ++ .../k9/activity/MessageLoaderHelperFactory.kt | 35 + .../java/com/fsck/k9/activity/Search.java | 21 + .../fsck/k9/activity/UpgradeDatabases.java | 223 + .../activity/compose/AttachmentPresenter.java | 477 ++ .../activity/compose/ComposeCryptoStatus.kt | 165 + .../k9/activity/compose/IdentityAdapter.java | 151 + .../k9/activity/compose/MessageActions.java | 104 + .../compose/PgpEnabledErrorDialog.java | 71 + .../compose/PgpEncryptDescriptionDialog.java | 49 + .../k9/activity/compose/PgpInlineDialog.java | 79 + .../activity/compose/PgpSignOnlyDialog.java | 79 + .../k9/activity/compose/RecipientAdapter.java | 224 + .../k9/activity/compose/RecipientLoader.java | 600 +++ .../k9/activity/compose/RecipientMvpView.kt | 420 ++ .../k9/activity/compose/RecipientPresenter.kt | 767 +++ .../k9/activity/compose/ReplyToPresenter.kt | 66 + .../fsck/k9/activity/compose/ReplyToView.kt | 129 + .../k9/activity/compose/SaveMessageTask.java | 39 + .../loader/AttachmentContentLoader.java | 97 + .../activity/loader/AttachmentInfoLoader.java | 115 + .../com/fsck/k9/activity/misc/Attachment.java | 211 + .../fsck/k9/activity/misc/ContactPicture.java | 13 + .../fsck/k9/activity/misc/InlineAttachment.kt | 3 + .../activity/setup/AccountSetupAccountType.kt | 131 + .../k9/activity/setup/AccountSetupBasics.kt | 404 ++ .../setup/AccountSetupCheckSettings.kt | 514 ++ .../setup/AccountSetupComposition.java | 146 + .../activity/setup/AccountSetupIncoming.java | 683 +++ .../k9/activity/setup/AccountSetupNames.java | 101 + .../activity/setup/AccountSetupOptions.java | 124 + .../activity/setup/AccountSetupOutgoing.java | 583 +++ .../k9/activity/setup/AuthTypeAdapter.java | 58 + .../k9/activity/setup/AuthTypeHolder.java | 52 + .../fsck/k9/activity/setup/AuthViewModel.kt | 262 + .../setup/ConnectionSecurityAdapter.java | 38 + .../setup/ConnectionSecurityHolder.java | 34 + .../activity/setup/InitialAccountSettings.kt | 13 + .../k9/activity/setup/OAuthFlowActivity.kt | 113 + .../fsck/k9/activity/setup/SpinnerOption.java | 33 + .../java/com/fsck/k9/contacts/ContactImage.kt | 52 + .../k9/contacts/ContactImageBitmapDecoder.kt | 46 + .../k9/contacts/ContactImageModelLoader.kt | 49 + .../k9/contacts/ContactLetterBitmapConfig.kt | 28 + .../k9/contacts/ContactLetterBitmapCreator.kt | 59 + .../k9/contacts/ContactLetterExtractor.kt | 17 + .../fsck/k9/contacts/ContactPhotoLoader.kt | 25 + .../contacts/ContactPictureGlideModule.java | 35 + .../fsck/k9/contacts/ContactPictureLoader.kt | 117 + .../java/com/fsck/k9/contacts/KoinModule.kt | 12 + .../AttachmentDownloadDialogFragment.java | 119 + .../fragment/ConfirmationDialogFragment.java | 127 + .../k9/fragment/ProgressDialogFragment.java | 55 + .../java/com/fsck/k9/ui/BundleExtensions.kt | 17 + .../java/com/fsck/k9/ui/ConnectionSettings.kt | 5 + .../java/com/fsck/k9/ui/ContactBadge.java | 227 + .../java/com/fsck/k9/ui/FlowExtensions.kt | 17 + .../java/com/fsck/k9/ui/FragmentExtras.kt | 27 + .../src/main/java/com/fsck/k9/ui/K9Drawer.kt | 560 ++ .../java/com/fsck/k9/ui/K9ThemeProvider.kt | 12 + .../main/java/com/fsck/k9/ui/KoinModule.kt | 23 + .../java/com/fsck/k9/ui/LiveDataExtras.kt | 8 + .../java/com/fsck/k9/ui/ThemeExtensions.kt | 65 + .../account/AccountFallbackImageProvider.kt | 22 + .../fsck/k9/ui/account/AccountImageLoader.kt | 37 + .../k9/ui/account/AccountImageModelLoader.kt | 91 + .../fsck/k9/ui/account/AccountsViewModel.kt | 63 + .../com/fsck/k9/ui/account/DisplayAccount.kt | 9 + .../java/com/fsck/k9/ui/account/KoinModule.kt | 13 + .../fsck/k9/ui/changelog/ChangeLogManager.kt | 46 + .../fsck/k9/ui/changelog/ChangelogFragment.kt | 113 + .../k9/ui/changelog/ChangelogViewModel.kt | 46 + .../com/fsck/k9/ui/changelog/KoinModule.kt | 13 + .../k9/ui/changelog/RecentChangesActivity.kt | 35 + .../k9/ui/changelog/RecentChangesViewModel.kt | 35 + .../ui/choosefolder/ChooseFolderActivity.kt | 271 + .../ui/choosefolder/ChooseFolderViewModel.kt | 39 + .../fsck/k9/ui/choosefolder/FolderListItem.kt | 32 + .../com/fsck/k9/ui/choosefolder/KoinModule.kt | 8 + .../k9/ui/compose/QuotedMessageMvpView.java | 148 + .../k9/ui/compose/QuotedMessagePresenter.java | 389 ++ .../k9/ui/compose/RecipientCircleImageView.kt | 27 + .../compose/RecipientTokenConstraintLayout.kt | 26 + .../fsck/k9/ui/compose/SimpleHighlightView.kt | 234 + .../k9/ui/crypto/MessageCryptoCallback.java | 15 + .../k9/ui/crypto/MessageCryptoHelper.java | 817 +++ .../fsck/k9/ui/crypto/OpenPgpApiFactory.java | 14 + .../endtoend/AutocryptKeyTransferActivity.kt | 188 + .../endtoend/AutocryptKeyTransferPresenter.kt | 104 + .../endtoend/AutocryptKeyTransferViewModel.kt | 8 + .../AutocryptSetupMessageLiveEvent.kt | 45 + .../AutocryptSetupTransferLiveEvent.kt | 38 + .../com/fsck/k9/ui/endtoend/KoinModule.kt | 21 + .../fsck/k9/ui/fab/HideFabOnScrollBehavior.kt | 49 + .../fsck/k9/ui/folders/FolderIconProvider.kt | 48 + .../fsck/k9/ui/folders/FolderNameFormatter.kt | 35 + .../fsck/k9/ui/folders/FoldersViewModel.kt | 85 + .../java/com/fsck/k9/ui/folders/KoinModule.kt | 11 + .../k9/ui/helper/BottomBaselineTextView.kt | 21 + .../fsck/k9/ui/helper/ContextExtensions.kt | 12 + .../fsck/k9/ui/helper/DisplayAddressHelper.kt | 18 + .../fsck/k9/ui/helper/DisplayHtmlUiFactory.kt | 13 + .../fsck/k9/ui/helper/HtmlSettingsProvider.kt | 18 + .../com/fsck/k9/ui/helper/HtmlToSpanned.kt | 45 + .../helper/RecyclerViewBackgroundDrawable.kt | 47 + .../k9/ui/helper/RelativeDateTimeFormatter.kt | 57 + .../com/fsck/k9/ui/helper/SizeFormatter.kt | 26 + .../k9/ui/managefolders/FolderListItem.kt | 31 + .../managefolders/FolderSettingsDataStore.kt | 72 + .../managefolders/FolderSettingsFragment.kt | 146 + .../managefolders/FolderSettingsViewModel.kt | 97 + .../fsck/k9/ui/managefolders/KoinModule.kt | 9 + .../ui/managefolders/ManageFoldersActivity.kt | 60 + .../ui/managefolders/ManageFoldersFragment.kt | 157 + .../managefolders/ManageFoldersViewModel.kt | 14 + .../message/LocalMessageExtractorLoader.java | 64 + .../k9/ui/message/LocalMessageLoader.java | 74 + .../messagedetails/AddToContactsLauncher.kt | 23 + .../messagedetails/ContactSettingsProvider.kt | 8 + .../k9/ui/messagedetails/CryptoStatusItem.kt | 53 + .../fsck/k9/ui/messagedetails/EmptyItem.kt | 25 + .../k9/ui/messagedetails/FolderNameItem.kt | 34 + .../fsck/k9/ui/messagedetails/KoinModule.kt | 25 + .../k9/ui/messagedetails/MessageDateItem.kt | 26 + .../MessageDetailsAppearance.kt | 6 + .../MessageDetailsDividerItem.kt | 19 + .../messagedetails/MessageDetailsFragment.kt | 363 ++ .../MessageDetailsParticipantFormatter.kt | 57 + .../k9/ui/messagedetails/MessageDetailsUi.kt | 40 + .../messagedetails/MessageDetailsViewModel.kt | 193 + .../k9/ui/messagedetails/ParticipantItem.kt | 72 + .../k9/ui/messagedetails/SectionHeaderItem.kt | 32 + .../ui/messagedetails/ShowContactLauncher.kt | 16 + .../ui/messagelist/DefaultFolderProvider.kt | 14 + .../com/fsck/k9/ui/messagelist/KoinModule.kt | 21 + .../k9/ui/messagelist/MessageListAdapter.kt | 619 +++ .../ui/messagelist/MessageListAppearance.kt | 16 + .../k9/ui/messagelist/MessageListConfig.kt | 20 + .../k9/ui/messagelist/MessageListFragment.kt | 2070 ++++++++ .../k9/ui/messagelist/MessageListHandler.java | 102 + .../fsck/k9/ui/messagelist/MessageListItem.kt | 30 + .../ui/messagelist/MessageListItemAnimator.kt | 29 + .../messagelist/MessageListItemDecoration.kt | 52 + .../ui/messagelist/MessageListItemMapper.kt | 56 + .../k9/ui/messagelist/MessageListLiveData.kt | 55 + .../messagelist/MessageListLiveDataFactory.kt | 15 + .../k9/ui/messagelist/MessageListLoader.kt | 195 + .../messagelist/MessageListSwipeCallback.kt | 259 + .../ui/messagelist/MessageListViewHolder.kt | 29 + .../k9/ui/messagelist/MessageListViewModel.kt | 39 + .../com/fsck/k9/ui/messagelist/MlfUtils.java | 43 + .../ui/messagelist/SortTypeToastProvider.kt | 37 + .../ui/messagelist/SwipeResourceProvider.kt | 86 + .../fsck/k9/ui/messagesource/KoinModule.kt | 8 + .../messagesource/MessageHeadersFragment.kt | 75 + .../messagesource/MessageHeadersViewModel.kt | 25 + .../ui/messagesource/MessageSourceActivity.kt | 56 + .../ui/messageview/AttachmentController.java | 266 + .../k9/ui/messageview/AttachmentView.java | 141 + .../messageview/AttachmentViewCallback.java | 10 + .../com/fsck/k9/ui/messageview/Direction.kt | 6 + .../messageview/DisplayRecipientsExtractor.kt | 58 + .../com/fsck/k9/ui/messageview/KoinModule.kt | 7 + .../fsck/k9/ui/messageview/LinkTextHandler.kt | 21 + .../ui/messageview/LockedAttachmentView.java | 73 + .../k9/ui/messageview/MessageContainerView.kt | 541 ++ .../messageview/MessageCryptoPresenter.java | 179 + .../messageview/MessageHeaderClickListener.kt | 6 + .../fsck/k9/ui/messageview/MessageTopView.kt | 376 ++ .../MessageViewContainerFragment.kt | 328 ++ .../k9/ui/messageview/MessageViewFragment.kt | 1006 ++++ .../MessageViewRecipientFormatter.kt | 90 + .../k9/ui/messageview/PlaceholderFragment.kt | 14 + .../ui/messageview/RecipientLayoutCreator.kt | 119 + .../k9/ui/messageview/RecipientNamesView.kt | 195 + .../k9/ui/messageview/TouchInterceptView.kt | 83 + .../DeleteConfirmationActivity.kt | 117 + .../k9/ui/onboarding/OnboardingActivity.kt | 43 + .../fsck/k9/ui/onboarding/WelcomeFragment.kt | 57 + .../com/fsck/k9/ui/permissions/Permission.kt | 21 + .../PermissionRationaleDialogFragment.kt | 46 + .../k9/ui/permissions/PermissionUiHelper.kt | 37 + .../com/fsck/k9/ui/push/PushInfoActivity.kt | 32 + .../com/fsck/k9/ui/push/PushInfoFragment.kt | 87 + .../com/fsck/k9/ui/settings/AboutFragment.kt | 162 + .../com/fsck/k9/ui/settings/AccountItem.kt | 74 + .../com/fsck/k9/ui/settings/KoinModule.kt | 44 + .../fsck/k9/ui/settings/PreferenceExtras.kt | 25 + .../fsck/k9/ui/settings/SettingsActionItem.kt | 41 + .../fsck/k9/ui/settings/SettingsActivity.kt | 46 + .../k9/ui/settings/SettingsDividerItem.kt | 30 + .../k9/ui/settings/SettingsListFragment.kt | 233 + .../fsck/k9/ui/settings/SettingsViewModel.kt | 29 + .../com/fsck/k9/ui/settings/UrlActionItem.kt | 40 + .../account/AccountSelectionSpinner.kt | 94 + .../account/AccountSettingsActivity.kt | 145 + .../account/AccountSettingsDataStore.kt | 289 ++ .../AccountSettingsDataStoreFactory.kt | 27 + .../account/AccountSettingsFragment.kt | 478 ++ .../account/AccountSettingsViewModel.kt | 99 + .../AutocryptPreferEncryptDialogFragment.kt | 48 + .../AutocryptPreferEncryptPreference.kt | 69 + .../settings/account/FolderListPreference.kt | 97 + .../account/NotificationSoundPreference.kt | 75 + .../account/NotificationsPreference.kt | 58 + .../account/OpenPgpAppSelectDialog.java | 312 ++ .../account/VibrationDialogFragment.kt | 210 + .../settings/account/VibrationPreference.kt | 95 + .../fsck/k9/ui/settings/account/Vibrator.kt | 32 + .../k9/ui/settings/export/CheckBoxItem.kt | 32 + .../settings/export/SettingsExportFragment.kt | 171 + .../export/SettingsExportListItems.kt | 38 + .../settings/export/SettingsExportUiModel.kt | 95 + .../export/SettingsExportViewModel.kt | 215 + .../general/GeneralSettingsActivity.kt | 144 + .../general/GeneralSettingsDataStore.kt | 304 ++ .../general/GeneralSettingsFragment.kt | 125 + .../general/GeneralSettingsViewModel.kt | 91 + .../ui/settings/general/LanguagePreference.kt | 39 + .../k9/ui/settings/import/AccountActivator.kt | 53 + .../import/PasswordPromptDialogFragment.kt | 148 + .../settings/import/PasswordPromptResult.kt | 29 + .../settings/import/SettingsImportFragment.kt | 245 + .../import/SettingsImportListItems.kt | 104 + .../import/SettingsImportResultViewModel.kt | 17 + .../settings/import/SettingsImportUiModel.kt | 208 + .../import/SettingsImportViewModel.kt | 501 ++ .../fsck/k9/ui/share/ShareIntentBuilder.kt | 96 + .../k9/view/ClientCertificateSpinner.java | 121 + .../com/fsck/k9/view/DraggableFrameLayout.kt | 33 + .../fsck/k9/view/FoldableLinearLayout.java | 255 + .../fsck/k9/view/HighlightDialogFragment.java | 90 + .../com/fsck/k9/view/K9WebViewClient.java | 142 + .../main/java/com/fsck/k9/view/KoinModule.kt | 13 + .../com/fsck/k9/view/LinearViewAnimator.java | 121 + .../k9/view/MessageCryptoDisplayStatus.kt | 325 ++ .../java/com/fsck/k9/view/MessageHeader.java | 348 ++ .../java/com/fsck/k9/view/MessageWebView.kt | 99 + .../fsck/k9/view/NonLockingScrollView.java | 187 + .../com/fsck/k9/view/RecipientSelectView.java | 803 +++ .../com/fsck/k9/view/StatusIndicator.java | 45 + .../java/com/fsck/k9/view/ThemeUtils.java | 26 + .../fsck/k9/view/ToolableViewAnimator.java | 101 + .../java/com/fsck/k9/view/ViewSwitcher.java | 132 + .../java/com/fsck/k9/view/WebViewConfig.kt | 7 + .../com/fsck/k9/view/WebViewConfigProvider.kt | 19 + app/ui/legacy/src/main/res/anim/fade_in.xml | 7 + app/ui/legacy/src/main/res/anim/fade_out.xml | 7 + .../src/main/res/anim/slide_in_left.xml | 8 + .../src/main/res/anim/slide_in_right.xml | 8 + .../src/main/res/anim/slide_out_left.xml | 8 + .../src/main/res/anim/slide_out_right.xml | 9 + .../animator/draggable_state_list_anim.xml | 32 + .../res/drawable-hdpi/btn_dialog_disable.png | Bin 0 -> 1990 bytes .../res/drawable-hdpi/btn_dialog_normal.png | Bin 0 -> 2015 bytes .../res/drawable-hdpi/btn_dialog_pressed.png | Bin 0 -> 2403 bytes .../res/drawable-hdpi/btn_dialog_selected.png | Bin 0 -> 2562 bytes .../res/drawable-hdpi/btn_edit_disable.png | Bin 0 -> 4882 bytes .../res/drawable-hdpi/btn_edit_normal.png | Bin 0 -> 4829 bytes .../res/drawable-hdpi/btn_edit_pressed.png | Bin 0 -> 5370 bytes .../res/drawable-hdpi/btn_edit_selected.png | Bin 0 -> 5422 bytes .../btn_google_signin_dark_disabled.9.png | Bin 0 -> 464 bytes .../btn_google_signin_dark_focus.9.png | Bin 0 -> 1289 bytes .../btn_google_signin_dark_normal.9.png | Bin 0 -> 1225 bytes .../btn_google_signin_dark_pressed.9.png | Bin 0 -> 1231 bytes .../divider_horizontal_email.9.png | Bin 0 -> 570 bytes .../drawer_header_background.png | Bin 0 -> 22712 bytes .../ic_action_request_read_receipt_dark.png | Bin 0 -> 979 bytes .../ic_action_request_read_receipt_light.png | Bin 0 -> 1390 bytes .../notification_icon_check_mail_anim_0.png | Bin 0 -> 839 bytes .../notification_icon_check_mail_anim_1.png | Bin 0 -> 812 bytes .../notification_icon_check_mail_anim_2.png | Bin 0 -> 788 bytes .../notification_icon_check_mail_anim_3.png | Bin 0 -> 820 bytes .../notification_icon_check_mail_anim_4.png | Bin 0 -> 800 bytes .../notification_icon_check_mail_anim_5.png | Bin 0 -> 805 bytes .../drawable-hdpi/preview_unread_widget.png | Bin 0 -> 7988 bytes .../res/drawable-mdpi/btn_dialog_disable.png | Bin 0 -> 1477 bytes .../res/drawable-mdpi/btn_dialog_normal.png | Bin 0 -> 1263 bytes .../res/drawable-mdpi/btn_dialog_pressed.png | Bin 0 -> 1496 bytes .../res/drawable-mdpi/btn_dialog_selected.png | Bin 0 -> 1522 bytes .../res/drawable-mdpi/btn_edit_disable.png | Bin 0 -> 4100 bytes .../res/drawable-mdpi/btn_edit_normal.png | Bin 0 -> 4106 bytes .../res/drawable-mdpi/btn_edit_pressed.png | Bin 0 -> 4412 bytes .../res/drawable-mdpi/btn_edit_selected.png | Bin 0 -> 4424 bytes .../btn_google_signin_dark_disabled.9.png | Bin 0 -> 331 bytes .../btn_google_signin_dark_focus.9.png | Bin 0 -> 811 bytes .../btn_google_signin_dark_normal.9.png | Bin 0 -> 758 bytes .../btn_google_signin_dark_pressed.9.png | Bin 0 -> 768 bytes .../divider_horizontal_email.9.png | Bin 0 -> 570 bytes .../drawer_header_background.png | Bin 0 -> 12769 bytes .../ic_action_request_read_receipt_dark.png | Bin 0 -> 705 bytes .../ic_action_request_read_receipt_light.png | Bin 0 -> 1003 bytes .../notification_icon_check_mail_anim_0.png | Bin 0 -> 561 bytes .../notification_icon_check_mail_anim_1.png | Bin 0 -> 556 bytes .../notification_icon_check_mail_anim_2.png | Bin 0 -> 566 bytes .../notification_icon_check_mail_anim_3.png | Bin 0 -> 544 bytes .../notification_icon_check_mail_anim_4.png | Bin 0 -> 551 bytes .../notification_icon_check_mail_anim_5.png | Bin 0 -> 572 bytes .../btn_google_signin_dark_disabled.9.png | Bin 0 -> 622 bytes .../btn_google_signin_dark_focus.9.png | Bin 0 -> 1615 bytes .../btn_google_signin_dark_normal.9.png | Bin 0 -> 1569 bytes .../btn_google_signin_dark_pressed.9.png | Bin 0 -> 1576 bytes .../drawer_header_background.png | Bin 0 -> 35726 bytes .../ic_action_request_read_receipt_dark.png | Bin 0 -> 1322 bytes .../ic_action_request_read_receipt_light.png | Bin 0 -> 1901 bytes .../notification_icon_check_mail_anim_0.png | Bin 0 -> 1084 bytes .../notification_icon_check_mail_anim_1.png | Bin 0 -> 1125 bytes .../notification_icon_check_mail_anim_2.png | Bin 0 -> 1040 bytes .../notification_icon_check_mail_anim_3.png | Bin 0 -> 1017 bytes .../notification_icon_check_mail_anim_4.png | Bin 0 -> 1076 bytes .../notification_icon_check_mail_anim_5.png | Bin 0 -> 1053 bytes .../btn_google_signin_dark_disabled.9.png | Bin 0 -> 949 bytes .../btn_google_signin_dark_focus.9.png | Bin 0 -> 2783 bytes .../btn_google_signin_dark_normal.9.png | Bin 0 -> 2536 bytes .../btn_google_signin_dark_pressed.9.png | Bin 0 -> 2554 bytes .../drawer_header_background.png | Bin 0 -> 65572 bytes .../src/main/res/drawable/btn_dialog.xml | 28 + .../legacy/src/main/res/drawable/btn_edit.xml | 28 + .../res/drawable/btn_google_signin_dark.xml | 7 + .../src/main/res/drawable/btn_select_star.xml | 5 + .../res/drawable/bullet_point_negative.xml | 4 + .../res/drawable/bullet_point_neutral.xml | 16 + .../res/drawable/bullet_point_positive.xml | 4 + .../src/main/res/drawable/compatibility.xml | 9 + .../src/main/res/drawable/dots_vertical.xml | 10 + .../res/drawable/drawer_account_fallback.xml | 9 + .../src/main/res/drawable/ic_account.xml | 10 + .../main/res/drawable/ic_account_color.xml | 10 + .../src/main/res/drawable/ic_account_plus.xml | 10 + .../main/res/drawable/ic_alert_octagon.xml | 10 + .../src/main/res/drawable/ic_archive.xml | 10 + .../src/main/res/drawable/ic_arrow_back.xml | 10 + .../main/res/drawable/ic_arrow_up_down.xml | 10 + .../src/main/res/drawable/ic_attachment.xml | 10 + .../res/drawable/ic_attachment_generic.xml | 9 + .../main/res/drawable/ic_attachment_image.xml | 9 + .../legacy/src/main/res/drawable/ic_bug.xml | 10 + .../main/res/drawable/ic_check_black_24dp.xml | 9 + .../src/main/res/drawable/ic_check_circle.xml | 10 + .../res/drawable/ic_check_circle_large.xml | 15 + .../src/main/res/drawable/ic_chevron_down.xml | 10 + .../main/res/drawable/ic_chevron_right.xml | 10 + .../drawable/ic_chevron_right_black_24dp.xml | 9 + .../src/main/res/drawable/ic_chevron_up.xml | 10 + .../legacy/src/main/res/drawable/ic_clear.xml | 10 + .../legacy/src/main/res/drawable/ic_close.xml | 10 + .../main/res/drawable/ic_close_black_24dp.xml | 9 + .../legacy/src/main/res/drawable/ic_code.xml | 10 + .../legacy/src/main/res/drawable/ic_cog.xml | 10 + .../main/res/drawable/ic_contact_picture.xml | 15 + .../src/main/res/drawable/ic_content_copy.xml | 10 + .../src/main/res/drawable/ic_description.xml | 10 + .../src/main/res/drawable/ic_download.xml | 10 + .../main/res/drawable/ic_drafts_folder.xml | 10 + .../src/main/res/drawable/ic_drag_handle.xml | 10 + .../src/main/res/drawable/ic_envelope.xml | 10 + .../legacy/src/main/res/drawable/ic_error.xml | 10 + .../src/main/res/drawable/ic_export.xml | 10 + .../src/main/res/drawable/ic_file_upload.xml | 10 + .../src/main/res/drawable/ic_floppy.xml | 10 + .../src/main/res/drawable/ic_folder.xml | 10 + .../main/res/drawable/ic_folder_magnify.xml | 10 + .../legacy/src/main/res/drawable/ic_forum.xml | 10 + .../legacy/src/main/res/drawable/ic_help.xml | 10 + .../src/main/res/drawable/ic_import.xml | 10 + .../main/res/drawable/ic_import_status.xml | 23 + .../legacy/src/main/res/drawable/ic_inbox.xml | 10 + .../main/res/drawable/ic_inbox_multiple.xml | 10 + .../legacy/src/main/res/drawable/ic_info.xml | 10 + .../legacy/src/main/res/drawable/ic_key.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 17 + .../res/drawable/ic_launcher_monochrome.xml | 70 + .../legacy/src/main/res/drawable/ic_link.xml | 10 + .../legacy/src/main/res/drawable/ic_login.xml | 10 + .../src/main/res/drawable/ic_magnify.xml | 10 + .../main/res/drawable/ic_magnify_cloud.xml | 10 + .../src/main/res/drawable/ic_mark_new.xml | 10 + .../src/main/res/drawable/ic_mastodon.xml | 10 + .../legacy/src/main/res/drawable/ic_menu.xml | 10 + .../res/drawable/ic_messagelist_answered.xml | 10 + .../ic_messagelist_answered_forwarded.xml | 16 + .../drawable/ic_messagelist_attachment.xml | 10 + .../ic_messagelist_attachment_light.xml | 9 + .../res/drawable/ic_messagelist_forwarded.xml | 10 + .../main/res/drawable/ic_move_to_folder.xml | 11 + .../src/main/res/drawable/ic_not_imported.xml | 10 + .../main/res/drawable/ic_notifications.xml | 10 + .../src/main/res/drawable/ic_open_book.xml | 10 + .../main/res/drawable/ic_opened_envelope.xml | 10 + .../src/main/res/drawable/ic_outbox.xml | 13 + .../src/main/res/drawable/ic_pencil.xml | 10 + .../src/main/res/drawable/ic_people.xml | 10 + .../src/main/res/drawable/ic_person_add.xml | 10 + .../legacy/src/main/res/drawable/ic_plus.xml | 10 + .../drawable/ic_preferences_check_mail.xml | 10 + .../res/drawable/ic_preferences_crypto.xml | 10 + .../res/drawable/ic_push_notification.xml | 10 + .../src/main/res/drawable/ic_refresh.xml | 10 + .../legacy/src/main/res/drawable/ic_reply.xml | 10 + .../src/main/res/drawable/ic_reply_all.xml | 10 + .../src/main/res/drawable/ic_select_all.xml | 10 + .../legacy/src/main/res/drawable/ic_send.xml | 10 + .../src/main/res/drawable/ic_shield.xml | 10 + .../legacy/src/main/res/drawable/ic_sort.xml | 10 + .../legacy/src/main/res/drawable/ic_star.xml | 10 + .../main/res/drawable/ic_star_no_padding.xml | 10 + .../src/main/res/drawable/ic_star_outline.xml | 10 + .../drawable/ic_star_outline_no_padding.xml | 10 + .../main/res/drawable/ic_status_corner.xml | 12 + .../legacy/src/main/res/drawable/ic_touch.xml | 10 + .../src/main/res/drawable/ic_trash_can.xml | 10 + app/ui/legacy/src/main/res/drawable/ic_tv.xml | 10 + .../src/main/res/drawable/ic_twitter.xml | 10 + .../src/main/res/drawable/ic_visibility.xml | 10 + .../drawable/notification_action_archive.xml | 9 + .../drawable/notification_action_delete.xml | 9 + .../notification_action_mark_as_read.xml | 9 + .../notification_action_mark_as_spam.xml | 9 + .../drawable/notification_action_reply.xml | 9 + .../drawable/notification_icon_check_mail.xml | 24 + .../drawable/notification_icon_new_mail.xml | 9 + .../drawable/notification_icon_warning.xml | 9 + .../src/main/res/drawable/rounded_corners.xml | 9 + .../src/main/res/drawable/status_dots.xml | 15 + .../src/main/res/drawable/status_dots_1.xml | 15 + .../src/main/res/drawable/status_dots_2.xml | 15 + .../src/main/res/drawable/status_dots_3.xml | 15 + .../src/main/res/drawable/status_lock.xml | 6 + .../res/drawable/status_lock_disabled.xml | 10 + .../drawable/status_lock_disabled_dots_1.xml | 8 + .../main/res/drawable/status_lock_dots_2.xml | 13 + .../main/res/drawable/status_lock_dots_3.xml | 17 + .../main/res/drawable/status_lock_error.xml | 6 + .../main/res/drawable/status_lock_unknown.xml | 6 + .../main/res/drawable/status_signature.xml | 6 + .../res/drawable/status_signature_dots_3.xml | 18 + .../res/drawable/status_signature_unknown.xml | 6 + .../res/drawable/thread_count_box_dark.xml | 10 + .../res/drawable/thread_count_box_light.xml | 10 + .../src/main/res/layout/about_library.xml | 31 + .../src/main/res/layout/account_list.xml | 33 + .../src/main/res/layout/account_list_item.xml | 72 + .../res/layout/account_setup_account_type.xml | 47 + .../main/res/layout/account_setup_basics.xml | 125 + .../layout/account_setup_check_settings.xml | 48 + .../res/layout/account_setup_composition.xml | 128 + .../res/layout/account_setup_incoming.xml | 264 + .../main/res/layout/account_setup_names.xml | 59 + .../main/res/layout/account_setup_oauth.xml | 70 + .../main/res/layout/account_setup_options.xml | 68 + .../res/layout/account_setup_outgoing.xml | 161 + .../layout/account_spinner_dropdown_item.xml | 31 + .../main/res/layout/account_spinner_item.xml | 28 + .../src/main/res/layout/accounts_item.xml | 53 + .../res/layout/activity_account_settings.xml | 30 + .../res/layout/activity_manage_folders.xml | 22 + .../main/res/layout/activity_onboarding.xml | 22 + .../main/res/layout/activity_push_info.xml | 19 + .../res/layout/activity_recent_changes.xml | 19 + .../src/main/res/layout/activity_settings.xml | 21 + .../res/layout/changelog_list_change_item.xml | 30 + .../layout/changelog_list_release_item.xml | 31 + .../main/res/layout/choose_account_item.xml | 30 + .../main/res/layout/choose_identity_item.xml | 33 + .../res/layout/client_certificate_spinner.xml | 32 + .../main/res/layout/crypto_key_transfer.xml | 174 + .../dialog_autocrypt_prefer_encrypt.xml | 67 + .../res/layout/dialog_openkeychain_info.xml | 17 + .../src/main/res/layout/drawer_contents.xml | 14 + .../res/layout/drawer_folder_list_item.xml | 86 + .../src/main/res/layout/edit_identity.xml | 127 + .../main/res/layout/empty_message_view.xml | 14 + .../main/res/layout/foldable_linearlayout.xml | 42 + .../src/main/res/layout/folder_list.xml | 21 + .../src/main/res/layout/folder_list_item.xml | 35 + .../src/main/res/layout/fragment_about.xml | 380 ++ .../main/res/layout/fragment_changelog.xml | 74 + .../res/layout/fragment_manage_folders.xml | 11 + .../main/res/layout/fragment_push_info.xml | 73 + .../res/layout/fragment_settings_export.xml | 96 + .../res/layout/fragment_settings_import.xml | 125 + .../res/layout/fragment_settings_list.xml | 10 + .../res/layout/fragment_welcome_message.xml | 38 + .../src/main/res/layout/general_settings.xml | 17 + .../main/res/layout/list_content_simple.xml | 17 + app/ui/legacy/src/main/res/layout/message.xml | 114 + .../main/res/layout/message_bottom_sheet.xml | 36 + .../src/main/res/layout/message_compose.xml | 19 + .../res/layout/message_compose_attachment.xml | 111 + .../res/layout/message_compose_content.xml | 155 + .../res/layout/message_compose_recipients.xml | 413 ++ .../src/main/res/layout/message_container.xml | 75 + .../message_content_crypto_cancelled.xml | 51 + .../layout/message_content_crypto_error.xml | 43 + .../message_content_crypto_incomplete.xml | 43 + .../message_content_crypto_no_provider.xml | 38 + .../res/layout/message_crypto_info_dialog.xml | 45 + .../message_details_crypto_status_item.xml | 60 + .../res/layout/message_details_date_item.xml | 12 + .../layout/message_details_divider_item.xml | 8 + .../message_details_folder_name_item.xml | 44 + .../message_details_participant_item.xml | 98 + .../message_details_section_header_item.xml | 28 + .../src/main/res/layout/message_list.xml | 56 + .../main/res/layout/message_list_error.xml | 39 + .../main/res/layout/message_list_fragment.xml | 40 + .../src/main/res/layout/message_list_item.xml | 208 + .../res/layout/message_list_item_footer.xml | 20 + .../res/layout/message_view_attachment.xml | 103 + .../layout/message_view_attachment_locked.xml | 96 + .../res/layout/message_view_container.xml | 11 + .../main/res/layout/message_view_header.xml | 178 + .../main/res/layout/message_view_headers.xml | 48 + .../layout/message_view_headers_activity.xml | 16 + .../layout/openpgp_enabled_error_dialog.xml | 29 + .../openpgp_encrypt_description_dialog.xml | 99 + .../main/res/layout/openpgp_inline_dialog.xml | 121 + .../res/layout/openpgp_sign_only_dialog.xml | 148 + .../res/layout/password_prompt_dialog.xml | 104 + .../preference_vibration_pattern_item.xml | 18 + .../preference_vibration_switch_item.xml | 58 + .../preference_vibration_times_item.xml | 48 + .../res/layout/recipient_alternate_item.xml | 148 + .../res/layout/recipient_dropdown_item.xml | 89 + .../src/main/res/layout/recipient_names.xml | 27 + .../main/res/layout/recipient_token_item.xml | 137 + .../res/layout/select_openpgp_app_item.xml | 33 + .../settings_export_account_list_item.xml | 59 + .../settings_export_general_list_item.xml | 44 + .../settings_import_account_list_item.xml | 59 + .../settings_import_general_list_item.xml | 56 + .../main/res/layout/split_message_list.xml | 66 + .../src/main/res/layout/status_indicator.xml | 30 + .../src/main/res/layout/swipe_left_action.xml | 48 + .../main/res/layout/swipe_right_action.xml | 48 + .../res/layout/text_divider_list_item.xml | 17 + .../main/res/layout/text_icon_list_item.xml | 44 + .../src/main/res/layout/upgrade_databases.xml | 32 + .../src/main/res/layout/wizard_cancel.xml | 31 + .../src/main/res/layout/wizard_done.xml | 31 + .../src/main/res/layout/wizard_next.xml | 31 + .../src/main/res/layout/wizard_setup.xml | 35 + .../src/main/res/layout/wizard_welcome.xml | 35 + .../main/res/menu/account_settings_option.xml | 10 + .../main/res/menu/choose_folder_option.xml | 28 + .../main/res/menu/debug_settings_option.xml | 10 + .../src/main/res/menu/edit_identity_menu.xml | 8 + .../src/main/res/menu/folder_list_option.xml | 32 + .../main/res/menu/folder_settings_option.xml | 10 + .../main/res/menu/general_settings_option.xml | 12 + .../res/menu/manage_identities_context.xml | 13 + .../res/menu/manage_identities_option.xml | 8 + .../main/res/menu/message_compose_option.xml | 65 + .../main/res/menu/message_list_context.xml | 69 + .../src/main/res/menu/message_list_option.xml | 204 + .../res/menu/participant_overflow_menu.xml | 16 + .../main/res/menu/single_message_options.xml | 21 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 8 + .../src/main/res/mipmap-anydpi-v26/icon.xml | 5 + .../main/res/mipmap-anydpi-v26/icon_round.xml | 5 + .../legacy/src/main/res/mipmap-hdpi/icon.png | Bin 0 -> 1780 bytes .../src/main/res/mipmap-hdpi/icon_round.png | Bin 0 -> 3812 bytes .../legacy/src/main/res/mipmap-mdpi/icon.png | Bin 0 -> 1209 bytes .../src/main/res/mipmap-mdpi/icon_round.png | Bin 0 -> 2357 bytes .../legacy/src/main/res/mipmap-xhdpi/icon.png | Bin 0 -> 2554 bytes .../src/main/res/mipmap-xhdpi/icon_round.png | Bin 0 -> 5459 bytes .../src/main/res/mipmap-xxhdpi/icon.png | Bin 0 -> 4031 bytes .../src/main/res/mipmap-xxhdpi/icon_round.png | Bin 0 -> 8785 bytes .../src/main/res/mipmap-xxxhdpi/icon.png | Bin 0 -> 5615 bytes .../main/res/mipmap-xxxhdpi/icon_round.png | Bin 0 -> 12687 bytes .../navigation/navigation_manage_folders.xml | 25 + .../res/navigation/navigation_onboarding.xml | 45 + .../res/navigation/navigation_settings.xml | 103 + .../legacy/src/main/res/raw-ja/changelog.xml | 279 + .../src/main/res/raw/changelog_master.xml | 1215 +++++ .../res/transition/transfer_transitions.xml | 15 + .../legacy/src/main/res/values-ar/strings.xml | 813 +++ .../legacy/src/main/res/values-be/strings.xml | 1125 ++++ .../legacy/src/main/res/values-bg/strings.xml | 1032 ++++ .../legacy/src/main/res/values-br/strings.xml | 972 ++++ .../legacy/src/main/res/values-ca/strings.xml | 1124 ++++ .../legacy/src/main/res/values-cs/strings.xml | 1117 ++++ .../legacy/src/main/res/values-cy/strings.xml | 1111 ++++ .../legacy/src/main/res/values-da/strings.xml | 1098 ++++ .../legacy/src/main/res/values-de/strings.xml | 1127 ++++ .../legacy/src/main/res/values-el/strings.xml | 1097 ++++ .../src/main/res/values-en-rGB/strings.xml | 89 + .../legacy/src/main/res/values-eo/strings.xml | 1052 ++++ .../legacy/src/main/res/values-es/strings.xml | 1129 ++++ .../legacy/src/main/res/values-et/strings.xml | 1123 ++++ .../legacy/src/main/res/values-eu/strings.xml | 1105 ++++ .../legacy/src/main/res/values-fa/strings.xml | 1094 ++++ .../legacy/src/main/res/values-fi/strings.xml | 1123 ++++ .../legacy/src/main/res/values-fr/strings.xml | 1127 ++++ .../legacy/src/main/res/values-fy/strings.xml | 1119 ++++ .../legacy/src/main/res/values-gd/strings.xml | 934 ++++ .../legacy/src/main/res/values-gl/strings.xml | 1058 ++++ .../legacy/src/main/res/values-hr/strings.xml | 953 ++++ .../legacy/src/main/res/values-hu/strings.xml | 1101 ++++ .../legacy/src/main/res/values-in/strings.xml | 1015 ++++ .../legacy/src/main/res/values-is/strings.xml | 1104 ++++ .../legacy/src/main/res/values-it/strings.xml | 1132 ++++ .../legacy/src/main/res/values-iw/strings.xml | 653 +++ .../legacy/src/main/res/values-ja/strings.xml | 1117 ++++ .../legacy/src/main/res/values-ko/strings.xml | 854 ++++ .../legacy/src/main/res/values-lt/strings.xml | 1125 ++++ .../legacy/src/main/res/values-lv/strings.xml | 1126 ++++ .../legacy/src/main/res/values-ml/strings.xml | 1044 ++++ .../legacy/src/main/res/values-nb/strings.xml | 987 ++++ .../src/main/res/values-night/strings.xml | 4 + .../src/main/res/values-night/themes.xml | 8 + .../legacy/src/main/res/values-nl/strings.xml | 1120 ++++ .../legacy/src/main/res/values-pl/strings.xml | 1139 +++++ .../src/main/res/values-pt-rBR/strings.xml | 1131 ++++ .../src/main/res/values-pt-rPT/strings.xml | 1108 ++++ .../legacy/src/main/res/values-ro/strings.xml | 1129 ++++ .../legacy/src/main/res/values-ru/strings.xml | 1138 +++++ .../legacy/src/main/res/values-sk/strings.xml | 931 ++++ .../legacy/src/main/res/values-sl/strings.xml | 1134 ++++ .../legacy/src/main/res/values-sq/strings.xml | 1125 ++++ .../legacy/src/main/res/values-sr/strings.xml | 1026 ++++ .../legacy/src/main/res/values-sv/strings.xml | 1125 ++++ .../src/main/res/values-sw360dp/strings.xml | 4 + .../res/values-sw360dp/values-preference.xml | 10 + .../legacy/src/main/res/values-tr/strings.xml | 1090 ++++ .../legacy/src/main/res/values-uk/strings.xml | 1120 ++++ .../src/main/res/values-v23/strings.xml | 4 + .../legacy/src/main/res/values-v23/styles.xml | 15 + .../legacy/src/main/res/values-v23/themes.xml | 14 + .../src/main/res/values-v26/drawables.xml | 4 + .../src/main/res/values-v26/strings.xml | 4 + .../src/main/res/values-v27/strings.xml | 4 + .../legacy/src/main/res/values-v27/themes.xml | 20 + .../src/main/res/values-v28/strings.xml | 4 + .../legacy/src/main/res/values-v28/themes.xml | 4 + .../src/main/res/values-w360dp/strings.xml | 4 + .../src/main/res/values-w360dp/styles.xml | 13 + .../src/main/res/values-zh-rCN/strings.xml | 1118 ++++ .../src/main/res/values-zh-rTW/strings.xml | 1118 ++++ app/ui/legacy/src/main/res/values/arrays.xml | 101 + .../arrays_account_settings_strings.xml | 191 + .../arrays_general_settings_strings.xml | 177 + app/ui/legacy/src/main/res/values/attrs.xml | 175 + app/ui/legacy/src/main/res/values/colors.xml | 12 + .../legacy/src/main/res/values/constants.xml | 14 + .../contact_picture_fallback_colors.xml | 38 + .../legacy/src/main/res/values/dimensions.xml | 40 + .../legacy/src/main/res/values/drawables.xml | 4 + app/ui/legacy/src/main/res/values/ids.xml | 24 + .../main/res/values/message_details_ids.xml | 10 + app/ui/legacy/src/main/res/values/strings.xml | 1354 +++++ app/ui/legacy/src/main/res/values/styles.xml | 123 + app/ui/legacy/src/main/res/values/themes.xml | 379 ++ .../src/main/res/xml/account_settings.xml | 441 ++ .../src/main/res/xml/empty_preferences.xml | 2 + .../res/xml/folder_settings_preferences.xml | 67 + .../src/main/res/xml/general_settings.xml | 465 ++ app/ui/legacy/src/main/res/xml/searchable.xml | 7 + .../k9/ui/settings/ExtraAccountDiscovery.kt | 8 + app/ui/legacy/src/test/AndroidManifest.xml | 21 + .../java/com/fsck/k9/K9RobolectricTest.kt | 16 + .../test/java/com/fsck/k9/RobolectricTest.kt | 17 + .../src/test/java/com/fsck/k9/TestApp.kt | 25 + .../com/fsck/k9/TestCoreResourceProvider.kt | 42 + .../java/com/fsck/k9/ViewTestExtensions.kt | 6 + .../fsck/k9/account/AccountCreatorTest.java | 75 + .../compose/AttachmentPresenterTest.kt | 177 + .../activity/compose/RecipientLoaderTest.java | 330 ++ .../compose/RecipientPresenterTest.kt | 317 ++ .../activity/compose/ReplyToPresenterTest.kt | 120 + .../autocrypt/AutocryptOperationsHelper.java | 23 + .../k9/contacts/ContactLetterExtractorTest.kt | 59 + .../fsck/k9/message/PgpMessageBuilderTest.kt | 820 +++ .../test/java/com/fsck/k9/ui/K9DrawerTest.kt | 19 + .../k9/ui/crypto/MessageCryptoHelperTest.java | 340 ++ .../helper/RelativeDateTimeFormatterTest.kt | 131 + .../fsck/k9/ui/helper/SizeFormatterTest.kt | 57 + .../k9/ui/identity/IdentityFormatterTest.kt | 68 + .../MessageDetailsParticipantFormatterTest.kt | 128 + .../ui/messagelist/MessageListAdapterTest.kt | 495 ++ .../DisplayRecipientsExtractorTest.kt | 148 + .../MessageViewRecipientFormatterTest.kt | 217 + .../messageview/RecipientLayoutCreatorTest.kt | 148 + .../general/GeneralSettingsViewModelTest.kt | 147 + app/ui/message-list-widget/build.gradle.kts | 27 + .../src/main/AndroidManifest.xml | 10 + .../app/k9mail/ui/widget/list/KoinModule.kt | 8 + .../ui/widget/list/MessageListConfig.kt | 12 + .../k9mail/ui/widget/list/MessageListItem.kt | 22 + .../ui/widget/list/MessageListItemMapper.kt | 62 + .../ui/widget/list/MessageListLoader.kt | 149 + .../list/MessageListRemoteViewFactory.kt | 127 + .../ui/widget/list/MessageListWidgetConfig.kt | 5 + .../widget/list/MessageListWidgetManager.kt | 106 + .../widget/list/MessageListWidgetProvider.kt | 85 + .../widget/list/MessageListWidgetService.kt | 10 + .../message_list_widget_preview.png | Bin 0 -> 96062 bytes .../res/layout/message_list_widget_layout.xml | 46 + .../layout/message_list_widget_list_item.xml | 93 + .../message_list_widget_list_item_loading.xml | 12 + .../layout/message_list_widget_loading.xml | 9 + .../src/main/res/values/colors.xml | 8 + .../main/res/xml/message_list_widget_info.xml | 13 + .../main/res/drawable/ic_monocles_graphic.xml | 14 + backend/api/build.gradle.kts | 9 + .../java/com/fsck/k9/backend/api/Backend.kt | 101 + .../com/fsck/k9/backend/api/BackendFolder.kt | 36 + .../com/fsck/k9/backend/api/BackendPusher.kt | 8 + .../k9/backend/api/BackendPusherCallback.kt | 7 + .../com/fsck/k9/backend/api/BackendStorage.kt | 27 + .../com/fsck/k9/backend/api/FolderInfo.kt | 5 + .../com/fsck/k9/backend/api/SyncConfig.kt | 19 + .../com/fsck/k9/backend/api/SyncListener.kt | 21 + backend/demo/build.gradle.kts | 16 + .../app/k9mail/backend/demo/DemoBackend.kt | 210 + .../k9mail/backend/demo/MessageStoreInfo.kt | 13 + backend/demo/src/main/resources/contents.json | 60 + .../inbox/inline_image_attachment.eml | 248 + .../resources/inbox/inline_image_data_uri.eml | 15 + .../demo/src/main/resources/inbox/intro.eml | 9 + .../main/resources/inbox/many_recipients.eml | 42 + .../src/main/resources/inbox/thread_1.eml | 9 + .../src/main/resources/inbox/thread_2.eml | 11 + .../resources/turing/turing_award_1966.eml | 84 + .../resources/turing/turing_award_1967.eml | 35 + .../resources/turing/turing_award_1968.eml | 40 + .../resources/turing/turing_award_1970.eml | 35 + .../resources/turing/turing_award_1971.eml | 32 + .../resources/turing/turing_award_1972.eml | 27 + .../resources/turing/turing_award_1975.eml | 30 + .../resources/turing/turing_award_1977.eml | 39 + .../resources/turing/turing_award_1978.eml | 36 + .../resources/turing/turing_award_1979.eml | 33 + .../resources/turing/turing_award_1981.eml | 51 + .../resources/turing/turing_award_1983.eml | 46 + .../resources/turing/turing_award_1987.eml | 42 + .../resources/turing/turing_award_1991.eml | 44 + .../resources/turing/turing_award_1996.eml | 28 + backend/imap/build.gradle.kts | 17 + .../backend/imap/BackendIdleRefreshManager.kt | 136 + .../fsck/k9/backend/imap/CommandDeleteAll.kt | 24 + .../k9/backend/imap/CommandDownloadMessage.kt | 55 + .../fsck/k9/backend/imap/CommandExpunge.kt | 40 + .../k9/backend/imap/CommandFetchMessage.kt | 21 + .../k9/backend/imap/CommandFindByMessageId.kt | 17 + .../k9/backend/imap/CommandMarkAllAsRead.kt | 22 + .../backend/imap/CommandMoveOrCopyMessages.kt | 77 + .../backend/imap/CommandRefreshFolderList.kt | 47 + .../com/fsck/k9/backend/imap/CommandSearch.kt | 24 + .../fsck/k9/backend/imap/CommandSetFlag.kt | 26 + .../k9/backend/imap/CommandUploadMessage.kt | 22 + .../com/fsck/k9/backend/imap/ImapBackend.kt | 162 + .../fsck/k9/backend/imap/ImapBackendPusher.kt | 251 + .../fsck/k9/backend/imap/ImapFolderPusher.kt | 101 + .../k9/backend/imap/ImapPushConfigProvider.kt | 8 + .../k9/backend/imap/ImapPusherCallback.kt | 7 + .../java/com/fsck/k9/backend/imap/ImapSync.kt | 729 +++ .../k9/backend/imap/SimpleSyncListener.kt | 18 + .../k9/backend/imap/SystemAlarmManager.kt | 7 + .../k9/backend/imap/UidReverseComparator.kt | 24 + .../imap/BackendIdleRefreshManagerTest.kt | 185 + .../com/fsck/k9/backend/imap/ImapSyncTest.kt | 330 ++ .../fsck/k9/backend/imap/TestImapFolder.kt | 179 + .../com/fsck/k9/backend/imap/TestImapStore.kt | 48 + .../k9/mail/store/imap/ImapMessageHelper.kt | 3 + backend/jmap/build.gradle.kts | 19 + .../com/fsck/k9/backend/jmap/CommandDelete.kt | 71 + .../com/fsck/k9/backend/jmap/CommandMove.kt | 56 + .../backend/jmap/CommandRefreshFolderList.kt | 165 + .../fsck/k9/backend/jmap/CommandSetFlag.kt | 108 + .../com/fsck/k9/backend/jmap/CommandSync.kt | 322 ++ .../com/fsck/k9/backend/jmap/CommandUpload.kt | 98 + .../k9/backend/jmap/JmapAccountDiscovery.kt | 46 + .../com/fsck/k9/backend/jmap/JmapBackend.kt | 160 + .../com/fsck/k9/backend/jmap/JmapConfig.kt | 8 + .../fsck/k9/backend/jmap/JmapExtensions.kt | 40 + .../k9/backend/jmap/JmapUploadResponse.kt | 11 + .../jmap/CommandRefreshFolderListTest.kt | 179 + .../fsck/k9/backend/jmap/CommandSyncTest.kt | 266 + .../k9/backend/jmap/LoggingSyncListener.kt | 96 + .../k9/backend/jmap/MockWebServerHelper.kt | 35 + .../jmap_responses/blob/email/email_1.eml | 14 + .../jmap_responses/blob/email/email_2.eml | 16 + .../jmap_responses/blob/email/email_3.eml | 9 + .../email/email_get_ids_M001_and_M002.json | 32 + .../email/email_get_ids_M003.json | 23 + .../email/email_get_ids_M003_and_M004.json | 30 + .../email/email_get_ids_M005.json | 23 + .../email_get_keywords_M001_and_M002.json | 26 + .../email/email_get_keywords_M002.json | 20 + .../email/email_query_M001_and_M002.json | 24 + .../email/email_query_M001_to_M005.json | 27 + .../email/email_query_M002_and_M003.json | 24 + ...query_changes_M001_deleted_M003_added.json | 21 + ...hanges_cannot_calculate_changes_error.json | 12 + .../email_query_changes_empty_result.json | 16 + .../email/email_query_empty_result.json | 21 + .../mailbox/mailbox_changes.json | 86 + .../mailbox/mailbox_changes_1.json | 61 + .../mailbox/mailbox_changes_2.json | 61 + ...hanges_error_cannot_calculate_changes.json | 26 + .../jmap_responses/mailbox/mailbox_get.json | 159 + .../session_with_maxObjectsInGet_2.json | 64 + .../jmap_responses/session/valid_session.json | 64 + backend/pop3/build.gradle.kts | 13 + .../k9/backend/pop3/CommandDownloadMessage.kt | 26 + .../backend/pop3/CommandRefreshFolderList.kt | 19 + .../fsck/k9/backend/pop3/CommandSetFlag.java | 47 + .../com/fsck/k9/backend/pop3/Pop3Backend.kt | 141 + .../com/fsck/k9/backend/pop3/Pop3Sync.java | 593 +++ backend/testing/build.gradle.kts | 12 + .../backend/testing/InMemoryBackendFolder.kt | 154 + .../backend/testing/InMemoryBackendStorage.kt | 62 + backend/webdav/build.gradle.kts | 12 + .../backend/webdav/CommandDownloadMessage.kt | 25 + .../webdav/CommandMoveOrCopyMessages.java | 78 + .../webdav/CommandRefreshFolderList.kt | 32 + .../k9/backend/webdav/CommandSetFlag.java | 44 + .../k9/backend/webdav/CommandUploadMessage.kt | 20 + .../fsck/k9/backend/webdav/WebDavBackend.kt | 149 + .../fsck/k9/backend/webdav/WebDavSync.java | 636 +++ build-plugin/README.md | 72 + build-plugin/build.gradle.kts | 21 + build-plugin/settings.gradle.kts | 21 + .../src/main/kotlin/AndroidExtension.kt | 69 + .../main/kotlin/DependencyHandlerExtension.kt | 14 + .../src/main/kotlin/ProjectExtension.kt | 10 + .../src/main/kotlin/ThunderbirdPlugins.kt | 12 + .../main/kotlin/ThunderbirdProjectConfig.kt | 10 + ....gradle.plugin.quality.spotless.gradle.kts | 28 + ...thunderbird.app.android.compose.gradle.kts | 23 + ...thunderbird.app.android.default.gradle.kts | 39 + .../kotlin/thunderbird.app.android.gradle.kts | 40 + .../thunderbird.dependency.check.gradle.kts | 18 + ...derbird.library.android.compose.gradle.kts | 18 + .../thunderbird.library.android.gradle.kts | 32 + .../kotlin/thunderbird.library.jvm.gradle.kts | 14 + .../thunderbird.quality.detekt.gradle.kts | 48 + .../thunderbird.quality.spotless.gradle.kts | 27 + build.gradle.kts | 95 + cli/html-cleaner-cli/README.md | 17 + cli/html-cleaner-cli/build.gradle.kts | 23 + cli/html-cleaner-cli/src/main/kotlin/Main.kt | 55 + config/detekt/baseline.xml | 814 +++ config/detekt/detekt.yml | 773 +++ config/lint/lint.xml | 19 + core/android/common/build.gradle.kts | 13 + .../android/common/CoreCommonAndroidModule.kt | 12 + .../core/android/common/contact/Contact.kt | 12 + .../common/contact/ContactDataSource.kt | 86 + .../common/contact/ContactKoinModule.kt | 34 + .../contact/ContactPermissionResolver.kt | 16 + .../common/contact/ContactRepository.kt | 48 + .../common/database/CursorExtensions.kt | 37 + .../android/common/database/EmptyCursor.kt | 26 + .../common/CoreCommonAndroidModuleKtTest.kt | 22 + .../AndroidContactPermissionResolverTest.kt | 43 + .../contact/CachingContactRepositoryTest.kt | 143 + .../android/common/contact/ContactFixture.kt | 19 + .../common/contact/ContactKoinModuleKtTest.kt | 25 + .../ContentResolverContactDataSourceTest.kt | 120 + .../contact/TestContactPermissionResolver.kt | 9 + .../database/CursorExtensionsKtAccessTest.kt | 100 + .../common/database/CursorExtensionsKtTest.kt | 33 + core/common/build.gradle.kts | 9 + .../k9mail/core/common/CoreCommonModule.kt | 9 + .../app/k9mail/core/common/cache/Cache.kt | 12 + .../k9mail/core/common/cache/ExpiringCache.kt | 46 + .../k9mail/core/common/cache/InMemoryCache.kt | 21 + .../core/common/cache/SynchronizedCache.kt | 30 + .../k9mail/core/common/mail/EmailAddress.kt | 8 + .../core/common/CoreCommonModuleKtTest.kt | 16 + .../app/k9mail/core/common/cache/CacheTest.kt | 81 + .../core/common/cache/ExpiringCacheTest.kt | 68 + .../core/common/mail/EmailAddressTest.kt | 29 + core/testing/build.gradle.kts | 7 + .../app/k9mail/core/testing/TestClock.kt | 19 + .../assertk/assertions/ListExtensions.kt | 13 + .../app/k9mail/core/testing/TestClockTest.kt | 39 + .../assertions/ListExtensionsKtTest.kt | 28 + core/ui/compose/common/README.md | 3 + core/ui/compose/common/build.gradle.kts | 8 + .../core/ui/compose/common/DevicePreviews.kt | 16 + .../compose/common/window/WindowSizeClass.kt | 37 + .../compose/common/window/WindowSizeInfo.kt | 30 + .../common/window/WindowSizeClassTest.kt | 80 + core/ui/compose/designsystem/README.md | 38 + .../assets/images/atomic_design.svg | 4 + core/ui/compose/designsystem/build.gradle.kts | 16 + .../compose/designsystem/atom/Background.kt | 32 + .../ui/compose/designsystem/atom/Checkbox.kt | 45 + .../ui/compose/designsystem/atom/Surface.kt | 37 + .../designsystem/atom/button/Button.kt | 52 + .../atom/button/ButtonOutlined.kt | 65 + .../designsystem/atom/button/ButtonText.kt | 52 + .../designsystem/atom/text/TextBody1.kt | 28 + .../designsystem/atom/text/TextBody2.kt | 28 + .../designsystem/atom/text/TextButton.kt | 28 + .../designsystem/atom/text/TextCaption.kt | 28 + .../designsystem/atom/text/TextHeadline1.kt | 28 + .../designsystem/atom/text/TextHeadline2.kt | 28 + .../designsystem/atom/text/TextHeadline3.kt | 28 + .../designsystem/atom/text/TextHeadline4.kt | 28 + .../designsystem/atom/text/TextHeadline5.kt | 28 + .../designsystem/atom/text/TextHeadline6.kt | 28 + .../designsystem/atom/text/TextOverline.kt | 28 + .../designsystem/atom/text/TextSubtitle1.kt | 28 + .../designsystem/atom/text/TextSubtitle2.kt | 28 + .../textfield/PasswordTextFieldOutlined.kt | 154 + .../atom/textfield/TextFieldOutlined.kt | 84 + .../template/LazyColumnWithFooter.kt | 86 + .../template/ResponsiveContent.kt | 129 + .../src/main/res/values/strings.xml | 5 + .../PasswordTextFieldOutlinedKtTest.kt | 98 + .../atom/textfield/TextFieldKtTest.kt | 154 + core/ui/compose/testing/README.md | 3 + core/ui/compose/testing/build.gradle.kts | 14 + .../core/ui/compose/testing/ComposeTest.kt | 22 + core/ui/compose/theme/README.md | 27 + core/ui/compose/theme/build.gradle.kts | 13 + .../core/ui/compose/theme/Elevations.kt | 15 + .../k9mail/core/ui/compose/theme/Images.kt | 14 + .../k9mail/core/ui/compose/theme/K9Theme.kt | 38 + .../k9mail/core/ui/compose/theme/MainTheme.kt | 85 + .../ui/compose/theme/PreviewWithThemes.kt | 64 + .../k9mail/core/ui/compose/theme/Shapes.kt | 11 + .../app/k9mail/core/ui/compose/theme/Sizes.kt | 19 + .../k9mail/core/ui/compose/theme/Spacings.kt | 19 + .../core/ui/compose/theme/ThunderbirdTheme.kt | 38 + .../core/ui/compose/theme/Typography.kt | 123 + .../core/ui/compose/theme/color/Colors.kt | 118 + .../ui/compose/theme/color/MaterialColor.kt | 218 + .../res/drawable/core_ui_theme_k9_logo.xml | 104 + .../core_ui_theme_thunderbird_logo.xml | 104 + docs/DESIGN.md | 36 + docs/Modules.png | Bin 0 -> 170059 bytes docs/ReadEmail.png | Bin 0 -> 98452 bytes docs/ReadEmailClasses.png | Bin 0 -> 56368 bytes docs/SendEmail.png | Bin 0 -> 52901 bytes docs/activity_diagram.graphml | 936 ++++ docs/draw.io/CreateAccount.xml | 1 + docs/draw.io/ImportExport.xml | 1 + docs/draw.io/Modules.xml | 1 + docs/draw.io/README.md | 1 + docs/draw.io/ReadEmail.xml | 1 + docs/draw.io/ReadEmailClasses.xml | 1 + docs/draw.io/SendEmail.xml | 1 + docs/google-play/full_description.txt | 14 + docs/google-play/short_description.txt | 1 + fastlane/metadata/android/de/changelogs/1.txt | 7 + .../metadata/android/de/changelogs/10.txt | 2 + .../metadata/android/de/changelogs/11.txt | 7 + .../metadata/android/de/changelogs/12.txt | 4 + fastlane/metadata/android/de/changelogs/3.txt | 10 + fastlane/metadata/android/de/changelogs/4.txt | 16 + fastlane/metadata/android/de/changelogs/5.txt | 19 + fastlane/metadata/android/de/changelogs/7.txt | 5 + fastlane/metadata/android/de/changelogs/8.txt | 1 + fastlane/metadata/android/de/changelogs/9.txt | 2 + fastlane/metadata/android/de/description.txt | 11 + fastlane/metadata/android/de/summary.txt | 1 + .../metadata/android/en-US/changelogs/1.txt | 7 + .../metadata/android/en-US/changelogs/10.txt | 2 + .../metadata/android/en-US/changelogs/11.txt | 7 + .../metadata/android/en-US/changelogs/12.txt | 4 + .../metadata/android/en-US/changelogs/3.txt | 10 + .../metadata/android/en-US/changelogs/4.txt | 16 + .../metadata/android/en-US/changelogs/5.txt | 19 + .../metadata/android/en-US/changelogs/7.txt | 5 + .../metadata/android/en-US/changelogs/8.txt | 1 + .../metadata/android/en-US/changelogs/9.txt | 2 + .../metadata/android/en-US/description.txt | 11 + .../metadata/android/en-US/featureGraphic.png | Bin 0 -> 32789 bytes fastlane/metadata/android/en-US/icon.png | Bin 0 -> 5905 bytes .../android/en-US/images/featureGraphic.png | Bin 0 -> 32789 bytes .../metadata/android/en-US/images/icon.png | Bin 0 -> 5905 bytes .../android/en-US/phoneScreenshots/00.png | Bin 0 -> 182666 bytes .../android/en-US/phoneScreenshots/01.png | Bin 0 -> 80035 bytes .../android/en-US/phoneScreenshots/02.png | Bin 0 -> 108074 bytes .../android/en-US/phoneScreenshots/03.png | Bin 0 -> 79998 bytes .../android/en-US/phoneScreenshots/04.png | Bin 0 -> 183293 bytes fastlane/metadata/android/en-US/summary.txt | 1 + feature/onboarding/build.gradle.kts | 12 + .../feature/onboarding/OnboardingContent.kt | 171 + .../feature/onboarding/OnboardingScreen.kt | 24 + .../navigation/OnboardingNavigation.kt | 27 + .../src/main/res/values/strings.xml | 7 + gradle.properties | 14 + gradle/libs.versions.toml | 179 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 61574 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 244 + gradlew.bat | 92 + html-cleaner | 3 + images/K-9_Mail-debug.svg | 385 ++ images/K-9_Mail.eps | 651 +++ images/K-9_Mail.svg | 390 ++ images/K-9_Mail_512x512.png | Bin 0 -> 43356 bytes images/drawable-src/btn_edit.png | Bin 0 -> 1917 bytes images/drawable-src/btn_empty_disable.png | Bin 0 -> 3688 bytes images/drawable-src/btn_empty_normal.png | Bin 0 -> 3560 bytes images/drawable-src/btn_empty_pressed.png | Bin 0 -> 3904 bytes images/drawable-src/btn_empty_selected.png | Bin 0 -> 4078 bytes images/drawable-src/ic_action_delete.svg | 11 + .../drawable-src/ic_action_mark_as_read.svg | 10 + .../drawable-src/ic_action_remote_search.ai | 4551 +++++++++++++++++ .../ic_action_request_read_receipt_dark.svg | 98 + .../ic_action_request_read_receipt_light.svg | 95 + .../ic_action_single_message_options.svg | 10 + images/drawable-src/ic_button_archive.ai | 1720 +++++++ images/drawable-src/ic_export.svg | 60 + images/drawable-src/ic_import.svg | 58 + .../drawable-src/ic_launcher_monochrome.svg | 133 + .../ic_notify_check_mail_anim_0.svg | 70 + .../ic_notify_check_mail_anim_0__legacy.svg | 122 + .../ic_notify_check_mail_anim_1.svg | 70 + .../ic_notify_check_mail_anim_1__legacy.svg | 122 + .../ic_notify_check_mail_anim_2.svg | 70 + .../ic_notify_check_mail_anim_2__legacy.svg | 122 + .../ic_notify_check_mail_anim_3.svg | 70 + .../ic_notify_check_mail_anim_3__legacy.svg | 122 + .../ic_notify_check_mail_anim_4.svg | 70 + .../ic_notify_check_mail_anim_4__legacy.svg | 122 + .../ic_notify_check_mail_anim_5.svg | 70 + .../ic_notify_check_mail_anim_5__legacy.svg | 122 + images/drawable-src/ic_notify_new_mail.svg | 69 + .../ic_notify_new_mail__legacy.svg | 84 + images/drawable-src/ic_outbox.svg | 60 + images/drawable-src/ic_unread_widget.svg | 110 + .../ic_unread_widget_selected.svg | 97 + images/drawables-pgp/12x24/status_corner.svg | 13 + images/drawables-pgp/12x24/status_dots_1.svg | 49 + images/drawables-pgp/12x24/status_dots_2.svg | 49 + images/drawables-pgp/12x24/status_dots_3.svg | 49 + .../24x24/bullet_point_negative.svg | 66 + .../24x24/bullet_point_neutral.svg | 3 + .../24x24/bullet_point_positive.svg | 65 + images/drawables-pgp/24x24/compatibility.svg | 1 + images/drawables-pgp/24x24/status_lock.svg | 50 + .../24x24/status_lock_closed.svg | 12 + .../24x24/status_lock_disabled.svg | 79 + .../drawables-pgp/24x24/status_lock_error.svg | 12 + .../drawables-pgp/24x24/status_lock_open.svg | 12 + .../24x24/status_lock_opportunistic.svg | 72 + .../24x24/status_signature_expired_cutout.svg | 12 + .../24x24/status_signature_invalid_cutout.svg | 12 + .../24x24/status_signature_revoked_cutout.svg | 12 + .../24x24/status_signature_unknown_cutout.svg | 12 + .../status_signature_unverified_cutout.svg | 12 + .../status_signature_verified_cutout.svg | 12 + .../36x24/status_check_dots_1.svg | 79 + .../36x24/status_check_dots_2.svg | 85 + .../36x24/status_check_dots_3.svg | 94 + images/drawables-pgp/36x24/status_dots.svg | 49 + .../36x24/status_lock_disabled_dots_1.svg | 40 + .../36x24/status_lock_dots_1.svg | 78 + .../36x24/status_lock_dots_2.svg | 59 + .../36x24/status_lock_dots_3.svg | 65 + .../36x24/status_lock_error_dots_1.svg | 53 + .../36x24/status_lock_none_dots_1.svg | 37 + .../36x24/status_none_dots_1.svg | 62 + .../36x24/status_none_dots_2.svg | 69 + .../36x24/status_none_dots_3.svg | 76 + images/drawables-pgp/docs/disabled.svg | 54 + .../docs/signcrypt_confirmed.svg | 67 + images/drawables-pgp/docs/signcrypt_error.svg | 91 + .../docs/signcrypt_unconfirmed.svg | 67 + .../drawables-pgp/docs/signcrypt_unknown.svg | 80 + images/drawer_header_background.svg | 263 + images/drawer_header_background_generate.sh | 11 + images/feature_graphic.png | Bin 0 -> 48209 bytes images/feature_graphic.svg | 923 ++++ images/show_more_indicator.svg | 20 + mail/common/build.gradle.kts | 26 + .../java/com/fsck/k9/helper/EmailHelper.kt | 15 + .../com/fsck/k9/helper/ExceptionHelper.java | 28 + .../main/java/com/fsck/k9/logging/Logger.kt | 26 + .../java/com/fsck/k9/logging/NoOpLogger.kt | 36 + .../main/java/com/fsck/k9/logging/Timber.kt | 83 + .../main/java/com/fsck/k9/mail/Address.java | 311 ++ .../main/java/com/fsck/k9/mail/AuthType.java | 34 + .../java/com/fsck/k9/mail/Authentication.java | 93 + .../k9/mail/AuthenticationFailedException.kt | 9 + .../src/main/java/com/fsck/k9/mail/Body.java | 26 + .../java/com/fsck/k9/mail/BodyFactory.java | 10 + .../main/java/com/fsck/k9/mail/BodyPart.java | 25 + .../com/fsck/k9/mail/BoundaryGenerator.kt | 33 + .../k9/mail/CertificateChainException.java | 27 + .../mail/CertificateValidationException.java | 127 + .../com/fsck/k9/mail/ConnectionSecurity.java | 7 + .../com/fsck/k9/mail/DefaultBodyFactory.java | 43 + .../java/com/fsck/k9/mail/FetchProfile.java | 59 + .../src/main/java/com/fsck/k9/mail/Flag.java | 70 + .../java/com/fsck/k9/mail/FolderClass.java | 5 + .../java/com/fsck/k9/mail/FolderType.java | 12 + .../src/main/java/com/fsck/k9/mail/Header.kt | 3 + .../main/java/com/fsck/k9/mail/K9MailLib.java | 101 + .../main/java/com/fsck/k9/mail/Message.java | 171 + .../com/fsck/k9/mail/MessageDownloadState.kt | 7 + .../k9/mail/MessageRetrievalListener.java | 7 + .../com/fsck/k9/mail/MessagingException.java | 35 + .../main/java/com/fsck/k9/mail/MimeType.kt | 44 + .../main/java/com/fsck/k9/mail/Multipart.java | 56 + .../java/com/fsck/k9/mail/NetworkTimeouts.kt | 6 + .../src/main/java/com/fsck/k9/mail/Part.java | 46 + .../java/com/fsck/k9/mail/ServerSettings.kt | 33 + .../java/com/fsck/k9/mail/filter/Base64.java | 785 +++ .../k9/mail/filter/Base64OutputStream.java | 183 + .../k9/mail/filter/CountingOutputStream.java | 34 + .../filter/EOLConvertingOutputStream.java | 64 + .../mail/filter/FixedLengthInputStream.java | 78 + .../main/java/com/fsck/k9/mail/filter/Hex.kt | 58 + .../k9/mail/filter/LineWrapOutputStream.java | 85 + .../k9/mail/filter/PeekableInputStream.java | 68 + .../k9/mail/filter/SignSafeOutputStream.java | 213 + .../fsck/k9/mail/filter/SmtpDataStuffing.java | 33 + .../fsck/k9/mail/helper/FetchProfileHelper.kt | 11 + .../com/fsck/k9/mail/helper/Rfc822Token.java | 205 + .../fsck/k9/mail/helper/Rfc822Tokenizer.java | 313 ++ .../java/com/fsck/k9/mail/helper/TextUtils.kt | 11 + .../k9/mail/helper/UrlEncodingHelper.java | 28 + .../main/java/com/fsck/k9/mail/helper/Utf8.kt | 115 + .../k9/mail/internet/AddressHeaderBuilder.kt | 35 + .../k9/mail/internet/BinaryTempFileBody.java | 149 + .../internet/BinaryTempFileMessageBody.java | 25 + .../fsck/k9/mail/internet/CharsetSupport.java | 1096 ++++ .../com/fsck/k9/mail/internet/DecoderUtil.kt | 201 + .../fsck/k9/mail/internet/EncoderUtil.java | 184 + .../k9/mail/internet/FlowedMessageUtils.kt | 83 + .../k9/mail/internet/FormatFlowedHelper.kt | 28 + .../java/com/fsck/k9/mail/internet/Headers.kt | 36 + .../Iso2022JpToShiftJisInputStream.java | 76 + .../com/fsck/k9/mail/internet/JisSupport.java | 112 + .../k9/mail/internet/MessageExtractor.java | 456 ++ .../k9/mail/internet/MessageIdGenerator.kt | 33 + .../fsck/k9/mail/internet/MessageIdParser.kt | 205 + .../fsck/k9/mail/internet/MimeBodyPart.java | 186 + .../fsck/k9/mail/internet/MimeExtensions.kt | 63 + .../com/fsck/k9/mail/internet/MimeHeader.kt | 146 + .../k9/mail/internet/MimeHeaderChecker.kt | 115 + .../k9/mail/internet/MimeHeaderEncoder.kt | 26 + .../fsck/k9/mail/internet/MimeHeaderParser.kt | 187 + .../fsck/k9/mail/internet/MimeMessage.java | 648 +++ .../k9/mail/internet/MimeMessageHelper.java | 56 + .../fsck/k9/mail/internet/MimeMultipart.java | 111 + .../k9/mail/internet/MimeParameterDecoder.kt | 310 ++ .../k9/mail/internet/MimeParameterEncoder.kt | 211 + .../fsck/k9/mail/internet/MimeUtility.java | 226 + .../com/fsck/k9/mail/internet/MimeValue.kt | 8 + .../fsck/k9/mail/internet/ParameterSection.kt | 32 + .../fsck/k9/mail/internet/PartExtensions.kt | 25 + .../fsck/k9/mail/internet/RawDataBody.java | 12 + .../com/fsck/k9/mail/internet/SizeAware.java | 6 + .../com/fsck/k9/mail/internet/TextBody.java | 133 + .../com/fsck/k9/mail/internet/Viewable.java | 104 + .../k9/mail/message/MessageHeaderParser.kt | 52 + .../k9/mail/oauth/OAuth2TokenProvider.java | 29 + .../k9/mail/oauth/XOAuth2ChallengeParser.java | 43 + .../fsck/k9/mail/oauth/XOAuth2Response.java | 6 + .../com/fsck/k9/mail/power/PowerManager.kt | 5 + .../java/com/fsck/k9/mail/power/WakeLock.kt | 8 + .../k9/mail/ssl/KeyStoreDirectoryProvider.kt | 7 + .../com/fsck/k9/mail/ssl/LocalKeyStore.kt | 162 + .../fsck/k9/mail/ssl/TrustManagerFactory.java | 119 + .../k9/mail/ssl/TrustedSocketFactory.java | 13 + .../fsck/k9/mailstore/BinaryMemoryBody.java | 48 + .../main/java/com/fsck/k9/sasl/OAuthBearer.kt | 15 + .../java/com/fsck/k9/mail/AddressTest.java | 154 + .../com/fsck/k9/mail/Address_quoteAtoms.java | 67 + .../com/fsck/k9/mail/BoundaryGeneratorTest.kt | 40 + .../test/java/com/fsck/k9/mail/MessageTest.kt | 341 ++ .../java/com/fsck/k9/mail/MimeTypeTest.kt | 82 + .../filter/EOLConvertingOutputStreamTest.java | 125 + .../filter/FixedLengthInputStreamTest.java | 273 + .../mail/filter/SignSafeOutputStreamTest.java | 86 + .../k9/mail/filter/SmtpDataStuffingTest.java | 55 + .../mail/internet/AddressHeaderBuilderTest.kt | 58 + .../k9/mail/internet/CharsetSupportTest.java | 122 + .../k9/mail/internet/DecoderUtilTest.java | 238 + .../fsck/k9/mail/internet/EncoderUtilTest.kt | 33 + .../mail/internet/FlowedMessageUtilsTest.kt | 217 + .../mail/internet/FormatFlowedHelperTest.kt | 66 + .../mail/internet/MessageExtractorTest.java | 119 + .../mail/internet/MessageIdGeneratorTest.kt | 45 + .../k9/mail/internet/MessageIdParserTest.kt | 173 + .../k9/mail/internet/MimeHeaderCheckerTest.kt | 210 + .../mail/internet/MimeMessageParseTest.java | 330 ++ .../mail/internet/MimeParameterDecoderTest.kt | 390 ++ .../mail/internet/MimeParameterEncoderTest.kt | 166 + .../k9/mail/internet/MimeUtilityTest.java | 95 + .../k9/mail/internet/PartExtensionsTest.kt | 55 + .../k9/mail/internet/TestCharsetProvider.kt | 28 + .../fsck/k9/mail/internet/TextBodyTest.java | 31 + .../com/fsck/k9/mail/ssl/LocalKeyStoreTest.kt | 91 + .../java.nio.charset.spi.CharsetProvider | 1 + .../mail.another-domain.example.pem | 31 + .../certificates/mail.domain.example.pem | 18 + mail/protocols/imap/build.gradle.kts | 23 + .../k9/mail/store/imap/AlertResponse.java | 26 + .../fsck/k9/mail/store/imap/Capabilities.java | 20 + .../mail/store/imap/CapabilityResponse.java | 64 + .../com/fsck/k9/mail/store/imap/Commands.java | 23 + .../k9/mail/store/imap/FetchBodyCallback.java | 34 + .../k9/mail/store/imap/FetchPartCallback.java | 34 + .../fsck/k9/mail/store/imap/FolderListItem.kt | 10 + .../k9/mail/store/imap/FolderNameCodec.kt | 27 + .../store/imap/FolderNotFoundException.java | 19 + .../com/fsck/k9/mail/store/imap/IdGrouper.kt | 62 + .../k9/mail/store/imap/IdleRefreshManager.kt | 11 + .../store/imap/IdleRefreshTimeoutProvider.kt | 5 + .../mail/store/imap/ImapCommandSplitter.java | 64 + .../fsck/k9/mail/store/imap/ImapConnection.kt | 47 + .../mail/store/imap/ImapConnectionManager.kt | 10 + .../mail/store/imap/ImapConnectionProvider.kt | 5 + .../com/fsck/k9/mail/store/imap/ImapFolder.kt | 92 + .../k9/mail/store/imap/ImapFolderIdler.kt | 41 + .../com/fsck/k9/mail/store/imap/ImapList.java | 163 + .../fsck/k9/mail/store/imap/ImapMessage.java | 15 + .../fsck/k9/mail/store/imap/ImapResponse.java | 62 + .../mail/store/imap/ImapResponseCallback.java | 24 + .../mail/store/imap/ImapResponseParser.java | 487 ++ .../imap/ImapResponseParserException.java | 8 + .../fsck/k9/mail/store/imap/ImapSettings.kt | 22 + .../com/fsck/k9/mail/store/imap/ImapStore.kt | 29 + .../k9/mail/store/imap/ImapStoreConfig.kt | 7 + .../k9/mail/store/imap/ImapStoreSettings.kt | 27 + .../fsck/k9/mail/store/imap/ImapUtility.java | 198 + .../k9/mail/store/imap/InternalImapStore.kt | 11 + .../fsck/k9/mail/store/imap/ListResponse.java | 105 + .../k9/mail/store/imap/NamespaceResponse.java | 62 + .../imap/NegativeImapResponseException.java | 43 + .../store/imap/PermanentFlagsResponse.java | 86 + .../k9/mail/store/imap/RealImapConnection.kt | 910 ++++ .../fsck/k9/mail/store/imap/RealImapFolder.kt | 1222 +++++ .../k9/mail/store/imap/RealImapFolderIdler.kt | 195 + .../fsck/k9/mail/store/imap/RealImapStore.kt | 321 ++ .../store/imap/ResponseCodeExtractor.java | 19 + .../mail/store/imap/ResponseTextExtractor.kt | 27 + .../fsck/k9/mail/store/imap/Responses.java | 20 + .../k9/mail/store/imap/SearchResponse.java | 50 + .../store/imap/SelectOrExamineResponse.java | 54 + .../k9/mail/store/imap/UidCopyResponse.java | 59 + .../store/imap/UidSearchCommandBuilder.java | 103 + .../k9/mail/store/imap/UidValidityResponse.kt | 24 + .../k9/mail/store/imap/UntaggedHandler.java | 7 + .../k9/mail/store/imap/AlertResponseTest.java | 66 + .../store/imap/CapabilityResponseTest.java | 124 + .../k9/mail/store/imap/FolderNameCodecTest.kt | 29 + .../fsck/k9/mail/store/imap/IdGrouperTest.kt | 57 + .../store/imap/ImapCommandSplitterTest.kt | 79 + .../fsck/k9/mail/store/imap/ImapListTest.java | 142 + .../mail/store/imap/ImapResponseHelper.java | 38 + .../mail/store/imap/ImapResponseParserTest.kt | 521 ++ .../k9/mail/store/imap/ImapUtilityTest.java | 146 + .../k9/mail/store/imap/ListResponseTest.java | 99 + .../store/imap/NamespaceResponseTest.java | 103 + .../imap/PermanentFlagsResponseTest.java | 109 + .../mail/store/imap/RealImapConnectionTest.kt | 1136 ++++ .../store/imap/RealImapFolderIdlerTest.kt | 344 ++ .../k9/mail/store/imap/RealImapFolderTest.kt | 1235 +++++ .../k9/mail/store/imap/RealImapStoreTest.kt | 446 ++ .../store/imap/ResponseCodeExtractorTest.java | 38 + .../store/imap/ResponseTextExtractorTest.kt | 45 + .../mail/store/imap/SearchResponseTest.java | 89 + .../imap/SelectOrExamineResponseTest.java | 96 + .../k9/mail/store/imap/SimpleImapSettings.kt | 21 + .../mail/store/imap/TestIdleRefreshManager.kt | 35 + .../k9/mail/store/imap/TestImapConnection.kt | 135 + .../fsck/k9/mail/store/imap/TestImapFolder.kt | 126 + .../fsck/k9/mail/store/imap/TestImapStore.kt | 24 + .../fsck/k9/mail/store/imap/TestWakeLock.kt | 47 + .../mail/store/imap/UidCopyResponseTest.java | 139 + .../imap/UidSearchCommandBuilderTest.java | 38 + .../store/imap/UidValidityResponseTest.kt | 63 + .../store/imap/mockserver/MockImapServer.java | 420 ++ mail/protocols/pop3/build.gradle.kts | 19 + .../k9/mail/store/pop3/Pop3Capabilities.java | 22 + .../fsck/k9/mail/store/pop3/Pop3Commands.java | 26 + .../k9/mail/store/pop3/Pop3Connection.java | 444 ++ .../k9/mail/store/pop3/Pop3ErrorResponse.java | 16 + .../fsck/k9/mail/store/pop3/Pop3Folder.java | 527 ++ .../fsck/k9/mail/store/pop3/Pop3Message.java | 16 + .../store/pop3/Pop3ResponseInputStream.java | 36 + .../fsck/k9/mail/store/pop3/Pop3Settings.java | 22 + .../fsck/k9/mail/store/pop3/Pop3Store.java | 100 + .../k9/mail/store/pop3/MockPop3Server.java | 420 ++ .../mail/store/pop3/Pop3CapabilitiesTest.java | 19 + .../k9/mail/store/pop3/Pop3ConnectionTest.kt | 414 ++ .../fsck/k9/mail/store/pop3/Pop3FolderTest.kt | 264 + .../fsck/k9/mail/store/pop3/Pop3StoreTest.kt | 119 + .../mail/store/pop3/SimplePop3Settings.java | 72 + mail/protocols/smtp/build.gradle.kts | 21 + .../mail/transport/smtp/EnhancedStatusCode.kt | 7 + .../smtp/NegativeSmtpReplyException.kt | 23 + .../mail/transport/smtp/SmtpHelloResponse.kt | 8 + .../fsck/k9/mail/transport/smtp/SmtpLogger.kt | 9 + .../k9/mail/transport/smtp/SmtpResponse.kt | 59 + .../mail/transport/smtp/SmtpResponseParser.kt | 418 ++ .../smtp/SmtpResponseParserException.kt | 3 + .../k9/mail/transport/smtp/SmtpTransport.kt | 690 +++ .../k9/mail/transport/smtp/StatusCodeClass.kt | 7 + .../transport/mockServer/MockSmtpServer.java | 425 ++ .../transport/smtp/SmtpResponseParserTest.kt | 727 +++ .../mail/transport/smtp/SmtpResponseTest.kt | 173 + .../mail/transport/smtp/SmtpTransportTest.kt | 990 ++++ .../k9/mail/transport/smtp/TestSmtpLogger.kt | 15 + mail/protocols/webdav/build.gradle.kts | 20 + .../k9/mail/store/webdav/ConnectionInfo.java | 11 + .../fsck/k9/mail/store/webdav/DataSet.java | 204 + .../store/webdav/DraftsFolderProvider.java | 5 + .../k9/mail/store/webdav/HttpGeneric.java | 83 + .../store/webdav/ParsedMessageEnvelope.java | 70 + .../k9/mail/store/webdav/SniHostSetter.java | 10 + .../k9/mail/store/webdav/WebDavConstants.java | 24 + .../k9/mail/store/webdav/WebDavFolder.java | 648 +++ .../k9/mail/store/webdav/WebDavHandler.java | 51 + .../mail/store/webdav/WebDavHttpClient.java | 68 + .../k9/mail/store/webdav/WebDavMessage.java | 91 + .../store/webdav/WebDavSocketFactory.java | 72 + .../k9/mail/store/webdav/WebDavStore.java | 955 ++++ .../store/webdav/WebDavStoreSettings.java | 49 + .../mail/store/webdav/WebDavFolderTest.java | 517 ++ .../mail/store/webdav/WebDavMessageTest.java | 51 + .../k9/mail/store/webdav/WebDavStoreTest.java | 376 ++ mail/testing/build.gradle.kts | 17 + .../com/fsck/k9/mail/MessageBuilderDsl.kt | 65 + .../java/com/fsck/k9/mail/StringHelper.kt | 5 + .../java/com/fsck/k9/mail/SystemOutLogger.kt | 70 + .../k9/mail/TestMessageConstructionUtils.java | 59 + .../k9/mail/XOAuth2ChallengeParserTest.java | 15 + .../k9/mail/helpers/KeyStoreProvider.java | 61 + .../com/fsck/k9/mail/helpers/TestMessage.java | 75 + .../k9/mail/helpers/TestMessageBuilder.java | 37 + .../helpers/TestTrustedSocketFactory.java | 46 + .../helpers/VeryTrustingTrustManager.java | 33 + mail/testing/src/main/resources/keystore.jks | Bin 0 -> 2254 bytes plugins/openpgp-api-lib/CHANGELOG.md | 48 + plugins/openpgp-api-lib/LICENSE | 202 + plugins/openpgp-api-lib/README.md | 169 + .../openpgp-api/build.gradle.kts | 17 + .../openintents/openpgp/IOpenPgpService.aidl | 27 + .../openintents/openpgp/IOpenPgpService2.aidl | 30 + .../openpgp/AutocryptPeerUpdate.java | 131 + .../openpgp/OpenPgpApiManager.java | 250 + .../openpgp/OpenPgpDecryptionResult.java | 121 + .../org/openintents/openpgp/OpenPgpError.java | 119 + .../openintents/openpgp/OpenPgpMetadata.java | 148 + .../openpgp/OpenPgpSignatureResult.java | 238 + .../openintents/openpgp/util/OpenPgpApi.java | 664 +++ .../openpgp/util/OpenPgpKeyPreference.java | 334 ++ .../openpgp/util/OpenPgpProviderUtil.java | 62 + .../util/OpenPgpServiceConnection.java | 124 + .../openpgp/util/OpenPgpUtils.java | 145 + .../util/ParcelFileDescriptorUtil.java | 178 + .../ic_action_cancel_launchersize.png | Bin 0 -> 1520 bytes .../ic_action_cancel_launchersize_light.png | Bin 0 -> 1940 bytes .../ic_action_cancel_launchersize.png | Bin 0 -> 1032 bytes .../ic_action_cancel_launchersize_light.png | Bin 0 -> 1098 bytes .../ic_action_cancel_launchersize.png | Bin 0 -> 1570 bytes .../ic_action_cancel_launchersize_light.png | Bin 0 -> 2039 bytes .../ic_action_cancel_launchersize.png | Bin 0 -> 2345 bytes .../ic_action_cancel_launchersize_light.png | Bin 0 -> 2404 bytes .../src/main/res/values-ar/strings.xml | 2 + .../src/main/res/values-bg/strings.xml | 2 + .../src/main/res/values-cs/strings.xml | 5 + .../src/main/res/values-de/strings.xml | 7 + .../src/main/res/values-es/strings.xml | 7 + .../src/main/res/values-et/strings.xml | 2 + .../src/main/res/values-eu/strings.xml | 7 + .../src/main/res/values-fi/strings.xml | 2 + .../src/main/res/values-fr/strings.xml | 7 + .../src/main/res/values-is/strings.xml | 2 + .../src/main/res/values-it/strings.xml | 5 + .../src/main/res/values-ja/strings.xml | 7 + .../src/main/res/values-nl/strings.xml | 7 + .../src/main/res/values-pl/strings.xml | 5 + .../src/main/res/values-pt/strings.xml | 2 + .../src/main/res/values-ro/strings.xml | 2 + .../src/main/res/values-ru/strings.xml | 7 + .../src/main/res/values-sl/strings.xml | 7 + .../src/main/res/values-sr/strings.xml | 7 + .../src/main/res/values-sv/strings.xml | 5 + .../src/main/res/values-tr/strings.xml | 5 + .../src/main/res/values-uk/strings.xml | 5 + .../src/main/res/values-zh-rTW/strings.xml | 4 + .../src/main/res/values-zh/strings.xml | 5 + .../src/main/res/values/strings.xml | 13 + settings.gradle.kts | 85 + tools/debian_build.sh | 68 + tools/fix_all_transifex_output.sh | 11 + tools/fix_transifex_output.sh | 26 + ui-flows/README.md | 30 + .../screenshots/user_manual_account_setup.yml | 158 + ui-flows/screenshots/user_manual_accounts.yml | 121 + ui-flows/screenshots/user_manual_reading.yml | 84 + ui-flows/shared/add_contact.yml | 23 + ...ge_display_settings_show_contact_names.yml | 21 + ui-flows/shared/close_display_settings.yml | 9 + ui-flows/shared/login_demo_account.yml | 29 + ui-flows/shared/open_display_settings.yml | 22 + ui-flows/shared/open_message_details.yml | 14 + ui-flows/shared/remove_contact.yml | 20 + ui-flows/validate/compose_simple_message.yml | 60 + .../message_details_show_contact_names.yml | 92 + ui-utils/ItemTouchHelper/build.gradle.kts | 11 + .../itemtouchhelper/ItemTouchHelper.java | 2638 ++++++++++ .../itemtouchhelper/ItemTouchUIUtilImpl.java | 93 + ui-utils/LinearLayoutManager/build.gradle.kts | 11 + .../linearlayoutmanager/LayoutManager.java | 102 + .../LinearLayoutManager.java | 2635 ++++++++++ .../linearlayoutmanager/ScrollbarHelper.java | 105 + .../linearlayoutmanager/ViewBoundsCheck.java | 269 + ui-utils/ToolbarBottomSheet/build.gradle.kts | 11 + .../LayoutAwareBottomSheetBehavior.kt | 52 + .../LayoutAwareBottomSheetCallback.kt | 13 + .../bottomsheet/ToolbarBottomSheetDialog.kt | 422 ++ .../ToolbarBottomSheetDialogFragment.kt | 106 + .../res/layout/design_bottom_sheet_dialog.xml | 62 + user-manual/README.md | 28 + user-manual/build_images.sh | 28 + user-manual/process_screenshots.sh | 44 + 2161 files changed, 246605 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .tx/config create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 app-ui-catalog/README.md create mode 100644 app-ui-catalog/build.gradle.kts create mode 100644 app-ui-catalog/proguard-rules.pro create mode 100644 app-ui-catalog/src/main/AndroidManifest.xml create mode 100644 app-ui-catalog/src/main/ic_launcher-playstore.png create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogActivity.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogContent.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogScreen.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogTheme.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeSelector.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeSwitch.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeVariant.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeVariantSelector.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ButtonItems.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ColorItems.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ImageItems.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SectionHeaderItem.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SectionSubtitleItem.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SelectionControlItems.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/TextFieldItems.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ThemeHeaderItem.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ThemeSelectorItems.kt create mode 100644 app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/TypographyItems.kt create mode 100644 app-ui-catalog/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 app-ui-catalog/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app-ui-catalog/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app-ui-catalog/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app-ui-catalog/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app-ui-catalog/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 app-ui-catalog/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app-ui-catalog/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app-ui-catalog/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 app-ui-catalog/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app-ui-catalog/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app-ui-catalog/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 app-ui-catalog/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app-ui-catalog/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app-ui-catalog/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 app-ui-catalog/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app-ui-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app-ui-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 app-ui-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app-ui-catalog/src/main/res/values/colors.xml create mode 100644 app-ui-catalog/src/main/res/values/strings.xml create mode 100644 app-ui-catalog/src/main/res/values/themes.xml create mode 100644 app/autodiscovery/api/build.gradle.kts create mode 100644 app/autodiscovery/api/src/main/java/com/fsck/k9/autodiscovery/api/ConnectionSettingsDiscovery.kt create mode 100644 app/autodiscovery/providersxml/build.gradle.kts create mode 100644 app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/KoinModule.kt create mode 100644 app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscovery.kt create mode 100644 app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlProvider.kt create mode 100644 app/autodiscovery/providersxml/src/main/res/xml/providers.xml create mode 100644 app/autodiscovery/providersxml/src/test/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscoveryTest.kt create mode 100644 app/autodiscovery/srvrecords/build.gradle.kts create mode 100644 app/autodiscovery/srvrecords/src/main/java/com/fsck/k9/autodiscovery/srvrecords/MiniDnsSrvResolver.kt create mode 100644 app/autodiscovery/srvrecords/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvResolver.kt create mode 100644 app/autodiscovery/srvrecords/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscovery.kt create mode 100644 app/autodiscovery/srvrecords/src/test/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscoveryTest.kt create mode 100644 app/autodiscovery/thunderbird/build.gradle.kts create mode 100644 app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigFetcher.kt create mode 100644 app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigParser.kt create mode 100644 app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigUrlProvider.kt create mode 100644 app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdDiscovery.kt create mode 100644 app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigFetcherTest.kt create mode 100644 app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigTest.kt create mode 100644 app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigUrlProviderTest.kt create mode 100644 app/core/build.gradle.kts create mode 100644 app/core/src/main/AndroidManifest.xml create mode 100644 app/core/src/main/java/com/fsck/k9/Account.kt create mode 100644 app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt create mode 100644 app/core/src/main/java/com/fsck/k9/AccountRemovedListener.kt create mode 100644 app/core/src/main/java/com/fsck/k9/AccountsChangeListener.java create mode 100644 app/core/src/main/java/com/fsck/k9/ActivityExtensions.kt create mode 100644 app/core/src/main/java/com/fsck/k9/AppConfig.kt create mode 100644 app/core/src/main/java/com/fsck/k9/BaseAccount.kt create mode 100644 app/core/src/main/java/com/fsck/k9/Core.kt create mode 100644 app/core/src/main/java/com/fsck/k9/CoreKoinModules.kt create mode 100644 app/core/src/main/java/com/fsck/k9/CoreResourceProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/DI.kt create mode 100644 app/core/src/main/java/com/fsck/k9/EmailAddressValidator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/FontSizes.java create mode 100644 app/core/src/main/java/com/fsck/k9/Identity.kt create mode 100644 app/core/src/main/java/com/fsck/k9/K9.kt create mode 100644 app/core/src/main/java/com/fsck/k9/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/LocalKeyStoreManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/NotificationLight.kt create mode 100644 app/core/src/main/java/com/fsck/k9/NotificationSettings.kt create mode 100644 app/core/src/main/java/com/fsck/k9/NotificationVibration.kt create mode 100644 app/core/src/main/java/com/fsck/k9/Preferences.kt create mode 100644 app/core/src/main/java/com/fsck/k9/QuietTimeChecker.java create mode 100644 app/core/src/main/java/com/fsck/k9/ServerSettingsSerializer.kt create mode 100644 app/core/src/main/java/com/fsck/k9/StrictMode.kt create mode 100644 app/core/src/main/java/com/fsck/k9/SwipeAction.kt create mode 100644 app/core/src/main/java/com/fsck/k9/TimberLogger.kt create mode 100644 app/core/src/main/java/com/fsck/k9/UiDensity.kt create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptDraftStateHeader.kt create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptDraftStateHeaderParser.kt create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptGossipHeader.java create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptGossipHeaderParser.java create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptHeader.java create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptHeaderParser.java create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptOpenPgpApiInteractor.java create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptOperations.java create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptStringProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptTransferMessageCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/autocrypt/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/backend/BackendFactory.kt create mode 100644 app/core/src/main/java/com/fsck/k9/backend/BackendManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/contact/ContactIntentHelper.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/ControllerExtension.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/MemorizingMessagingListener.java create mode 100644 app/core/src/main/java/com/fsck/k9/controller/MessageCountsProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/MessageReference.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/MessageReferenceHelper.java create mode 100644 app/core/src/main/java/com/fsck/k9/controller/MessagingController.java create mode 100644 app/core/src/main/java/com/fsck/k9/controller/MessagingControllerCommands.java create mode 100644 app/core/src/main/java/com/fsck/k9/controller/MessagingListener.java create mode 100644 app/core/src/main/java/com/fsck/k9/controller/NotificationOperations.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/NotificationState.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/PendingCommandSerializer.java create mode 100644 app/core/src/main/java/com/fsck/k9/controller/Preconditions.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/ProgressBodyFactory.java create mode 100644 app/core/src/main/java/com/fsck/k9/controller/SimpleMessagingListener.java create mode 100644 app/core/src/main/java/com/fsck/k9/controller/UidReverseComparator.java create mode 100644 app/core/src/main/java/com/fsck/k9/controller/push/AccountPushController.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/push/AccountPushControllerFactory.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/push/AutoSyncManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/push/BootCompleteReceiver.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/push/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/push/PushController.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/push/PushService.kt create mode 100644 app/core/src/main/java/com/fsck/k9/controller/push/PushServiceManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/crypto/EncryptionExtractor.kt create mode 100644 app/core/src/main/java/com/fsck/k9/crypto/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/crypto/MessageCryptoStructureDetector.java create mode 100644 app/core/src/main/java/com/fsck/k9/crypto/OpenPgpApiHelper.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/AlarmManagerCompat.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/AndroidKeyStoreDirectoryProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/ClipboardManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/CollectionExtensions.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/ContactNameProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/Contacts.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/CrLfConverter.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/DefaultTrustedSocketFactory.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/FileHelper.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/IdentityHelper.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/KeyChainKeyManager.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/ListHeaders.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/ListUnsubscribeHelper.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/MailTo.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/MessageHelper.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/MimeTypeUtil.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/MutableBoolean.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/NamedThreadFactory.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/ParcelableUtil.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/PendingIntentCompat.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/ReplyToParser.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/RetainFragment.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/SimpleTextWatcher.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/SingleLiveEvent.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/StringHelper.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/Timing.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/UnsubscribeUri.kt create mode 100644 app/core/src/main/java/com/fsck/k9/helper/UrlEncodingHelper.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/Utility.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/jsoup/AdvancedNodeTraversor.java create mode 100644 app/core/src/main/java/com/fsck/k9/helper/jsoup/NodeFilter.java create mode 100644 app/core/src/main/java/com/fsck/k9/job/K9JobManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/job/K9WorkerFactory.kt create mode 100644 app/core/src/main/java/com/fsck/k9/job/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/job/MailSyncWorker.kt create mode 100644 app/core/src/main/java/com/fsck/k9/job/MailSyncWorkerManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/job/WorkManagerConfigurationProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/logging/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/logging/LogFileWriter.kt create mode 100644 app/core/src/main/java/com/fsck/k9/logging/ProcessExecutor.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mail/MailServerDirection.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/AttachmentResolver.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/AttachmentViewInfo.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/AutoExpandFolderBackendFoldersRefreshListener.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/BackendFoldersRefreshListener.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/BinaryAttachmentBody.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/CacheAwareMessageMapper.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/CreateFolderInfo.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/CryptoResultAnnotation.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/DatabasePreviewType.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/DeferredFileBody.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/FileBackedBody.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/FolderMapper.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/FolderNotFoundException.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/FolderRepository.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/FolderSettings.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/FolderSettingsProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/FolderTypeConverter.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/K9BackendFolder.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/K9BackendStorage.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/K9BackendStorageFactory.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/ListenableMessageStore.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/LocalBodyPart.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/LocalFolder.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/LocalMimeMessage.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/LocalPart.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/LocalStoreProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/LockableDatabase.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageColumns.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageCryptoAnnotations.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageDetails.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageHelper.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageListCache.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageListRepository.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageMapper.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageNotFoundException.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageRepository.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageStoreFactory.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageStoreManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageViewInfo.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageViewInfoExtractor.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MessageViewInfoExtractorFactory.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MigrationsHelper.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MimePartStreamParser.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/MoreMessages.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/NotificationMessage.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/NotifierMessageStore.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/OutboxState.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/OutboxStateRepository.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/SaveMessageData.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/SaveMessageDataCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/SchemaDefinitionFactory.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/SearchStatusManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/SendState.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/SpecialFolderBackendFoldersRefreshListener.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/SpecialFolderSelectionStrategy.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/SpecialFolderUpdater.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/SpecialLocalFoldersCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/StorageManager.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/TempFileBody.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/ThreadInfo.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/util/DeferredFileOutputStream.java create mode 100644 app/core/src/main/java/com/fsck/k9/mailstore/util/FileFactory.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/Attachment.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/AutocryptStatusInteractor.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/ComposePgpEnableByDefaultDecider.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/ComposePgpInlineDecider.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/CryptoStatus.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/IdentityField.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/IdentityHeaderBuilder.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/IdentityHeaderParser.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/MessageBuilder.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/PgpMessageBuilder.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/QuotedTextMode.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/ReplyActionStrategy.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/SimpleMessageBuilder.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/SimpleMessageFormat.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/TextBodyBuilder.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/AttachmentCounter.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/AttachmentInfoExtractor.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/BasicPartInfoExtractor.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/BodyTextExtractor.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/MessageFulltextCreator.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/MessagePreviewCreator.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/PreviewExtractionException.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/PreviewResult.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/PreviewTextExtractor.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/extractors/TextPartFinder.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/DisplayHtml.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/DisplayHtmlFactory.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/DividerReplacer.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/EmailSection.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/EmailSectionExtractor.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/EmailTextToHtml.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/GenericUriParser.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/HtmlConverter.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/HtmlModification.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/HtmlProcessorFactory.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/HtmlSettings.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/HtmlToPlainText.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/HttpUriParser.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/SignatureWrapper.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/TextToHtml.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/UriLinkifier.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/UriMatch.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/html/UriParser.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/quote/HtmlQuoteCreator.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/quote/InsertableHtmlContent.java create mode 100644 app/core/src/main/java/com/fsck/k9/message/quote/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/quote/QuoteDateFormatter.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/quote/TextQuoteCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.kt create mode 100644 app/core/src/main/java/com/fsck/k9/message/signature/TextSignatureRemover.java create mode 100644 app/core/src/main/java/com/fsck/k9/network/ConnectivityManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi21.kt create mode 100644 app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi23.kt create mode 100644 app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi24.kt create mode 100644 app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerBase.kt create mode 100644 app/core/src/main/java/com/fsck/k9/network/KointModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/AddNotificationResult.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotificationController.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/BaseNotificationDataCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotificationController.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/CoreKoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/LockScreenNotificationCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationChannelManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationConfigurationConverter.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationContent.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationData.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationHelper.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationLightDecoder.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationSettingsUpdater.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationStore.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationStoreOperation.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationStoreProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationStrategy.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/NotificationVibrationDecoder.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/PushNotificationManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/RemoveNotificationsResult.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/SendFailedNotificationController.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt create mode 100644 app/core/src/main/java/com/fsck/k9/notification/SyncNotificationController.kt create mode 100644 app/core/src/main/java/com/fsck/k9/oauth/OAuthConfiguration.kt create mode 100644 app/core/src/main/java/com/fsck/k9/oauth/OAuthConfigurationProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/power/AndroidPowerManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/power/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/AccountManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/AccountSettingsDescriptions.java create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsDescriptions.java create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsProvider.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/GeneralSettings.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/IdentitySettingsDescriptions.java create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/Protocols.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/ServerTypeConverter.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/Settings.java create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/SettingsExporter.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/SettingsImportExportException.java create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/Storage.java create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/StorageEditor.kt create mode 100644 app/core/src/main/java/com/fsck/k9/preferences/StoragePersister.kt create mode 100644 app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java create mode 100644 app/core/src/main/java/com/fsck/k9/provider/AttachmentTempFileProvider.java create mode 100644 app/core/src/main/java/com/fsck/k9/provider/DecryptedFileProvider.java create mode 100644 app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java create mode 100644 app/core/src/main/java/com/fsck/k9/search/AccountSearchConditions.kt create mode 100644 app/core/src/main/java/com/fsck/k9/search/ConditionsTreeNode.java create mode 100644 app/core/src/main/java/com/fsck/k9/search/LocalSearch.java create mode 100644 app/core/src/main/java/com/fsck/k9/search/LocalSearchExtensions.kt create mode 100644 app/core/src/main/java/com/fsck/k9/search/SearchAccount.kt create mode 100644 app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java create mode 100644 app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java create mode 100644 app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java create mode 100644 app/core/src/main/java/com/fsck/k9/setup/ServerNameSuggester.kt create mode 100644 app/core/src/main/res/values/arrays_account_settings_values.xml create mode 100644 app/core/src/main/res/values/arrays_drawer.xml create mode 100644 app/core/src/main/res/values/arrays_general_settings_values.xml create mode 100644 app/core/src/main/res/values/material_colors.xml create mode 100644 app/core/src/main/res/xml/decrypted_file_provider_paths.xml create mode 100644 app/core/src/main/res/xml/temp_file_provider_paths.xml create mode 100644 app/core/src/test/java/com/fsck/k9/EmailAddressValidatorTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/K9RobolectricTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/QuietTimeCheckerTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/ServerSettingsSerializerTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/TestApp.kt create mode 100644 app/core/src/test/java/com/fsck/k9/TestCoreResourceProvider.kt create mode 100644 app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptDraftStateHeaderParserTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptGossipHeaderParserTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderParserTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/controller/DefaultMessageCountsProviderTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/controller/MessageReferenceTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/controller/PendingCommandSerializerTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/controller/UidReverseComparatorTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/crypto/MessageCryptoStructureDetectorTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/crypto/OpenPgpApiHelperTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/helper/EmailHelperTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/helper/IdentityHelperTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/helper/ListHeadersTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/helper/ListUnsubscribeHelperTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/helper/MailToTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/helper/ReplyToParserTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/helper/UtilityTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/logging/LogcatLogFileWriterTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/AttachmentResolverTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/DeferredFileBodyTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/LocalStoreTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/MessageListCacheTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/MessageListRepositoryTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/MessageStoreManagerTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/MimePartStreamParserTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/mailstore/MoreMessagesTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/message/IdentityHeaderBuilderTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/IdentityHeaderParserTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/MessageBuilderTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/message/MessageCreationHelper.java create mode 100644 app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/TextBodyBuilderTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/extractors/AttachmentInfoExtractorTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/message/extractors/BasicPartInfoExtractorTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/extractors/MessagePreviewCreatorTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/message/extractors/PreviewTextExtractorTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/extractors/TextPartFinderTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/html/DisplayHtmlTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/html/EmailSectionExtractorTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/html/EmailSectionTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/html/GenericUriParserTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/html/HtmlConverterTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/html/HtmlHelper.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/html/HttpUriParserTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/message/html/UriMatcherTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/quote/QuoteDateFormatterTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/quote/TextQuoteCreatorTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/message/signature/TextSignatureRemoverTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/notification/AuthenticationErrorNotificationControllerTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/BaseNotificationDataCreatorTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/CertificateErrorNotificationControllerTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/LockScreenNotificationCreatorTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/NotificationIdsTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/SendFailedNotificationControllerTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/SyncNotificationControllerTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt create mode 100644 app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java create mode 100644 app/core/src/test/java/com/fsck/k9/sasl/OAuthBearerTest.kt create mode 100644 app/core/src/test/java/com/fsck/k9/setup/ServerNameSuggesterTest.java create mode 100644 app/core/src/test/resources/autocrypt/no_autocrypt.eml create mode 100644 app/core/src/test/resources/autocrypt/rsa2048-broken-base64.eml create mode 100644 app/core/src/test/resources/autocrypt/rsa2048-explicit-type.eml create mode 100644 app/core/src/test/resources/autocrypt/rsa2048-simple-to-bot.eml create mode 100644 app/core/src/test/resources/autocrypt/rsa2048-simple.eml create mode 100644 app/core/src/test/resources/autocrypt/rsa2048-unknown-critical.eml create mode 100644 app/core/src/test/resources/autocrypt/rsa2048-unknown-non-critical.eml create mode 100644 app/core/src/test/resources/autocrypt/unknown-type.eml create mode 100644 app/crypto-openpgp/build.gradle.kts create mode 100644 app/crypto-openpgp/src/main/java/com/fsck/k9/crypto/openpgp/EncryptionDetector.java create mode 100644 app/crypto-openpgp/src/main/java/com/fsck/k9/crypto/openpgp/OpenPgpEncryptionExtractor.kt create mode 100644 app/crypto-openpgp/src/test/java/com/fsck/k9/crypto/openpgp/EncryptionDetectorTest.java create mode 100644 app/crypto-openpgp/src/test/java/com/fsck/k9/crypto/openpgp/MessageCreationHelper.java create mode 100644 app/html-cleaner/build.gradle.kts create mode 100644 app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt create mode 100644 app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HeadCleaner.kt create mode 100644 app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlHeadProvider.kt create mode 100644 app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlProcessor.kt create mode 100644 app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlSanitizer.kt create mode 100644 app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt create mode 100644 app/k9mail/build.gradle.kts create mode 100644 app/k9mail/proguard-rules.pro create mode 100644 app/k9mail/src/debug/java/app/k9mail/dev/DebugConfig.kt create mode 100644 app/k9mail/src/debug/java/app/k9mail/dev/DemoBackendFactory.kt create mode 100644 app/k9mail/src/debug/res/mipmap-hdpi/icon.png create mode 100644 app/k9mail/src/debug/res/mipmap-hdpi/icon_round.png create mode 100644 app/k9mail/src/debug/res/mipmap-mdpi/icon.png create mode 100644 app/k9mail/src/debug/res/mipmap-mdpi/icon_round.png create mode 100644 app/k9mail/src/debug/res/mipmap-xhdpi/icon.png create mode 100644 app/k9mail/src/debug/res/mipmap-xhdpi/icon_round.png create mode 100644 app/k9mail/src/debug/res/mipmap-xxhdpi/icon.png create mode 100644 app/k9mail/src/debug/res/mipmap-xxhdpi/icon_round.png create mode 100644 app/k9mail/src/debug/res/mipmap-xxxhdpi/icon.png create mode 100644 app/k9mail/src/debug/res/mipmap-xxxhdpi/icon_round.png create mode 100644 app/k9mail/src/main/AndroidManifest.xml create mode 100644 app/k9mail/src/main/icon-playstore.png create mode 100644 app/k9mail/src/main/java/com/fsck/k9/App.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/MessagingListenerProvider.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/auth/OAuthConfigurations.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/backends/AndroidAlarmManager.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/backends/ImapBackendFactory.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/backends/KoinModule.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/backends/Pop3BackendFactory.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/glide/K9AppGlideModule.java create mode 100644 app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/notification/KoinModule.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/provider/UnreadWidgetProvider.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/resources/K9AutocryptStringProvider.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/resources/KoinModule.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationActivity.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationFragment.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetData.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetMigrations.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetRepository.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetUpdateListener.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetUpdater.kt create mode 100644 app/k9mail/src/main/res/drawable-hdpi/ic_unread_widget.png create mode 100644 app/k9mail/src/main/res/drawable-hdpi/ic_unread_widget_selected.png create mode 100644 app/k9mail/src/main/res/drawable-mdpi/ic_unread_widget.png create mode 100644 app/k9mail/src/main/res/drawable-mdpi/ic_unread_widget_selected.png create mode 100644 app/k9mail/src/main/res/drawable-xhdpi/ic_unread_widget.png create mode 100644 app/k9mail/src/main/res/drawable-xhdpi/ic_unread_widget_selected.png create mode 100644 app/k9mail/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/k9mail/src/main/res/drawable/unread_count_background.xml create mode 100644 app/k9mail/src/main/res/drawable/unread_widget_background.xml create mode 100644 app/k9mail/src/main/res/drawable/unread_widget_icon.xml create mode 100644 app/k9mail/src/main/res/layout/activity_unread_widget_configuration.xml create mode 100644 app/k9mail/src/main/res/layout/unread_widget_layout.xml create mode 100644 app/k9mail/src/main/res/menu/unread_widget_option.xml create mode 100644 app/k9mail/src/main/res/mipmap-anydpi-v26/icon.xml create mode 100644 app/k9mail/src/main/res/mipmap-anydpi-v26/icon_round.xml create mode 100644 app/k9mail/src/main/res/mipmap-hdpi/icon.png create mode 100644 app/k9mail/src/main/res/mipmap-hdpi/icon_foreground.png create mode 100644 app/k9mail/src/main/res/mipmap-hdpi/icon_round.png create mode 100644 app/k9mail/src/main/res/mipmap-mdpi/icon.png create mode 100644 app/k9mail/src/main/res/mipmap-mdpi/icon_foreground.png create mode 100644 app/k9mail/src/main/res/mipmap-mdpi/icon_round.png create mode 100644 app/k9mail/src/main/res/mipmap-xhdpi/icon.png create mode 100644 app/k9mail/src/main/res/mipmap-xhdpi/icon_foreground.png create mode 100644 app/k9mail/src/main/res/mipmap-xhdpi/icon_round.png create mode 100644 app/k9mail/src/main/res/mipmap-xxhdpi/icon.png create mode 100644 app/k9mail/src/main/res/mipmap-xxhdpi/icon_foreground.png create mode 100644 app/k9mail/src/main/res/mipmap-xxhdpi/icon_round.png create mode 100644 app/k9mail/src/main/res/mipmap-xxxhdpi/icon.png create mode 100644 app/k9mail/src/main/res/mipmap-xxxhdpi/icon_foreground.png create mode 100644 app/k9mail/src/main/res/mipmap-xxxhdpi/icon_round.png create mode 100644 app/k9mail/src/main/res/values-land/unread_widget_styles.xml create mode 100644 app/k9mail/src/main/res/values-sw600dp-land/unread_widget_styles.xml create mode 100644 app/k9mail/src/main/res/values-sw600dp-port/unread_widget_styles.xml create mode 100644 app/k9mail/src/main/res/values-v31/manifest_values.xml create mode 100644 app/k9mail/src/main/res/values/ic_launcher_background.xml create mode 100644 app/k9mail/src/main/res/values/icon_background.xml create mode 100644 app/k9mail/src/main/res/values/manifest_values.xml create mode 100644 app/k9mail/src/main/res/values/unread_widget_styles.xml create mode 100644 app/k9mail/src/main/res/xml/network_security_config.xml create mode 100644 app/k9mail/src/main/res/xml/unread_widget_configuration.xml create mode 100644 app/k9mail/src/main/res/xml/unread_widget_info.xml create mode 100644 app/k9mail/src/release/java/app/k9mail/dev/ReleaseConfig.kt create mode 100644 app/k9mail/src/test/java/com/fsck/k9/AppRobolectricTest.kt create mode 100644 app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt create mode 100644 app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt create mode 100644 app/storage/build.gradle.kts create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/K9StorageEditor.java create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo10.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo11.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo12.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo13.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo14.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo15.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo16.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo17.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo18.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo19.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo2.java create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo3.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo4.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo5.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo6.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo7.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo8.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationsHelper.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/ImapStoreUriDecoder.java create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/Pop3StoreUriDecoder.java create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/SmtpTransportUriDecoder.java create mode 100644 app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/WebDavStoreUriDecoder.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/K9SchemaDefinitionFactory.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/StoreSchemaDefinition.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/AttachmentFileManager.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/CheckFolderOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/CopyMessageOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/CreateFolderOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/DataLocation.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/DatabaseOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/DeleteFolderOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/DeleteMessageOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/FlagMessageOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStoreFactory.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/KeyValueStoreOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/MoveMessageOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageListOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/SaveMessageOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/ThreadMessageOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateMessageOperations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingAppend.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingCommand.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingDelete.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingExpunge.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMarkAllAsRead.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMoveAndMarkAsRead.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMoveOrCopy.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingSetFlag.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo62.java create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo64.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo65.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo66.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo67.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo68.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo69.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo70.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo71.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo72.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo73.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo74.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo75.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo76.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo78.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo79.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo80.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo81.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo82.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo83.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo84.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/migrations/Migrations.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStore.kt create mode 100644 app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStoreProvider.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/preferences/StorageEditorTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/K9RobolectricTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/RobolectricTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/CopyMessageOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/CreateFolderOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteFolderOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteMessageOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/FileHelpers.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/FlagMessageOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/FolderHelpers.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueHelpers.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueStoreOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/MessagePartDatabaseHelpers.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/MoveMessageOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/SaveMessageOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadDatabaseHelpers.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadMessageOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt create mode 100644 app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt create mode 100644 app/storage/src/test/resources/attach/k9small.png create mode 100644 app/testing/build.gradle.kts create mode 100644 app/testing/src/main/java/com/fsck/k9/RobolectricTest.kt create mode 100644 app/testing/src/main/java/com/fsck/k9/preferences/InMemoryStoragePersister.kt create mode 100644 app/testing/src/main/java/com/fsck/k9/testing/MockHelper.kt create mode 100644 app/testing/src/main/java/com/fsck/k9/testing/StringExtensions.kt create mode 100644 app/ui/base/build.gradle.kts create mode 100644 app/ui/base/src/main/AndroidManifest.xml create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/AppLanguageManager.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/K9Activity.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/LocaleContextWrapper.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeProvider.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/ConfigurationExtensions.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/NavigationExtensions.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/loader/LiveDataLoader.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/loader/LoaderStateObserver.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/locale/LocaleBroadcastReceiver.kt create mode 100644 app/ui/base/src/main/java/com/fsck/k9/ui/base/locale/SystemLocaleManager.kt create mode 100644 app/ui/base/src/main/res/layout/toolbar.xml create mode 100644 app/ui/legacy/build.gradle.kts create mode 100644 app/ui/legacy/sampledata/accounts.json create mode 100644 app/ui/legacy/sampledata/folders.json create mode 100644 app/ui/legacy/src/debug/java/com/fsck/k9/ui/settings/ExtraAccountDiscovery.kt create mode 100644 app/ui/legacy/src/main/AndroidManifest.xml create mode 100644 app/ui/legacy/src/main/icon-playstore.png create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/account/AccountCreator.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/account/AccountRemover.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/account/AccountRemoverService.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/account/BackgroundAccountRemover.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/account/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/AccountList.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/AlternateRecipientAdapter.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/ChooseAccount.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/ChooseIdentity.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/FolderInfoHolder.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/K9ListActivity.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/LauncherShortcuts.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/ManageIdentities.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageLoaderHelper.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageLoaderHelperFactory.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/Search.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/UpgradeDatabases.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/AttachmentPresenter.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ComposeCryptoStatus.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/IdentityAdapter.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/MessageActions.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/PgpEnabledErrorDialog.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/PgpEncryptDescriptionDialog.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/PgpInlineDialog.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/PgpSignOnlyDialog.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientAdapter.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientLoader.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToPresenter.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/SaveMessageTask.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentContentLoader.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentInfoLoader.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/misc/Attachment.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/misc/ContactPicture.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/misc/InlineAttachment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupAccountType.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupIncoming.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupNames.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOptions.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOutgoing.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthTypeAdapter.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthTypeHolder.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/ConnectionSecurityAdapter.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/ConnectionSecurityHolder.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/InitialAccountSettings.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/OAuthFlowActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/SpinnerOption.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactImage.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactImageBitmapDecoder.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactImageModelLoader.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactLetterBitmapConfig.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactLetterBitmapCreator.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactLetterExtractor.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPhotoLoader.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPictureGlideModule.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPictureLoader.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/contacts/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/fragment/AttachmentDownloadDialogFragment.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/fragment/ConfirmationDialogFragment.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/fragment/ProgressDialogFragment.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/BundleExtensions.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/ConnectionSettings.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/ContactBadge.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/FlowExtensions.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/FragmentExtras.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/K9Drawer.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/K9ThemeProvider.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/LiveDataExtras.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/ThemeExtensions.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountFallbackImageProvider.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountImageLoader.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountImageModelLoader.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/account/DisplayAccount.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/changelog/ChangeLogManager.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/changelog/ChangelogFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/changelog/ChangelogViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/changelog/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/changelog/RecentChangesActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/changelog/RecentChangesViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/ChooseFolderActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/ChooseFolderViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/FolderListItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/compose/QuotedMessageMvpView.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/compose/QuotedMessagePresenter.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/compose/RecipientCircleImageView.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/compose/RecipientTokenConstraintLayout.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/compose/SimpleHighlightView.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoCallback.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoHelper.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/OpenPgpApiFactory.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptKeyTransferActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptKeyTransferPresenter.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptKeyTransferViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptSetupMessageLiveEvent.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptSetupTransferLiveEvent.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/fab/HideFabOnScrollBehavior.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FolderIconProvider.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FolderNameFormatter.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/BottomBaselineTextView.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/ContextExtensions.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/DisplayAddressHelper.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/DisplayHtmlUiFactory.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/HtmlSettingsProvider.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/HtmlToSpanned.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/RecyclerViewBackgroundDrawable.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/RelativeDateTimeFormatter.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/SizeFormatter.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderListItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsDataStore.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/message/LocalMessageExtractorLoader.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/message/LocalMessageLoader.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/AddToContactsLauncher.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ContactSettingsProvider.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/CryptoStatusItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/EmptyItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/FolderNameItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDateItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsAppearance.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsDividerItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsParticipantFormatter.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsUi.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ParticipantItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/SectionHeaderItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ShowContactLauncher.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/DefaultFolderProvider.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAppearance.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListConfig.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemAnimator.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemDecoration.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemMapper.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveData.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveDataFactory.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListViewHolder.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MlfUtils.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SortTypeToastProvider.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagesource/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagesource/MessageHeadersFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagesource/MessageHeadersViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagesource/MessageSourceActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentView.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentViewCallback.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/Direction.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/DisplayRecipientsExtractor.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/LinkTextHandler.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/LockedAttachmentView.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageContainerView.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageCryptoPresenter.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageHeaderClickListener.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewRecipientFormatter.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/PlaceholderFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/RecipientLayoutCreator.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/RecipientNamesView.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/TouchInterceptView.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/notification/DeleteConfirmationActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/onboarding/OnboardingActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/onboarding/WelcomeFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/permissions/Permission.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/permissions/PermissionRationaleDialogFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/permissions/PermissionUiHelper.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/push/PushInfoActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/push/PushInfoFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/AboutFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/AccountItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/PreferenceExtras.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsActionItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsDividerItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsListFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/UrlActionItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSelectionSpinner.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStore.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStoreFactory.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AutocryptPreferEncryptDialogFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AutocryptPreferEncryptPreference.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/FolderListPreference.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/NotificationSoundPreference.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/NotificationsPreference.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/OpenPgpAppSelectDialog.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/VibrationDialogFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/VibrationPreference.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/Vibrator.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/export/CheckBoxItem.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/export/SettingsExportFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/export/SettingsExportListItems.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/export/SettingsExportUiModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/export/SettingsExportViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsActivity.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/LanguagePreference.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/AccountActivator.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/PasswordPromptDialogFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/PasswordPromptResult.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportFragment.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportListItems.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportResultViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportUiModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportViewModel.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/share/ShareIntentBuilder.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/ClientCertificateSpinner.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/DraggableFrameLayout.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/FoldableLinearLayout.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/HighlightDialogFragment.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/K9WebViewClient.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/LinearViewAnimator.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/MessageCryptoDisplayStatus.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/MessageWebView.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/NonLockingScrollView.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/RecipientSelectView.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/StatusIndicator.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/ThemeUtils.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/ToolableViewAnimator.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/ViewSwitcher.java create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/WebViewConfig.kt create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/view/WebViewConfigProvider.kt create mode 100644 app/ui/legacy/src/main/res/anim/fade_in.xml create mode 100644 app/ui/legacy/src/main/res/anim/fade_out.xml create mode 100644 app/ui/legacy/src/main/res/anim/slide_in_left.xml create mode 100644 app/ui/legacy/src/main/res/anim/slide_in_right.xml create mode 100644 app/ui/legacy/src/main/res/anim/slide_out_left.xml create mode 100644 app/ui/legacy/src/main/res/anim/slide_out_right.xml create mode 100644 app/ui/legacy/src/main/res/animator/draggable_state_list_anim.xml create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_dialog_disable.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_dialog_normal.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_dialog_pressed.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_dialog_selected.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_edit_disable.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_edit_normal.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_edit_pressed.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_edit_selected.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_disabled.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_focus.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_normal.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_pressed.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/divider_horizontal_email.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/drawer_header_background.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/ic_action_request_read_receipt_dark.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/ic_action_request_read_receipt_light.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/notification_icon_check_mail_anim_0.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/notification_icon_check_mail_anim_1.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/notification_icon_check_mail_anim_2.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/notification_icon_check_mail_anim_3.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/notification_icon_check_mail_anim_4.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/notification_icon_check_mail_anim_5.png create mode 100644 app/ui/legacy/src/main/res/drawable-hdpi/preview_unread_widget.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_dialog_disable.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_dialog_normal.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_dialog_pressed.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_dialog_selected.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_edit_disable.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_edit_normal.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_edit_pressed.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_edit_selected.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_disabled.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_focus.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_normal.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_pressed.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/divider_horizontal_email.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/drawer_header_background.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/ic_action_request_read_receipt_dark.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/ic_action_request_read_receipt_light.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/notification_icon_check_mail_anim_0.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/notification_icon_check_mail_anim_1.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/notification_icon_check_mail_anim_2.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/notification_icon_check_mail_anim_3.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/notification_icon_check_mail_anim_4.png create mode 100644 app/ui/legacy/src/main/res/drawable-mdpi/notification_icon_check_mail_anim_5.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_disabled.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_focus.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_normal.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_pressed.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/drawer_header_background.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/ic_action_request_read_receipt_dark.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/ic_action_request_read_receipt_light.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/notification_icon_check_mail_anim_0.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/notification_icon_check_mail_anim_1.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/notification_icon_check_mail_anim_2.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/notification_icon_check_mail_anim_3.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/notification_icon_check_mail_anim_4.png create mode 100644 app/ui/legacy/src/main/res/drawable-xhdpi/notification_icon_check_mail_anim_5.png create mode 100644 app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_disabled.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_focus.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_normal.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_pressed.9.png create mode 100644 app/ui/legacy/src/main/res/drawable-xxhdpi/drawer_header_background.png create mode 100644 app/ui/legacy/src/main/res/drawable/btn_dialog.xml create mode 100644 app/ui/legacy/src/main/res/drawable/btn_edit.xml create mode 100644 app/ui/legacy/src/main/res/drawable/btn_google_signin_dark.xml create mode 100644 app/ui/legacy/src/main/res/drawable/btn_select_star.xml create mode 100644 app/ui/legacy/src/main/res/drawable/bullet_point_negative.xml create mode 100644 app/ui/legacy/src/main/res/drawable/bullet_point_neutral.xml create mode 100644 app/ui/legacy/src/main/res/drawable/bullet_point_positive.xml create mode 100644 app/ui/legacy/src/main/res/drawable/compatibility.xml create mode 100644 app/ui/legacy/src/main/res/drawable/dots_vertical.xml create mode 100644 app/ui/legacy/src/main/res/drawable/drawer_account_fallback.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_account.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_account_color.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_account_plus.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_alert_octagon.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_archive.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_arrow_back.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_arrow_up_down.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_attachment.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_attachment_generic.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_attachment_image.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_bug.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_check_black_24dp.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_check_circle.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_check_circle_large.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_chevron_down.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_chevron_right.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_chevron_right_black_24dp.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_chevron_up.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_clear.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_close.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_close_black_24dp.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_code.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_cog.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_contact_picture.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_content_copy.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_description.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_download.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_drafts_folder.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_drag_handle.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_envelope.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_error.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_export.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_file_upload.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_floppy.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_folder.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_folder_magnify.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_forum.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_help.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_import.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_import_status.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_inbox.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_inbox_multiple.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_info.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_key.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_link.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_login.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_magnify.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_magnify_cloud.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_mark_new.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_mastodon.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_menu.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_messagelist_answered.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_messagelist_answered_forwarded.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_messagelist_attachment.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_messagelist_attachment_light.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_messagelist_forwarded.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_move_to_folder.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_not_imported.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_notifications.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_open_book.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_opened_envelope.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_outbox.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_pencil.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_people.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_person_add.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_plus.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_preferences_check_mail.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_preferences_crypto.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_push_notification.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_refresh.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_reply.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_reply_all.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_select_all.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_send.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_shield.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_sort.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_star.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_star_no_padding.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_star_outline.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_star_outline_no_padding.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_status_corner.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_touch.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_trash_can.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_tv.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_twitter.xml create mode 100644 app/ui/legacy/src/main/res/drawable/ic_visibility.xml create mode 100644 app/ui/legacy/src/main/res/drawable/notification_action_archive.xml create mode 100644 app/ui/legacy/src/main/res/drawable/notification_action_delete.xml create mode 100644 app/ui/legacy/src/main/res/drawable/notification_action_mark_as_read.xml create mode 100644 app/ui/legacy/src/main/res/drawable/notification_action_mark_as_spam.xml create mode 100644 app/ui/legacy/src/main/res/drawable/notification_action_reply.xml create mode 100644 app/ui/legacy/src/main/res/drawable/notification_icon_check_mail.xml create mode 100644 app/ui/legacy/src/main/res/drawable/notification_icon_new_mail.xml create mode 100644 app/ui/legacy/src/main/res/drawable/notification_icon_warning.xml create mode 100644 app/ui/legacy/src/main/res/drawable/rounded_corners.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_dots.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_dots_1.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_dots_2.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_dots_3.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_lock.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_lock_disabled.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_lock_disabled_dots_1.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_lock_dots_2.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_lock_dots_3.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_lock_error.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_lock_unknown.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_signature.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_signature_dots_3.xml create mode 100644 app/ui/legacy/src/main/res/drawable/status_signature_unknown.xml create mode 100644 app/ui/legacy/src/main/res/drawable/thread_count_box_dark.xml create mode 100644 app/ui/legacy/src/main/res/drawable/thread_count_box_light.xml create mode 100644 app/ui/legacy/src/main/res/layout/about_library.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_list.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_list_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_setup_account_type.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_setup_basics.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_setup_check_settings.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_setup_composition.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_setup_incoming.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_setup_names.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_setup_oauth.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_setup_options.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_setup_outgoing.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_spinner_dropdown_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/account_spinner_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/accounts_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/activity_account_settings.xml create mode 100644 app/ui/legacy/src/main/res/layout/activity_manage_folders.xml create mode 100644 app/ui/legacy/src/main/res/layout/activity_onboarding.xml create mode 100644 app/ui/legacy/src/main/res/layout/activity_push_info.xml create mode 100644 app/ui/legacy/src/main/res/layout/activity_recent_changes.xml create mode 100644 app/ui/legacy/src/main/res/layout/activity_settings.xml create mode 100644 app/ui/legacy/src/main/res/layout/changelog_list_change_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/changelog_list_release_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/choose_account_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/choose_identity_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/client_certificate_spinner.xml create mode 100644 app/ui/legacy/src/main/res/layout/crypto_key_transfer.xml create mode 100644 app/ui/legacy/src/main/res/layout/dialog_autocrypt_prefer_encrypt.xml create mode 100644 app/ui/legacy/src/main/res/layout/dialog_openkeychain_info.xml create mode 100644 app/ui/legacy/src/main/res/layout/drawer_contents.xml create mode 100644 app/ui/legacy/src/main/res/layout/drawer_folder_list_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/edit_identity.xml create mode 100644 app/ui/legacy/src/main/res/layout/empty_message_view.xml create mode 100644 app/ui/legacy/src/main/res/layout/foldable_linearlayout.xml create mode 100644 app/ui/legacy/src/main/res/layout/folder_list.xml create mode 100644 app/ui/legacy/src/main/res/layout/folder_list_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/fragment_about.xml create mode 100644 app/ui/legacy/src/main/res/layout/fragment_changelog.xml create mode 100644 app/ui/legacy/src/main/res/layout/fragment_manage_folders.xml create mode 100644 app/ui/legacy/src/main/res/layout/fragment_push_info.xml create mode 100644 app/ui/legacy/src/main/res/layout/fragment_settings_export.xml create mode 100644 app/ui/legacy/src/main/res/layout/fragment_settings_import.xml create mode 100644 app/ui/legacy/src/main/res/layout/fragment_settings_list.xml create mode 100644 app/ui/legacy/src/main/res/layout/fragment_welcome_message.xml create mode 100644 app/ui/legacy/src/main/res/layout/general_settings.xml create mode 100644 app/ui/legacy/src/main/res/layout/list_content_simple.xml create mode 100644 app/ui/legacy/src/main/res/layout/message.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_bottom_sheet.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_compose.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_compose_attachment.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_compose_content.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_compose_recipients.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_container.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_content_crypto_cancelled.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_content_crypto_error.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_content_crypto_incomplete.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_content_crypto_no_provider.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_crypto_info_dialog.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_details_crypto_status_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_details_date_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_details_divider_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_details_folder_name_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_details_participant_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_details_section_header_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_list.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_list_error.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_list_fragment.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_list_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_list_item_footer.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_view_attachment.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_view_attachment_locked.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_view_container.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_view_header.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_view_headers.xml create mode 100644 app/ui/legacy/src/main/res/layout/message_view_headers_activity.xml create mode 100644 app/ui/legacy/src/main/res/layout/openpgp_enabled_error_dialog.xml create mode 100644 app/ui/legacy/src/main/res/layout/openpgp_encrypt_description_dialog.xml create mode 100644 app/ui/legacy/src/main/res/layout/openpgp_inline_dialog.xml create mode 100644 app/ui/legacy/src/main/res/layout/openpgp_sign_only_dialog.xml create mode 100644 app/ui/legacy/src/main/res/layout/password_prompt_dialog.xml create mode 100644 app/ui/legacy/src/main/res/layout/preference_vibration_pattern_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/preference_vibration_switch_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/preference_vibration_times_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/recipient_alternate_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/recipient_dropdown_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/recipient_names.xml create mode 100644 app/ui/legacy/src/main/res/layout/recipient_token_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/select_openpgp_app_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/settings_export_account_list_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/settings_export_general_list_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/settings_import_account_list_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/settings_import_general_list_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/split_message_list.xml create mode 100644 app/ui/legacy/src/main/res/layout/status_indicator.xml create mode 100644 app/ui/legacy/src/main/res/layout/swipe_left_action.xml create mode 100644 app/ui/legacy/src/main/res/layout/swipe_right_action.xml create mode 100644 app/ui/legacy/src/main/res/layout/text_divider_list_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/text_icon_list_item.xml create mode 100644 app/ui/legacy/src/main/res/layout/upgrade_databases.xml create mode 100644 app/ui/legacy/src/main/res/layout/wizard_cancel.xml create mode 100644 app/ui/legacy/src/main/res/layout/wizard_done.xml create mode 100644 app/ui/legacy/src/main/res/layout/wizard_next.xml create mode 100644 app/ui/legacy/src/main/res/layout/wizard_setup.xml create mode 100644 app/ui/legacy/src/main/res/layout/wizard_welcome.xml create mode 100644 app/ui/legacy/src/main/res/menu/account_settings_option.xml create mode 100644 app/ui/legacy/src/main/res/menu/choose_folder_option.xml create mode 100644 app/ui/legacy/src/main/res/menu/debug_settings_option.xml create mode 100644 app/ui/legacy/src/main/res/menu/edit_identity_menu.xml create mode 100644 app/ui/legacy/src/main/res/menu/folder_list_option.xml create mode 100644 app/ui/legacy/src/main/res/menu/folder_settings_option.xml create mode 100644 app/ui/legacy/src/main/res/menu/general_settings_option.xml create mode 100644 app/ui/legacy/src/main/res/menu/manage_identities_context.xml create mode 100644 app/ui/legacy/src/main/res/menu/manage_identities_option.xml create mode 100644 app/ui/legacy/src/main/res/menu/message_compose_option.xml create mode 100644 app/ui/legacy/src/main/res/menu/message_list_context.xml create mode 100644 app/ui/legacy/src/main/res/menu/message_list_option.xml create mode 100644 app/ui/legacy/src/main/res/menu/participant_overflow_menu.xml create mode 100644 app/ui/legacy/src/main/res/menu/single_message_options.xml create mode 100644 app/ui/legacy/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/ui/legacy/src/main/res/mipmap-anydpi-v26/icon.xml create mode 100644 app/ui/legacy/src/main/res/mipmap-anydpi-v26/icon_round.xml create mode 100644 app/ui/legacy/src/main/res/mipmap-hdpi/icon.png create mode 100644 app/ui/legacy/src/main/res/mipmap-hdpi/icon_round.png create mode 100644 app/ui/legacy/src/main/res/mipmap-mdpi/icon.png create mode 100644 app/ui/legacy/src/main/res/mipmap-mdpi/icon_round.png create mode 100644 app/ui/legacy/src/main/res/mipmap-xhdpi/icon.png create mode 100644 app/ui/legacy/src/main/res/mipmap-xhdpi/icon_round.png create mode 100644 app/ui/legacy/src/main/res/mipmap-xxhdpi/icon.png create mode 100644 app/ui/legacy/src/main/res/mipmap-xxhdpi/icon_round.png create mode 100644 app/ui/legacy/src/main/res/mipmap-xxxhdpi/icon.png create mode 100644 app/ui/legacy/src/main/res/mipmap-xxxhdpi/icon_round.png create mode 100644 app/ui/legacy/src/main/res/navigation/navigation_manage_folders.xml create mode 100644 app/ui/legacy/src/main/res/navigation/navigation_onboarding.xml create mode 100644 app/ui/legacy/src/main/res/navigation/navigation_settings.xml create mode 100644 app/ui/legacy/src/main/res/raw-ja/changelog.xml create mode 100644 app/ui/legacy/src/main/res/raw/changelog_master.xml create mode 100644 app/ui/legacy/src/main/res/transition/transfer_transitions.xml create mode 100644 app/ui/legacy/src/main/res/values-ar/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-be/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-bg/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-br/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-ca/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-cs/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-cy/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-da/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-de/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-el/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-en-rGB/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-eo/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-es/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-et/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-eu/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-fa/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-fi/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-fr/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-fy/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-gd/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-gl/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-hr/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-hu/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-in/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-is/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-it/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-iw/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-ja/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-ko/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-lt/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-lv/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-ml/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-nb/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-night/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-night/themes.xml create mode 100644 app/ui/legacy/src/main/res/values-nl/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-pl/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-pt-rBR/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-pt-rPT/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-ro/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-ru/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-sk/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-sl/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-sq/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-sr/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-sv/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-sw360dp/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-sw360dp/values-preference.xml create mode 100644 app/ui/legacy/src/main/res/values-tr/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-uk/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-v23/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-v23/styles.xml create mode 100644 app/ui/legacy/src/main/res/values-v23/themes.xml create mode 100644 app/ui/legacy/src/main/res/values-v26/drawables.xml create mode 100644 app/ui/legacy/src/main/res/values-v26/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-v27/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-v27/themes.xml create mode 100644 app/ui/legacy/src/main/res/values-v28/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-v28/themes.xml create mode 100644 app/ui/legacy/src/main/res/values-w360dp/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-w360dp/styles.xml create mode 100644 app/ui/legacy/src/main/res/values-zh-rCN/strings.xml create mode 100644 app/ui/legacy/src/main/res/values-zh-rTW/strings.xml create mode 100644 app/ui/legacy/src/main/res/values/arrays.xml create mode 100644 app/ui/legacy/src/main/res/values/arrays_account_settings_strings.xml create mode 100644 app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml create mode 100644 app/ui/legacy/src/main/res/values/attrs.xml create mode 100644 app/ui/legacy/src/main/res/values/colors.xml create mode 100644 app/ui/legacy/src/main/res/values/constants.xml create mode 100644 app/ui/legacy/src/main/res/values/contact_picture_fallback_colors.xml create mode 100644 app/ui/legacy/src/main/res/values/dimensions.xml create mode 100644 app/ui/legacy/src/main/res/values/drawables.xml create mode 100644 app/ui/legacy/src/main/res/values/ids.xml create mode 100644 app/ui/legacy/src/main/res/values/message_details_ids.xml create mode 100644 app/ui/legacy/src/main/res/values/strings.xml create mode 100644 app/ui/legacy/src/main/res/values/styles.xml create mode 100644 app/ui/legacy/src/main/res/values/themes.xml create mode 100644 app/ui/legacy/src/main/res/xml/account_settings.xml create mode 100644 app/ui/legacy/src/main/res/xml/empty_preferences.xml create mode 100644 app/ui/legacy/src/main/res/xml/folder_settings_preferences.xml create mode 100644 app/ui/legacy/src/main/res/xml/general_settings.xml create mode 100644 app/ui/legacy/src/main/res/xml/searchable.xml create mode 100644 app/ui/legacy/src/release/java/com/fsck/k9/ui/settings/ExtraAccountDiscovery.kt create mode 100644 app/ui/legacy/src/test/AndroidManifest.xml create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/K9RobolectricTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/RobolectricTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/TestApp.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/TestCoreResourceProvider.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ViewTestExtensions.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/account/AccountCreatorTest.java create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/AttachmentPresenterTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/RecipientLoaderTest.java create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/RecipientPresenterTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/ReplyToPresenterTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/autocrypt/AutocryptOperationsHelper.java create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/contacts/ContactLetterExtractorTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/message/PgpMessageBuilderTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/K9DrawerTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/crypto/MessageCryptoHelperTest.java create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/helper/RelativeDateTimeFormatterTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/helper/SizeFormatterTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/identity/IdentityFormatterTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/messagedetails/MessageDetailsParticipantFormatterTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/messageview/DisplayRecipientsExtractorTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/messageview/MessageViewRecipientFormatterTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/messageview/RecipientLayoutCreatorTest.kt create mode 100644 app/ui/legacy/src/test/java/com/fsck/k9/ui/settings/general/GeneralSettingsViewModelTest.kt create mode 100644 app/ui/message-list-widget/build.gradle.kts create mode 100644 app/ui/message-list-widget/src/main/AndroidManifest.xml create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/KoinModule.kt create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListConfig.kt create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListLoader.kt create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetConfig.kt create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetManager.kt create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetService.kt create mode 100644 app/ui/message-list-widget/src/main/res/drawable-xxhdpi/message_list_widget_preview.png create mode 100644 app/ui/message-list-widget/src/main/res/layout/message_list_widget_layout.xml create mode 100644 app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item.xml create mode 100644 app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item_loading.xml create mode 100644 app/ui/message-list-widget/src/main/res/layout/message_list_widget_loading.xml create mode 100644 app/ui/message-list-widget/src/main/res/values/colors.xml create mode 100644 app/ui/message-list-widget/src/main/res/xml/message_list_widget_info.xml create mode 100644 app/ui/setup/src/main/res/drawable/ic_monocles_graphic.xml create mode 100644 backend/api/build.gradle.kts create mode 100644 backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt create mode 100644 backend/api/src/main/java/com/fsck/k9/backend/api/BackendFolder.kt create mode 100644 backend/api/src/main/java/com/fsck/k9/backend/api/BackendPusher.kt create mode 100644 backend/api/src/main/java/com/fsck/k9/backend/api/BackendPusherCallback.kt create mode 100644 backend/api/src/main/java/com/fsck/k9/backend/api/BackendStorage.kt create mode 100644 backend/api/src/main/java/com/fsck/k9/backend/api/FolderInfo.kt create mode 100644 backend/api/src/main/java/com/fsck/k9/backend/api/SyncConfig.kt create mode 100644 backend/api/src/main/java/com/fsck/k9/backend/api/SyncListener.kt create mode 100644 backend/demo/build.gradle.kts create mode 100644 backend/demo/src/main/java/app/k9mail/backend/demo/DemoBackend.kt create mode 100644 backend/demo/src/main/java/app/k9mail/backend/demo/MessageStoreInfo.kt create mode 100644 backend/demo/src/main/resources/contents.json create mode 100644 backend/demo/src/main/resources/inbox/inline_image_attachment.eml create mode 100644 backend/demo/src/main/resources/inbox/inline_image_data_uri.eml create mode 100644 backend/demo/src/main/resources/inbox/intro.eml create mode 100644 backend/demo/src/main/resources/inbox/many_recipients.eml create mode 100644 backend/demo/src/main/resources/inbox/thread_1.eml create mode 100644 backend/demo/src/main/resources/inbox/thread_2.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1966.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1967.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1968.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1970.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1971.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1972.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1975.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1977.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1978.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1979.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1981.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1983.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1987.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1991.eml create mode 100644 backend/demo/src/main/resources/turing/turing_award_1996.eml create mode 100644 backend/imap/build.gradle.kts create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/BackendIdleRefreshManager.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDeleteAll.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDownloadMessage.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandExpunge.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandFetchMessage.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandFindByMessageId.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandMarkAllAsRead.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandMoveOrCopyMessages.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandRefreshFolderList.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandSearch.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandSetFlag.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandUploadMessage.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackend.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackendPusher.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapFolderPusher.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapPushConfigProvider.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapPusherCallback.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapSync.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/SimpleSyncListener.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/SystemAlarmManager.kt create mode 100644 backend/imap/src/main/java/com/fsck/k9/backend/imap/UidReverseComparator.kt create mode 100644 backend/imap/src/test/java/com/fsck/k9/backend/imap/BackendIdleRefreshManagerTest.kt create mode 100644 backend/imap/src/test/java/com/fsck/k9/backend/imap/ImapSyncTest.kt create mode 100644 backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapFolder.kt create mode 100644 backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapStore.kt create mode 100644 backend/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapMessageHelper.kt create mode 100644 backend/jmap/build.gradle.kts create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandDelete.kt create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandMove.kt create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandRefreshFolderList.kt create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSetFlag.kt create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSync.kt create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandUpload.kt create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapAccountDiscovery.kt create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapConfig.kt create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt create mode 100644 backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapUploadResponse.kt create mode 100644 backend/jmap/src/test/java/com/fsck/k9/backend/jmap/CommandRefreshFolderListTest.kt create mode 100644 backend/jmap/src/test/java/com/fsck/k9/backend/jmap/CommandSyncTest.kt create mode 100644 backend/jmap/src/test/java/com/fsck/k9/backend/jmap/LoggingSyncListener.kt create mode 100644 backend/jmap/src/test/java/com/fsck/k9/backend/jmap/MockWebServerHelper.kt create mode 100644 backend/jmap/src/test/resources/jmap_responses/blob/email/email_1.eml create mode 100644 backend/jmap/src/test/resources/jmap_responses/blob/email/email_2.eml create mode 100644 backend/jmap/src/test/resources/jmap_responses/blob/email/email_3.eml create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M001_and_M002.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M003.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M003_and_M004.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M005.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_get_keywords_M001_and_M002.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_get_keywords_M002.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_query_M001_and_M002.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_query_M001_to_M005.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_query_M002_and_M003.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_M001_deleted_M003_added.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_cannot_calculate_changes_error.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_empty_result.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/email/email_query_empty_result.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_1.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_2.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_error_cannot_calculate_changes.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_get.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/session/session_with_maxObjectsInGet_2.json create mode 100644 backend/jmap/src/test/resources/jmap_responses/session/valid_session.json create mode 100644 backend/pop3/build.gradle.kts create mode 100644 backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandDownloadMessage.kt create mode 100644 backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandRefreshFolderList.kt create mode 100644 backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandSetFlag.java create mode 100644 backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Backend.kt create mode 100644 backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Sync.java create mode 100644 backend/testing/build.gradle.kts create mode 100644 backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendFolder.kt create mode 100644 backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendStorage.kt create mode 100644 backend/webdav/build.gradle.kts create mode 100644 backend/webdav/src/main/java/com/fsck/k9/backend/webdav/CommandDownloadMessage.kt create mode 100644 backend/webdav/src/main/java/com/fsck/k9/backend/webdav/CommandMoveOrCopyMessages.java create mode 100644 backend/webdav/src/main/java/com/fsck/k9/backend/webdav/CommandRefreshFolderList.kt create mode 100644 backend/webdav/src/main/java/com/fsck/k9/backend/webdav/CommandSetFlag.java create mode 100644 backend/webdav/src/main/java/com/fsck/k9/backend/webdav/CommandUploadMessage.kt create mode 100644 backend/webdav/src/main/java/com/fsck/k9/backend/webdav/WebDavBackend.kt create mode 100644 backend/webdav/src/main/java/com/fsck/k9/backend/webdav/WebDavSync.java create mode 100644 build-plugin/README.md create mode 100644 build-plugin/build.gradle.kts create mode 100644 build-plugin/settings.gradle.kts create mode 100644 build-plugin/src/main/kotlin/AndroidExtension.kt create mode 100644 build-plugin/src/main/kotlin/DependencyHandlerExtension.kt create mode 100644 build-plugin/src/main/kotlin/ProjectExtension.kt create mode 100644 build-plugin/src/main/kotlin/ThunderbirdPlugins.kt create mode 100644 build-plugin/src/main/kotlin/ThunderbirdProjectConfig.kt create mode 100644 build-plugin/src/main/kotlin/app.k9mail.gradle.plugin.quality.spotless.gradle.kts create mode 100644 build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts create mode 100644 build-plugin/src/main/kotlin/thunderbird.app.android.default.gradle.kts create mode 100644 build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts create mode 100644 build-plugin/src/main/kotlin/thunderbird.dependency.check.gradle.kts create mode 100644 build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts create mode 100644 build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts create mode 100644 build-plugin/src/main/kotlin/thunderbird.library.jvm.gradle.kts create mode 100644 build-plugin/src/main/kotlin/thunderbird.quality.detekt.gradle.kts create mode 100644 build-plugin/src/main/kotlin/thunderbird.quality.spotless.gradle.kts create mode 100644 build.gradle.kts create mode 100644 cli/html-cleaner-cli/README.md create mode 100644 cli/html-cleaner-cli/build.gradle.kts create mode 100644 cli/html-cleaner-cli/src/main/kotlin/Main.kt create mode 100644 config/detekt/baseline.xml create mode 100644 config/detekt/detekt.yml create mode 100644 config/lint/lint.xml create mode 100644 core/android/common/build.gradle.kts create mode 100644 core/android/common/src/main/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModule.kt create mode 100644 core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/Contact.kt create mode 100644 core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactDataSource.kt create mode 100644 core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactKoinModule.kt create mode 100644 core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactPermissionResolver.kt create mode 100644 core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactRepository.kt create mode 100644 core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/CursorExtensions.kt create mode 100644 core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/EmptyCursor.kt create mode 100644 core/android/common/src/test/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModuleKtTest.kt create mode 100644 core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/AndroidContactPermissionResolverTest.kt create mode 100644 core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/CachingContactRepositoryTest.kt create mode 100644 core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt create mode 100644 core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactKoinModuleKtTest.kt create mode 100644 core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContentResolverContactDataSourceTest.kt create mode 100644 core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/TestContactPermissionResolver.kt create mode 100644 core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtAccessTest.kt create mode 100644 core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtTest.kt create mode 100644 core/common/build.gradle.kts create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/CoreCommonModule.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/cache/Cache.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/cache/ExpiringCache.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/cache/InMemoryCache.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/cache/SynchronizedCache.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddress.kt create mode 100644 core/common/src/test/kotlin/app/k9mail/core/common/CoreCommonModuleKtTest.kt create mode 100644 core/common/src/test/kotlin/app/k9mail/core/common/cache/CacheTest.kt create mode 100644 core/common/src/test/kotlin/app/k9mail/core/common/cache/ExpiringCacheTest.kt create mode 100644 core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailAddressTest.kt create mode 100644 core/testing/build.gradle.kts create mode 100644 core/testing/src/main/kotlin/app/k9mail/core/testing/TestClock.kt create mode 100644 core/testing/src/main/kotlin/assertk/assertions/ListExtensions.kt create mode 100644 core/testing/src/test/kotlin/app/k9mail/core/testing/TestClockTest.kt create mode 100644 core/testing/src/test/kotlin/assertk/assertions/ListExtensionsKtTest.kt create mode 100644 core/ui/compose/common/README.md create mode 100644 core/ui/compose/common/build.gradle.kts create mode 100644 core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/DevicePreviews.kt create mode 100644 core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeClass.kt create mode 100644 core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeInfo.kt create mode 100644 core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeClassTest.kt create mode 100644 core/ui/compose/designsystem/README.md create mode 100644 core/ui/compose/designsystem/assets/images/atomic_design.svg create mode 100644 core/ui/compose/designsystem/build.gradle.kts create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Background.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Checkbox.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Surface.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/Button.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonOutlined.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonText.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBody1.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBody2.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextButton.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextCaption.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadline1.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadline2.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadline3.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadline4.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadline5.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadline6.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextOverline.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextSubtitle1.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextSubtitle2.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/PasswordTextFieldOutlined.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlined.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/LazyColumnWithFooter.kt create mode 100644 core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContent.kt create mode 100644 core/ui/compose/designsystem/src/main/res/values/strings.xml create mode 100644 core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/PasswordTextFieldOutlinedKtTest.kt create mode 100644 core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldKtTest.kt create mode 100644 core/ui/compose/testing/README.md create mode 100644 core/ui/compose/testing/build.gradle.kts create mode 100644 core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/ComposeTest.kt create mode 100644 core/ui/compose/theme/README.md create mode 100644 core/ui/compose/theme/build.gradle.kts create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/Elevations.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/Images.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/K9Theme.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/MainTheme.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/PreviewWithThemes.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/Shapes.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/Sizes.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/Spacings.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/ThunderbirdTheme.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/Typography.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/color/Colors.kt create mode 100644 core/ui/compose/theme/src/main/java/app/k9mail/core/ui/compose/theme/color/MaterialColor.kt create mode 100644 core/ui/compose/theme/src/main/res/drawable/core_ui_theme_k9_logo.xml create mode 100644 core/ui/compose/theme/src/main/res/drawable/core_ui_theme_thunderbird_logo.xml create mode 100644 docs/DESIGN.md create mode 100644 docs/Modules.png create mode 100644 docs/ReadEmail.png create mode 100644 docs/ReadEmailClasses.png create mode 100644 docs/SendEmail.png create mode 100644 docs/activity_diagram.graphml create mode 100644 docs/draw.io/CreateAccount.xml create mode 100644 docs/draw.io/ImportExport.xml create mode 100644 docs/draw.io/Modules.xml create mode 100644 docs/draw.io/README.md create mode 100644 docs/draw.io/ReadEmail.xml create mode 100644 docs/draw.io/ReadEmailClasses.xml create mode 100644 docs/draw.io/SendEmail.xml create mode 100644 docs/google-play/full_description.txt create mode 100644 docs/google-play/short_description.txt create mode 100644 fastlane/metadata/android/de/changelogs/1.txt create mode 100644 fastlane/metadata/android/de/changelogs/10.txt create mode 100644 fastlane/metadata/android/de/changelogs/11.txt create mode 100644 fastlane/metadata/android/de/changelogs/12.txt create mode 100644 fastlane/metadata/android/de/changelogs/3.txt create mode 100644 fastlane/metadata/android/de/changelogs/4.txt create mode 100644 fastlane/metadata/android/de/changelogs/5.txt create mode 100644 fastlane/metadata/android/de/changelogs/7.txt create mode 100644 fastlane/metadata/android/de/changelogs/8.txt create mode 100644 fastlane/metadata/android/de/changelogs/9.txt create mode 100644 fastlane/metadata/android/de/description.txt create mode 100644 fastlane/metadata/android/de/summary.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/1.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/10.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/11.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/12.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/3.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/4.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/5.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/7.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/8.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/9.txt create mode 100644 fastlane/metadata/android/en-US/description.txt create mode 100644 fastlane/metadata/android/en-US/featureGraphic.png create mode 100644 fastlane/metadata/android/en-US/icon.png create mode 100644 fastlane/metadata/android/en-US/images/featureGraphic.png create mode 100644 fastlane/metadata/android/en-US/images/icon.png create mode 100755 fastlane/metadata/android/en-US/phoneScreenshots/00.png create mode 100755 fastlane/metadata/android/en-US/phoneScreenshots/01.png create mode 100755 fastlane/metadata/android/en-US/phoneScreenshots/02.png create mode 100755 fastlane/metadata/android/en-US/phoneScreenshots/03.png create mode 100755 fastlane/metadata/android/en-US/phoneScreenshots/04.png create mode 100644 fastlane/metadata/android/en-US/summary.txt create mode 100644 feature/onboarding/build.gradle.kts create mode 100644 feature/onboarding/src/main/kotlin/net/thunderbird/feature/onboarding/OnboardingContent.kt create mode 100644 feature/onboarding/src/main/kotlin/net/thunderbird/feature/onboarding/OnboardingScreen.kt create mode 100644 feature/onboarding/src/main/kotlin/net/thunderbird/feature/onboarding/navigation/OnboardingNavigation.kt create mode 100644 feature/onboarding/src/main/res/values/strings.xml create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100755 html-cleaner create mode 100644 images/K-9_Mail-debug.svg create mode 100644 images/K-9_Mail.eps create mode 100644 images/K-9_Mail.svg create mode 100644 images/K-9_Mail_512x512.png create mode 100644 images/drawable-src/btn_edit.png create mode 100644 images/drawable-src/btn_empty_disable.png create mode 100644 images/drawable-src/btn_empty_normal.png create mode 100644 images/drawable-src/btn_empty_pressed.png create mode 100644 images/drawable-src/btn_empty_selected.png create mode 100644 images/drawable-src/ic_action_delete.svg create mode 100644 images/drawable-src/ic_action_mark_as_read.svg create mode 100644 images/drawable-src/ic_action_remote_search.ai create mode 100644 images/drawable-src/ic_action_request_read_receipt_dark.svg create mode 100644 images/drawable-src/ic_action_request_read_receipt_light.svg create mode 100644 images/drawable-src/ic_action_single_message_options.svg create mode 100644 images/drawable-src/ic_button_archive.ai create mode 100644 images/drawable-src/ic_export.svg create mode 100644 images/drawable-src/ic_import.svg create mode 100644 images/drawable-src/ic_launcher_monochrome.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_0.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_0__legacy.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_1.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_1__legacy.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_2.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_2__legacy.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_3.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_3__legacy.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_4.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_4__legacy.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_5.svg create mode 100644 images/drawable-src/ic_notify_check_mail_anim_5__legacy.svg create mode 100644 images/drawable-src/ic_notify_new_mail.svg create mode 100644 images/drawable-src/ic_notify_new_mail__legacy.svg create mode 100644 images/drawable-src/ic_outbox.svg create mode 100644 images/drawable-src/ic_unread_widget.svg create mode 100644 images/drawable-src/ic_unread_widget_selected.svg create mode 100644 images/drawables-pgp/12x24/status_corner.svg create mode 100644 images/drawables-pgp/12x24/status_dots_1.svg create mode 100644 images/drawables-pgp/12x24/status_dots_2.svg create mode 100644 images/drawables-pgp/12x24/status_dots_3.svg create mode 100644 images/drawables-pgp/24x24/bullet_point_negative.svg create mode 100644 images/drawables-pgp/24x24/bullet_point_neutral.svg create mode 100644 images/drawables-pgp/24x24/bullet_point_positive.svg create mode 100644 images/drawables-pgp/24x24/compatibility.svg create mode 100644 images/drawables-pgp/24x24/status_lock.svg create mode 100644 images/drawables-pgp/24x24/status_lock_closed.svg create mode 100644 images/drawables-pgp/24x24/status_lock_disabled.svg create mode 100644 images/drawables-pgp/24x24/status_lock_error.svg create mode 100644 images/drawables-pgp/24x24/status_lock_open.svg create mode 100644 images/drawables-pgp/24x24/status_lock_opportunistic.svg create mode 100644 images/drawables-pgp/24x24/status_signature_expired_cutout.svg create mode 100644 images/drawables-pgp/24x24/status_signature_invalid_cutout.svg create mode 100644 images/drawables-pgp/24x24/status_signature_revoked_cutout.svg create mode 100644 images/drawables-pgp/24x24/status_signature_unknown_cutout.svg create mode 100644 images/drawables-pgp/24x24/status_signature_unverified_cutout.svg create mode 100644 images/drawables-pgp/24x24/status_signature_verified_cutout.svg create mode 100644 images/drawables-pgp/36x24/status_check_dots_1.svg create mode 100644 images/drawables-pgp/36x24/status_check_dots_2.svg create mode 100644 images/drawables-pgp/36x24/status_check_dots_3.svg create mode 100644 images/drawables-pgp/36x24/status_dots.svg create mode 100644 images/drawables-pgp/36x24/status_lock_disabled_dots_1.svg create mode 100644 images/drawables-pgp/36x24/status_lock_dots_1.svg create mode 100644 images/drawables-pgp/36x24/status_lock_dots_2.svg create mode 100644 images/drawables-pgp/36x24/status_lock_dots_3.svg create mode 100644 images/drawables-pgp/36x24/status_lock_error_dots_1.svg create mode 100644 images/drawables-pgp/36x24/status_lock_none_dots_1.svg create mode 100644 images/drawables-pgp/36x24/status_none_dots_1.svg create mode 100644 images/drawables-pgp/36x24/status_none_dots_2.svg create mode 100644 images/drawables-pgp/36x24/status_none_dots_3.svg create mode 100644 images/drawables-pgp/docs/disabled.svg create mode 100644 images/drawables-pgp/docs/signcrypt_confirmed.svg create mode 100644 images/drawables-pgp/docs/signcrypt_error.svg create mode 100644 images/drawables-pgp/docs/signcrypt_unconfirmed.svg create mode 100644 images/drawables-pgp/docs/signcrypt_unknown.svg create mode 100644 images/drawer_header_background.svg create mode 100755 images/drawer_header_background_generate.sh create mode 100644 images/feature_graphic.png create mode 100644 images/feature_graphic.svg create mode 100644 images/show_more_indicator.svg create mode 100644 mail/common/build.gradle.kts create mode 100644 mail/common/src/main/java/com/fsck/k9/helper/EmailHelper.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/helper/ExceptionHelper.java create mode 100644 mail/common/src/main/java/com/fsck/k9/logging/Logger.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/logging/NoOpLogger.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/logging/Timber.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/Address.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/AuthType.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/Authentication.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/AuthenticationFailedException.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/Body.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/BodyFactory.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/BodyPart.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/CertificateChainException.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/CertificateValidationException.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/ConnectionSecurity.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/DefaultBodyFactory.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/FetchProfile.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/Flag.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/FolderClass.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/FolderType.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/Header.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/K9MailLib.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/Message.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/MessageDownloadState.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/MessageRetrievalListener.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/MessagingException.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/MimeType.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/Multipart.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/NetworkTimeouts.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/Part.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/ServerSettings.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/filter/Base64.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/filter/Base64OutputStream.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/filter/CountingOutputStream.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/filter/EOLConvertingOutputStream.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/filter/FixedLengthInputStream.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/filter/Hex.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/filter/LineWrapOutputStream.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/filter/PeekableInputStream.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/filter/SignSafeOutputStream.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/filter/SmtpDataStuffing.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/helper/FetchProfileHelper.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/helper/Rfc822Token.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/helper/Rfc822Tokenizer.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/helper/TextUtils.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/helper/UrlEncodingHelper.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/helper/Utf8.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/AddressHeaderBuilder.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileBody.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileMessageBody.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/CharsetSupport.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/DecoderUtil.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/EncoderUtil.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/FlowedMessageUtils.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/FormatFlowedHelper.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/Headers.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/Iso2022JpToShiftJisInputStream.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/JisSupport.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MessageExtractor.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MessageIdGenerator.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MessageIdParser.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeBodyPart.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeExtensions.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeader.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderChecker.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderEncoder.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeHeaderParser.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMessage.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMessageHelper.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeMultipart.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterDecoder.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeUtility.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/MimeValue.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/ParameterSection.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/PartExtensions.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/RawDataBody.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/SizeAware.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/TextBody.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/internet/Viewable.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/message/MessageHeaderParser.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/oauth/XOAuth2ChallengeParser.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/oauth/XOAuth2Response.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/power/PowerManager.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/power/WakeLock.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/ssl/KeyStoreDirectoryProvider.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/ssl/TrustManagerFactory.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mail/ssl/TrustedSocketFactory.java create mode 100644 mail/common/src/main/java/com/fsck/k9/mailstore/BinaryMemoryBody.java create mode 100644 mail/common/src/main/java/com/fsck/k9/sasl/OAuthBearer.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/AddressTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/Address_quoteAtoms.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/MessageTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/MimeTypeTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/filter/EOLConvertingOutputStreamTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/filter/FixedLengthInputStreamTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/filter/SignSafeOutputStreamTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/filter/SmtpDataStuffingTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/AddressHeaderBuilderTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/CharsetSupportTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/DecoderUtilTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/EncoderUtilTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/FlowedMessageUtilsTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/FormatFlowedHelperTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/MessageExtractorTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/MessageIdGeneratorTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/MessageIdParserTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/MimeHeaderCheckerTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/MimeMessageParseTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterDecoderTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterEncoderTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/MimeUtilityTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/PartExtensionsTest.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/TestCharsetProvider.kt create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/internet/TextBodyTest.java create mode 100644 mail/common/src/test/java/com/fsck/k9/mail/ssl/LocalKeyStoreTest.kt create mode 100644 mail/common/src/test/resources/META-INF/services/java.nio.charset.spi.CharsetProvider create mode 100644 mail/common/src/test/resources/certificates/mail.another-domain.example.pem create mode 100644 mail/common/src/test/resources/certificates/mail.domain.example.pem create mode 100644 mail/protocols/imap/build.gradle.kts create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/AlertResponse.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Capabilities.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/CapabilityResponse.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Commands.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FetchBodyCallback.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FetchPartCallback.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderListItem.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNotFoundException.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdleRefreshManager.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdleRefreshTimeoutProvider.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapCommandSplitter.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnection.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnectionManager.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnectionProvider.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolder.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolderIdler.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapList.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapMessage.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponse.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseCallback.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParserException.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreConfig.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreSettings.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapUtility.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/InternalImapStore.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ListResponse.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/NamespaceResponse.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/NegativeImapResponseException.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponse.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolderIdler.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractor.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseTextExtractor.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Responses.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/SearchResponse.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponse.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilder.java create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidValidityResponse.kt create mode 100644 mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UntaggedHandler.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/AlertResponseTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/CapabilityResponseTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapListTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseHelper.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapUtilityTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ListResponseTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/NamespaceResponseTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponseTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderIdlerTest.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractorTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseTextExtractorTest.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SearchResponseTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponseTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestIdleRefreshManager.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapConnection.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapFolder.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapStore.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestWakeLock.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidValidityResponseTest.kt create mode 100644 mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/mockserver/MockImapServer.java create mode 100644 mail/protocols/pop3/build.gradle.kts create mode 100644 mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Capabilities.java create mode 100644 mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Commands.java create mode 100644 mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Connection.java create mode 100644 mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3ErrorResponse.java create mode 100644 mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Folder.java create mode 100644 mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Message.java create mode 100644 mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3ResponseInputStream.java create mode 100644 mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Settings.java create mode 100644 mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Store.java create mode 100644 mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/MockPop3Server.java create mode 100644 mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3CapabilitiesTest.java create mode 100644 mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt create mode 100644 mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt create mode 100644 mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt create mode 100644 mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/SimplePop3Settings.java create mode 100644 mail/protocols/smtp/build.gradle.kts create mode 100644 mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/EnhancedStatusCode.kt create mode 100644 mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/NegativeSmtpReplyException.kt create mode 100644 mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpHelloResponse.kt create mode 100644 mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpLogger.kt create mode 100644 mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponse.kt create mode 100644 mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt create mode 100644 mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserException.kt create mode 100644 mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt create mode 100644 mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/StatusCodeClass.kt create mode 100644 mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/mockServer/MockSmtpServer.java create mode 100644 mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt create mode 100644 mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseTest.kt create mode 100644 mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt create mode 100644 mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/TestSmtpLogger.kt create mode 100644 mail/protocols/webdav/build.gradle.kts create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/ConnectionInfo.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/DataSet.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/DraftsFolderProvider.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/HttpGeneric.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/ParsedMessageEnvelope.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/SniHostSetter.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavConstants.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavFolder.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavHandler.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavHttpClient.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavMessage.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavSocketFactory.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavStore.java create mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavStoreSettings.java create mode 100644 mail/protocols/webdav/src/test/java/com/fsck/k9/mail/store/webdav/WebDavFolderTest.java create mode 100644 mail/protocols/webdav/src/test/java/com/fsck/k9/mail/store/webdav/WebDavMessageTest.java create mode 100644 mail/protocols/webdav/src/test/java/com/fsck/k9/mail/store/webdav/WebDavStoreTest.java create mode 100644 mail/testing/build.gradle.kts create mode 100644 mail/testing/src/main/java/com/fsck/k9/mail/MessageBuilderDsl.kt create mode 100644 mail/testing/src/main/java/com/fsck/k9/mail/StringHelper.kt create mode 100644 mail/testing/src/main/java/com/fsck/k9/mail/SystemOutLogger.kt create mode 100644 mail/testing/src/main/java/com/fsck/k9/mail/TestMessageConstructionUtils.java create mode 100644 mail/testing/src/main/java/com/fsck/k9/mail/XOAuth2ChallengeParserTest.java create mode 100644 mail/testing/src/main/java/com/fsck/k9/mail/helpers/KeyStoreProvider.java create mode 100644 mail/testing/src/main/java/com/fsck/k9/mail/helpers/TestMessage.java create mode 100644 mail/testing/src/main/java/com/fsck/k9/mail/helpers/TestMessageBuilder.java create mode 100644 mail/testing/src/main/java/com/fsck/k9/mail/helpers/TestTrustedSocketFactory.java create mode 100644 mail/testing/src/main/java/com/fsck/k9/mail/helpers/VeryTrustingTrustManager.java create mode 100644 mail/testing/src/main/resources/keystore.jks create mode 100644 plugins/openpgp-api-lib/CHANGELOG.md create mode 100644 plugins/openpgp-api-lib/LICENSE create mode 100644 plugins/openpgp-api-lib/README.md create mode 100644 plugins/openpgp-api-lib/openpgp-api/build.gradle.kts create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/aidl/org/openintents/openpgp/IOpenPgpService.aidl create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/aidl/org/openintents/openpgp/IOpenPgpService2.aidl create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/AutocryptPeerUpdate.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/OpenPgpApiManager.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/OpenPgpDecryptionResult.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/OpenPgpError.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/OpenPgpMetadata.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/OpenPgpSignatureResult.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/util/OpenPgpApi.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/util/OpenPgpKeyPreference.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/util/OpenPgpProviderUtil.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/util/OpenPgpServiceConnection.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/util/OpenPgpUtils.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/util/ParcelFileDescriptorUtil.java create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/drawable-hdpi/ic_action_cancel_launchersize.png create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/drawable-hdpi/ic_action_cancel_launchersize_light.png create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/drawable-mdpi/ic_action_cancel_launchersize.png create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/drawable-mdpi/ic_action_cancel_launchersize_light.png create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/drawable-xhdpi/ic_action_cancel_launchersize.png create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/drawable-xhdpi/ic_action_cancel_launchersize_light.png create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/drawable-xxhdpi/ic_action_cancel_launchersize.png create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/drawable-xxhdpi/ic_action_cancel_launchersize_light.png create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-ar/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-bg/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-cs/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-de/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-es/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-et/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-eu/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-fi/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-fr/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-is/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-it/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-ja/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-nl/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-pl/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-pt/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-ro/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-ru/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-sl/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-sr/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-sv/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-tr/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-uk/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-zh-rTW/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values-zh/strings.xml create mode 100644 plugins/openpgp-api-lib/openpgp-api/src/main/res/values/strings.xml create mode 100644 settings.gradle.kts create mode 100755 tools/debian_build.sh create mode 100755 tools/fix_all_transifex_output.sh create mode 100755 tools/fix_transifex_output.sh create mode 100644 ui-flows/README.md create mode 100644 ui-flows/screenshots/user_manual_account_setup.yml create mode 100644 ui-flows/screenshots/user_manual_accounts.yml create mode 100644 ui-flows/screenshots/user_manual_reading.yml create mode 100644 ui-flows/shared/add_contact.yml create mode 100644 ui-flows/shared/change_display_settings_show_contact_names.yml create mode 100644 ui-flows/shared/close_display_settings.yml create mode 100644 ui-flows/shared/login_demo_account.yml create mode 100644 ui-flows/shared/open_display_settings.yml create mode 100644 ui-flows/shared/open_message_details.yml create mode 100644 ui-flows/shared/remove_contact.yml create mode 100644 ui-flows/validate/compose_simple_message.yml create mode 100644 ui-flows/validate/message_details_show_contact_names.yml create mode 100644 ui-utils/ItemTouchHelper/build.gradle.kts create mode 100644 ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java create mode 100644 ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchUIUtilImpl.java create mode 100644 ui-utils/LinearLayoutManager/build.gradle.kts create mode 100644 ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LayoutManager.java create mode 100644 ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LinearLayoutManager.java create mode 100644 ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/ScrollbarHelper.java create mode 100644 ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/ViewBoundsCheck.java create mode 100644 ui-utils/ToolbarBottomSheet/build.gradle.kts create mode 100644 ui-utils/ToolbarBottomSheet/src/main/java/app/k9mail/ui/utils/bottomsheet/LayoutAwareBottomSheetBehavior.kt create mode 100644 ui-utils/ToolbarBottomSheet/src/main/java/app/k9mail/ui/utils/bottomsheet/LayoutAwareBottomSheetCallback.kt create mode 100644 ui-utils/ToolbarBottomSheet/src/main/java/app/k9mail/ui/utils/bottomsheet/ToolbarBottomSheetDialog.kt create mode 100644 ui-utils/ToolbarBottomSheet/src/main/java/app/k9mail/ui/utils/bottomsheet/ToolbarBottomSheetDialogFragment.kt create mode 100644 ui-utils/ToolbarBottomSheet/src/main/res/layout/design_bottom_sheet_dialog.xml create mode 100644 user-manual/README.md create mode 100755 user-manual/build_images.sh create mode 100755 user-manual/process_screenshots.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..56b592b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +insert_final_newline = true + +[*.{kt,kts}] +ij_kotlin_imports_layout = *,^ +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true + +[*.{yml,yaml,json,toml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..411c077 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0a1a43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Local per-repo rules can be added to the .git/info/exclude file in your +# repo. These rules are not committed with the repo so they are not shared +# with others. This method can be used for locally-generated files that you +# don’t expect other users to generate, like files created by your editor. +.DS_Store +.settings +.classpath +bin +captures +coverage +coverage.ec +coverage.em +gen +javadoc +junit-report.xml +lint-results.*ml +lint-results_files +local.properties +monkey.txt +*~ +*.iws +atlassian-ide-plugin.xml +target +build +.gradle +out +build.xml +proguard-project.txt +.idea/ +*.iml +user-manual/screenshots +user-manual/output diff --git a/.tx/config b/.tx/config new file mode 100644 index 0000000..12eb537 --- /dev/null +++ b/.tx/config @@ -0,0 +1,8 @@ +[main] +host = https://www.transifex.com +lang_map = de_LI: de-rLI, es_GT: es-rGT, fa_IR: fa-rIR, fo_FO: fo-rFO, am_ET: am-rET, ar_DZ: ar-rDZ, ca_ES: ca-rES, ii_CN: ii-rCN, mr_IN: mr-rIN, ms_BN: ms-rBN, zh_MO: zh-rMO, ba_RU: ba-rRU, es_MX: es-rMX, es_PE: es-rPE, tk_TM: tk-rTM, es_PR: es-rPR, pt_BR: pt-rBR, smn_FI: smn-rFI, xh_ZA: xh-rZA, zu_ZA: zu-rZA, ar_EG: ar-rEG, si_LK: si-rLK, tzm_DZ: tzm-rDZ, es_ES: es-rES, ne_NP: ne-rNP, qut_GT: qut-rGT, th_TH: th-rTH, he_IL: iw-rIL, cy_GB: cy-rGB, es_AR: es-rAR, es_EC: es-rEC, fr_FR: fr-rFR, gu_IN: gu-rIN, ja_JP: ja-rJP, pl_PL: pl-rPL, sr_CS: sr-rCS, ar_YE: ar-rYE, dsb_DE: dsb-rDE, en_ZA: en-rZA, se_FI: se-rFI, tt_RU: tt-rRU, uk_UA: uk-rUA, zh_CN: zh-rCN, ar_OM: ar-rOM, et_EE: et-rEE, ky_KG: ky-rKG, el_GR: el-rGR, es_PY: es-rPY, hi_IN: hi-rIN, sma_SE: sma-rSE, tn_ZA: tn-rZA, ar_IQ: ar-rIQ, en_TT: en-rTT, se_NO: se-rNO, kk_KZ: kk-rKZ, mn_CN: mn-rCN, mt_MT: mt-rMT, nso_ZA: nso-rZA, ro_RO: ro-rRO, ar_LB: ar-rLB, es_VE: es-rVE, ka_GE: ka-rGE, nl_BE: nl-rBE, pa_IN: pa-rIN, es_SV: es-rSV, it_CH: it-rCH, lb_LU: lb-rLU, arn_CL: arn-rCL, bo_CN: bo-rCN, en_GB: en-rGB, fi_FI: fi-rFI, mi_NZ: mi-rNZ, ar_BH: ar-rBH, ar_MA: ar-rMA, ar_SY: ar-rSY, vi_VN: vi-rVN, fr_CH: fr-rCH, ko_KR: ko-rKR, quz_PE: quz-rPE, af_ZA: af-rZA, dv_MV: dv-rMV, en_JM: en-rJM, sr_ME: sr-rME, sv_SE: sv-rSE, ur_PK: ur-rPK, zh_SG: zh-rSG, ar_QA: ar-rQA, nb_NO: nb-rNO, sk_SK: sk-rSK, hr_HR: hr-rHR, kok_IN: kok-rIN, ms_MY: ms-rMY, nl_NL: nl-rNL, te_IN: te-rIN, en_US: en-rUS, en_ZW: en-rZW, fr_LU: fr-rLU, syr_SY: syr-rSY, he: iw, en_NZ: en-rNZ, fr_MC: fr-rMC, ru_RU: ru-rRU, es_PA: es-rPA, es_UY: es-rUY, se_SE: se-rSE, as_IN: as-rIN, de_LU: de-rLU, es_DO: es-rDO, sms_FI: sms-rFI, quz_BO: quz-rBO, smj_NO: smj-rNO, sr_BA: sr-rBA, sv_FI: sv-rFI, ar_TN: ar-rTN, en_CA: en-rCA, ig_NG: ig-rNG, de_DE: de-rDE, es_NI: es-rNI, it_IT: it-rIT, pt_PT: pt-rPT, sah_RU: sah-rRU, ar_AE: ar-rAE, da_DK: da-rDK, de_AT: de-rAT, sq_AL: sq-rAL, sr_RS: sr-rRS, no_NO: no-rNO, ar_KW: ar-rKW, nn_NO: nn-rNO, sma_NO: sma-rNO, gsw_FR: gsw-rFR, hr_BA: hr-rBA, prs_AF: prs-rAF, hu_HU: hu-rHU, id_ID: id-rID, is_IS: is-rIS, kl_GL: kl-rGL, lt_LT: lt-rLT, en_AU: en-rAU, en_BZ: en-rBZ, en_PH: en-rPH, rm_CH: rm-rCH, cs_CZ: cs-rCZ, es_CL: es-rCL, fil_PH: fil-rPH, fr_BE: fr-rBE, ga_IE: ga-rIE, ar_LY: ar-rLY, bn_IN: bn-rIN, co_FR: co-rFR, gd_GB: gd-rGB, or_IN: or-rIN, ta_IN: ta-rIN, yo_NG: yo-rNG, en_MY: en-rMY, lv_LV: lv-rLV, sa_IN: sa-rIN, km_KH: km-rKH, mk_MK: mk-rMK, ps_AF: ps-rAF, rw_RW: rw-rRW, uz_UZ: uz-rUZ, ar_SA: ar-rSA, be_BY: be-rBY, es_CO: es-rCO, hsb_DE: hsb-rDE, de_CH: de-rCH, es_CR: es-rCR, eu_ES: eu-rES, ug_CN: ug-rCN, es_BO: es-rBO, gl_ES: gl-rES, sl_SI: sl-rSI, es_US: es-rUS, fy_NL: fy-rNL, kn_IN: kn-rIN, oc_FR: oc-rFR, tg_TJ: tg-rTJ, bg_BG: bg-rBG, bn_BD: bn-rBD, bs_BA: bs-rBA, tr_TR: tr-rTR, wo_SN: wo-rSN, az_AZ: az-rAZ, en_SG: en-rSG, moh_CA: moh-rCA, fr_CA: fr-rCA, ha_NG: ha-rNG, lo_LA: lo-rLA, mn_MN: mn-rMN, quz_EC: quz-rEC, en_IE: en-rIE, en_IN: en-rIN, es_HN: es-rHN, sw_KE: sw-rKE, id: in, iu_CA: iu-rCA, ml_IN: ml-rIN, smj_SE: smj-rSE, zh_HK: zh-rHK, zh_TW: zh-rTW, ar_JO: ar-rJO, br_FR: br-rFR, hy_AM: hy-rAM + +[o:k-9:p:k9mail:r:strings] +file_filter = app/ui/legacy/src/main/res/values-/strings.xml +source_file = app/ui/legacy/src/main/res/values/strings.xml +source_lang = en diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..a415c81 --- /dev/null +++ b/NOTICE @@ -0,0 +1,3 @@ +K-9 Mail +Copyright 2008-2016, K-9 Mail Developers +Copyright 2005-2016, The Android Open Source Project diff --git a/README.md b/README.md index 2802dbc..9e6532e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ -# mail +# K-9 Mail -Monocles E-Mail Client for Android \ No newline at end of file +[![Latest release](https://img.shields.io/github/release/thundernest/k-9.svg?style=flat-square)](https://github.com/thundernest/k-9/releases/latest) +[![Latest beta release](https://img.shields.io/github/v/release/thundernest/k-9.svg?include_prereleases&style=flat-square)](https://github.com/thundernest/k-9/releases) + +K-9 Mail is an open-source email client for Android. + +## Download + +K-9 Mail can be downloaded from a couple of sources: + +- [Google Play](https://play.google.com/store/apps/details?id=com.fsck.k9) +- [F-Droid](https://f-droid.org/repository/browse/?fdid=com.fsck.k9) +- [Github Releases](https://github.com/thundernest/k-9/releases) + +You might also be interested in becoming a [tester](https://forum.k9mail.app/t/how-do-i-become-a-beta-tester/68) to get an early look at new versions. + +## Release Notes + +Check out the [Release Notes](https://github.com/thundernest/k-9/wiki/ReleaseNotes) to find out what changed +in each version of K-9 Mail. + +## Need Help? + +If the app is not behaving like it should, you might find these resources helpful: + +- [User Manual](https://docs.k9mail.app/) +- [Frequently Asked Questions](https://forum.k9mail.app/c/faq) +- [Support Forum](https://forum.k9mail.app/) + +## Translations + +Interested in helping to translate K-9 Mail? Contribute here: + +https://www.transifex.com/projects/p/k9mail/ + +## Contributing + +Thank you for contributing! If you're unfamiliar with the code, start by reading the [developer documentation](docs/DESIGN.md) + +Please fork this repository and contribute back using [pull requests](https://github.com/thundernest/k-9/pulls). + +Any contributions, large or small, major features, bug fixes, unit/integration tests are welcomed and appreciated +but will be thoroughly reviewed and discussed. +Please make sure you read the [Code Style Guidelines](https://github.com/thundernest/k-9/wiki/CodeStyle). + +## Communication + +Aside from discussing changes in [pull requests](https://github.com/thundernest/k-9/pulls) and +[issues](https://github.com/thundernest/k-9/issues) we use the following communication services: + +- Matrix: [#k9mail:matrix.org](https://matrix.to/#/#tb-android:mozilla.org) +- IRC: [#k9mail on Libera Chat](https://web.libera.chat/#k9mail) +- [Developer mailing list](https://groups.google.com/forum/#!forum/k-9-dev) + +## License + + 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. diff --git a/app-ui-catalog/README.md b/app-ui-catalog/README.md new file mode 100644 index 0000000..1d5eac2 --- /dev/null +++ b/app-ui-catalog/README.md @@ -0,0 +1,7 @@ +# Thunderbird UI Catalog + +Uses [`:core:ui:compose:designsystem`](../core/ui/compose/designsystem/README.md) + +This is a catalog of all the components in the Thunderbird design system. + +It is a work in progress, and will be updated as the design system evolves. diff --git a/app-ui-catalog/build.gradle.kts b/app-ui-catalog/build.gradle.kts new file mode 100644 index 0000000..dabda86 --- /dev/null +++ b/app-ui-catalog/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id(ThunderbirdPlugins.App.androidCompose) +} + +android { + namespace = "app.k9mail.ui.catalog" + + defaultConfig { + applicationId = "app.k9mail.ui.catalog" + versionCode = 1 + versionName = "1.0" + } +} + +dependencies { + implementation(projects.core.ui.compose.designsystem) + implementation(libs.androidx.compose.material) + + androidTestImplementation(libs.androidx.test.ext.junit.ktx) + androidTestImplementation(libs.androidx.test.espresso.core) +} diff --git a/app-ui-catalog/proguard-rules.pro b/app-ui-catalog/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/app-ui-catalog/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app-ui-catalog/src/main/AndroidManifest.xml b/app-ui-catalog/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ee15c4a --- /dev/null +++ b/app-ui-catalog/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/app-ui-catalog/src/main/ic_launcher-playstore.png b/app-ui-catalog/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..ef882246865da04e2fc9c9df7337a0218417de0c GIT binary patch literal 21464 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
  • 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 Unit, + onThemeVariantChange: () -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + Surface { + ResponsiveContent { + LazyVerticalGrid( + columns = GridCells.Adaptive(300.dp), + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + modifier = modifier.padding(MainTheme.spacings.double), + ) { + themeHeaderItem(text = "Thunderbird Catalog") + themeSelectorItems( + catalogTheme = catalogTheme, + catalogThemeVariant = catalogThemeVariant, + onThemeChange = onThemeChange, + onThemeVariantChange = onThemeVariantChange, + ) + + typographyItems() + colorItems() + buttonItems() + selectionControlItems() + textFieldItems() + imageItems() + } + } + } +} + +@DevicePreviews +@Composable +internal fun CatalogContentK9ThemePreview() { + K9Theme { + CatalogContent( + catalogTheme = CatalogTheme.K9, + catalogThemeVariant = CatalogThemeVariant.LIGHT, + onThemeChange = {}, + onThemeVariantChange = {}, + contentPadding = PaddingValues(), + ) + } +} + +@DevicePreviews +@Composable +internal fun CatalogContentThunderbirdThemePreview() { + ThunderbirdTheme { + CatalogContent( + catalogTheme = CatalogTheme.THUNDERBIRD, + catalogThemeVariant = CatalogThemeVariant.LIGHT, + onThemeChange = {}, + onThemeVariantChange = {}, + contentPadding = PaddingValues(), + ) + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogScreen.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogScreen.kt new file mode 100644 index 0000000..f1100a7 --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogScreen.kt @@ -0,0 +1,50 @@ +package app.k9mail.ui.catalog + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.DevicePreviews + +@Composable +fun CatalogScreen( + modifier: Modifier = Modifier, +) { + val themeState = remember { mutableStateOf(CatalogTheme.K9) } + val themeVariantState = remember { mutableStateOf(CatalogThemeVariant.LIGHT) } + + CatalogThemeSwitch(theme = themeState.value, themeVariation = themeVariantState.value) { + val contentPadding = WindowInsets.systemBars.asPaddingValues() + + CatalogContent( + catalogTheme = themeState.value, + catalogThemeVariant = themeVariantState.value, + onThemeChange = { + themeState.value = when (themeState.value) { + CatalogTheme.K9 -> CatalogTheme.THUNDERBIRD + CatalogTheme.THUNDERBIRD -> CatalogTheme.K9 + } + }, + onThemeVariantChange = { + themeVariantState.value = when (themeVariantState.value) { + CatalogThemeVariant.LIGHT -> CatalogThemeVariant.DARK + CatalogThemeVariant.DARK -> CatalogThemeVariant.LIGHT + } + }, + contentPadding = contentPadding, + modifier = Modifier + .fillMaxSize() + .then(modifier), + ) + } +} + +@DevicePreviews +@Composable +internal fun CatalogScreenPreview() { + CatalogScreen() +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogTheme.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogTheme.kt new file mode 100644 index 0000000..123dd4e --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogTheme.kt @@ -0,0 +1,13 @@ +package app.k9mail.ui.catalog + +enum class CatalogTheme( + private val displayName: String, +) { + K9("K-9"), + THUNDERBIRD("Thunderbird"), + ; + + override fun toString(): String { + return displayName + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeSelector.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeSelector.kt new file mode 100644 index 0000000..88896fa --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeSelector.kt @@ -0,0 +1,29 @@ +package app.k9mail.ui.catalog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.button.Button +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBody1 +import app.k9mail.core.ui.compose.theme.MainTheme + +@Composable +fun CatalogThemeSelector( + catalogTheme: CatalogTheme, + modifier: Modifier = Modifier, + onThemeChangeClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + TextBody1(text = "Change theme:") + Button( + text = catalogTheme.toString(), + onClick = onThemeChangeClick, + ) + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeSwitch.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeSwitch.kt new file mode 100644 index 0000000..c0138b2 --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeSwitch.kt @@ -0,0 +1,26 @@ +package app.k9mail.ui.catalog + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.theme.K9Theme +import app.k9mail.core.ui.compose.theme.ThunderbirdTheme + +@Composable +fun CatalogThemeSwitch( + theme: CatalogTheme, + themeVariation: CatalogThemeVariant, + content: @Composable () -> Unit, +) { + when (theme) { + CatalogTheme.K9 -> K9Theme( + darkTheme = isDarkVariation(themeVariation), + content = content, + ) + CatalogTheme.THUNDERBIRD -> ThunderbirdTheme( + darkTheme = isDarkVariation(themeVariation), + content = content, + ) + } +} + +private fun isDarkVariation(themeVariation: CatalogThemeVariant): Boolean = + themeVariation == CatalogThemeVariant.DARK diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeVariant.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeVariant.kt new file mode 100644 index 0000000..0b8cee4 --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeVariant.kt @@ -0,0 +1,5 @@ +package app.k9mail.ui.catalog + +enum class CatalogThemeVariant { + LIGHT, DARK +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeVariantSelector.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeVariantSelector.kt new file mode 100644 index 0000000..31c85e7 --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/CatalogThemeVariantSelector.kt @@ -0,0 +1,29 @@ +package app.k9mail.ui.catalog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.Checkbox +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBody1 +import app.k9mail.core.ui.compose.theme.MainTheme + +@Composable +fun CatalogThemeVariantSelector( + catalogThemeVariant: CatalogThemeVariant, + modifier: Modifier = Modifier, + onThemeVariantChange: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + TextBody1(text = "Set dark mode:") + Checkbox( + checked = catalogThemeVariant == CatalogThemeVariant.DARK, + onCheckedChange = { onThemeVariantChange() }, + ) + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ButtonItems.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ButtonItems.kt new file mode 100644 index 0000000..c2f03df --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ButtonItems.kt @@ -0,0 +1,40 @@ +package app.k9mail.ui.catalog.items + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.grid.LazyGridScope +import app.k9mail.core.ui.compose.designsystem.atom.button.Button +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonOutlined +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText +import app.k9mail.core.ui.compose.theme.MainTheme + +fun LazyGridScope.buttonItems() { + sectionHeaderItem(text = "Buttons") + sectionSubtitleItem(text = "Contained") + item { + Row( + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + Button(text = "Enabled", onClick = { }) + Button(text = "Disabled", onClick = { }, enabled = false) + } + } + sectionSubtitleItem(text = "Outlined") + item { + Row( + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + ButtonOutlined(text = "Enabled", onClick = { }) + ButtonOutlined(text = "Disabled", onClick = { }, enabled = false) + } + } + sectionSubtitleItem(text = "Text") + item { + Row( + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + ButtonText(text = "Enabled", onClick = { }) + ButtonText(text = "Disabled", onClick = { }, enabled = false) + } + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ColorItems.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ColorItems.kt new file mode 100644 index 0000000..34a49ce --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ColorItems.kt @@ -0,0 +1,78 @@ +package app.k9mail.ui.catalog.items + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBody1 +import app.k9mail.core.ui.compose.theme.MainTheme + +fun LazyGridScope.colorItems() { + sectionHeaderItem(text = "Colors") + item { + ColorContent( + name = "Primary", + color = MainTheme.colors.primary, + ) + } + item { + ColorContent( + name = "Primary Variant", + color = MainTheme.colors.primaryVariant, + ) + } + item { + ColorContent( + name = "Secondary", + color = MainTheme.colors.secondary, + ) + } + item { + ColorContent( + name = "Secondary Variant", + color = MainTheme.colors.secondaryVariant, + ) + } + item { + ColorContent( + name = "Background", + color = MainTheme.colors.background, + ) + } + item { + ColorContent( + name = "Surface", + color = MainTheme.colors.surface, + ) + } + item { + ColorContent( + name = "Error", + color = MainTheme.colors.error, + ) + } +} + +@Composable +private fun ColorContent( + name: String, + color: Color, + modifier: Modifier = Modifier, +) { + Surface( + color = color, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = modifier.padding(MainTheme.spacings.double), + ) { + TextBody1(text = name) + } + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ImageItems.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ImageItems.kt new file mode 100644 index 0000000..e4ff3e9 --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ImageItems.kt @@ -0,0 +1,13 @@ +package app.k9mail.ui.catalog.items + +import androidx.compose.foundation.Image +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.ui.res.painterResource +import app.k9mail.core.ui.compose.theme.MainTheme + +fun LazyGridScope.imageItems() { + sectionHeaderItem(text = "Images") + item { + Image(painter = painterResource(id = MainTheme.images.logo), contentDescription = "logo") + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SectionHeaderItem.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SectionHeaderItem.kt new file mode 100644 index 0000000..7e14402 --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SectionHeaderItem.kt @@ -0,0 +1,16 @@ +package app.k9mail.ui.catalog.items + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline6 +import app.k9mail.core.ui.compose.theme.MainTheme + +fun LazyGridScope.sectionHeaderItem( + text: String, +) { + item(span = { GridItemSpan(maxLineSpan) }) { + TextHeadline6(text = text, modifier = Modifier.padding(top = MainTheme.spacings.default)) + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SectionSubtitleItem.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SectionSubtitleItem.kt new file mode 100644 index 0000000..015466a --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SectionSubtitleItem.kt @@ -0,0 +1,16 @@ +package app.k9mail.ui.catalog.items + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextSubtitle1 +import app.k9mail.core.ui.compose.theme.MainTheme + +fun LazyGridScope.sectionSubtitleItem( + text: String, +) { + item(span = { GridItemSpan(maxLineSpan) }) { + TextSubtitle1(text = text, modifier = Modifier.padding(top = MainTheme.spacings.default)) + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SelectionControlItems.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SelectionControlItems.kt new file mode 100644 index 0000000..cd583cb --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/SelectionControlItems.kt @@ -0,0 +1,39 @@ +package app.k9mail.ui.catalog.items + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import app.k9mail.core.ui.compose.designsystem.atom.Checkbox +import app.k9mail.core.ui.compose.designsystem.atom.text.TextCaption + +fun LazyGridScope.selectionControlItems() { + sectionHeaderItem(text = "Selection Controls") + sectionSubtitleItem(text = "Checkbox") + captionItem(caption = "Checked") { + Checkbox(checked = true, onCheckedChange = {}) + } + captionItem(caption = "Unchecked") { + Checkbox(checked = false, onCheckedChange = {}) + } + captionItem(caption = "Disabled Checked") { + Checkbox(checked = true, onCheckedChange = {}, enabled = false) + } + captionItem(caption = "Disabled") { + Checkbox(checked = false, onCheckedChange = {}, enabled = false) + } +} + +private fun LazyGridScope.captionItem( + caption: String, + content: @Composable () -> Unit, +) { + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content() + TextCaption(text = caption) + } + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/TextFieldItems.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/TextFieldItems.kt new file mode 100644 index 0000000..9bfdc5f --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/TextFieldItems.kt @@ -0,0 +1,90 @@ +package app.k9mail.ui.catalog.items + +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import app.k9mail.core.ui.compose.designsystem.atom.textfield.PasswordTextFieldOutlined +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlined + +fun LazyGridScope.textFieldItems() { + sectionHeaderItem(text = "Text fields") + textFieldOutlinedItems() + passwordTextFieldOutlinedItems() +} + +private fun LazyGridScope.textFieldOutlinedItems() { + sectionSubtitleItem(text = "Outlined") + item { + WithRememberedInput(text = "Initial text") { input -> + TextFieldOutlined( + value = input.value, + label = "Label", + onValueChange = { input.value = it }, + ) + } + } + item { + WithRememberedInput(text = "Input text with error") { input -> + TextFieldOutlined( + value = input.value, + label = "Label", + onValueChange = { input.value = it }, + isError = true, + ) + } + } + item { + WithRememberedInput(text = "Input text disabled") { input -> + TextFieldOutlined( + value = input.value, + label = "Label", + onValueChange = { input.value = it }, + enabled = false, + ) + } + } +} + +private fun LazyGridScope.passwordTextFieldOutlinedItems() { + sectionSubtitleItem(text = "Password outlined") + item { + WithRememberedInput(text = "Input text") { input -> + PasswordTextFieldOutlined( + value = input.value, + label = "Label", + onValueChange = { input.value = it }, + ) + } + } + item { + WithRememberedInput(text = "Input text with error") { input -> + PasswordTextFieldOutlined( + value = input.value, + label = "Label", + onValueChange = { input.value = it }, + isError = true, + ) + } + } + item { + WithRememberedInput(text = "Input text disabled") { input -> + PasswordTextFieldOutlined( + value = input.value, + label = "Label", + onValueChange = { input.value = it }, + enabled = false, + ) + } + } +} + +@Composable +private fun WithRememberedInput( + text: String, + content: @Composable (text: MutableState) -> Unit, +) { + val inputText = remember { mutableStateOf(text) } + content(inputText) +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ThemeHeaderItem.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ThemeHeaderItem.kt new file mode 100644 index 0000000..023b72e --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ThemeHeaderItem.kt @@ -0,0 +1,16 @@ +package app.k9mail.ui.catalog.items + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline4 +import app.k9mail.core.ui.compose.theme.MainTheme + +fun LazyGridScope.themeHeaderItem( + text: String, +) { + item(span = { GridItemSpan(maxLineSpan) }) { + TextHeadline4(text = text, modifier = Modifier.padding(top = MainTheme.spacings.default)) + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ThemeSelectorItems.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ThemeSelectorItems.kt new file mode 100644 index 0000000..38dc19a --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/ThemeSelectorItems.kt @@ -0,0 +1,31 @@ +package app.k9mail.ui.catalog.items + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.ui.Modifier +import app.k9mail.ui.catalog.CatalogTheme +import app.k9mail.ui.catalog.CatalogThemeSelector +import app.k9mail.ui.catalog.CatalogThemeVariant +import app.k9mail.ui.catalog.CatalogThemeVariantSelector + +fun LazyGridScope.themeSelectorItems( + catalogTheme: CatalogTheme, + catalogThemeVariant: CatalogThemeVariant, + onThemeChange: () -> Unit, + onThemeVariantChange: () -> Unit, +) { + item { + CatalogThemeSelector( + catalogTheme = catalogTheme, + modifier = Modifier.fillMaxWidth(), + onThemeChangeClick = onThemeChange, + ) + } + item { + CatalogThemeVariantSelector( + catalogThemeVariant = catalogThemeVariant, + modifier = Modifier.fillMaxWidth(), + onThemeVariantChange = onThemeVariantChange, + ) + } +} diff --git a/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/TypographyItems.kt b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/TypographyItems.kt new file mode 100644 index 0000000..7ad1735 --- /dev/null +++ b/app-ui-catalog/src/main/java/app/k9mail/ui/catalog/items/TypographyItems.kt @@ -0,0 +1,33 @@ +package app.k9mail.ui.catalog.items + +import androidx.compose.foundation.lazy.grid.LazyGridScope +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBody1 +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBody2 +import app.k9mail.core.ui.compose.designsystem.atom.text.TextButton +import app.k9mail.core.ui.compose.designsystem.atom.text.TextCaption +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline1 +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline2 +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline3 +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline4 +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline5 +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline6 +import app.k9mail.core.ui.compose.designsystem.atom.text.TextOverline +import app.k9mail.core.ui.compose.designsystem.atom.text.TextSubtitle1 +import app.k9mail.core.ui.compose.designsystem.atom.text.TextSubtitle2 + +fun LazyGridScope.typographyItems() { + sectionHeaderItem(text = "Typography") + item { TextHeadline1(text = "Headline1") } + item { TextHeadline2(text = "Headline2") } + item { TextHeadline3(text = "Headline3") } + item { TextHeadline4(text = "Headline4") } + item { TextHeadline5(text = "Headline5") } + item { TextHeadline6(text = "Headline6") } + item { TextSubtitle1(text = "Subtitle1") } + item { TextSubtitle2(text = "Subtitle2") } + item { TextBody1(text = "Body1") } + item { TextBody2(text = "Body2") } + item { TextButton(text = "Button") } + item { TextCaption(text = "Caption") } + item { TextOverline(text = "Overline") } +} diff --git a/app-ui-catalog/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app-ui-catalog/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..02f9cf0 --- /dev/null +++ b/app-ui-catalog/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-ui-catalog/src/main/res/drawable/ic_launcher_foreground.xml b/app-ui-catalog/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..587ad87 --- /dev/null +++ b/app-ui-catalog/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-ui-catalog/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app-ui-catalog/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/app-ui-catalog/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app-ui-catalog/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app-ui-catalog/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app-ui-catalog/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-ui-catalog/src/main/res/mipmap-hdpi/ic_launcher.png b/app-ui-catalog/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..b1be3f9984284d9ba66c71ed36b8038041a8a9b5 GIT binary patch literal 1871 zcmV-V2e9~wP)l#`zweY)aA5Vtx!`_^M$8ozhda*5R1i*tIC~3v?)QVK&n6CosPu`u?R~I6vznFt8f-X?7Z$7F z*f1)MSdEE?v@GZP!71TEQyDfO%GsmP z!JT_%-hD)CkQ*9A;}2A@wGxd>m$j|TJnng(bl_}6vmsUA>fc=B&3!b2dmbTrgNXJn z3;da(9}--StL_Hq^RZ^39Ca3Rr2xIAs-o_9>Z0%NHxiOF$bD@~s|k|QT_zE$a4)*yWSU) zF{q>40?9`;FmuUCcz^B@FG3@adcU7{6y|UE2lsbP;Bbt-BzVpZx+Q23t%j*;mTb=F z8R6#B<(a(Ch)))ufRxNi+=q=kV0#*L?|CxFi3aV<(89t^7qCm*^!lX-5Sp2q?)2b} z`wA1v74L?+RaRjQgw6A9UMR9~QyvtRH{#~h>a`}K`_Lx#r|qTi_3E>@4;1W{!83>| z=wObHRmJS6Q?Na?4ATF;fomNDq-JSxy;B4qEmH6Vdoq2&F~~2i!&%D)Kg6Epea~gi zn$xf&tqhLm>CjXIq~%n>x}C+C=Qzw-nvLVub5ud@F=!6{awBz}(FmJ=4EI;phP!5c zOE-Ll)x_h}aT00na!J`&+SX29XoH=x^OWjlX2`npb7 z`fI_M43Air4a>Hu*dmkiaohRK3?k}Udf3*1Oj;Cs5oThUk0dTM()CM!8FdB)DcL@Y zwiR5W6EEWob{>W2jB5Ia-z#w3$%B>KO4K_WvoNC4R&0c3*& zkPQ+*Hb?;3AOU291dt69;E5O{`TuI5LFuG5AcKrXqhu?HG?78o)zyJlPh*eEZ*MQy?RKEVVurev$z+0x ziVA&rc=&YE7#A1kfA{i?u&}Tf@$e?{UUG8shWz||MM+6XZgFvOwo0YS@kFUqdcEep z_xGHqk#p#nO>HL)q=ht*wrSI*z2vQWY!Nbb>eQF<_&V}7@(%K@$RQ1+<+UkOro4j3 zv2`t9fhJAFYMO+{$(Zz2F+&=LwD>yZy_#bd9$br7`9AE$d002ov JPDHLkV1oZhjPd{g literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app-ui-catalog/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..527e9949f68e69a5c262a7eb7d6e27ae0effcc54 GIT binary patch literal 2767 zcmd5;dpHwp8~5sc`6|+q%(+_8K{3^e8m}-a#}FkXNpe`4mF1995@yO_N{EQU7RiR$ zXj-xyD&{z=&^EKpYqQB|pX__Tzu!OK=enNf`Q!dw_w~CE&-1(QyE@yeuF+njprD|7 z%)$2L@?HFE|3_*0d^^2KM?qmt&M{kSk7z$W^QV^vTT6LrA;_uN=tIckI>WNE@W%|p z2sexax)@B0!<^^^Vz)&^+IZ|wIkO#Ry)|NooAt2k?;7j(q$X^hiz@i*EQqC*Bkerv z_eDBy&R(=8UKf0nqH^auuj28fqqhHSjU6h&Dlup~fl333Zz9(uSxe4^Ke-){0rl1? zI|sa=_OCTN75k1sJc{#F?$XjG7bgv|RME_G)}ZY#6dSr1zMbKX@^=0Z@r{H&s#hJfL=1BaqY= zmCDe(w~({gcSR3rJP_?C=(}EU@)aO3DIk~Q1lqMY&^bKaeN0xY`mXwkN_w%x zI@Ne&3UQ7ZRZt;f+K0DTZGxQtxJ;xe+_qHs)~K_utxUGAK9|E=DvYkP~uv4)7eMUOQc1wXIvhf+5%?!uOY)OeHKJ8>uEv6R~yV7pjFD znqg^LNda32U>=ym1?^I+k&NmsA{g|v?#wPbFY)D!y$1g7>_iI;pFj6{CC=o}$*b+Q zOD}wX_?7h6>+MG4!=*8^Qw}Ng3B`_QHtZ^+rk@obN)ph(eJWI2U37VE0)k?$b19>G zgal@DoVXm*kOxoK!AI&%@NfO~$+XE{yCqWZMjh})VA6e}wi`4o1Pi~GrKD~5eu7{6 zxqmp_g{#ptHjC7x`(}H7@AArQkyGx3c{H#GZ&vc4*pc+0;?JRJ=Kjh;-98l85AISS z>bX!Y=i3NSk0+ls%52D`=Qp^H7G_SSFD;4R4R#}PETl(#wBrOH?VQ%@F*y&SsZdQHOu#&Dl)j@VZZLyCAx~p>ZyxtlH46-yN z!|bK5a4*K|xN+kWZx{sj+XJFFU6dh&`MA`)M&6k`SXf9ncK1Oc-G#j?=&z)$T2Quk zS)Yq&sp-n1*3LsdTq|dT!d?GoGP6xuUF>5P!Udp$#~?pDnuP_Jc!ThV(tVO+b0o!| zJfbd&IWJA6^u6baraL_x?CnXsck(3mqN4>T->Br(;oT8;(FgBEs4l3yzn3X-2#EB= z7*S(V<&E)D+YNu%#M=khPqov-#}VBw;*JMR2@+vo&n+4^goGu7rd}5$kvr>Fl|Eo#(Lj(2^-jFW_woVM z18p~Um?o~LeTqB3s^fEBk+QM9Gc&8NiQv273#YYdDVK~5LPzUCkY)5wKYixS&QhZl zy)bOAm?)J!?U-h}NZQIzqhRRryCzNXF3*j-M0u-qN(g}q#ndI`(JyTMH48YxGuBfl zvp73)7PFj(v@z5MhdYxOf+qdAR?(@2_8a9c#Z?^sx6|=p|9LhHbv>$XOt)!*uQjpQ zcrL?FSVeRZ~^*^RfNiqiNgRb%-Y&bI^ z(0mV1<__hxIC>#%QX*;X@^eRY&l^0GYeEsCwa8tKUeWD3cn+@Cm8C`#=F|=-FE%l{ zcg~Mtp4BXz4_vkFFk{kKP;2QQVW}7GSYy+N`14GT%A*=R4D zl&lo+#dzFJ9M~~#6eLsly)ZMSDCX!QXv__vvW)9R?#Yj^ob{oRn}?#F*swNeR#|PS z!Zc7?JoBO$FK=hz=`5ooL+TUVx?eK#~Qco$1(d{zK z1&UDu(U-%9Hq!DlQ!SY{r_xWYRiryz72n12|=%+b>w%lfF5UI+wA-1t4NL{ zbe&tkuhC@NGtx`2ShV$iQH%q#B1C;5qV*ao)cZNfTs#m>*BPu_MPhOH_};MCf&0O` z%n1*ivk(T4wI$Ss$!3ty0(>SRzM()^DwJT7sgi{IMTL2v;578AU-A#DiHa{_nN+a~ zzP8HVvBS$E;612ssVg~b(_hi+8s*T@jVg(gE91rHn18itJ>Va$nFq4pVqlEzk=}>$ Rmj_jaV|LEAxTEK8{}+5&ZgT(t literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app-ui-catalog/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..28b7d00d149c3a3c95fa374776d50c8180d0114f GIT binary patch literal 3911 zcmV-N54iA&P)% zda2t(%<1eDBJOr~)w#O~_JlPA55h4*DxvWD_3M>{8UY*^ifd?twyQ(DA*5g@m68I&z!=_4W15g6sz!1b?CPUd1>_-v&3NLkaF8Q-nMnk0yLeC{~eD ze@%!u#3{&i6>w469RI<2BVo`j#u%*_7wOlrB1(DZ=-7^M1lB7^kq$yM(a}suRsnua zkcBbrOxe_KBJLKC8u}be*ry~$eb-w7*Toq8|AHvkFj#G0n+pxxLvg&EP$h_wMzL9p7~D=k zosvXl)YQ~GrnJ4)go(meS6AnNFjkPGHn2lLVOw=YMa6SUA6i@_`!wjwNOEC-u|RfI zNE_*cm6n#yQ2NqhA{A2POzceLNWfM^JK4_)3JNAGeY$CpAxU|Rno}(YR2$LJAxR=X zs?*cc|DyEGYGF6lIJPKuqULluawH^4m6es5iHV7KDt)v{WM|ULr}AqZxFA~!=|fRb zQ6Ra-pGB$MwoGLB)z;R&b*qr0KpmwT%gV}HrS#QeVPdmaRaG^J$k7X>5cNQv6@0mU z_3Bmq*|TT=p!C@yk&P*8d~U($;ebyBmY<)0po<~ZEF(yU8%57EV7b0+_f%~PlIwcC zo}E8`-a+YmlOl^2trbUS(zm?4ys$2o6l@u0#Of#!J-{s+K?MGMgtj5<%$YOK zC^;}GGQ_H@tG}@{dZ+=*&MRlWK`HE$Z_ltzU!U#}*zTFY!cJtf3Vn^n<_7i~gk@%C zdMG*2hzu_Zw|>;IP$+r zda=fK73yhHVl?Hyq2!{0h}`Q+>GhMP=~Y!-%Xa&oGu|x##~8AouGur9vTwPuJD>;z_amJ|r*du!CEu6WO1bz8KBCD&r#$wLqviH9{VT>xzgGsD} z?ykCTOX1f`MeO4}amMuC<`K{Gu2is`{Hw+&vVb7)a~-o;SXkH;a1kCJek09f&QYOS zZE1P|rU(L(GfLQJZfCi{?+{6tQPOkD7%g2jI;;iKJ1Xund+!Uw%~-Sb81tq&R9K?t z*PBZa;Tra5@$vERbup}o8W4mCvY`U)fyOsOki1q&ys5?hvvRI{JwrVaxa=K4SO7a00#yW&t6ZzJSDtVM_tX5zOO%Vj3{sc#7 zvj2P*!`rN)nh^L)hS{}AV<4+oZb1dxPP3$3Rv^7DP@1XRT0;=BCnF;xmbmC?Qh9scG93FjJjRDddE#*lbAMo!*Ij zkqY9}?X4h)7oyzU+|*&ihV_xbV_X!uySv{*jdY>|M59CvO9+xk5p@Y|arZyndxkrw zZf6RxJ?wZkTfhCdF%nU4%C1&x+DbH+pnCkn_)J!?X3d%#3r0y$U|`@NBB)F@HlaX+ z<`4vwl0)u4Uo;y2J{8zwH0#yWG`I2;1dzj+29^+x$%& zJC|H&(QIf1L59~wii`C`&_gUSRNH7L+a}4%0**{T7d@E5(FSZGH7kIlloyP0H6S) z5M!oW_qid$*3w=ITZl?kgqedC1o0FrEG)e0?Ckt!B|&@^b8&I$Ut3$-YDKy+Tq)M` zSxs&W-lde2$0*8J3|g~Yv6dhd>1E574H5+1nCAQT?c0aeK`lPbn*$=Lw7iOW9HhO} zCpUKC>pqWR#}jh-ido&q5;T_}In8HhXQ$cP+TPWGAVJdaXeAvj8n?(ou_@@u@#6_O zY}41#Z0_2h*o%()*yLqC>=maV_R`xwu}RCk+4L3r*=rvJus!?Fu*}>NUc1zK8iN%C z@r7b?a`Fk{!dAhHajS8=PN(ydjnxWF!CHcJg%xa@`*G&5_B4KfHaX*3!!HGebVPn>l_95$UcC)H(qXbsN5hpX)%+-Dx0wnC` zdk{(5^MD;)+ps;1rvFm@m_w~!iRKbSxQ2&j!NI{FfeXQjksz?uJt!z>ig?^%5h`g< z^_g27bNnchebEF#xa;XTCG3muPqHbmd21Y(K=f?b9?qsY`f9G-Xye8fu0O~EB9mE3 zncn0!1KBQk^K}gURbCu;+f$QFNVRGv3+Ho;BgH5+LtfqDd7gj zexIM8e^7KXn`STs5j1Xr7khS|JDa%3i@mh`N49XoLAL0%bkE1G$CJVWHk)dd zCFGg{`aS>JKK90cBh1zyDl7GPn&|EAy%ro0QQDWQL`e0RF=NJ`^m=`P=nT;)l)xr) z&;AR%5H46BLU-~9jiKf+nWppIK5=|z!=qUVJcN(Gm``aG!lP5&uPK0>muaa0a= zyP(t9E+Nw^Cnu+H!h{KUv8snAy`=d1`u3!cyHZk8EPk>W6Q9f85l;Dw zffO~+8_k~ptRsREJ&o=D>HhN#YEe-UTfct&%kVwiRQ*wkqoX5!qjg_qW@e~RVm6B8 zgZl83IqYa`R=XpVfNxjKJ++j+ojP?YihS-bd~aI94Gi|#v17;d>gsBXqNp=MMi1@Y z*~X0<=fdZ&zWPdAoK4C6`SWk1kNYoNxZscJ@7gW+0u`Z7)E<1BdcW}5GND3+TGVIe z%$Z~G2vS55L_J8Sgq*pkjFeBEI@KP&Iy*ZzU3=6*9l+>)etv$=cngb2GFyu}1CSB$ zrL?q^?cKX~6MS5;V#V!h|1FWOPe1)MzWx}Jn3x!haHIh`b*c$+cxpvPMuw7a4+|en zuY1gi2)jE63>ffudU|@Q7^KsZ1lv(A&Ye4#*1v!Mk??KFk|mZE=Ej5+^rnw{=gpfp zh4yLX$X!dO{*DIN4BOMv(yr2O;RX04e7kv>J$tq-eLUde;3s^5sF{lXaZsG{I7u`(f7e)TP)C7WqDXS+2Csk*Q>IM8 z?=)JB94R(q#*BgDAyk_dWoKveZ(&jNIwDE3Afx6`T9rFFIU#a}j2=B259GD)AhwX$ zj2kx&fB9hWoH=u*Uc7iQO{})%Z`?J8wg+GfY{L6K2?+^l)2B~=5jG3kTa6qkHVzIB zxC;YqZEc^3ii!#;DJek|!#G4!yKPIv*gU0RQ)p=DQL=FqY!x=QIyq9hjvqfBza}0) z7`|uE9+!-aj8fdO_8>}03~Zn!N$Kw0yEl_<_%ZJQVQY)m9COljJ{Se_%TDHF$9UUEsnGW?y2zLYy-ArVmuh0Qf_)Ln3!B`lR#$E^;V2iM+dHbtY z6dp~32M@+yV;Mpiv2^LuIROCyzNx9H`S?0TMioTt>)gG##^VaSvN8lVbcCpBOu9Y!ozRUjFc!wd*s!62MIBVSVG}rf_;5tjg9N;> z^%&jVzpq@ma?y9+eYYhfBxHYFTwE+I6_Tl_=3c&hxrkDw48SoJ{oJIaq-3Tx=*Mdg5+lH9KO&hvtcWM>>Ky z6#0PyIL5jDXoI%sgTBxKT}G|9u<)g!}yrXh>Via((<*!HYovcd+cTRUTDy{-dmHt$&+*MIq&lOb>k35l%HxM2qJfIVJ?hPwxTQQ^r zYVRhLFD@=R7Zw(#^h8WUIct(@Trs4G5)1Ynv*N&s?*9ztVZLJGW&VU37#w!t z-D0zt^usTDU>|Z}a%vW?n*i6@9WKH1L77$X4iJAr4fI`^#QR6P&~w=-uGpdSUUB=w z;iw{SHE=%Has8*27hxNm5WIa?oQk}}7%1z4(=!W?FNn&9Aru_9;aunMmsFwOzbf_!*H* zaNA4g++T9iE{v#alfnSQADBlKEhT;}o^HE}AT6=R^P|{PYC%<_LyW6y7)fJ*F&~Pc zt?RmYs}z3TEm}V3es!({$$+MBA@vv|6pqkU9LKS08@3gmMfUzyY%4e`&U-G8Ar!|% zQW%(U2T)wug)O`4@RXri;OXpdmX4p_bqdA`i^x;gYPN(d_A%rgZpZaMr(+ZFt8s?j z>%U@qVGA~GtyvPhDZ2(ozA}q;sgK5Y95{nk%ICUhjFtFYOk2 z5A@C_7x3D9Ewrw`#pWHS1o-&X-Hq6Junqa2DR-**wE5dv)F#H|HCyQT^}T1X+jv3n zGJir12otj31N;}&VjmaaWA#3NB`9iSzXzCCJy${uXo-{oHKEo>8Bh~yjhi*_|NZB# z)oOh#S?@q?ZS9kCt%^YGqd`MM!~NlK*uzE9B>BKW+Titir;JA9!>qBRqhq-N4*dQ3 z=B|;Ek$N)0bzmcUg*1046heQ0|7p^;4)H!Qal{7GGBPqY_Vx8OyWMWD&*z&a_kM1* zgfyqera literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app-ui-catalog/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..af1cf1ad2d78ffb7cf6cf81cfca8a5067a7a28c9 GIT binary patch literal 1758 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 literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/mipmap-xhdpi/ic_launcher.png b/app-ui-catalog/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a45029668fa1044e5836162d30cee6e14f6b14 GIT binary patch literal 2586 zcmaKuXE+;*9>x=?I7U+1YLlSo2*sf#V$_aURaLcWB{*$^QnA9d_0(Q%t%%(k)zVU- zl-TPaVpTQ9QB`SFtr8=+Js<9e`#kr#AO7$E!~48n-Y>s5!P3GQB7hJ8000nE6GN+C zAOFw&2L7d=b4cm{0ROzHp}uvn%SyhWrS&AdHwCGxa=naPPp)?`{&qn#aP$*dr!4=2 z@hurLEf1Bfs$v^sn%t9`+hv@*741<{?|D)L%OLOkEnQS{^8=sRxtJn?o}QDi$T4fZ z^gCF5_SP~-H+0pn@%UKU1i@n{S zch3_pI93q`KH*td46KVn4il*Cw^H3UhvGvy@FvpgSq*V$0?$wUpzX^o-m1GCiAu`p zq($bmUd_2M$I#IC`MS)DsX^EzG@%om@w~iT_vOS7nn6{UPs5SeJsq{X3q3zHwDNd? zP*e@IzTWEg?J0+fTz5K9o2c6KBgP0hV* zMzxXPNrnYtP53bw zmW*n{KL>)(LoUNSqS{SBLPiMu{ z5_MQ<%L1(^{7W~0;$^?SuZc^1L0hkOoQhHbOncXiB)l>s@|8d#lGuVp z*Gh{)_}&IB#$C|Nhv+P^a1b|b#Qx?rKIqLJco>BO=qdbmXgb~qtoWFBbiKU)h;=SLwR_nKU+b%@Qx>NY;i!eJAP{65m9>~dBIXU)~(rl8065d>QkZC;+ zNN#h#6COUB4h}A=o>#;oPX+wzzVOgz6q+g^m9f3v^K~a|y+R+Crep$Y0vxA5Nh4{g zgI<;lkFcwjoOT=56x``Na%va2zZXCm|qrYRwv)sIja}Y+fie`CH?(iB)AcA!KU_J$g zkgRm1>Bsesj-rklbL>lU36A@ zH{Xu!_KBJL>8)U_R6|nfmcpTFE_W8_5>lR|!x{*DGzX7*d+%Ud{IplLFn?!ztn*Zz z8Z~~jc7V0>P2NDy{7q!;;z9DHviq*ICyj}WAn-5B>$kREJ>=4T9BAv;5|`@J?qv8W z+I=(GyOS8NeeJ<(6OpgymICuaTWLI9;%y78j9osDW+OoIP{~s_A<~&XvG&bvMrmy- zDM-M~la;BF;Ir5)y`cv+QY?%kMQQo0S8*5K3A|sCk3*!=h7uqI z#9wRL@~@wy%r4u~%B76+F89IJgbzLe(tW?wzI&z@SN1!4}<;(H*)-D$C&*DjbzRaG06a-eHa_l)-<*&6<|L=7R-1%Y&pZy2TPhuPW?eYU8lAI*T+5Y-sqzkRjEt(IC!nMP% z8AJyMG+#ldb-4TSijjxb&vfZDO!kKIvp;nOmiX9Roko88F%EUPp(dK-^!P1Ja(2xT z#KZf4DDl566xe=$JSrn2qfp}?`nUi^L_}JngTmeP+}u7Bs+(QB4d>o;C3xxu1_pMr zCtgNESAI+axCcE&HSWDS%+XOR*`to80kRpq%H>`bq*OaP>AMc}GdDJ|6yW1dq zC`MHD9-8o!LUB|jel|33yAk*3Q8?!Hy$7Fb`k%@yNJhshI=%B1!13Fpq@;8N!s%^q z+-vMv+}+Iy|F45&fhy$n+@GkrF^{R`;IJ=!kLMPLmyqT;mx*(kC2cMcN-fU z6@TMhp$mqE@^W$tjB}SRU5c1~M^w^359K~~oZ~(%IN|HJR&n!L?Ant5;EgyT_=e-z ztQZUigA?TEC!dHfT4h90cO+2J=tQH3qCM=X}n}ca^F3wg81SyC^ zGGbeGHQHWGfcW1fn(k6CanJ*LyvTW)ZrIM6q>vc;zKK)}4Fe(oub=YX;|_985sq;h rU`q_buXs^6M#SYmgKA+U{s^SW_wsHI?0&$n%?B{WS{POv;A8#`Pa*cb literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app-ui-catalog/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..34ca926fecfc2216b1f159b5726b025e73a4adf0 GIT binary patch literal 3856 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 literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app-ui-catalog/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..9041e878b070b25fd5ebdd127b333f6dafde17bc GIT binary patch literal 4094 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 literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app-ui-catalog/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..e496b98aa89d7ca2b2ae21d6b5fdedb6c9dcffbb GIT binary patch literal 6185 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 FzW@Ylq%;5k literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app-ui-catalog/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..4761031c01c33d8005d790fd0404670f9fbe9e13 GIT binary patch literal 8799 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 G{r(HirRvQ9 literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app-ui-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..fce7d33a69c344ff11cea14d0a82553a5f878445 GIT binary patch literal 9320 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 UxkhD=f0}1-!{j<{9 literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app-ui-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..6d6bb2d0663fb827fec9244be12cfb276ceb7a05 GIT binary patch literal 12843 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 xtWbXBuzK~oFycWi$BW&Zm6B!f{{Y2Td~N^$ literal 0 HcmV?d00001 diff --git a/app-ui-catalog/src/main/res/values/colors.xml b/app-ui-catalog/src/main/res/values/colors.xml new file mode 100644 index 0000000..1e7f3d9 --- /dev/null +++ b/app-ui-catalog/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FCE8DC + diff --git a/app-ui-catalog/src/main/res/values/strings.xml b/app-ui-catalog/src/main/res/values/strings.xml new file mode 100644 index 0000000..4977ffa --- /dev/null +++ b/app-ui-catalog/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Thunderbird Catalog + diff --git a/app-ui-catalog/src/main/res/values/themes.xml b/app-ui-catalog/src/main/res/values/themes.xml new file mode 100644 index 0000000..fa7f5df --- /dev/null +++ b/app-ui-catalog/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + + " + } else { + "" + } + } + + /** + * Dynamically generate a CSS style for `
    ` elements.
    +     *
    +     * The style incorporates the user's current preference setting for the font family used for plain text messages.
    +     *
    +     * @return A `"
    +    }
    +
    +    private fun cssStyleSignature(): String {
    +        return """"""
    +    }
    +}
    diff --git a/app/core/src/main/java/com/fsck/k9/message/html/DisplayHtmlFactory.kt b/app/core/src/main/java/com/fsck/k9/message/html/DisplayHtmlFactory.kt
    new file mode 100644
    index 0000000..8e717af
    --- /dev/null
    +++ b/app/core/src/main/java/com/fsck/k9/message/html/DisplayHtmlFactory.kt
    @@ -0,0 +1,7 @@
    +package com.fsck.k9.message.html
    +
    +class DisplayHtmlFactory {
    +    fun create(settings: HtmlSettings): DisplayHtml {
    +        return DisplayHtml(settings)
    +    }
    +}
    diff --git a/app/core/src/main/java/com/fsck/k9/message/html/DividerReplacer.kt b/app/core/src/main/java/com/fsck/k9/message/html/DividerReplacer.kt
    new file mode 100644
    index 0000000..e5067fe
    --- /dev/null
    +++ b/app/core/src/main/java/com/fsck/k9/message/html/DividerReplacer.kt
    @@ -0,0 +1,31 @@
    +package com.fsck.k9.message.html
    +
    +internal object DividerReplacer : TextToHtml.HtmlModifier {
    +    private const val SIMPLE_DIVIDER = "[-=_]{3,}"
    +    private const val ASCII_SCISSORS = "(?:-{2,}\\s?(?:>[%8]|[%8]<)\\s?-{2,})+"
    +    private val PATTERN = Regex(
    +        "(?:^|\\n)" +
    +            "(?:" +
    +            "\\s*" +
    +            "(?:" + SIMPLE_DIVIDER + "|" + ASCII_SCISSORS + ")" +
    +            "\\s*" +
    +            "(?:\\n|$)" +
    +            ")+"
    +    )
    +
    +    override fun findModifications(text: CharSequence): List {
    +        return PATTERN.findAll(text).map { matchResult ->
    +            Divider(matchResult.range.start, matchResult.range.endInclusive + 1)
    +        }.toList()
    +    }
    +
    +    class Divider(startIndex: Int, endIndex: Int) : HtmlModification.Replace(startIndex, endIndex) {
    +        override fun replace(textToHtml: TextToHtml) {
    +            textToHtml.appendHtml("
    ") + } + + override fun toString(): String { + return "Divider{startIndex=$startIndex, endIndex=$endIndex}" + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/EmailSection.kt b/app/core/src/main/java/com/fsck/k9/message/html/EmailSection.kt new file mode 100644 index 0000000..fbc277a --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/EmailSection.kt @@ -0,0 +1,126 @@ +package com.fsck.k9.message.html + +/** + * Represents a section of an email's plain text body. + * + * See [EmailSectionExtractor]. + */ +class EmailSection private constructor(builder: Builder) : CharSequence { + val quoteDepth = builder.quoteDepth + private val text = builder.text + private val segments: List = if (builder.indent == 0) { + builder.segments.toList() + } else { + builder.segments.map { segment -> + val minLength = if (text[segment.endIndex - 1] == '\n') 1 else 0 + val adjustedStartIndex = (segment.startIndex + builder.indent).coerceAtMost(segment.endIndex - minLength) + Segment(adjustedStartIndex, segment.endIndex) + } + } + + override val length = segments.map { it.endIndex - it.startIndex }.sum() + + override fun get(index: Int): Char { + require(index in 0..(length - 1)) { "index: $index; length: $length" } + + var offset = index + for (i in 0..(segments.size - 1)) { + val segment = segments[i] + val segmentLength = segment.endIndex - segment.startIndex + if (offset < segmentLength) { + return text[segment.startIndex + offset] + } + offset -= segmentLength + } + + throw AssertionError() + } + + override fun subSequence(startIndex: Int, endIndex: Int): CharSequence { + require(startIndex in 0..(length - 1)) { "startIndex: $startIndex; length: $length" } + require(endIndex in 0..length) { "endIndex: $endIndex; length: $length" } + require(startIndex <= endIndex) { "startIndex > endIndex" } + + if (startIndex == endIndex) return "" + if (startIndex == 0 && endIndex == length) return this + + val builder = Builder(text, quoteDepth) + + val (startSegmentIndex, startOffset) = findSegmentIndexAndOffset(startIndex) + val (endSegmentIndex, endOffset) = findSegmentIndexAndOffset(endIndex, isEndIndex = true) + val startSegment = segments[startSegmentIndex] + + if (startSegmentIndex == endSegmentIndex) { + builder.addSegment(0, startSegment.startIndex + startOffset, startSegment.startIndex + endOffset) + return builder.build() + } + + if (startOffset == 0) { + builder.addSegment(startSegment) + } else { + builder.addSegment(0, startSegment.startIndex + startOffset, startSegment.endIndex) + } + + for (segmentIndex in startSegmentIndex + 1 until endSegmentIndex) { + builder.addSegment(segments[segmentIndex]) + } + + val endSegment = segments[endSegmentIndex] + if (endSegment.startIndex + endOffset == endSegment.endIndex) { + builder.addSegment(endSegment) + } else { + builder.addSegment(0, endSegment.startIndex, endSegment.startIndex + endOffset) + } + + return builder.build() + } + + private fun findSegmentIndexAndOffset(index: Int, isEndIndex: Boolean = false): Pair { + var offset = index + segments.forEachIndexed { segmentIndex, segment -> + val segmentLength = segment.endIndex - segment.startIndex + if (offset < segmentLength || (isEndIndex && offset == segmentLength)) { + return Pair(segmentIndex, offset) + } + offset -= segmentLength + } + + throw AssertionError() + } + + override fun toString() = StringBuilder().apply { + segments.forEach { + append(text, it.startIndex, it.endIndex) + } + }.toString() + + internal data class Segment(val startIndex: Int, val endIndex: Int) + + class Builder(val text: String, val quoteDepth: Int) { + internal val segments: MutableList = mutableListOf() + internal var indent = Int.MAX_VALUE + + val hasSegments + get() = segments.isNotEmpty() + + fun addSegment(leadingSpaces: Int, startIndex: Int, endIndex: Int): Builder { + indent = minOf(indent, leadingSpaces) + segments.add(Segment(startIndex, endIndex)) + return this + } + + fun addBlankSegment(startIndex: Int, endIndex: Int) { + segments.add(Segment(startIndex, endIndex)) + } + + internal fun addSegment(segment: Segment) { + indent = 0 + segments.add(segment) + } + + fun build(): EmailSection { + if (indent == Int.MAX_VALUE) indent = 0 + return EmailSection(this) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/EmailSectionExtractor.kt b/app/core/src/main/java/com/fsck/k9/message/html/EmailSectionExtractor.kt new file mode 100644 index 0000000..d7ac09c --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/EmailSectionExtractor.kt @@ -0,0 +1,126 @@ +package com.fsck.k9.message.html + +/** + * Extract sections from a plain text email. + * + * A section consists of all consecutive lines of the same quote depth. Quote characters and spaces at the beginning of + * a line are stripped and not part of the section's content. + * + * ### Example: + * + * ``` + * On 2018-01-25 Alice wrote: + * > Hi Bob + * + * Hi Alice + * ``` + * + * This message consists of three sections with the following contents: + * * `On 2018-01-25 Alice wrote:` + * * `Hi Bob` + * * `Hi Alice` + */ +class EmailSectionExtractor private constructor(val text: String) { + private val sections = mutableListOf() + private var sectionBuilder = EmailSection.Builder(text, 0) + private var sectionStartIndex = 0 + private var newlineIndex = -1 + private var startOfContentIndex = 0 + private var isStartOfLine = true + private var spaces = 0 + private var quoteDepth = 0 + private var currentQuoteDepth = 0 + + fun extract(): List { + text.forEachIndexed { index, character -> + if (isStartOfLine) { + detectQuoteCharacters(index, character) + } else if (character == '\n') { + addQuotedLineToSection(endIndex = index + 1) + } + + if (character == '\n') { + newlineIndex = index + resetForStartOfLine() + } + } + + completeLastSection() + + return sections + } + + private fun detectQuoteCharacters(index: Int, character: Char) { + when (character) { + ' ' -> spaces++ + '>' -> { + if (quoteDepth == 0 && currentQuoteDepth == 0) { + addUnquotedLineToSection(newlineIndex + 1) + } + currentQuoteDepth++ + spaces = 0 + } + '\n' -> { + if (quoteDepth != currentQuoteDepth) { + finishSection() + sectionStartIndex = index - spaces + } + if (currentQuoteDepth > 0) { + sectionBuilder.addBlankSegment(startIndex = index - spaces, endIndex = index + 1) + } + } + else -> { + isStartOfLine = false + startOfContentIndex = index - spaces + if (quoteDepth != currentQuoteDepth) { + finishSection() + sectionStartIndex = startOfContentIndex + } + } + } + } + + private fun addUnquotedLineToSection(endIndex: Int) { + if (sectionStartIndex != endIndex) { + sectionBuilder.addSegment(0, sectionStartIndex, endIndex) + } + } + + private fun addQuotedLineToSection(startIndex: Int = startOfContentIndex, endIndex: Int) { + if (currentQuoteDepth > 0) { + sectionBuilder.addSegment(spaces, startIndex, endIndex) + } + } + + private fun finishSection() { + appendSection() + sectionBuilder = EmailSection.Builder(text, currentQuoteDepth) + quoteDepth = currentQuoteDepth + } + + private fun completeLastSection() { + if (quoteDepth == 0) { + sectionBuilder.addSegment(0, sectionStartIndex, text.length) + } else if (!isStartOfLine) { + sectionBuilder.addSegment(spaces, startOfContentIndex, text.length) + } + + appendSection() + } + + private fun appendSection() { + if (sectionBuilder.hasSegments) { + sections.add(sectionBuilder.build()) + } + } + + private fun resetForStartOfLine() { + isStartOfLine = true + currentQuoteDepth = 0 + spaces = 0 + } + + companion object { + fun extract(text: String) = EmailSectionExtractor(text).extract() + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/EmailTextToHtml.kt b/app/core/src/main/java/com/fsck/k9/message/html/EmailTextToHtml.kt new file mode 100644 index 0000000..fc487a7 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/EmailTextToHtml.kt @@ -0,0 +1,69 @@ +package com.fsck.k9.message.html + +class EmailTextToHtml private constructor(private val text: String) { + private val html = StringBuilder(text.length + EXTRA_BUFFER_LENGTH) + private var previousQuoteDepth = 0 + + fun convert(): String { + appendHtmlPrefix() + + val sections = EmailSectionExtractor.extract(text) + sections.forEach { section -> + appendBlockQuoteElement(section.quoteDepth) + + TextToHtml.appendAsHtmlFragment(html, section, retainOriginalWhitespace = true) + } + + appendBlockQuoteElement(quoteDepth = 0) + + appendHtmlSuffix() + + return html.toString() + } + + private fun appendHtmlPrefix() { + html.append("
    ")
    +    }
    +
    +    private fun appendHtmlSuffix() {
    +        html.append("
    ") + } + + private fun appendBlockQuoteElement(quoteDepth: Int) { + if (previousQuoteDepth > quoteDepth) { + repeat(previousQuoteDepth - quoteDepth) { + html.append("") + } + } else if (quoteDepth > previousQuoteDepth) { + for (depth in (previousQuoteDepth + 1)..quoteDepth) { + html.append( + "
    ") + } + } + previousQuoteDepth = quoteDepth + } + + private fun quoteColor(depth: Int): String = when (depth) { + 1 -> "#729fcf" + 2 -> "#ad7fa8" + 3 -> "#8ae234" + 4 -> "#fcaf3e" + 5 -> "#e9b96e" + else -> "#ccc" + } + + companion object { + private const val EXTRA_BUFFER_LENGTH = 2048 + const val K9MAIL_CSS_CLASS = "k9mail" + + @JvmStatic + fun convert(text: String): String { + return EmailTextToHtml(text).convert() + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/GenericUriParser.kt b/app/core/src/main/java/com/fsck/k9/message/html/GenericUriParser.kt new file mode 100644 index 0000000..14cede4 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/GenericUriParser.kt @@ -0,0 +1,34 @@ +package com.fsck.k9.message.html + +import java.util.regex.Pattern + +/** + * Matches the URI generic syntax. + * + * See [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986). + */ +class GenericUriParser : UriParser { + override fun parseUri(text: CharSequence, startPos: Int): UriMatch? { + val matcher = PATTERN.matcher(text) + if (!matcher.find(startPos) || matcher.start() != startPos) return null + + val startIndex = matcher.start() + val endIndex = matcher.end() + val uri = text.subSequence(startIndex, endIndex) + + return UriMatch(startIndex, endIndex, uri) + } + + companion object { + private const val SCHEME = "[a-zA-Z][a-zA-Z0-9+.\\-]*" + private const val AUTHORITY = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:\\[\\]@]*" + private const val PATH = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:@/]*" + private const val QUERY = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:@/?]*" + private const val FRAGMENT = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:@/?]*" + + // This regular expression matches more than allowed by the generic URI syntax. So we might end up linkifying + // text that is not a proper URI. We leave apps actually handling the URI when the user clicks on such a link + // to deal with this case. + private val PATTERN = Pattern.compile("$SCHEME:(?://$AUTHORITY)?(?:$PATH)?(?:\\?$QUERY)?(?:#$FRAGMENT)?") + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/HtmlConverter.kt b/app/core/src/main/java/com/fsck/k9/message/html/HtmlConverter.kt new file mode 100644 index 0000000..4bca852 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/HtmlConverter.kt @@ -0,0 +1,52 @@ +package com.fsck.k9.message.html + +import org.jsoup.Jsoup + +/** + * Contains common routines to convert html to text and vice versa. + */ +object HtmlConverter { + /** + * When generating previews, Spannable objects that can't be converted into a String are + * represented as 0xfffc. When displayed, these show up as undisplayed squares. These constants + * define the object character and the replacement character. + */ + private const val PREVIEW_OBJECT_CHARACTER = 0xfffc.toChar() + private const val PREVIEW_OBJECT_REPLACEMENT = 0x20.toChar() // space + + /** + * toHtml() converts non-breaking spaces into the UTF-8 non-breaking space, which doesn't get + * rendered properly in some clients. Replace it with a simple space. + */ + private const val NBSP_CHARACTER = 0x00a0.toChar() // utf-8 non-breaking space + private const val NBSP_REPLACEMENT = 0x20.toChar() // space + + /** + * Convert an HTML string to a plain text string. + */ + @JvmStatic + fun htmlToText(html: String): String { + val document = Jsoup.parse(html) + return HtmlToPlainText.toPlainText(document.body()) + .replace(PREVIEW_OBJECT_CHARACTER, PREVIEW_OBJECT_REPLACEMENT) + .replace(NBSP_CHARACTER, NBSP_REPLACEMENT) + } + + /** + * Convert a text string into an HTML document. + * + * No HTML headers or footers are added to the result. Headers and footers are added at display time. + */ + @JvmStatic + fun textToHtml(text: String): String { + return EmailTextToHtml.convert(text) + } + + /** + * Convert a plain text string into an HTML fragment. + */ + @JvmStatic + fun textToHtmlFragment(text: String): String { + return TextToHtml.toHtmlFragment(text, retainOriginalWhitespace = false) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/HtmlModification.kt b/app/core/src/main/java/com/fsck/k9/message/html/HtmlModification.kt new file mode 100644 index 0000000..4494f71 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/HtmlModification.kt @@ -0,0 +1,12 @@ +package com.fsck.k9.message.html + +internal abstract class HtmlModification private constructor(val startIndex: Int, val endIndex: Int) { + abstract class Wrap(startIndex: Int, endIndex: Int) : HtmlModification(startIndex, endIndex) { + abstract fun appendPrefix(textToHtml: TextToHtml) + abstract fun appendSuffix(textToHtml: TextToHtml) + } + + abstract class Replace(startIndex: Int, endIndex: Int) : HtmlModification(startIndex, endIndex) { + abstract fun replace(textToHtml: TextToHtml) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/HtmlProcessorFactory.kt b/app/core/src/main/java/com/fsck/k9/message/html/HtmlProcessorFactory.kt new file mode 100644 index 0000000..555bcbe --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/HtmlProcessorFactory.kt @@ -0,0 +1,12 @@ +package com.fsck.k9.message.html + +import app.k9mail.html.cleaner.HtmlProcessor + +class HtmlProcessorFactory( + private val displayHtmlFactory: DisplayHtmlFactory +) { + fun create(settings: HtmlSettings): HtmlProcessor { + val displayHtml = displayHtmlFactory.create(settings) + return HtmlProcessor(displayHtml) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/HtmlSettings.kt b/app/core/src/main/java/com/fsck/k9/message/html/HtmlSettings.kt new file mode 100644 index 0000000..5d6a059 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/HtmlSettings.kt @@ -0,0 +1,6 @@ +package com.fsck.k9.message.html + +data class HtmlSettings( + val useDarkMode: Boolean, + val useFixedWidthFont: Boolean +) diff --git a/app/core/src/main/java/com/fsck/k9/message/html/HtmlToPlainText.kt b/app/core/src/main/java/com/fsck/k9/message/html/HtmlToPlainText.kt new file mode 100644 index 0000000..9ea06ea --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/HtmlToPlainText.kt @@ -0,0 +1,111 @@ +package com.fsck.k9.message.html + +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.select.NodeTraversor +import org.jsoup.select.NodeVisitor + +/** + * Convert an HTML element to plain text. + * + * Based on Jsoup's HtmlToPlainText example. + */ +object HtmlToPlainText { + @JvmStatic + fun toPlainText(element: Element): String { + val formatter = FormattingVisitor() + NodeTraversor.traverse(formatter, element) + + return formatter.toString() + } +} + +private class FormattingVisitor : NodeVisitor { + private val output = StringBuilder() + private var collectLinkText = false + private var linkText = StringBuilder() + + override fun head(node: Node, depth: Int) { + val name = node.nodeName() + when { + node is TextNode -> { + val text = node.text() + append(text) + + if (collectLinkText) { + linkText.append(text) + } + } + name == "li" -> { + startNewLine() + append("* ") + } + name == "a" && node.hasAttr("href") -> { + collectLinkText = true + linkText.clear() + } + node is Element && node.isBlock -> startNewLine() + } + } + + override fun tail(node: Node, depth: Int) { + val name = node.nodeName() + when { + name == "li" -> append("\n") + name == "br" -> append("\n") + node is Element && node.isBlock -> { + if (node.hasText()) { + addEmptyLine() + } + } + name == "a" && node.hasAttr("href") -> { + collectLinkText = false + + if (node.absUrl("href").isNotEmpty()) { + if (linkText.toString() != node.attr("href")) { + append(" <${node.attr("href")}>") + } + } + } + } + } + + private fun append(text: String) { + if (text == " " && (output.isEmpty() || output.last() in listOf(' ', '\n'))) { + return + } + + output.append(text) + } + + private fun startNewLine() { + if (output.isEmpty() || output.last() == '\n') { + return + } + + append("\n") + } + + private fun addEmptyLine() { + if (output.isEmpty() || output.endsWith("\n\n")) { + return + } + + startNewLine() + append("\n") + } + + override fun toString(): String { + if (output.isEmpty()) { + return "" + } + + var lastIndex = output.lastIndex + while (lastIndex >= 0 && output[lastIndex] == '\n') { + lastIndex-- + } + + return output.substring(0, lastIndex + 1) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/HttpUriParser.kt b/app/core/src/main/java/com/fsck/k9/message/html/HttpUriParser.kt new file mode 100644 index 0000000..009e727 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/HttpUriParser.kt @@ -0,0 +1,273 @@ +package com.fsck.k9.message.html + +import kotlin.math.min + +/** + * Parses http/https/rtsp URIs + * + * This class is in parts inspired by OkHttp's + * [HttpUrl](https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/HttpUrl.java). + * But much of the parsing parts have been left out. + */ +internal class HttpUriParser : UriParser { + override fun parseUri(text: CharSequence, startPos: Int): UriMatch? { + val matchResult = SCHEME_REGEX.find(text, startPos) ?: return null + if (matchResult.range.first != startPos) return null + + val skipChar = getSkipChar(text, startPos) + var currentPos = matchResult.range.last + 1 + + // Authority + val matchedAuthorityEnd = tryMatchAuthority(text, currentPos) + if (matchedAuthorityEnd == currentPos) return null + currentPos = matchedAuthorityEnd + + // Path + if (currentPos < text.length && text[currentPos] == '/') { + currentPos = matchUnreservedPCTEncodedSubDelimClassesGreedy(text, currentPos + 1, "/:@") + } + + // Query + if (currentPos < text.length && text[currentPos] == '?') { + currentPos = matchUnreservedPCTEncodedSubDelimClassesGreedy(text, currentPos + 1, ":@/?") + } + + // Fragment + if (currentPos < text.length && text[currentPos] == '#') { + currentPos = matchUnreservedPCTEncodedSubDelimClassesGreedy(text, currentPos + 1, ":@/?") + } + + if (text.isEndOfSentence(currentPos - 1)) { + currentPos-- + } + + if (text[currentPos - 1] == skipChar) { + currentPos-- + } + + val uri = text.subSequence(startPos, currentPos) + return UriMatch(startPos, currentPos, uri) + } + + private fun getSkipChar(text: CharSequence, startPos: Int): Char? { + if (startPos == 0) return null + + return when (text[startPos - 1]) { + '(' -> ')' + else -> null + } + } + + private fun tryMatchAuthority(text: CharSequence, startPos: Int): Int { + var authorityLimit = text.indexOf('/', startPos) + if (authorityLimit == -1) { + authorityLimit = text.length + } + val authorityStart = tryMatchUserInfo(text, startPos, authorityLimit) + + var authorityEnd = tryMatchDomainName(text, authorityStart) + if (authorityEnd != authorityStart) return authorityEnd + + authorityEnd = tryMatchIpv4Address(text, authorityStart, true) + if (authorityEnd != authorityStart) return authorityEnd + + authorityEnd = tryMatchIpv6Address(text, authorityStart) + if (authorityEnd != authorityStart) return authorityEnd + + return startPos + } + + private fun tryMatchUserInfo(text: CharSequence, startPos: Int, limit: Int): Int { + val userInfoEnd = text.indexOf('@', startPos) + if (userInfoEnd == -1 || userInfoEnd >= limit) return startPos + + return if (matchUnreservedPCTEncodedSubDelimClassesGreedy(text, startPos, ":") != userInfoEnd) { + // Illegal character in user info + startPos + } else { + userInfoEnd + 1 + } + } + + private fun tryMatchDomainName(text: CharSequence, startPos: Int): Int { + val matchResult = DOMAIN_REGEX.find(text, startPos) ?: return startPos + if (matchResult.range.first != startPos) return startPos + + val portString = matchResult.groupValues[1] + if (portString.isNotEmpty()) { + val port = portString.toInt() + if (port > 65535) return startPos + } + + return matchResult.range.last + 1 + } + + private fun tryMatchIpv4Address(text: CharSequence, startPos: Int, portAllowed: Boolean): Int { + val matchResult = IPv4_REGEX.find(text, startPos) ?: return startPos + if (matchResult.range.first != startPos) return startPos + + for (i in 1..4) { + val segment = matchResult.groupValues[1].toInt() + if (segment > 255) return startPos + } + + if (!portAllowed && matchResult.groupValues[5].isNotEmpty()) return startPos + + val portString = matchResult.groupValues[6] + if (portString.isNotEmpty()) { + val port = portString.toInt() + if (port > 65535) return startPos + } + + return matchResult.range.last + 1 + } + + private fun tryMatchIpv6Address(text: CharSequence, startPos: Int): Int { + if (startPos == text.length || text[startPos] != '[') return startPos + + val addressEnd = text.indexOf(']', startPos) + if (addressEnd == -1) return startPos + + // Actual parsing + var currentPos = startPos + 1 + var beginSegmentsCount = 0 + var endSegmentsCount = 0 + + // Handle :: separator and segments in front of it + val compressionPos = text.indexOf("::", currentPos) + val compressionEnabled = compressionPos != -1 && compressionPos < addressEnd + if (compressionEnabled) { + while (currentPos < compressionPos) { + // Check segment separator + if (beginSegmentsCount > 0) { + if (text[currentPos] != ':') return startPos + currentPos++ + } + + // Parse segment + val possibleSegmentEnd = parse16BitHexSegment(text, currentPos, min(currentPos + 4, compressionPos)) + if (possibleSegmentEnd == currentPos) return startPos + currentPos = possibleSegmentEnd + beginSegmentsCount++ + } + + currentPos += 2 // Skip :: separator + } + + // Parse end segments + while (currentPos < addressEnd && beginSegmentsCount + endSegmentsCount < 8) { + // Check segment separator + if (endSegmentsCount > 0) { + if (text[currentPos] != ':') return startPos + currentPos++ + } + + // Small look ahead, do not run into IPv4 tail (7 is IPv4 minimum length) + val nextColon = text.indexOf(':', currentPos) + if ((nextColon == -1 || nextColon > addressEnd) && addressEnd - currentPos >= 7) break + + // Parse segment + val possibleSegmentEnd = parse16BitHexSegment(text, currentPos, min(currentPos + 4, addressEnd)) + if (possibleSegmentEnd == currentPos) return startPos + currentPos = possibleSegmentEnd + endSegmentsCount++ + } + + // We have 3 valid cases here + if (currentPos == addressEnd) { + // 1) No compression and full address, everything fine + // 2) Compression enabled and whole address parsed, everything fine as well + if (!compressionEnabled && beginSegmentsCount + endSegmentsCount == 8 || + compressionEnabled && beginSegmentsCount + endSegmentsCount < 8 + ) { + // Only optional port left, skip address bracket + currentPos++ + } else { + return startPos + } + } else { + // 3) Still some stuff missing, check for IPv4 as tail necessary + if (tryMatchIpv4Address(text, currentPos, false) != addressEnd) return startPos + currentPos = addressEnd + 1 + } + + // Check optional port + if (currentPos == text.length || text[currentPos] != ':') return currentPos + currentPos++ + + var port = 0 + while (currentPos < text.length) { + val c = text[currentPos] + if (c !in '0'..'9') { + break + } + port = port * 10 + (c - '0') + currentPos++ + } + + return if (port <= 65535) currentPos else startPos + } + + private fun parse16BitHexSegment(text: CharSequence, startPos: Int, endPos: Int): Int { + var currentPos = startPos + while (isHexDigit(text[currentPos]) && currentPos < endPos) { + currentPos++ + } + + return currentPos + } + + @Suppress("ConvertToStringTemplate") + private fun matchUnreservedPCTEncodedSubDelimClassesGreedy( + text: CharSequence, + startPos: Int, + additionalCharacters: String + ): Int { + val allowedCharacters = SUB_DELIM + "-._~" + additionalCharacters + var shouldBeHex = 0 + var currentPos = startPos + while (currentPos < text.length) { + val c = text[currentPos] + if (isHexDigit(c)) { + shouldBeHex = (shouldBeHex - 1).coerceAtLeast(0) + } else if (shouldBeHex == 0) { + if (c in allowedCharacters) { + // Everything ok here :) + } else if (c == '%') { + shouldBeHex = 2 + } else { + break + } + } else { + break + } + currentPos++ + } + + return currentPos + } + + private fun isHexDigit(c: Char): Boolean { + return c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' + } + + // This checks if the URL ends in a character that should be ignored because it most likely indicates the end of + // a sentence rather than being part of the URL + private fun CharSequence.isEndOfSentence(position: Int): Boolean { + // We want to keep everything if the URL is wrapped in angle brackets. + // Example: + if (position < lastIndex && this[position + 1] == '>') return false + + return this[position] in ".?!" && (position == lastIndex || this[position + 1].isWhitespace()) + } + + companion object { + // This string represent character group sub-delim as described in RFC 3986 + private const val SUB_DELIM = "!$&'()*+,;=" + private val SCHEME_REGEX = "(https?|rtsp)://".toRegex(RegexOption.IGNORE_CASE) + private val DOMAIN_REGEX = + "[\\da-z](?:[\\da-z-]*[\\da-z])*(?:\\.[\\da-z](?:[\\da-z-]*[\\da-z])*)*(?::(\\d{0,5}))?" + .toRegex(RegexOption.IGNORE_CASE) + private val IPv4_REGEX = "(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(:(\\d{0,5}))?".toRegex() + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/message/html/KoinModule.kt new file mode 100644 index 0000000..7c6151a --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/KoinModule.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.message.html + +import org.koin.dsl.module + +val htmlModule = module { + single { HtmlProcessorFactory(displayHtmlFactory = get()) } + single { DisplayHtmlFactory() } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/SignatureWrapper.kt b/app/core/src/main/java/com/fsck/k9/message/html/SignatureWrapper.kt new file mode 100644 index 0000000..50f0864 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/SignatureWrapper.kt @@ -0,0 +1,24 @@ +package com.fsck.k9.message.html + +internal object SignatureWrapper : TextToHtml.HtmlModifier { + private val SIGNATURE_REGEX = "(?m)^-- $".toRegex() + + override fun findModifications(text: CharSequence): List { + val matchResult = SIGNATURE_REGEX.find(text) ?: return emptyList() + return listOf(Signature(matchResult.range.first, text.length)) + } + + class Signature(startIndex: Int, endIndex: Int) : HtmlModification.Wrap(startIndex, endIndex) { + override fun appendPrefix(textToHtml: TextToHtml) { + textToHtml.appendHtml("
    ") + } + + override fun appendSuffix(textToHtml: TextToHtml) { + textToHtml.appendHtml("
    ") + } + + override fun toString(): String { + return "Signature{startIndex=$startIndex, endIndex=$endIndex}" + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/TextToHtml.kt b/app/core/src/main/java/com/fsck/k9/message/html/TextToHtml.kt new file mode 100644 index 0000000..ad0b5e6 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/TextToHtml.kt @@ -0,0 +1,164 @@ +package com.fsck.k9.message.html + +import java.util.ArrayDeque + +class TextToHtml private constructor( + private val text: CharSequence, + private val html: StringBuilder, + private val retainOriginalWhitespace: Boolean +) { + fun appendAsHtmlFragment() { + appendHtmlPrefix() + + val modifications = HTML_MODIFIERS + .flatMap { it.findModifications(text) } + .sortedBy { it.startIndex } + + val modificationStack = ArrayDeque() + var currentIndex = 0 + modifications.forEach { modification -> + while (modification.startIndex >= modificationStack.peek()?.endIndex ?: Int.MAX_VALUE) { + val outerModification = modificationStack.pop() + appendHtmlEncoded(currentIndex, outerModification.endIndex) + outerModification.appendSuffix(this) + currentIndex = outerModification.endIndex + } + + appendHtmlEncoded(currentIndex, modification.startIndex) + + if (modification.endIndex > modificationStack.peek()?.endIndex ?: Int.MAX_VALUE) { + error( + "HtmlModification $modification must be fully contained within " + + "outer HtmlModification ${modificationStack.peek()}" + ) + } + + when (modification) { + is HtmlModification.Wrap -> { + modification.appendPrefix(this) + modificationStack.push(modification) + currentIndex = modification.startIndex + } + is HtmlModification.Replace -> { + modification.replace(this) + currentIndex = modification.endIndex + } + } + } + + while (modificationStack.isNotEmpty()) { + val outerModification = modificationStack.pop() + appendHtmlEncoded(currentIndex, outerModification.endIndex) + outerModification.appendSuffix(this) + currentIndex = outerModification.endIndex + } + + appendHtmlEncoded(currentIndex, text.length) + + appendHtmlSuffix() + } + + private fun appendHtmlPrefix() { + html.append("""
    """) + } + + private fun appendHtmlSuffix() { + html.append("
    ") + } + + private fun appendHtmlEncoded(startIndex: Int, endIndex: Int) { + if (retainOriginalWhitespace) { + appendHtmlEncodedWithOriginalWhitespace(startIndex, endIndex) + } else { + appendHtmlEncodedWithNonBreakingSpaces(startIndex, endIndex) + } + } + + private fun appendHtmlEncodedWithOriginalWhitespace(startIndex: Int, endIndex: Int) { + for (i in startIndex until endIndex) { + appendHtmlEncoded(text[i]) + } + } + + private fun appendHtmlEncodedWithNonBreakingSpaces(startIndex: Int, endIndex: Int) { + var adjustedStartIndex = startIndex + if (startIndex < endIndex && text[startIndex] == SPACE) { + html.append(NON_BREAKING_SPACE) + adjustedStartIndex++ + } + + var spaces = 0 + for (i in adjustedStartIndex until endIndex) { + if (text[i] == SPACE) { + spaces++ + } else { + appendSpaces(spaces) + spaces = 0 + appendHtmlEncoded(text[i]) + } + } + + appendSpaces(spaces) + } + + private fun appendSpaces(count: Int) { + if (count <= 0) return + + repeat(count - 1) { + html.append(NON_BREAKING_SPACE) + } + html.append(SPACE) + } + + internal fun appendHtml(text: String) { + html.append(text) + } + + internal fun appendHtmlEncoded(ch: Char) { + when (ch) { + '&' -> html.append("&") + '<' -> html.append("<") + '>' -> html.append(">") + '\r' -> Unit + '\n' -> html.append(HTML_NEWLINE) + else -> html.append(ch) + } + } + + internal fun appendHtmlAttributeEncoded(attributeValue: CharSequence) { + for (ch in attributeValue) { + when (ch) { + '&' -> html.append("&") + '<' -> html.append("<") + '"' -> html.append(""") + else -> html.append(ch) + } + } + } + + companion object { + private val HTML_MODIFIERS = listOf(DividerReplacer, UriLinkifier, SignatureWrapper) + + private const val SPACE = ' ' + private const val NON_BREAKING_SPACE = '\u00A0' + + private const val HTML_NEWLINE = "
    " + private const val TEXT_TO_HTML_EXTRA_BUFFER_LENGTH = 512 + + @JvmStatic + fun appendAsHtmlFragment(html: StringBuilder, text: CharSequence, retainOriginalWhitespace: Boolean) { + TextToHtml(text, html, retainOriginalWhitespace).appendAsHtmlFragment() + } + + @JvmStatic + fun toHtmlFragment(text: CharSequence, retainOriginalWhitespace: Boolean): String { + val html = StringBuilder(text.length + TEXT_TO_HTML_EXTRA_BUFFER_LENGTH) + TextToHtml(text, html, retainOriginalWhitespace).appendAsHtmlFragment() + return html.toString() + } + } + + internal interface HtmlModifier { + fun findModifications(text: CharSequence): List + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/UriLinkifier.kt b/app/core/src/main/java/com/fsck/k9/message/html/UriLinkifier.kt new file mode 100644 index 0000000..5e7b1b4 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/UriLinkifier.kt @@ -0,0 +1,30 @@ +package com.fsck.k9.message.html + +internal object UriLinkifier : TextToHtml.HtmlModifier { + override fun findModifications(text: CharSequence): List { + return UriMatcher.findUris(text).map { + LinkifyUri(it.startIndex, it.endIndex, it.uri) + } + } + + class LinkifyUri( + startIndex: Int, + endIndex: Int, + val uri: CharSequence + ) : HtmlModification.Wrap(startIndex, endIndex) { + + override fun appendPrefix(textToHtml: TextToHtml) { + textToHtml.appendHtml("") + } + + override fun appendSuffix(textToHtml: TextToHtml) { + textToHtml.appendHtml("") + } + + override fun toString(): String { + return "LinkifyUri{startIndex=$startIndex, endIndex=$endIndex, uri='$uri'}" + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/UriMatch.kt b/app/core/src/main/java/com/fsck/k9/message/html/UriMatch.kt new file mode 100644 index 0000000..6c61124 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/UriMatch.kt @@ -0,0 +1,7 @@ +package com.fsck.k9.message.html + +data class UriMatch( + val startIndex: Int, + val endIndex: Int, + val uri: CharSequence +) diff --git a/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt b/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt new file mode 100644 index 0000000..2d29463 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt @@ -0,0 +1,34 @@ +package com.fsck.k9.message.html + +object UriMatcher { + private val SUPPORTED_URIS = run { + val httpUriParser = HttpUriParser() + val genericUriParser = GenericUriParser() + mapOf( + "http:" to httpUriParser, + "https:" to httpUriParser, + "mailto:" to genericUriParser, + "matrix:" to genericUriParser, + "rtsp:" to httpUriParser, + "xmpp:" to genericUriParser + ) + } + + private const val SCHEME_SEPARATORS = "\\s(\\n<" + private const val ALLOWED_SEPARATORS_PATTERN = "(?:^|[$SCHEME_SEPARATORS])" + private val URI_SCHEME = Regex( + "$ALLOWED_SEPARATORS_PATTERN(${ SUPPORTED_URIS.keys.joinToString("|") })", + RegexOption.IGNORE_CASE + ) + + fun findUris(text: CharSequence): List { + return URI_SCHEME.findAll(text).map { matchResult -> + val matchGroup = matchResult.groups[1]!! + val startIndex = matchGroup.range.first + val scheme = matchGroup.value.lowercase() + val parser = SUPPORTED_URIS[scheme] ?: throw AssertionError("Scheme not found: $scheme") + + parser.parseUri(text, startIndex) + }.filterNotNull().toList() + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/UriParser.kt b/app/core/src/main/java/com/fsck/k9/message/html/UriParser.kt new file mode 100644 index 0000000..89ca570 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/html/UriParser.kt @@ -0,0 +1,13 @@ +package com.fsck.k9.message.html + +internal interface UriParser { + /** + * Parse scheme specific URI beginning from given position. + * + * @param text String to parse URI from. + * @param startPos Position where URI starts (first letter of scheme). + * + * @return [UriMatch] if a valid URI was found. `null` otherwise. + */ + fun parseUri(text: CharSequence, startPos: Int): UriMatch? +} diff --git a/app/core/src/main/java/com/fsck/k9/message/quote/HtmlQuoteCreator.java b/app/core/src/main/java/com/fsck/k9/message/quote/HtmlQuoteCreator.java new file mode 100644 index 0000000..1fa365f --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/quote/HtmlQuoteCreator.java @@ -0,0 +1,216 @@ +package com.fsck.k9.message.quote; + + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.fsck.k9.CoreResourceProvider; +import com.fsck.k9.DI; +import timber.log.Timber; + +import com.fsck.k9.Account.QuoteStyle; +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.Message.RecipientType; +import com.fsck.k9.message.html.HtmlConverter; + + +public class HtmlQuoteCreator { + // Regular expressions to look for various HTML tags. This is no HTML::Parser, but hopefully it's good enough for + // our purposes. + private static final Pattern FIND_INSERTION_POINT_HTML = Pattern.compile("(?si:.*?(|\\s+[^>]*>)).*)"); + private static final Pattern FIND_INSERTION_POINT_HEAD = Pattern.compile("(?si:.*?(|\\s+[^>]*>)).*)"); + private static final Pattern FIND_INSERTION_POINT_BODY = Pattern.compile("(?si:.*?(|\\s+[^>]*>)).*)"); + private static final Pattern FIND_INSERTION_POINT_HTML_END = Pattern.compile("(?si:.*().*?)"); + private static final Pattern FIND_INSERTION_POINT_BODY_END = Pattern.compile("(?si:.*().*?)"); + // The first group in a Matcher contains the first capture group. We capture the tag found in the above REs so that + // we can locate the *end* of that tag. + private static final int FIND_INSERTION_POINT_FIRST_GROUP = 1; + // HTML bits to insert as appropriate + // TODO is it safe to assume utf-8 here? + private static final String FIND_INSERTION_POINT_HTML_CONTENT = "\r\n"; + private static final String FIND_INSERTION_POINT_HTML_END_CONTENT = ""; + private static final String FIND_INSERTION_POINT_HEAD_CONTENT = ""; + // Index of the start of the beginning of a String. + private static final int FIND_INSERTION_POINT_START_OF_STRING = 0; + + + /** + * Add quoting markup to a HTML message. + * @param originalMessage Metadata for message being quoted. + * @param messageBody Text of the message to be quoted. + * @param quoteStyle Style of quoting. + * @return Modified insertable message. + */ + public static InsertableHtmlContent quoteOriginalHtmlMessage(Message originalMessage, + String messageBody, QuoteStyle quoteStyle) { + CoreResourceProvider resourceProvider = DI.get(CoreResourceProvider.class); + InsertableHtmlContent insertable = findInsertionPoints(messageBody); + + String sentDate = new QuoteDateFormatter().format(originalMessage.getSentDate()); + String fromAddress = Address.toString(originalMessage.getFrom()); + if (quoteStyle == QuoteStyle.PREFIX) { + StringBuilder header = new StringBuilder(); + header.append("
    "); + if (sentDate.length() != 0) { + String replyHeader = resourceProvider.replyHeader(fromAddress, sentDate); + header.append(HtmlConverter.textToHtmlFragment(replyHeader)); + } else { + String replyHeader = resourceProvider.replyHeader(fromAddress); + header.append(HtmlConverter.textToHtmlFragment(replyHeader)); + } + header.append("
    \r\n"); + + String footer = "
    "; + + insertable.insertIntoQuotedHeader(header.toString()); + insertable.insertIntoQuotedFooter(footer); + } else if (quoteStyle == QuoteStyle.HEADER) { + + StringBuilder header = new StringBuilder(); + header.append("
    \r\n"); + header.append("
    \r\n"); // This gets converted into a horizontal line during html to text conversion. + if (originalMessage.getFrom() != null && fromAddress.length() != 0) { + header.append("").append(resourceProvider.messageHeaderFrom()).append(" ") + .append(HtmlConverter.textToHtmlFragment(fromAddress)) + .append("
    \r\n"); + } + if (sentDate.length() != 0) { + header.append("").append(resourceProvider.messageHeaderDate()).append(" ") + .append(sentDate) + .append("
    \r\n"); + } + if (originalMessage.getRecipients(RecipientType.TO) != null && originalMessage.getRecipients(RecipientType.TO).length != 0) { + header.append("").append(resourceProvider.messageHeaderTo()).append(" ") + .append(HtmlConverter.textToHtmlFragment(Address.toString(originalMessage.getRecipients(RecipientType.TO)))) + .append("
    \r\n"); + } + if (originalMessage.getRecipients(RecipientType.CC) != null && originalMessage.getRecipients(RecipientType.CC).length != 0) { + header.append("").append(resourceProvider.messageHeaderCc()).append(" ") + .append(HtmlConverter.textToHtmlFragment(Address.toString(originalMessage.getRecipients(RecipientType.CC)))) + .append("
    \r\n"); + } + if (originalMessage.getSubject() != null) { + header.append("").append(resourceProvider.messageHeaderSubject()).append(" ") + .append(HtmlConverter.textToHtmlFragment(originalMessage.getSubject())) + .append("
    \r\n"); + } + header.append("
    \r\n"); + header.append("
    \r\n"); + + insertable.insertIntoQuotedHeader(header.toString()); + } + + return insertable; + } + + /** + *

    Find the start and end positions of the HTML in the string. This should be the very top + * and bottom of the displayable message. It returns a {@link InsertableHtmlContent}, which + * contains both the insertion points and potentially modified HTML. The modified HTML should be + * used in place of the HTML in the original message.

    + * + *

    This method loosely mimics the HTML forward/reply behavior of BlackBerry OS 4.5/BIS 2.5, + * which in turn mimics Outlook 2003 (as best I can tell).

    + * + * @param content Content to examine for HTML insertion points + * @return Insertion points and HTML to use for insertion. + */ + private static InsertableHtmlContent findInsertionPoints(final String content) { + InsertableHtmlContent insertable = new InsertableHtmlContent(); + + // If there is no content, don't bother doing any of the regex dancing. + if (content == null || content.equals("")) { + return insertable; + } + + // Search for opening tags. + boolean hasHtmlTag = false; + boolean hasHeadTag = false; + boolean hasBodyTag = false; + // First see if we have an opening HTML tag. If we don't find one, we'll add one later. + Matcher htmlMatcher = FIND_INSERTION_POINT_HTML.matcher(content); + if (htmlMatcher.matches()) { + hasHtmlTag = true; + } + // Look for a HEAD tag. If we're missing a BODY tag, we'll use the close of the HEAD to start our content. + Matcher headMatcher = FIND_INSERTION_POINT_HEAD.matcher(content); + if (headMatcher.matches()) { + hasHeadTag = true; + } + // Look for a BODY tag. This is the ideal place for us to start our content. + Matcher bodyMatcher = FIND_INSERTION_POINT_BODY.matcher(content); + if (bodyMatcher.matches()) { + hasBodyTag = true; + } + + Timber.d("Open: hasHtmlTag:%s hasHeadTag:%s hasBodyTag:%s", hasHtmlTag, hasHeadTag, hasBodyTag); + + // Given our inspections, let's figure out where to start our content. + // This is the ideal case -- there's a BODY tag and we insert ourselves just after it. + if (hasBodyTag) { + insertable.setQuotedContent(new StringBuilder(content)); + insertable.setHeaderInsertionPoint(bodyMatcher.end(FIND_INSERTION_POINT_FIRST_GROUP)); + } else if (hasHeadTag) { + // Now search for a HEAD tag. We can insert after there. + + // If BlackBerry sees a HEAD tag, it inserts right after that, so long as there is no BODY tag. It doesn't + // try to add BODY, either. Right or wrong, it seems to work fine. + insertable.setQuotedContent(new StringBuilder(content)); + insertable.setHeaderInsertionPoint(headMatcher.end(FIND_INSERTION_POINT_FIRST_GROUP)); + } else if (hasHtmlTag) { + // Lastly, check for an HTML tag. + // In this case, it will add a HEAD, but no BODY. + StringBuilder newContent = new StringBuilder(content); + // Insert the HEAD content just after the HTML tag. + newContent.insert(htmlMatcher.end(FIND_INSERTION_POINT_FIRST_GROUP), FIND_INSERTION_POINT_HEAD_CONTENT); + insertable.setQuotedContent(newContent); + // The new insertion point is the end of the HTML tag, plus the length of the HEAD content. + insertable.setHeaderInsertionPoint(htmlMatcher.end(FIND_INSERTION_POINT_FIRST_GROUP) + FIND_INSERTION_POINT_HEAD_CONTENT.length()); + } else { + // If we have none of the above, we probably have a fragment of HTML. Yahoo! and Gmail both do this. + // Again, we add a HEAD, but not BODY. + StringBuilder newContent = new StringBuilder(content); + // Add the HTML and HEAD tags. + newContent.insert(FIND_INSERTION_POINT_START_OF_STRING, FIND_INSERTION_POINT_HEAD_CONTENT); + newContent.insert(FIND_INSERTION_POINT_START_OF_STRING, FIND_INSERTION_POINT_HTML_CONTENT); + // Append the tag. + newContent.append(FIND_INSERTION_POINT_HTML_END_CONTENT); + insertable.setQuotedContent(newContent); + insertable.setHeaderInsertionPoint(FIND_INSERTION_POINT_HTML_CONTENT.length() + FIND_INSERTION_POINT_HEAD_CONTENT.length()); + } + + // Search for closing tags. We have to do this after we deal with opening tags since it may + // have modified the message. + boolean hasHtmlEndTag = false; + boolean hasBodyEndTag = false; + // First see if we have an opening HTML tag. If we don't find one, we'll add one later. + Matcher htmlEndMatcher = FIND_INSERTION_POINT_HTML_END.matcher(insertable.getQuotedContent()); + if (htmlEndMatcher.matches()) { + hasHtmlEndTag = true; + } + // Look for a BODY tag. This is the ideal place for us to place our footer. + Matcher bodyEndMatcher = FIND_INSERTION_POINT_BODY_END.matcher(insertable.getQuotedContent()); + if (bodyEndMatcher.matches()) { + hasBodyEndTag = true; + } + + Timber.d("Close: hasHtmlEndTag:%s hasBodyEndTag:%s", hasHtmlEndTag, hasBodyEndTag); + + // Now figure out where to put our footer. + // This is the ideal case -- there's a BODY tag and we insert ourselves just before it. + if (hasBodyEndTag) { + insertable.setFooterInsertionPoint(bodyEndMatcher.start(FIND_INSERTION_POINT_FIRST_GROUP)); + } else if (hasHtmlEndTag) { + // Check for an HTML tag. Add ourselves just before it. + insertable.setFooterInsertionPoint(htmlEndMatcher.start(FIND_INSERTION_POINT_FIRST_GROUP)); + } else { + // If we have none of the above, we probably have a fragment of HTML. + // Set our footer insertion point as the end of the string. + insertable.setFooterInsertionPoint(insertable.getQuotedContent().length()); + } + + return insertable; + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/quote/InsertableHtmlContent.java b/app/core/src/main/java/com/fsck/k9/message/quote/InsertableHtmlContent.java new file mode 100644 index 0000000..4ce04f9 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/quote/InsertableHtmlContent.java @@ -0,0 +1,161 @@ +package com.fsck.k9.message.quote; + +import java.io.Serializable; + +/** + *

    Represents an HTML document with an insertion point for placing a reply. The quoted + * document may have been modified to make it suitable for insertion. The modified quoted + * document should be used in place of the original document.

    + * + *

    Changes to the user-generated inserted content should be done with {@link + * #setUserContent(String)}.

    + * + * TODO: This container should also have a text part, along with its insertion point. Or maybe a generic InsertableContent and maintain one each for Html and Text? + */ +public class InsertableHtmlContent implements Serializable { + private static final long serialVersionUID = 2397327034L; + // Default to a headerInsertionPoint at the beginning of the message. + private int headerInsertionPoint = 0; + private int footerInsertionPoint = 0; + // Quoted message, if any. headerInsertionPoint refers to a position in this string. + private StringBuilder quotedContent = new StringBuilder(); + // User content (typically their reply or comments on a forward) + private StringBuilder userContent = new StringBuilder(); + // Where to insert the content. Default to top posting. + private InsertionLocation insertionLocation = InsertionLocation.BEFORE_QUOTE; + + /** + * Defines where user content should be inserted, either before or after quoted content. + */ + public enum InsertionLocation { + BEFORE_QUOTE, AFTER_QUOTE + } + + public void setHeaderInsertionPoint(int headerInsertionPoint) { + if (headerInsertionPoint < 0 || headerInsertionPoint > quotedContent.length()) { + this.headerInsertionPoint = 0; + } else { + this.headerInsertionPoint = headerInsertionPoint; + } + } + + public void setFooterInsertionPoint(int footerInsertionPoint) { + int len = quotedContent.length(); + if (footerInsertionPoint < 0 || footerInsertionPoint > len) { + this.footerInsertionPoint = len; + } else { + this.footerInsertionPoint = footerInsertionPoint; + } + } + + /** + * Get the quoted content. + * @return Quoted content. + */ + public String getQuotedContent() { + return quotedContent.toString(); + } + + /** + * Set the quoted content. The insertion point should be set against this content. + * @param content + */ + public void setQuotedContent(StringBuilder content) { + this.quotedContent = content; + } + + /** + *

    Insert something into the quoted content header. This is typically used for inserting + * reply/forward headers into the quoted content rather than inserting the user-generated reply + * content.

    + * + *

    Subsequent calls to {@link #insertIntoQuotedHeader(String)} will prepend text onto any + * existing header and quoted content.

    + * @param content Content to add. + */ + public void insertIntoQuotedHeader(final String content) { + quotedContent.insert(headerInsertionPoint, content); + // Update the location of the footer insertion point. + footerInsertionPoint += content.length(); + } + + /** + *

    Insert something into the quoted content footer. This is typically used for inserting closing + * tags of reply/forward headers rather than inserting the user-generated reply content.

    + * + *

    Subsequent calls to {@link #insertIntoQuotedFooter(String)} will append text onto any + * existing footer and quoted content.

    + * @param content Content to add. + */ + public void insertIntoQuotedFooter(final String content) { + quotedContent.insert(footerInsertionPoint, content); + // Update the location of the footer insertion point to the end of the inserted content. + footerInsertionPoint += content.length(); + } + + /** + * Set the inserted content to the specified content. Replaces anything currently in the + * inserted content buffer. + * @param content + */ + public void setUserContent(final String content) { + userContent = new StringBuilder(content); + } + + /** + * Configure where user content should be inserted, either before or after the quoted content. + * @param insertionLocation Where to insert user content. + */ + public void setInsertionLocation(final InsertionLocation insertionLocation) { + this.insertionLocation = insertionLocation; + } + + /** + * Fetch the insertion point based upon the quote style. + * @return Insertion point + */ + public int getInsertionPoint() { + if (insertionLocation == InsertionLocation.BEFORE_QUOTE) { + return headerInsertionPoint; + } else { + return footerInsertionPoint; + } + } + + /** + * Get the footer insertion point. + * @return Footer insertion point + */ + public int getFooterInsertionPoint() { + return footerInsertionPoint; + } + + /** + * Build the composed string with the inserted and original content. + * @return Composed string. + */ + @Override + public String toString() { + final int insertionPoint = getInsertionPoint(); + // Inserting and deleting was twice as fast as instantiating a new StringBuilder and + // using substring() to build the new pieces. + String result = quotedContent.insert(insertionPoint, userContent.toString()).toString(); + quotedContent.delete(insertionPoint, insertionPoint + userContent.length()); + return result; + } + + /** + * Return debugging information for this container. + * @return Debug string. + */ + public String toDebugString() { + return "InsertableHtmlContent{" + + "headerInsertionPoint=" + headerInsertionPoint + + ", footerInsertionPoint=" + footerInsertionPoint + + ", insertionLocation=" + insertionLocation + + ", quotedContent=" + quotedContent + + ", userContent=" + userContent + + ", compiledResult=" + toString() + + '}'; + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/quote/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/message/quote/KoinModule.kt new file mode 100644 index 0000000..19e6d61 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/quote/KoinModule.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.message.quote + +import org.koin.dsl.module + +val quoteModule = module { + factory { QuoteDateFormatter() } + factory { TextQuoteCreator(get(), get()) } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/quote/QuoteDateFormatter.kt b/app/core/src/main/java/com/fsck/k9/message/quote/QuoteDateFormatter.kt new file mode 100644 index 0000000..608c5cb --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/quote/QuoteDateFormatter.kt @@ -0,0 +1,34 @@ +package com.fsck.k9.message.quote + +import com.fsck.k9.K9 +import java.text.DateFormat +import java.util.Date +import java.util.TimeZone + +/** + * Convert a date into a locale-specific date string suitable for use in a header for a quoted message. + */ +class QuoteDateFormatter { + + fun format(date: Date): String { + return try { + val dateFormat = createDateFormat() + dateFormat.format(date) + } catch (e: Exception) { + "" + } + } + + private fun createDateFormat(): DateFormat { + return DateFormat.getDateTimeInstance(DATE_STYLE, TIME_STYLE).apply { + if (K9.isHideTimeZone) { + timeZone = TimeZone.getTimeZone("UTC") + } + } + } + + companion object { + private const val DATE_STYLE = DateFormat.LONG + private const val TIME_STYLE = DateFormat.LONG + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/quote/TextQuoteCreator.kt b/app/core/src/main/java/com/fsck/k9/message/quote/TextQuoteCreator.kt new file mode 100644 index 0000000..4148660 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/quote/TextQuoteCreator.kt @@ -0,0 +1,101 @@ +package com.fsck.k9.message.quote + +import com.fsck.k9.Account.QuoteStyle +import com.fsck.k9.CoreResourceProvider +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.Message.RecipientType + +class TextQuoteCreator( + private val quoteDateFormatter: QuoteDateFormatter, + private val resourceProvider: CoreResourceProvider +) { + private val prefixInsertionRegex = Regex("(?m)^") + + fun quoteOriginalTextMessage( + originalMessage: Message, + messageBody: String?, + quoteStyle: QuoteStyle, + prefix: String + ): String { + val body = messageBody ?: "" + return when (quoteStyle) { + QuoteStyle.PREFIX -> prefixQuoteText(body, originalMessage, prefix) + QuoteStyle.HEADER -> headerQuoteText(body, originalMessage) + } + } + + private fun prefixQuoteText(body: String, originalMessage: Message, prefix: String): String { + val sentDate = quoteDateFormatter.format(originalMessage.sentDate) + val sender = Address.toString(originalMessage.from) + + return buildString { + val replyHeader = if (sentDate.isEmpty()) { + resourceProvider.replyHeader(sender) + } else { + resourceProvider.replyHeader(sender, sentDate) + } + append(replyHeader) + append(CRLF) + + val escapedPrefix = Regex.escapeReplacement(prefix) + val prefixedText = body.replace(prefixInsertionRegex, escapedPrefix) + + append(prefixedText) + } + } + + private fun headerQuoteText(body: String, originalMessage: Message): String { + val sentDate = quoteDateFormatter.format(originalMessage.sentDate) + + return buildString { + append(CRLF) + append(resourceProvider.messageHeaderSeparator()) + append(CRLF) + + originalMessage.from.displayString()?.let { fromAddresses -> + append(resourceProvider.messageHeaderFrom()) + append(" ") + append(fromAddresses) + append(CRLF) + } + + if (sentDate.isNotEmpty()) { + append(resourceProvider.messageHeaderDate()) + append(" ") + append(sentDate) + append(CRLF) + } + + originalMessage.getRecipients(RecipientType.TO).displayString()?.let { toAddresses -> + append(resourceProvider.messageHeaderTo()) + append(" ") + append(toAddresses) + append(CRLF) + } + + originalMessage.getRecipients(RecipientType.CC).displayString()?.let { ccAddresses -> + append(resourceProvider.messageHeaderCc()) + append(" ") + append(ccAddresses) + append(CRLF) + } + + originalMessage.subject?.let { subject -> + append(resourceProvider.messageHeaderSubject()) + append(" ") + append(subject) + append(CRLF) + } + + append(CRLF) + append(body) + } + } + + private fun Array
    .displayString() = Address.toString(this)?.let { if (it.isEmpty()) null else it } + + companion object { + private const val CRLF = "\r\n" + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.kt b/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.kt new file mode 100644 index 0000000..d7a0de6 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.kt @@ -0,0 +1,142 @@ +package com.fsck.k9.message.signature + +import com.fsck.k9.helper.jsoup.AdvancedNodeTraversor +import com.fsck.k9.helper.jsoup.NodeFilter +import com.fsck.k9.helper.jsoup.NodeFilter.HeadFilterDecision +import com.fsck.k9.helper.jsoup.NodeFilter.TailFilterDecision +import java.util.Stack +import java.util.regex.Pattern +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.parser.Tag + +class HtmlSignatureRemover { + private fun stripSignatureInternal(content: String): String { + val document = Jsoup.parse(content) + + val nodeTraversor = AdvancedNodeTraversor(StripSignatureFilter()) + nodeTraversor.filter(document.body()) + + return toCompactString(document) + } + + private fun toCompactString(document: Document): String { + document.outputSettings() + .prettyPrint(false) + .indentAmount(0) + + return document.html() + } + + private class StripSignatureFilter : NodeFilter { + private var signatureFound = false + private var signatureParentNode: Node? = null + + override fun head(node: Node, depth: Int): HeadFilterDecision { + if (signatureFound) return HeadFilterDecision.REMOVE + + if (node.isBlockquote()) { + return HeadFilterDecision.SKIP_ENTIRELY + } else if (node.isSignatureDelimiter()) { + val precedingLineBreak = node.findPrecedingLineBreak() + if (precedingLineBreak != null && node.isFollowedByLineBreak()) { + signatureFound = true + signatureParentNode = node.parent() + precedingLineBreak.takeIf { it.isBR() }?.remove() + + return HeadFilterDecision.REMOVE + } + } + + return HeadFilterDecision.CONTINUE + } + + override fun tail(node: Node, depth: Int): TailFilterDecision { + if (signatureFound) { + val signatureParentNode = this.signatureParentNode + if (node == signatureParentNode) { + return if (signatureParentNode.isEmpty()) { + this.signatureParentNode = signatureParentNode.parent() + TailFilterDecision.REMOVE + } else { + TailFilterDecision.STOP + } + } + } + + return TailFilterDecision.CONTINUE + } + + private fun Node.isBlockquote(): Boolean { + return this is Element && tag() == BLOCKQUOTE + } + + private fun Node.isSignatureDelimiter(): Boolean { + return this is TextNode && DASH_SIGNATURE_HTML.matcher(wholeText).matches() + } + + private fun Node.findPrecedingLineBreak(): Node? { + val stack = Stack() + stack.push(this) + + while (stack.isNotEmpty()) { + val node = stack.pop() + val previousSibling = node.previousSibling() + if (previousSibling == null) { + val parent = node.parent() + if (parent is Element && parent.isBlock) { + return parent + } else { + stack.push(parent) + } + } else if (previousSibling.isLineBreak()) { + return previousSibling + } + } + + return null + } + + private fun Node.isFollowedByLineBreak(): Boolean { + val stack = Stack() + stack.push(this) + + while (stack.isNotEmpty()) { + val node = stack.pop() + val nextSibling = node.nextSibling() + if (nextSibling == null) { + val parent = node.parent() + if (parent is Element && parent.isBlock) { + return true + } else { + stack.push(parent) + } + } else if (nextSibling.isLineBreak()) { + return true + } + } + + return false + } + + private fun Node?.isBR() = this is Element && tag() == BR + + private fun Node?.isLineBreak() = isBR() || (this is Element && this.isBlock) + + private fun Node.isEmpty(): Boolean = childNodeSize() == 0 + } + + companion object { + private val DASH_SIGNATURE_HTML = Pattern.compile("\\s*--[ \u00A0]\\s*") + private val BLOCKQUOTE = Tag.valueOf("blockquote") + private val BR = Tag.valueOf("br") + + @JvmStatic + fun stripSignature(content: String): String { + return HtmlSignatureRemover().stripSignatureInternal(content) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/message/signature/TextSignatureRemover.java b/app/core/src/main/java/com/fsck/k9/message/signature/TextSignatureRemover.java new file mode 100644 index 0000000..2e610ff --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/signature/TextSignatureRemover.java @@ -0,0 +1,17 @@ +package com.fsck.k9.message.signature; + + +import java.util.regex.Pattern; + + +public class TextSignatureRemover { + private static final Pattern DASH_SIGNATURE_PLAIN = Pattern.compile("\r\n-- \r\n.*", Pattern.DOTALL); + + + public static String stripSignature(String content) { + if (DASH_SIGNATURE_PLAIN.matcher(content).find()) { + content = DASH_SIGNATURE_PLAIN.matcher(content).replaceFirst("\r\n"); + } + return content; + } +} diff --git a/app/core/src/main/java/com/fsck/k9/network/ConnectivityManager.kt b/app/core/src/main/java/com/fsck/k9/network/ConnectivityManager.kt new file mode 100644 index 0000000..c427c9f --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/network/ConnectivityManager.kt @@ -0,0 +1,25 @@ +package com.fsck.k9.network + +import android.os.Build +import android.net.ConnectivityManager as SystemConnectivityManager + +interface ConnectivityManager { + fun start() + fun stop() + fun isNetworkAvailable(): Boolean + fun addListener(listener: ConnectivityChangeListener) + fun removeListener(listener: ConnectivityChangeListener) +} + +interface ConnectivityChangeListener { + fun onConnectivityChanged() + fun onConnectivityLost() +} + +internal fun ConnectivityManager(systemConnectivityManager: SystemConnectivityManager): ConnectivityManager { + return when { + Build.VERSION.SDK_INT >= 24 -> ConnectivityManagerApi24(systemConnectivityManager) + Build.VERSION.SDK_INT >= 23 -> ConnectivityManagerApi23(systemConnectivityManager) + else -> ConnectivityManagerApi21(systemConnectivityManager) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi21.kt b/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi21.kt new file mode 100644 index 0000000..8832f30 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi21.kt @@ -0,0 +1,66 @@ +package com.fsck.k9.network + +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkRequest +import timber.log.Timber +import android.net.ConnectivityManager as SystemConnectivityManager + +@Suppress("DEPRECATION") +internal class ConnectivityManagerApi21( + private val systemConnectivityManager: SystemConnectivityManager +) : ConnectivityManagerBase() { + private var isRunning = false + private var lastNetworkType: Int? = null + private var wasConnected: Boolean? = null + + private val networkCallback = object : NetworkCallback() { + override fun onAvailable(network: Network) { + Timber.v("Network available: $network") + notifyIfConnectivityHasChanged() + } + + override fun onLost(network: Network) { + Timber.v("Network lost: $network") + notifyIfConnectivityHasChanged() + } + + private fun notifyIfConnectivityHasChanged() { + val networkType = systemConnectivityManager.activeNetworkInfo?.type + val isConnected = isNetworkAvailable() + + synchronized(this@ConnectivityManagerApi21) { + if (networkType != lastNetworkType || isConnected != wasConnected) { + lastNetworkType = networkType + wasConnected = isConnected + if (isConnected) { + notifyOnConnectivityChanged() + } else { + notifyOnConnectivityLost() + } + } + } + } + } + + @Synchronized + override fun start() { + if (!isRunning) { + isRunning = true + + val networkRequest = NetworkRequest.Builder().build() + systemConnectivityManager.registerNetworkCallback(networkRequest, networkCallback) + } + } + + @Synchronized + override fun stop() { + if (isRunning) { + isRunning = false + + systemConnectivityManager.unregisterNetworkCallback(networkCallback) + } + } + + override fun isNetworkAvailable(): Boolean = systemConnectivityManager.activeNetworkInfo?.isConnected == true +} diff --git a/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi23.kt b/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi23.kt new file mode 100644 index 0000000..17fb87f --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi23.kt @@ -0,0 +1,73 @@ +package com.fsck.k9.network + +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import androidx.annotation.RequiresApi +import timber.log.Timber +import android.net.ConnectivityManager as SystemConnectivityManager + +@RequiresApi(Build.VERSION_CODES.M) +internal class ConnectivityManagerApi23( + private val systemConnectivityManager: SystemConnectivityManager +) : ConnectivityManagerBase() { + private var isRunning = false + private var lastActiveNetwork: Network? = null + private var wasConnected: Boolean? = null + + private val networkCallback = object : NetworkCallback() { + override fun onAvailable(network: Network) { + Timber.v("Network available: $network") + notifyIfActiveNetworkOrConnectivityHasChanged() + } + + override fun onLost(network: Network) { + Timber.v("Network lost: $network") + notifyIfActiveNetworkOrConnectivityHasChanged() + } + + private fun notifyIfActiveNetworkOrConnectivityHasChanged() { + val activeNetwork = systemConnectivityManager.activeNetwork + val isConnected = isNetworkAvailable() + + synchronized(this@ConnectivityManagerApi23) { + if (activeNetwork != lastActiveNetwork || isConnected != wasConnected) { + lastActiveNetwork = activeNetwork + wasConnected = isConnected + if (isConnected) { + notifyOnConnectivityChanged() + } else { + notifyOnConnectivityLost() + } + } + } + } + } + + @Synchronized + override fun start() { + if (!isRunning) { + isRunning = true + + val networkRequest = NetworkRequest.Builder().build() + systemConnectivityManager.registerNetworkCallback(networkRequest, networkCallback) + } + } + + @Synchronized + override fun stop() { + if (isRunning) { + isRunning = false + + systemConnectivityManager.unregisterNetworkCallback(networkCallback) + } + } + + override fun isNetworkAvailable(): Boolean { + val activeNetwork = systemConnectivityManager.activeNetwork ?: return false + val networkCapabilities = systemConnectivityManager.getNetworkCapabilities(activeNetwork) + return networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } +} diff --git a/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi24.kt b/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi24.kt new file mode 100644 index 0000000..b0a3daf --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerApi24.kt @@ -0,0 +1,65 @@ +package com.fsck.k9.network + +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import androidx.annotation.RequiresApi +import timber.log.Timber +import android.net.ConnectivityManager as SystemConnectivityManager + +@RequiresApi(Build.VERSION_CODES.N) +internal class ConnectivityManagerApi24( + private val systemConnectivityManager: SystemConnectivityManager +) : ConnectivityManagerBase() { + private var isRunning = false + private var isNetworkAvailable: Boolean? = null + + private val networkCallback = object : NetworkCallback() { + override fun onAvailable(network: Network) { + Timber.v("Network available: $network") + synchronized(this@ConnectivityManagerApi24) { + isNetworkAvailable = true + notifyOnConnectivityChanged() + } + } + + override fun onLost(network: Network) { + Timber.v("Network lost: $network") + synchronized(this@ConnectivityManagerApi24) { + isNetworkAvailable = false + notifyOnConnectivityLost() + } + } + } + + @Synchronized + override fun start() { + if (!isRunning) { + isRunning = true + + systemConnectivityManager.registerDefaultNetworkCallback(networkCallback) + } + } + + @Synchronized + override fun stop() { + if (isRunning) { + isRunning = false + + systemConnectivityManager.unregisterNetworkCallback(networkCallback) + } + } + + override fun isNetworkAvailable(): Boolean { + return synchronized(this) { isNetworkAvailable } ?: isNetworkAvailableSynchronous() + } + + // Sometimes this will return 'true' even though networkCallback has already received onLost(). + // That's why isNetworkAvailable() prefers the state derived from the callbacks over this method. + private fun isNetworkAvailableSynchronous(): Boolean { + val activeNetwork = systemConnectivityManager.activeNetwork ?: return false + val networkCapabilities = systemConnectivityManager.getNetworkCapabilities(activeNetwork) + return networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } +} diff --git a/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerBase.kt b/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerBase.kt new file mode 100644 index 0000000..598acfe --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/network/ConnectivityManagerBase.kt @@ -0,0 +1,31 @@ +package com.fsck.k9.network + +import java.util.concurrent.CopyOnWriteArraySet + +internal abstract class ConnectivityManagerBase : ConnectivityManager { + private val listeners = CopyOnWriteArraySet() + + @Synchronized + override fun addListener(listener: ConnectivityChangeListener) { + listeners.add(listener) + } + + @Synchronized + override fun removeListener(listener: ConnectivityChangeListener) { + listeners.remove(listener) + } + + @Synchronized + protected fun notifyOnConnectivityChanged() { + for (listener in listeners) { + listener.onConnectivityChanged() + } + } + + @Synchronized + protected fun notifyOnConnectivityLost() { + for (listener in listeners) { + listener.onConnectivityLost() + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/network/KointModule.kt b/app/core/src/main/java/com/fsck/k9/network/KointModule.kt new file mode 100644 index 0000000..281c477 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/network/KointModule.kt @@ -0,0 +1,10 @@ +package com.fsck.k9.network + +import android.content.Context +import org.koin.dsl.module +import android.net.ConnectivityManager as SystemConnectivityManager + +internal val connectivityModule = module { + single { get().getSystemService(Context.CONNECTIVITY_SERVICE) as SystemConnectivityManager } + single { ConnectivityManager(systemConnectivityManager = get()) } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/AddNotificationResult.kt b/app/core/src/main/java/com/fsck/k9/notification/AddNotificationResult.kt new file mode 100644 index 0000000..662235e --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/AddNotificationResult.kt @@ -0,0 +1,42 @@ +package com.fsck.k9.notification + +internal class AddNotificationResult private constructor( + val notificationData: NotificationData, + val notificationStoreOperations: List, + val notificationHolder: NotificationHolder, + val shouldCancelNotification: Boolean +) { + val cancelNotificationId: Int + get() { + check(shouldCancelNotification) { "shouldCancelNotification == false" } + return notificationHolder.notificationId + } + + companion object { + fun newNotification( + notificationData: NotificationData, + notificationStoreOperations: List, + notificationHolder: NotificationHolder + ): AddNotificationResult { + return AddNotificationResult( + notificationData, + notificationStoreOperations, + notificationHolder, + shouldCancelNotification = false + ) + } + + fun replaceNotification( + notificationData: NotificationData, + notificationStoreOperations: List, + notificationHolder: NotificationHolder + ): AddNotificationResult { + return AddNotificationResult( + notificationData, + notificationStoreOperations, + notificationHolder, + shouldCancelNotification = true + ) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotificationController.kt new file mode 100644 index 0000000..332c7db --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotificationController.kt @@ -0,0 +1,63 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account + +internal open class AuthenticationErrorNotificationController( + private val notificationHelper: NotificationHelper, + private val actionCreator: NotificationActionCreator, + private val resourceProvider: NotificationResourceProvider +) { + fun showAuthenticationErrorNotification(account: Account, incoming: Boolean) { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, incoming) + val editServerSettingsPendingIntent = createContentIntent(account, incoming) + val title = resourceProvider.authenticationErrorTitle() + val text = resourceProvider.authenticationErrorBody(account.displayName) + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(editServerSettingsPendingIntent) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setPublicVersion(createLockScreenNotification(account)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setErrorAppearance() + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun clearAuthenticationErrorNotification(account: Account, incoming: Boolean) { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, incoming) + notificationManager.cancel(notificationId) + } + + protected open fun createContentIntent(account: Account, incoming: Boolean): PendingIntent { + return if (incoming) { + actionCreator.getEditIncomingServerSettingsIntent(account) + } else { + actionCreator.getEditOutgoingServerSettingsIntent(account) + } + } + + private fun createLockScreenNotification(account: Account): Notification { + return notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setContentTitle(resourceProvider.authenticationErrorTitle()) + .build() + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/BaseNotificationDataCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/BaseNotificationDataCreator.kt new file mode 100644 index 0000000..9fe65d3 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/BaseNotificationDataCreator.kt @@ -0,0 +1,48 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.K9.LockScreenNotificationVisibility + +private const val MAX_NUMBER_OF_SENDERS_IN_LOCK_SCREEN_NOTIFICATION = 5 + +internal class BaseNotificationDataCreator { + + fun createBaseNotificationData(notificationData: NotificationData): BaseNotificationData { + val account = notificationData.account + return BaseNotificationData( + account = account, + groupKey = NotificationGroupKeys.getGroupKey(account), + accountName = account.displayName, + color = account.chipColor, + newMessagesCount = notificationData.newMessagesCount, + lockScreenNotificationData = createLockScreenNotificationData(notificationData), + appearance = createNotificationAppearance(account) + ) + } + + private fun createLockScreenNotificationData(data: NotificationData): LockScreenNotificationData { + return when (K9.lockScreenNotificationVisibility) { + LockScreenNotificationVisibility.NOTHING -> LockScreenNotificationData.None + LockScreenNotificationVisibility.APP_NAME -> LockScreenNotificationData.AppName + LockScreenNotificationVisibility.EVERYTHING -> LockScreenNotificationData.Public + LockScreenNotificationVisibility.MESSAGE_COUNT -> LockScreenNotificationData.MessageCount + LockScreenNotificationVisibility.SENDERS -> LockScreenNotificationData.SenderNames(getSenderNames(data)) + } + } + + private fun getSenderNames(data: NotificationData): String { + return data.activeNotifications.asSequence() + .map { it.content.sender } + .distinct() + .take(MAX_NUMBER_OF_SENDERS_IN_LOCK_SCREEN_NOTIFICATION) + .joinToString() + } + + private fun createNotificationAppearance(account: Account): NotificationAppearance { + return with(account.notificationSettings) { + val vibrationPattern = vibration.systemPattern.takeIf { vibration.isEnabled } + NotificationAppearance(ringtone, vibrationPattern, account.notificationSettings.light.toColor(account)) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotificationController.kt new file mode 100644 index 0000000..bae4f7e --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotificationController.kt @@ -0,0 +1,63 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account + +internal open class CertificateErrorNotificationController( + private val notificationHelper: NotificationHelper, + private val actionCreator: NotificationActionCreator, + private val resourceProvider: NotificationResourceProvider +) { + fun showCertificateErrorNotification(account: Account, incoming: Boolean) { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, incoming) + val editServerSettingsPendingIntent = createContentIntent(account, incoming) + val title = resourceProvider.certificateErrorTitle(account.displayName) + val text = resourceProvider.certificateErrorBody() + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(editServerSettingsPendingIntent) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setPublicVersion(createLockScreenNotification(account)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setErrorAppearance() + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun clearCertificateErrorNotifications(account: Account, incoming: Boolean) { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, incoming) + notificationManager.cancel(notificationId) + } + + protected open fun createContentIntent(account: Account, incoming: Boolean): PendingIntent { + return if (incoming) { + actionCreator.getEditIncomingServerSettingsIntent(account) + } else { + actionCreator.getEditOutgoingServerSettingsIntent(account) + } + } + + private fun createLockScreenNotification(account: Account): Notification { + return notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setContentTitle(resourceProvider.certificateErrorTitle()) + .build() + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/CoreKoinModule.kt b/app/core/src/main/java/com/fsck/k9/notification/CoreKoinModule.kt new file mode 100644 index 0000000..389a596 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/CoreKoinModule.kt @@ -0,0 +1,128 @@ +package com.fsck.k9.notification + +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.AccountPreferenceSerializer +import java.util.concurrent.Executors +import org.koin.dsl.module + +val coreNotificationModule = module { + single { + NotificationController( + certificateErrorNotificationController = get(), + authenticationErrorNotificationController = get(), + syncNotificationController = get(), + sendFailedNotificationController = get(), + newMailNotificationController = get() + ) + } + single { NotificationManagerCompat.from(get()) } + single { + NotificationHelper(context = get(), notificationManager = get(), notificationChannelManager = get(), resourceProvider = get()) + } + single { + NotificationChannelManager( + preferences = get(), + backgroundExecutor = Executors.newSingleThreadExecutor(), + notificationManager = get().getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager, + resourceProvider = get(), + notificationLightDecoder = get() + ) + } + single { + AccountPreferenceSerializer( + storageManager = get(), + resourceProvider = get(), + serverSettingsSerializer = get() + ) + } + single { + CertificateErrorNotificationController( + notificationHelper = get(), + actionCreator = get(), + resourceProvider = get() + ) + } + single { + AuthenticationErrorNotificationController( + notificationHelper = get(), + actionCreator = get(), + resourceProvider = get() + ) + } + single { + SyncNotificationController(notificationHelper = get(), actionBuilder = get(), resourceProvider = get()) + } + single { + SendFailedNotificationController(notificationHelper = get(), actionBuilder = get(), resourceProvider = get()) + } + single { + NewMailNotificationController( + notificationManager = get(), + newMailNotificationManager = get(), + summaryNotificationCreator = get(), + singleMessageNotificationCreator = get() + ) + } + single { + NewMailNotificationManager( + contentCreator = get(), + notificationRepository = get(), + baseNotificationDataCreator = get(), + singleMessageNotificationDataCreator = get(), + summaryNotificationDataCreator = get(), + clock = get() + ) + } + factory { NotificationContentCreator(resourceProvider = get(), contactRepository = get()) } + factory { BaseNotificationDataCreator() } + factory { SingleMessageNotificationDataCreator() } + factory { SummaryNotificationDataCreator(singleMessageNotificationDataCreator = get()) } + factory { + SingleMessageNotificationCreator( + notificationHelper = get(), + actionCreator = get(), + resourceProvider = get(), + lockScreenNotificationCreator = get() + ) + } + factory { + SummaryNotificationCreator( + notificationHelper = get(), + actionCreator = get(), + lockScreenNotificationCreator = get(), + singleMessageNotificationCreator = get(), + resourceProvider = get() + ) + } + factory { LockScreenNotificationCreator(notificationHelper = get(), resourceProvider = get()) } + single { + PushNotificationManager( + context = get(), + resourceProvider = get(), + notificationChannelManager = get(), + notificationManager = get() + ) + } + single { + NotificationRepository( + notificationStoreProvider = get(), + localStoreProvider = get(), + messageStoreManager = get(), + notificationContentCreator = get() + ) + } + factory { NotificationLightDecoder() } + factory { NotificationVibrationDecoder() } + factory { + NotificationConfigurationConverter(notificationLightDecoder = get(), notificationVibrationDecoder = get()) + } + factory { + NotificationSettingsUpdater( + preferences = get(), + notificationChannelManager = get(), + notificationConfigurationConverter = get() + ) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotificationCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotificationCreator.kt new file mode 100644 index 0000000..e071201 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotificationCreator.kt @@ -0,0 +1,60 @@ +package com.fsck.k9.notification + +import android.app.Notification +import androidx.core.app.NotificationCompat + +internal class LockScreenNotificationCreator( + private val notificationHelper: NotificationHelper, + private val resourceProvider: NotificationResourceProvider +) { + fun configureLockScreenNotification( + builder: NotificationCompat.Builder, + baseNotificationData: BaseNotificationData + ) { + when (baseNotificationData.lockScreenNotificationData) { + LockScreenNotificationData.None -> { + builder.setVisibility(NotificationCompat.VISIBILITY_SECRET) + } + LockScreenNotificationData.AppName -> { + builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + } + LockScreenNotificationData.Public -> { + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + is LockScreenNotificationData.SenderNames -> { + val publicNotification = createPublicNotificationWithSenderList(baseNotificationData) + builder.setPublicVersion(publicNotification) + } + LockScreenNotificationData.MessageCount -> { + val publicNotification = createPublicNotificationWithNewMessagesCount(baseNotificationData) + builder.setPublicVersion(publicNotification) + } + } + } + + private fun createPublicNotificationWithSenderList(baseNotificationData: BaseNotificationData): Notification { + val notificationData = baseNotificationData.lockScreenNotificationData as LockScreenNotificationData.SenderNames + return createPublicNotification(baseNotificationData) + .setContentText(notificationData.senderNames) + .build() + } + + private fun createPublicNotificationWithNewMessagesCount(baseNotificationData: BaseNotificationData): Notification { + return createPublicNotification(baseNotificationData) + .setContentText(baseNotificationData.accountName) + .build() + } + + private fun createPublicNotification(baseNotificationData: BaseNotificationData): NotificationCompat.Builder { + val account = baseNotificationData.account + val newMessagesCount = baseNotificationData.newMessagesCount + val title = resourceProvider.newMessagesTitle(newMessagesCount) + + return notificationHelper.createNotificationBuilder(account, NotificationChannelManager.ChannelType.MESSAGES) + .setSmallIcon(resourceProvider.iconNewMail) + .setColor(baseNotificationData.color) + .setNumber(newMessagesCount) + .setContentTitle(title) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt new file mode 100644 index 0000000..21c850d --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt @@ -0,0 +1,92 @@ +package com.fsck.k9.notification + +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LocalMessage + +/** + * Handle notifications for new messages. + */ +internal class NewMailNotificationController( + private val notificationManager: NotificationManagerCompat, + private val newMailNotificationManager: NewMailNotificationManager, + private val summaryNotificationCreator: SummaryNotificationCreator, + private val singleMessageNotificationCreator: SingleMessageNotificationCreator +) { + @Synchronized + fun restoreNewMailNotifications(accounts: List) { + for (account in accounts) { + val notificationData = newMailNotificationManager.restoreNewMailNotifications(account) + + if (notificationData != null) { + processNewMailNotificationData(notificationData) + } + } + } + + @Synchronized + fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean) { + val notificationData = newMailNotificationManager.addNewMailNotification(account, message, silent) + + if (notificationData != null) { + processNewMailNotificationData(notificationData) + } + } + + @Synchronized + fun removeNewMailNotifications( + account: Account, + clearNewMessageState: Boolean, + selector: (List) -> List + ) { + val notificationData = newMailNotificationManager.removeNewMailNotifications( + account, + clearNewMessageState, + selector + ) + + if (notificationData != null) { + processNewMailNotificationData(notificationData) + } + } + + @Synchronized + fun clearNewMailNotifications(account: Account, clearNewMessageState: Boolean) { + val cancelNotificationIds = newMailNotificationManager.clearNewMailNotifications(account, clearNewMessageState) + + cancelNotifications(cancelNotificationIds) + } + + private fun processNewMailNotificationData(notificationData: NewMailNotificationData) { + cancelNotifications(notificationData.cancelNotificationIds) + + for (singleNotificationData in notificationData.singleNotificationData) { + createSingleNotification(notificationData.baseNotificationData, singleNotificationData) + } + + notificationData.summaryNotificationData?.let { summaryNotificationData -> + createSummaryNotification(notificationData.baseNotificationData, summaryNotificationData) + } + } + + private fun cancelNotifications(notificationIds: List) { + for (notificationId in notificationIds) { + notificationManager.cancel(notificationId) + } + } + + private fun createSingleNotification( + baseNotificationData: BaseNotificationData, + singleNotificationData: SingleNotificationData + ) { + singleMessageNotificationCreator.createSingleNotification(baseNotificationData, singleNotificationData) + } + + private fun createSummaryNotification( + baseNotificationData: BaseNotificationData, + summaryNotificationData: SummaryNotificationData + ) { + summaryNotificationCreator.createSummaryNotification(baseNotificationData, summaryNotificationData) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt new file mode 100644 index 0000000..9069b4f --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt @@ -0,0 +1,87 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference + +internal data class NewMailNotificationData( + val cancelNotificationIds: List, + val baseNotificationData: BaseNotificationData, + val singleNotificationData: List, + val summaryNotificationData: SummaryNotificationData? +) + +internal data class BaseNotificationData( + val account: Account, + val accountName: String, + val groupKey: String, + val color: Int, + val newMessagesCount: Int, + val lockScreenNotificationData: LockScreenNotificationData, + val appearance: NotificationAppearance +) + +internal sealed interface LockScreenNotificationData { + object None : LockScreenNotificationData + object AppName : LockScreenNotificationData + object Public : LockScreenNotificationData + object MessageCount : LockScreenNotificationData + data class SenderNames(val senderNames: String) : LockScreenNotificationData +} + +internal data class NotificationAppearance( + val ringtone: String?, + val vibrationPattern: LongArray?, + val ledColor: Int? +) + +internal data class SingleNotificationData( + val notificationId: Int, + val isSilent: Boolean, + val timestamp: Long, + val content: NotificationContent, + val actions: List, + val wearActions: List, + val addLockScreenNotification: Boolean +) + +internal sealed interface SummaryNotificationData + +internal data class SummarySingleNotificationData( + val singleNotificationData: SingleNotificationData +) : SummaryNotificationData + +internal data class SummaryInboxNotificationData( + val notificationId: Int, + val isSilent: Boolean, + val timestamp: Long, + val content: List, + val additionalMessagesCount: Int, + val messageReferences: List, + val actions: List, + val wearActions: List +) : SummaryNotificationData + +internal enum class NotificationAction { + Reply, + MarkAsRead, + Delete +} + +internal enum class WearNotificationAction { + Reply, + MarkAsRead, + Delete, + Archive, + Spam +} + +internal enum class SummaryNotificationAction { + MarkAsRead, + Delete +} + +internal enum class SummaryWearNotificationAction { + MarkAsRead, + Delete, + Archive +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt new file mode 100644 index 0000000..fbd7dad --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt @@ -0,0 +1,134 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LocalMessage +import kotlinx.datetime.Clock + +/** + * Manages notifications for new messages + */ +internal class NewMailNotificationManager( + private val contentCreator: NotificationContentCreator, + private val notificationRepository: NotificationRepository, + private val baseNotificationDataCreator: BaseNotificationDataCreator, + private val singleMessageNotificationDataCreator: SingleMessageNotificationDataCreator, + private val summaryNotificationDataCreator: SummaryNotificationDataCreator, + private val clock: Clock +) { + fun restoreNewMailNotifications(account: Account): NewMailNotificationData? { + val notificationData = notificationRepository.restoreNotifications(account) ?: return null + + val addLockScreenNotification = notificationData.isSingleMessageNotification + val singleNotificationDataList = notificationData.activeNotifications.map { notificationHolder -> + createSingleNotificationData( + account = account, + notificationId = notificationHolder.notificationId, + content = notificationHolder.content, + timestamp = notificationHolder.timestamp, + addLockScreenNotification = addLockScreenNotification + ) + } + + return NewMailNotificationData( + cancelNotificationIds = emptyList(), + baseNotificationData = createBaseNotificationData(notificationData), + singleNotificationData = singleNotificationDataList, + summaryNotificationData = createSummaryNotificationData(notificationData, silent = true) + ) + } + + fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean): NewMailNotificationData? { + val content = contentCreator.createFromMessage(account, message) + + val result = notificationRepository.addNotification(account, content, timestamp = now()) ?: return null + + val singleNotificationData = createSingleNotificationData( + account = account, + notificationId = result.notificationHolder.notificationId, + content = result.notificationHolder.content, + timestamp = result.notificationHolder.timestamp, + addLockScreenNotification = result.notificationData.isSingleMessageNotification + ) + + return NewMailNotificationData( + cancelNotificationIds = if (result.shouldCancelNotification) { + listOf(result.cancelNotificationId) + } else { + emptyList() + }, + baseNotificationData = createBaseNotificationData(result.notificationData), + singleNotificationData = listOf(singleNotificationData), + summaryNotificationData = createSummaryNotificationData(result.notificationData, silent) + ) + } + + fun removeNewMailNotifications( + account: Account, + clearNewMessageState: Boolean, + selector: (List) -> List + ): NewMailNotificationData? { + val result = notificationRepository.removeNotifications(account, clearNewMessageState, selector) ?: return null + + val cancelNotificationIds = when { + result.notificationData.isEmpty() -> { + result.cancelNotificationIds + NotificationIds.getNewMailSummaryNotificationId(account) + } + else -> { + result.cancelNotificationIds + } + } + + val singleNotificationData = result.notificationHolders.map { notificationHolder -> + createSingleNotificationData( + account = account, + notificationId = notificationHolder.notificationId, + content = notificationHolder.content, + timestamp = notificationHolder.timestamp, + addLockScreenNotification = result.notificationData.isSingleMessageNotification + ) + } + + return NewMailNotificationData( + cancelNotificationIds = cancelNotificationIds, + baseNotificationData = createBaseNotificationData(result.notificationData), + singleNotificationData = singleNotificationData, + summaryNotificationData = createSummaryNotificationData(result.notificationData, silent = true) + ) + } + + fun clearNewMailNotifications(account: Account, clearNewMessageState: Boolean): List { + notificationRepository.clearNotifications(account, clearNewMessageState) + return NotificationIds.getAllMessageNotificationIds(account) + } + + private fun createBaseNotificationData(notificationData: NotificationData): BaseNotificationData { + return baseNotificationDataCreator.createBaseNotificationData(notificationData) + } + + private fun createSingleNotificationData( + account: Account, + notificationId: Int, + content: NotificationContent, + timestamp: Long, + addLockScreenNotification: Boolean + ): SingleNotificationData { + return singleMessageNotificationDataCreator.createSingleNotificationData( + account, + notificationId, + content, + timestamp, + addLockScreenNotification + ) + } + + private fun createSummaryNotificationData(data: NotificationData, silent: Boolean): SummaryNotificationData? { + return if (data.isEmpty()) { + null + } else { + summaryNotificationDataCreator.createSummaryNotificationData(data, silent) + } + } + + private fun now(): Long = clock.now().toEpochMilliseconds() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt new file mode 100644 index 0000000..84297e0 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt @@ -0,0 +1,39 @@ +package com.fsck.k9.notification + +import android.app.PendingIntent +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference + +interface NotificationActionCreator { + fun createViewMessagePendingIntent(messageReference: MessageReference): PendingIntent + + fun createViewFolderPendingIntent(account: Account, folderId: Long): PendingIntent + + fun createViewMessagesPendingIntent(account: Account, messageReferences: List): PendingIntent + + fun createViewFolderListPendingIntent(account: Account): PendingIntent + + fun createDismissAllMessagesPendingIntent(account: Account): PendingIntent + + fun createDismissMessagePendingIntent(messageReference: MessageReference): PendingIntent + + fun createReplyPendingIntent(messageReference: MessageReference): PendingIntent + + fun createMarkMessageAsReadPendingIntent(messageReference: MessageReference): PendingIntent + + fun createMarkAllAsReadPendingIntent(account: Account, messageReferences: List): PendingIntent + + fun getEditIncomingServerSettingsIntent(account: Account): PendingIntent + + fun getEditOutgoingServerSettingsIntent(account: Account): PendingIntent + + fun createDeleteMessagePendingIntent(messageReference: MessageReference): PendingIntent + + fun createDeleteAllPendingIntent(account: Account, messageReferences: List): PendingIntent + + fun createArchiveMessagePendingIntent(messageReference: MessageReference): PendingIntent + + fun createArchiveAllPendingIntent(account: Account, messageReferences: List): PendingIntent + + fun createMarkMessageAsSpamPendingIntent(messageReference: MessageReference): PendingIntent +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt new file mode 100644 index 0000000..5dfa9a8 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt @@ -0,0 +1,264 @@ +package com.fsck.k9.notification + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.Preferences +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.controller.MessageReferenceHelper +import com.fsck.k9.controller.MessagingController +import com.fsck.k9.mail.Flag +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.koin.core.qualifier.named +import timber.log.Timber + +class NotificationActionService : Service() { + private val preferences: Preferences by inject() + private val messagingController: MessagingController by inject() + private val coroutineScope: CoroutineScope by inject(named("AppCoroutineScope")) + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + Timber.i("NotificationActionService started with startId = %d", startId) + + startHandleCommand(intent, startId) + + return START_NOT_STICKY + } + + private fun startHandleCommand(intent: Intent, startId: Int) { + coroutineScope.launch(Dispatchers.IO) { + handleCommand(intent) + stopSelf(startId) + } + } + + private fun handleCommand(intent: Intent) { + val accountUuid = intent.getStringExtra(EXTRA_ACCOUNT_UUID) + if (accountUuid == null) { + Timber.w("Missing account UUID.") + return + } + + val account = preferences.getAccount(accountUuid) + if (account == null) { + Timber.w("Could not find account for notification action.") + return + } + + when (intent.action) { + ACTION_MARK_AS_READ -> markMessagesAsRead(intent, account) + ACTION_DELETE -> deleteMessages(intent) + ACTION_ARCHIVE -> archiveMessages(intent, account) + ACTION_SPAM -> markMessageAsSpam(intent, account) + ACTION_DISMISS -> Timber.i("Notification dismissed") + } + + cancelNotifications(intent, account) + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + private fun markMessagesAsRead(intent: Intent, account: Account) { + Timber.i("NotificationActionService marking messages as read") + + val messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES) + val messageReferences = MessageReferenceHelper.toMessageReferenceList(messageReferenceStrings) + + for (messageReference in messageReferences) { + val folderId = messageReference.folderId + val uid = messageReference.uid + messagingController.setFlag(account, folderId, uid, Flag.SEEN, true) + } + } + + private fun deleteMessages(intent: Intent) { + Timber.i("NotificationActionService deleting messages") + + val messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES) + val messageReferences = MessageReferenceHelper.toMessageReferenceList(messageReferenceStrings) + + messagingController.deleteMessages(messageReferences) + } + + private fun archiveMessages(intent: Intent, account: Account) { + Timber.i("NotificationActionService archiving messages") + + val archiveFolderId = account.archiveFolderId + if (archiveFolderId == null || !messagingController.isMoveCapable(account)) { + Timber.w("Cannot archive messages") + return + } + + val messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES) + val messageReferences = MessageReferenceHelper.toMessageReferenceList(messageReferenceStrings) + + for (messageReference in messageReferences) { + if (messagingController.isMoveCapable(messageReference)) { + val sourceFolderId = messageReference.folderId + messagingController.moveMessage(account, sourceFolderId, messageReference, archiveFolderId) + } + } + } + + private fun markMessageAsSpam(intent: Intent, account: Account) { + Timber.i("NotificationActionService moving messages to spam") + + val messageReferenceString = intent.getStringExtra(EXTRA_MESSAGE_REFERENCE) + val messageReference = MessageReference.parse(messageReferenceString) + + if (messageReference == null) { + Timber.w("Invalid message reference: %s", messageReferenceString) + return + } + + val spamFolderId = account.spamFolderId + if (spamFolderId == null) { + Timber.w("No spam folder configured") + return + } + + if (!K9.isConfirmSpam && messagingController.isMoveCapable(account)) { + val sourceFolderId = messageReference.folderId + messagingController.moveMessage(account, sourceFolderId, messageReference, spamFolderId) + } + } + + private fun cancelNotifications(intent: Intent, account: Account) { + if (intent.hasExtra(EXTRA_MESSAGE_REFERENCE)) { + val messageReferenceString = intent.getStringExtra(EXTRA_MESSAGE_REFERENCE) + val messageReference = MessageReference.parse(messageReferenceString) + + if (messageReference != null) { + messagingController.cancelNotificationForMessage(account, messageReference) + } else { + Timber.w("Invalid message reference: %s", messageReferenceString) + } + } else if (intent.hasExtra(EXTRA_MESSAGE_REFERENCES)) { + val messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES) + val messageReferences = MessageReferenceHelper.toMessageReferenceList(messageReferenceStrings) + + for (messageReference in messageReferences) { + messagingController.cancelNotificationForMessage(account, messageReference) + } + } else { + messagingController.cancelNotificationsForAccount(account) + } + } + + companion object { + private const val ACTION_MARK_AS_READ = "ACTION_MARK_AS_READ" + private const val ACTION_DELETE = "ACTION_DELETE" + private const val ACTION_ARCHIVE = "ACTION_ARCHIVE" + private const val ACTION_SPAM = "ACTION_SPAM" + private const val ACTION_DISMISS = "ACTION_DISMISS" + private const val EXTRA_ACCOUNT_UUID = "accountUuid" + private const val EXTRA_MESSAGE_REFERENCE = "messageReference" + private const val EXTRA_MESSAGE_REFERENCES = "messageReferences" + + fun createMarkMessageAsReadIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_MARK_AS_READ + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)) + } + } + + fun createMarkAllAsReadIntent( + context: Context, + accountUuid: String, + messageReferences: List + ): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_MARK_AS_READ + putExtra(EXTRA_ACCOUNT_UUID, accountUuid) + putExtra( + EXTRA_MESSAGE_REFERENCES, + MessageReferenceHelper.toMessageReferenceStringList(messageReferences) + ) + } + } + + fun createDismissMessageIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DISMISS + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) + } + } + + fun createDismissAllMessagesIntent(context: Context, account: Account): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DISMISS + putExtra(EXTRA_ACCOUNT_UUID, account.uuid) + } + } + + fun createDeleteMessageIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DELETE + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)) + } + } + + fun createDeleteAllMessagesIntent( + context: Context, + accountUuid: String, + messageReferences: List + ): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DELETE + putExtra(EXTRA_ACCOUNT_UUID, accountUuid) + putExtra( + EXTRA_MESSAGE_REFERENCES, + MessageReferenceHelper.toMessageReferenceStringList(messageReferences) + ) + } + } + + fun createArchiveMessageIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_ARCHIVE + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)) + } + } + + fun createArchiveAllIntent( + context: Context, + account: Account, + messageReferences: List + ): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_ARCHIVE + putExtra(EXTRA_ACCOUNT_UUID, account.uuid) + putExtra( + EXTRA_MESSAGE_REFERENCES, + MessageReferenceHelper.toMessageReferenceStringList(messageReferences) + ) + } + } + + fun createMarkMessageAsSpamIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_SPAM + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) + } + } + + private fun createSingleItemArrayList(messageReference: MessageReference): ArrayList { + return ArrayList(1).apply { + add(messageReference.toIdentityString()) + } + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationChannelManager.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationChannelManager.kt new file mode 100644 index 0000000..ad33d10 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationChannelManager.kt @@ -0,0 +1,257 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationChannelGroup +import android.app.NotificationManager +import android.net.Uri +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.net.toUri +import com.fsck.k9.Account +import com.fsck.k9.NotificationLight +import com.fsck.k9.NotificationSettings +import com.fsck.k9.Preferences +import java.util.concurrent.Executor +import timber.log.Timber + +class NotificationChannelManager( + private val preferences: Preferences, + private val backgroundExecutor: Executor, + private val notificationManager: NotificationManager, + private val resourceProvider: NotificationResourceProvider, + private val notificationLightDecoder: NotificationLightDecoder +) { + val pushChannelId = "push" + + enum class ChannelType { + MESSAGES, MISCELLANEOUS + } + + init { + preferences.addOnAccountsChangeListener(this::updateChannels) + } + + fun updateChannels() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + backgroundExecutor.execute { + addGeneralChannels() + + val accounts = preferences.accounts + + removeChannelsForNonExistingOrChangedAccounts(notificationManager, accounts) + addChannelsForAccounts(notificationManager, accounts) + } + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private fun addGeneralChannels() { + notificationManager.createNotificationChannel(getChannelPush()) + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private fun addChannelsForAccounts( + notificationManager: NotificationManager, + accounts: List + ) { + for (account in accounts) { + val groupId = account.notificationChannelGroupId + val group = NotificationChannelGroup(groupId, account.displayName) + + val channelMessages = getChannelMessages(account) + val channelMiscellaneous = getChannelMiscellaneous(account) + + notificationManager.createNotificationChannelGroup(group) + notificationManager.createNotificationChannel(channelMessages) + notificationManager.createNotificationChannel(channelMiscellaneous) + } + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private fun removeChannelsForNonExistingOrChangedAccounts( + notificationManager: NotificationManager, + accounts: List + ) { + val accountUuids = accounts.map { it.uuid }.toSet() + + val groups = notificationManager.notificationChannelGroups + for (group in groups) { + val accountUuid = group.id.toAccountUuid() + if (accountUuid !in accountUuids) { + notificationManager.deleteNotificationChannelGroup(group.id) + } + } + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private fun getChannelPush(): NotificationChannel { + val channelName = resourceProvider.pushChannelName + val channelDescription = resourceProvider.pushChannelDescription + val importance = NotificationManager.IMPORTANCE_LOW + + return NotificationChannel(pushChannelId, channelName, importance).apply { + description = channelDescription + setShowBadge(false) + enableLights(false) + enableVibration(false) + setSound(null, null) + } + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private fun getChannelMessages(account: Account): NotificationChannel { + val channelName = resourceProvider.messagesChannelName + val channelId = getChannelIdFor(account, ChannelType.MESSAGES) + val importance = NotificationManager.IMPORTANCE_DEFAULT + + return NotificationChannel(channelId, channelName, importance).apply { + description = resourceProvider.messagesChannelDescription + group = account.uuid + + setPropertiesFrom(account) + } + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private fun getChannelMiscellaneous(account: Account): NotificationChannel { + val channelName = resourceProvider.miscellaneousChannelName + val channelDescription = resourceProvider.miscellaneousChannelDescription + val channelId = getChannelIdFor(account, ChannelType.MISCELLANEOUS) + val importance = NotificationManager.IMPORTANCE_LOW + val channelGroupId = account.uuid + + val miscellaneousChannel = NotificationChannel(channelId, channelName, importance) + miscellaneousChannel.description = channelDescription + miscellaneousChannel.group = channelGroupId + + return miscellaneousChannel + } + + fun getChannelIdFor(account: Account, channelType: ChannelType): String { + return if (channelType == ChannelType.MESSAGES) { + getMessagesChannelId(account, account.messagesNotificationChannelSuffix) + } else { + "miscellaneous_channel_${account.uuid}" + } + } + + private fun getMessagesChannelId(account: Account, suffix: String): String { + return "messages_channel_${account.uuid}$suffix" + } + + @RequiresApi(Build.VERSION_CODES.O) + fun getNotificationConfiguration(account: Account): NotificationConfiguration { + val channelId = getChannelIdFor(account, ChannelType.MESSAGES) + val notificationChannel = notificationManager.getNotificationChannel(channelId) + + return NotificationConfiguration( + sound = notificationChannel.sound, + isBlinkLightsEnabled = notificationChannel.shouldShowLights(), + lightColor = notificationChannel.lightColor, + isVibrationEnabled = notificationChannel.shouldVibrate(), + vibrationPattern = notificationChannel.vibrationPattern?.toList() + ) + } + + fun recreateMessagesNotificationChannel(account: Account) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val oldChannelId = getChannelIdFor(account, ChannelType.MESSAGES) + val oldNotificationChannel = notificationManager.getNotificationChannel(oldChannelId) + + if (oldNotificationChannel.matches(account)) { + Timber.v("Not recreating NotificationChannel. The current one already matches the app's settings.") + return + } + + val newChannelVersion = account.messagesNotificationChannelVersion + 1 + val newChannelId = getMessagesChannelId(account, "_$newChannelVersion") + val channelName = resourceProvider.messagesChannelName + val importance = oldNotificationChannel.importance + + val newNotificationChannel = NotificationChannel(newChannelId, channelName, importance).apply { + description = resourceProvider.messagesChannelDescription + group = account.uuid + + copyPropertiesFrom(oldNotificationChannel) + setPropertiesFrom(account) + } + + Timber.v("Recreating NotificationChannel(%s => %s)", oldChannelId, newChannelId) + Timber.v("Old NotificationChannel: %s", oldNotificationChannel) + Timber.v("New NotificationChannel: %s", newNotificationChannel) + notificationManager.createNotificationChannel(newNotificationChannel) + + // To avoid a race condition we first create the new NotificationChannel, point the Account to it, + // then delete the old one. + account.messagesNotificationChannelVersion = newChannelVersion + notificationManager.deleteNotificationChannel(oldChannelId) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun NotificationChannel.matches(account: Account): Boolean { + val systemLight = notificationLightDecoder.decode( + isBlinkLightsEnabled = shouldShowLights(), + lightColor = lightColor, + accountColor = account.chipColor + ) + val notificationSettings = account.notificationSettings + return sound == notificationSettings.ringtoneUri && + systemLight == notificationSettings.light && + shouldVibrate() == notificationSettings.vibration.isEnabled && + vibrationPattern.contentEquals(notificationSettings.vibration.systemPattern) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun NotificationChannel.copyPropertiesFrom(otherNotificationChannel: NotificationChannel) { + setShowBadge(otherNotificationChannel.canShowBadge()) + setSound(otherNotificationChannel.sound, otherNotificationChannel.audioAttributes) + enableVibration(otherNotificationChannel.shouldVibrate()) + enableLights(otherNotificationChannel.shouldShowLights()) + setBypassDnd(otherNotificationChannel.canBypassDnd()) + lockscreenVisibility = otherNotificationChannel.lockscreenVisibility + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setAllowBubbles(otherNotificationChannel.canBubble()) + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun NotificationChannel.setPropertiesFrom(account: Account) { + val notificationSettings = account.notificationSettings + + if (notificationSettings.isRingEnabled) { + setSound(notificationSettings.ringtone?.toUri(), Notification.AUDIO_ATTRIBUTES_DEFAULT) + } + + notificationSettings.light.toColor(account)?.let { lightColor -> + this.lightColor = lightColor + } + val isLightEnabled = notificationSettings.light != NotificationLight.Disabled + enableLights(isLightEnabled) + + vibrationPattern = notificationSettings.vibration.systemPattern + enableVibration(notificationSettings.vibration.isEnabled) + } + + private val Account.notificationChannelGroupId: String + get() = uuid + + private fun String.toAccountUuid(): String = this + + private val Account.messagesNotificationChannelSuffix: String + get() = messagesNotificationChannelVersion.let { version -> if (version == 0) "" else "_$version" } + + private val NotificationSettings.ringtoneUri: Uri? + get() = if (isRingEnabled) ringtone?.toUri() else null +} + +data class NotificationConfiguration( + val sound: Uri?, + val isBlinkLightsEnabled: Boolean, + val lightColor: Int, + val isVibrationEnabled: Boolean, + val vibrationPattern: List? +) diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationConfigurationConverter.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationConfigurationConverter.kt new file mode 100644 index 0000000..3f40015 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationConfigurationConverter.kt @@ -0,0 +1,32 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.NotificationSettings + +/** + * Converts the [NotificationConfiguration] read from a `NotificationChannel` into a [NotificationSettings] instance. + */ +class NotificationConfigurationConverter( + private val notificationLightDecoder: NotificationLightDecoder, + private val notificationVibrationDecoder: NotificationVibrationDecoder +) { + fun convert(account: Account, notificationConfiguration: NotificationConfiguration): NotificationSettings { + val light = notificationLightDecoder.decode( + isBlinkLightsEnabled = notificationConfiguration.isBlinkLightsEnabled, + lightColor = notificationConfiguration.lightColor, + accountColor = account.chipColor + ) + + val vibration = notificationVibrationDecoder.decode( + isVibrationEnabled = notificationConfiguration.isVibrationEnabled, + systemPattern = notificationConfiguration.vibrationPattern + ) + + return NotificationSettings( + isRingEnabled = notificationConfiguration.sound != null, + ringtone = notificationConfiguration.sound?.toString(), + light = light, + vibration = vibration + ) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.kt new file mode 100644 index 0000000..374979d --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.notification + +import com.fsck.k9.controller.MessageReference + +internal data class NotificationContent( + val messageReference: MessageReference, + val sender: String, + val subject: String, + val preview: CharSequence, + val summary: CharSequence +) diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.kt new file mode 100644 index 0000000..9749a5d --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.kt @@ -0,0 +1,101 @@ +package com.fsck.k9.notification + +import android.text.SpannableStringBuilder +import app.k9mail.core.android.common.contact.ContactRepository +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.helper.MessageHelper +import com.fsck.k9.mail.Message +import com.fsck.k9.mailstore.LocalMessage +import com.fsck.k9.message.extractors.PreviewResult.PreviewType + +internal class NotificationContentCreator( + private val resourceProvider: NotificationResourceProvider, + private val contactRepository: ContactRepository, +) { + fun createFromMessage(account: Account, message: LocalMessage): NotificationContent { + val sender = getMessageSender(account, message) + + return NotificationContent( + messageReference = message.makeMessageReference(), + sender = getMessageSenderForDisplay(sender), + subject = getMessageSubject(message), + preview = getMessagePreview(message), + summary = buildMessageSummary(sender, getMessageSubject(message)), + ) + } + + private fun getMessagePreview(message: LocalMessage): CharSequence { + val snippet = getPreview(message) + if (message.subject.isNullOrEmpty() && snippet != null) { + return snippet + } + + return SpannableStringBuilder().apply { + val displaySubject = getMessageSubject(message) + append(displaySubject) + + if (snippet != null) { + append('\n') + append(snippet) + } + } + } + + private fun getPreview(message: LocalMessage): String? { + val previewType = message.previewType ?: error("previewType == null") + return when (previewType) { + PreviewType.NONE, PreviewType.ERROR -> null + PreviewType.TEXT -> message.preview + PreviewType.ENCRYPTED -> resourceProvider.previewEncrypted() + } + } + + private fun buildMessageSummary(sender: String?, subject: String): CharSequence { + return if (sender == null) { + subject + } else { + SpannableStringBuilder().apply { + append(sender) + append(" ") + append(subject) + } + } + } + + private fun getMessageSubject(message: Message): String { + val subject = message.subject.orEmpty() + return subject.ifEmpty { resourceProvider.noSubject() } + } + + private fun getMessageSender(account: Account, message: Message): String? { + val localContactRepository = if (K9.isShowContactName) contactRepository else null + var isSelf = false + + val fromAddresses = message.from + if (!fromAddresses.isNullOrEmpty()) { + isSelf = account.isAnIdentity(fromAddresses) + if (!isSelf) { + return MessageHelper.toFriendly(fromAddresses.first(), localContactRepository).toString() + } + } + + if (isSelf) { + // show To: if the message was sent from me + val recipients = message.getRecipients(Message.RecipientType.TO) + if (!recipients.isNullOrEmpty()) { + val recipientDisplayName = MessageHelper.toFriendly( + address = recipients.first(), + contactRepository = localContactRepository, + ).toString() + return resourceProvider.recipientDisplayName(recipientDisplayName) + } + } + + return null + } + + private fun getMessageSenderForDisplay(sender: String?): String { + return sender ?: resourceProvider.noSender() + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt new file mode 100644 index 0000000..6d50685 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt @@ -0,0 +1,94 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LocalFolder +import com.fsck.k9.mailstore.LocalMessage +import timber.log.Timber + +class NotificationController internal constructor( + private val certificateErrorNotificationController: CertificateErrorNotificationController, + private val authenticationErrorNotificationController: AuthenticationErrorNotificationController, + private val syncNotificationController: SyncNotificationController, + private val sendFailedNotificationController: SendFailedNotificationController, + private val newMailNotificationController: NewMailNotificationController +) { + fun showCertificateErrorNotification(account: Account, incoming: Boolean) { + certificateErrorNotificationController.showCertificateErrorNotification(account, incoming) + } + + fun clearCertificateErrorNotifications(account: Account, incoming: Boolean) { + certificateErrorNotificationController.clearCertificateErrorNotifications(account, incoming) + } + + fun showAuthenticationErrorNotification(account: Account, incoming: Boolean) { + authenticationErrorNotificationController.showAuthenticationErrorNotification(account, incoming) + } + + fun clearAuthenticationErrorNotification(account: Account, incoming: Boolean) { + authenticationErrorNotificationController.clearAuthenticationErrorNotification(account, incoming) + } + + fun showSendingNotification(account: Account) { + syncNotificationController.showSendingNotification(account) + } + + fun clearSendingNotification(account: Account) { + syncNotificationController.clearSendingNotification(account) + } + + fun showSendFailedNotification(account: Account, exception: Exception) { + sendFailedNotificationController.showSendFailedNotification(account, exception) + } + + fun clearSendFailedNotification(account: Account) { + sendFailedNotificationController.clearSendFailedNotification(account) + } + + fun showFetchingMailNotification(account: Account, folder: LocalFolder) { + syncNotificationController.showFetchingMailNotification(account, folder) + } + + fun showEmptyFetchingMailNotification(account: Account) { + syncNotificationController.showEmptyFetchingMailNotification(account) + } + + fun clearFetchingMailNotification(account: Account) { + syncNotificationController.clearFetchingMailNotification(account) + } + + fun restoreNewMailNotifications(accounts: List) { + newMailNotificationController.restoreNewMailNotifications(accounts) + } + + fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean) { + Timber.v( + "Creating notification for message %s:%s:%s", + message.account.uuid, + message.folder.databaseId, + message.uid + ) + + newMailNotificationController.addNewMailNotification(account, message, silent) + } + + fun removeNewMailNotification(account: Account, messageReference: MessageReference) { + Timber.v("Removing notification for message %s", messageReference) + + newMailNotificationController.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(messageReference) + } + } + + fun clearNewMailNotifications(account: Account, selector: (List) -> List) { + Timber.v("Removing some notifications for account %s", account.uuid) + + newMailNotificationController.removeNewMailNotifications(account, clearNewMessageState = false, selector) + } + + fun clearNewMailNotifications(account: Account, clearNewMessageState: Boolean) { + Timber.v("Removing all notifications for account %s", account.uuid) + + newMailNotificationController.clearNewMailNotifications(account, clearNewMessageState) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationData.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationData.kt new file mode 100644 index 0000000..d3f8dcd --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationData.kt @@ -0,0 +1,42 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference + +/** + * Holds information about active and inactive new message notifications of an account. + */ +internal data class NotificationData( + val account: Account, + val activeNotifications: List, + val inactiveNotifications: List +) { + val newMessagesCount: Int + get() = activeNotifications.size + inactiveNotifications.size + + val isSingleMessageNotification: Boolean + get() = activeNotifications.size == 1 + + val messageReferences: List + get() { + return buildList(capacity = newMessagesCount) { + for (activeNotification in activeNotifications) { + add(activeNotification.content.messageReference) + } + for (inactiveNotification in inactiveNotifications) { + add(inactiveNotification.content.messageReference) + } + } + } + + val activeMessageReferences: List + get() = activeNotifications.map { it.content.messageReference } + + fun isEmpty() = activeNotifications.isEmpty() + + companion object { + fun create(account: Account): NotificationData { + return NotificationData(account, activeNotifications = emptyList(), inactiveNotifications = emptyList()) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt new file mode 100644 index 0000000..058f229 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt @@ -0,0 +1,234 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference + +internal const val MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS = 8 + +/** + * Stores information about new message notifications for all accounts. + * + * We only use a limited number of system notifications per account (see [MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS]); + * those are called active notifications. The rest are called inactive notifications. When an active notification is + * removed, the latest inactive notification is promoted to an active notification. + */ +internal class NotificationDataStore { + private val notificationDataMap = mutableMapOf() + + @Synchronized + fun isAccountInitialized(account: Account): Boolean { + return notificationDataMap[account.uuid] != null + } + + @Synchronized + fun initializeAccount( + account: Account, + activeNotifications: List, + inactiveNotifications: List + ): NotificationData { + require(activeNotifications.size <= MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) + + return NotificationData(account, activeNotifications, inactiveNotifications).also { notificationData -> + notificationDataMap[account.uuid] = notificationData + } + } + + @Synchronized + fun addNotification(account: Account, content: NotificationContent, timestamp: Long): AddNotificationResult? { + val notificationData = getNotificationData(account) + val messageReference = content.messageReference + + val activeNotification = notificationData.activeNotifications.firstOrNull { notificationHolder -> + notificationHolder.content.messageReference == messageReference + } + val inactiveNotification = notificationData.inactiveNotifications.firstOrNull { inactiveNotificationHolder -> + inactiveNotificationHolder.content.messageReference == messageReference + } + + return if (activeNotification != null) { + val newActiveNotification = activeNotification.copy(content = content) + val notificationHolder = activeNotification.copy( + content = content + ) + + val operations = emptyList() + + val newActiveNotifications = notificationData.activeNotifications + .replace(activeNotification, newActiveNotification) + val newNotificationData = notificationData.copy( + activeNotifications = newActiveNotifications + ) + notificationDataMap[account.uuid] = newNotificationData + + AddNotificationResult.newNotification(newNotificationData, operations, notificationHolder) + } else if (inactiveNotification != null) { + val newInactiveNotification = inactiveNotification.copy(content = content) + val newInactiveNotifications = notificationData.inactiveNotifications + .replace(inactiveNotification, newInactiveNotification) + + val newNotificationData = notificationData.copy( + inactiveNotifications = newInactiveNotifications + ) + notificationDataMap[account.uuid] = newNotificationData + + null + } else if (notificationData.isMaxNumberOfActiveNotificationsReached) { + val lastNotificationHolder = notificationData.activeNotifications.last() + val inactiveNotificationHolder = lastNotificationHolder.toInactiveNotificationHolder() + + val notificationId = lastNotificationHolder.notificationId + val notificationHolder = NotificationHolder(notificationId, timestamp, content) + + val operations = listOf( + NotificationStoreOperation.ChangeToInactive(lastNotificationHolder.content.messageReference), + NotificationStoreOperation.Add(messageReference, notificationId, timestamp) + ) + + val newNotificationData = notificationData.copy( + activeNotifications = listOf(notificationHolder) + notificationData.activeNotifications.dropLast(1), + inactiveNotifications = listOf(inactiveNotificationHolder) + notificationData.inactiveNotifications + ) + notificationDataMap[account.uuid] = newNotificationData + + AddNotificationResult.replaceNotification(newNotificationData, operations, notificationHolder) + } else { + val notificationId = notificationData.getNewNotificationId() + val notificationHolder = NotificationHolder(notificationId, timestamp, content) + + val operations = listOf( + NotificationStoreOperation.Add(messageReference, notificationId, timestamp) + ) + + val newNotificationData = notificationData.copy( + activeNotifications = listOf(notificationHolder) + notificationData.activeNotifications + ) + notificationDataMap[account.uuid] = newNotificationData + + AddNotificationResult.newNotification(newNotificationData, operations, notificationHolder) + } + } + + @Synchronized + fun removeNotifications( + account: Account, + selector: (List) -> List + ): RemoveNotificationsResult? { + var notificationData = getNotificationData(account) + if (notificationData.isEmpty()) return null + + val removeMessageReferences = selector.invoke(notificationData.messageReferences) + if (removeMessageReferences.isEmpty()) return null + + val operations = mutableListOf() + val newNotificationHolders = mutableListOf() + val cancelNotificationIds = mutableListOf() + + val activeMessageReferences = notificationData.activeNotifications.map { it.content.messageReference }.toSet() + val (removeActiveMessageReferences, removeInactiveMessageReferences) = removeMessageReferences + .partition { it in activeMessageReferences } + + if (removeInactiveMessageReferences.isNotEmpty()) { + val inactiveMessageReferences = notificationData.inactiveNotifications + .map { it.content.messageReference }.toSet() + + for (messageReference in removeInactiveMessageReferences) { + if (messageReference in inactiveMessageReferences) { + operations.add(NotificationStoreOperation.Remove(messageReference)) + } + } + + val removeMessageReferenceSet = removeInactiveMessageReferences.toSet() + notificationData = notificationData.copy( + inactiveNotifications = notificationData.inactiveNotifications + .filter { it.content.messageReference !in removeMessageReferenceSet } + ) + } + + for (messageReference in removeActiveMessageReferences) { + val notificationHolder = notificationData.activeNotifications.first { + it.content.messageReference == messageReference + } + + if (notificationData.inactiveNotifications.isNotEmpty()) { + val newNotificationHolder = notificationData.inactiveNotifications.first() + .toNotificationHolder(notificationHolder.notificationId) + + newNotificationHolders.add(newNotificationHolder) + cancelNotificationIds.add(notificationHolder.notificationId) + + operations.add(NotificationStoreOperation.Remove(messageReference)) + operations.add( + NotificationStoreOperation.ChangeToActive( + newNotificationHolder.content.messageReference, + newNotificationHolder.notificationId + ) + ) + + notificationData = notificationData.copy( + activeNotifications = notificationData.activeNotifications - notificationHolder + + newNotificationHolder, + inactiveNotifications = notificationData.inactiveNotifications.drop(1) + ) + } else { + cancelNotificationIds.add(notificationHolder.notificationId) + + operations.add(NotificationStoreOperation.Remove(messageReference)) + + notificationData = notificationData.copy( + activeNotifications = notificationData.activeNotifications - notificationHolder + ) + } + } + + notificationDataMap[account.uuid] = notificationData + + return if (operations.isEmpty()) { + null + } else { + RemoveNotificationsResult( + notificationData = notificationData, + notificationStoreOperations = operations, + notificationHolders = newNotificationHolders, + cancelNotificationIds = cancelNotificationIds + ) + } + } + + @Synchronized + fun clearNotifications(account: Account) { + notificationDataMap.remove(account.uuid) + } + + private fun getNotificationData(account: Account): NotificationData { + return notificationDataMap[account.uuid] ?: NotificationData.create(account).also { notificationData -> + notificationDataMap[account.uuid] = notificationData + } + } + + private val NotificationData.isMaxNumberOfActiveNotificationsReached: Boolean + get() = activeNotifications.size == MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + + private fun NotificationData.getNewNotificationId(): Int { + val notificationIdsInUse = activeNotifications.map { it.notificationId }.toSet() + for (index in 0 until MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) { + val notificationId = NotificationIds.getSingleMessageNotificationId(account, index) + if (notificationId !in notificationIdsInUse) { + return notificationId + } + } + + throw AssertionError("getNewNotificationId() called with no free notification ID") + } + + private fun NotificationHolder.toInactiveNotificationHolder() = InactiveNotificationHolder(timestamp, content) + + private fun InactiveNotificationHolder.toNotificationHolder(notificationId: Int): NotificationHolder { + return NotificationHolder(notificationId, timestamp, content) + } + + private fun List.replace(old: T, new: T): List { + return map { element -> + if (element === old) new else element + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.kt new file mode 100644 index 0000000..1f53762 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account + +object NotificationGroupKeys { + private const val NOTIFICATION_GROUP_KEY_PREFIX = "newMailNotifications-" + + fun getGroupKey(account: Account): String { + return NOTIFICATION_GROUP_KEY_PREFIX + account.accountNumber + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationHelper.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationHelper.kt new file mode 100644 index 0000000..eef470e --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationHelper.kt @@ -0,0 +1,132 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.helper.PendingIntentCompat +import com.fsck.k9.notification.NotificationChannelManager.ChannelType +import timber.log.Timber + +class NotificationHelper( + private val context: Context, + private val notificationManager: NotificationManagerCompat, + private val notificationChannelManager: NotificationChannelManager, + private val resourceProvider: NotificationResourceProvider +) { + fun getContext(): Context { + return context + } + + fun getNotificationManager(): NotificationManagerCompat { + return notificationManager + } + + fun createNotificationBuilder(account: Account, channelType: ChannelType): NotificationCompat.Builder { + val notificationChannel = notificationChannelManager.getChannelIdFor(account, channelType) + return NotificationCompat.Builder(context, notificationChannel) + } + + fun notify(account: Account, notificationId: Int, notification: Notification) { + try { + notificationManager.notify(notificationId, notification) + } catch (e: SecurityException) { + // When importing settings from another device, we could end up with a NotificationChannel that references a + // non-existing notification sound. In that case, we end up with a SecurityException with a message similar + // to this: + // UID 123 does not have permission to content://media/external_primary/audio/media/42?title=Coins&canonical=1 [user 0] + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + e.message?.contains("does not have permission to") == true + ) { + Timber.e(e, "Failed to create a notification for a new message") + showNotifyErrorNotification(account) + } else { + throw e + } + } + } + + private fun showNotifyErrorNotification(account: Account) { + val title = resourceProvider.notifyErrorTitle() + val text = resourceProvider.notifyErrorText() + + val messagesNotificationChannelId = notificationChannelManager.getChannelIdFor(account, ChannelType.MESSAGES) + val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_CHANNEL_ID, messagesNotificationChannelId) + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + + val notificationSettingsPendingIntent = + PendingIntent.getActivity(context, account.accountNumber, intent, PendingIntentCompat.FLAG_IMMUTABLE) + + val notification = createNotificationBuilder(account, ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(notificationSettingsPendingIntent) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setErrorAppearance() + .build() + + val notificationId = NotificationIds.getNewMailSummaryNotificationId(account) + notificationManager.notify(notificationId, notification) + } + + companion object { + internal const val NOTIFICATION_LED_ON_TIME = 500 + internal const val NOTIFICATION_LED_OFF_TIME = 2000 + internal const val NOTIFICATION_LED_FAST_ON_TIME = 100 + internal const val NOTIFICATION_LED_FAST_OFF_TIME = 100 + + internal const val NOTIFICATION_LED_FAILURE_COLOR = 0xFFFF0000L.toInt() + } +} + +internal fun NotificationCompat.Builder.setErrorAppearance(): NotificationCompat.Builder = apply { + setSilent(true) + + if (!K9.isQuietTime) { + setLights( + NotificationHelper.NOTIFICATION_LED_FAILURE_COLOR, + NotificationHelper.NOTIFICATION_LED_FAST_ON_TIME, + NotificationHelper.NOTIFICATION_LED_FAST_OFF_TIME + ) + } +} + +internal fun NotificationCompat.Builder.setAppearance( + silent: Boolean, + appearance: NotificationAppearance +): NotificationCompat.Builder = apply { + if (silent) { + setSilent(true) + } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + if (!appearance.ringtone.isNullOrEmpty()) { + setSound(Uri.parse(appearance.ringtone)) + } + + if (appearance.vibrationPattern != null) { + setVibrate(appearance.vibrationPattern) + } + + if (appearance.ledColor != null) { + setLights( + appearance.ledColor, + NotificationHelper.NOTIFICATION_LED_ON_TIME, + NotificationHelper.NOTIFICATION_LED_OFF_TIME + ) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.kt new file mode 100644 index 0000000..73a3647 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.kt @@ -0,0 +1,12 @@ +package com.fsck.k9.notification + +internal data class NotificationHolder( + val notificationId: Int, + val timestamp: Long, + val content: NotificationContent +) + +internal data class InactiveNotificationHolder( + val timestamp: Long, + val content: NotificationContent +) diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt new file mode 100644 index 0000000..951071c --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt @@ -0,0 +1,64 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account + +internal object NotificationIds { + const val PUSH_NOTIFICATION_ID = 1 + + private const val NUMBER_OF_GENERAL_NOTIFICATIONS = 1 + private const val OFFSET_SEND_FAILED_NOTIFICATION = 0 + private const val OFFSET_CERTIFICATE_ERROR_INCOMING = 1 + private const val OFFSET_CERTIFICATE_ERROR_OUTGOING = 2 + private const val OFFSET_AUTHENTICATION_ERROR_INCOMING = 3 + private const val OFFSET_AUTHENTICATION_ERROR_OUTGOING = 4 + private const val OFFSET_FETCHING_MAIL = 5 + private const val OFFSET_NEW_MAIL_SUMMARY = 6 + private const val OFFSET_NEW_MAIL_SINGLE = 7 + private const val NUMBER_OF_MISC_ACCOUNT_NOTIFICATIONS = 7 + private const val NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS = MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + private const val NUMBER_OF_NOTIFICATIONS_PER_ACCOUNT = + NUMBER_OF_MISC_ACCOUNT_NOTIFICATIONS + NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + + fun getNewMailSummaryNotificationId(account: Account): Int { + return getBaseNotificationId(account) + OFFSET_NEW_MAIL_SUMMARY + } + + fun getSingleMessageNotificationId(account: Account, index: Int): Int { + require(index in 0 until NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) { "Invalid index: $index" } + + return getBaseNotificationId(account) + OFFSET_NEW_MAIL_SINGLE + index + } + + fun getAllMessageNotificationIds(account: Account): List { + val singleMessageNotificationIdRange = (0 until NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS).map { index -> + getBaseNotificationId(account) + OFFSET_NEW_MAIL_SINGLE + index + } + + return singleMessageNotificationIdRange.toList() + getNewMailSummaryNotificationId(account) + } + + fun getFetchingMailNotificationId(account: Account): Int { + return getBaseNotificationId(account) + OFFSET_FETCHING_MAIL + } + + fun getSendFailedNotificationId(account: Account): Int { + return getBaseNotificationId(account) + OFFSET_SEND_FAILED_NOTIFICATION + } + + fun getCertificateErrorNotificationId(account: Account, incoming: Boolean): Int { + val offset = if (incoming) OFFSET_CERTIFICATE_ERROR_INCOMING else OFFSET_CERTIFICATE_ERROR_OUTGOING + + return getBaseNotificationId(account) + offset + } + + fun getAuthenticationErrorNotificationId(account: Account, incoming: Boolean): Int { + val offset = if (incoming) OFFSET_AUTHENTICATION_ERROR_INCOMING else OFFSET_AUTHENTICATION_ERROR_OUTGOING + + return getBaseNotificationId(account) + offset + } + + private fun getBaseNotificationId(account: Account): Int { + return 1 /* skip notification ID 0 */ + NUMBER_OF_GENERAL_NOTIFICATIONS + + account.accountNumber * NUMBER_OF_NOTIFICATIONS_PER_ACCOUNT + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationLightDecoder.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationLightDecoder.kt new file mode 100644 index 0000000..b849f14 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationLightDecoder.kt @@ -0,0 +1,27 @@ +package com.fsck.k9.notification + +import com.fsck.k9.NotificationLight + +/** + * Converts the "blink lights" values read from a `NotificationChannel` into [NotificationLight]. + */ +class NotificationLightDecoder { + fun decode(isBlinkLightsEnabled: Boolean, lightColor: Int, accountColor: Int): NotificationLight { + if (!isBlinkLightsEnabled) return NotificationLight.Disabled + + return when (lightColor.rgb) { + accountColor.rgb -> NotificationLight.AccountColor + 0xFFFFFF -> NotificationLight.White + 0xFF0000 -> NotificationLight.Red + 0x00FF00 -> NotificationLight.Green + 0x0000FF -> NotificationLight.Blue + 0xFFFF00 -> NotificationLight.Yellow + 0x00FFFF -> NotificationLight.Cyan + 0xFF00FF -> NotificationLight.Magenta + else -> NotificationLight.SystemDefaultColor + } + } + + private val Int.rgb + get() = this and 0x00FFFFFF +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt new file mode 100644 index 0000000..05d26a6 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt @@ -0,0 +1,133 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LocalStoreProvider +import com.fsck.k9.mailstore.MessageStoreManager + +internal class NotificationRepository( + private val notificationStoreProvider: NotificationStoreProvider, + private val localStoreProvider: LocalStoreProvider, + private val messageStoreManager: MessageStoreManager, + private val notificationContentCreator: NotificationContentCreator +) { + private val notificationDataStore = NotificationDataStore() + + @Synchronized + fun restoreNotifications(account: Account): NotificationData? { + if (notificationDataStore.isAccountInitialized(account)) return null + + val localStore = localStoreProvider.getInstance(account) + + val (activeNotificationMessages, inactiveNotificationMessages) = localStore.notificationMessages.partition { + it.notificationId != null + } + + val activeNotifications = activeNotificationMessages.map { notificationMessage -> + val content = notificationContentCreator.createFromMessage(account, notificationMessage.message) + NotificationHolder(notificationMessage.notificationId!!, notificationMessage.timestamp, content) + } + + val inactiveNotifications = inactiveNotificationMessages.map { notificationMessage -> + val content = notificationContentCreator.createFromMessage(account, notificationMessage.message) + InactiveNotificationHolder(notificationMessage.timestamp, content) + } + + val notificationData = notificationDataStore.initializeAccount( + account, + activeNotifications, + inactiveNotifications + ) + + return if (notificationData.activeNotifications.isNotEmpty()) notificationData else null + } + + @Synchronized + fun addNotification(account: Account, content: NotificationContent, timestamp: Long): AddNotificationResult? { + restoreNotifications(account) + + return notificationDataStore.addNotification(account, content, timestamp)?.also { result -> + persistNotificationDataStoreChanges( + account = account, + operations = result.notificationStoreOperations, + updateNewMessageState = true + ) + } + } + + @Synchronized + fun removeNotifications( + account: Account, + clearNewMessageState: Boolean = true, + selector: (List) -> List + ): RemoveNotificationsResult? { + restoreNotifications(account) + + return notificationDataStore.removeNotifications(account, selector)?.also { result -> + persistNotificationDataStoreChanges( + account = account, + operations = result.notificationStoreOperations, + updateNewMessageState = clearNewMessageState + ) + } + } + + @Synchronized + fun clearNotifications(account: Account, clearNewMessageState: Boolean) { + notificationDataStore.clearNotifications(account) + clearNotificationStore(account) + + if (clearNewMessageState) { + clearNewMessageState(account) + } + } + + private fun persistNotificationDataStoreChanges( + account: Account, + operations: List, + updateNewMessageState: Boolean + ) { + val notificationStore = notificationStoreProvider.getNotificationStore(account) + notificationStore.persistNotificationChanges(operations) + + if (updateNewMessageState) { + setNewMessageState(account, operations) + } + } + + private fun setNewMessageState(account: Account, operations: List) { + val messageStore = messageStoreManager.getMessageStore(account) + + for (operation in operations) { + when (operation) { + is NotificationStoreOperation.Add -> { + val messageReference = operation.messageReference + messageStore.setNewMessageState( + folderId = messageReference.folderId, + messageServerId = messageReference.uid, + newMessage = true + ) + } + is NotificationStoreOperation.Remove -> { + val messageReference = operation.messageReference + messageStore.setNewMessageState( + folderId = messageReference.folderId, + messageServerId = messageReference.uid, + newMessage = false + ) + } + else -> Unit + } + } + } + + private fun clearNewMessageState(account: Account) { + val messageStore = messageStoreManager.getMessageStore(account) + messageStore.clearNewMessageState() + } + + private fun clearNotificationStore(account: Account) { + val notificationStore = notificationStoreProvider.getNotificationStore(account) + notificationStore.clearNotifications() + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt new file mode 100644 index 0000000..ca15c49 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt @@ -0,0 +1,57 @@ +package com.fsck.k9.notification + +interface NotificationResourceProvider { + val iconWarning: Int + val iconMarkAsRead: Int + val iconDelete: Int + val iconReply: Int + val iconNewMail: Int + val iconSendingMail: Int + val iconCheckingMail: Int + val wearIconMarkAsRead: Int + val wearIconDelete: Int + val wearIconArchive: Int + val wearIconReplyAll: Int + val wearIconMarkAsSpam: Int + + val pushChannelName: String + val pushChannelDescription: String + val messagesChannelName: String + val messagesChannelDescription: String + val miscellaneousChannelName: String + val miscellaneousChannelDescription: String + + fun authenticationErrorTitle(): String + fun authenticationErrorBody(accountName: String): String + + fun notifyErrorTitle(): String + fun notifyErrorText(): String + + fun certificateErrorTitle(): String + fun certificateErrorTitle(accountName: String): String + fun certificateErrorBody(): String + + fun newMessagesTitle(newMessagesCount: Int): String + fun additionalMessages(overflowMessagesCount: Int, accountName: String): String + fun previewEncrypted(): String + fun noSubject(): String + fun recipientDisplayName(recipientDisplayName: String): String + fun noSender(): String + + fun sendFailedTitle(): String + fun sendingMailTitle(): String + fun sendingMailBody(accountName: String): String + + fun checkingMailTicker(accountName: String, folderName: String): String + fun checkingMailTitle(): String + fun checkingMailSeparator(): String + + fun actionMarkAsRead(): String + fun actionMarkAllAsRead(): String + fun actionDelete(): String + fun actionDeleteAll(): String + fun actionReply(): String + fun actionArchive(): String + fun actionArchiveAll(): String + fun actionMarkAsSpam(): String +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationSettingsUpdater.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationSettingsUpdater.kt new file mode 100644 index 0000000..00ea6e8 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationSettingsUpdater.kt @@ -0,0 +1,36 @@ +package com.fsck.k9.notification + +import android.os.Build +import androidx.annotation.RequiresApi +import com.fsck.k9.Account +import com.fsck.k9.Preferences + +/** + * Update accounts with notification settings read from their "Messages" `NotificationChannel`. + */ +class NotificationSettingsUpdater( + private val preferences: Preferences, + private val notificationChannelManager: NotificationChannelManager, + private val notificationConfigurationConverter: NotificationConfigurationConverter +) { + fun updateNotificationSettings(accountUuids: Collection) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + accountUuids + .mapNotNull { accountUuid -> preferences.getAccount(accountUuid) } + .forEach { account -> + updateNotificationSettings(account) + preferences.saveAccount(account) + } + } + + @RequiresApi(Build.VERSION_CODES.O) + fun updateNotificationSettings(account: Account) { + val notificationConfiguration = notificationChannelManager.getNotificationConfiguration(account) + val notificationSettings = notificationConfigurationConverter.convert(account, notificationConfiguration) + + if (notificationSettings != account.notificationSettings) { + account.updateNotificationSettings { notificationSettings } + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationStore.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationStore.kt new file mode 100644 index 0000000..4068bad --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationStore.kt @@ -0,0 +1,6 @@ +package com.fsck.k9.notification + +interface NotificationStore { + fun persistNotificationChanges(operations: List) + fun clearNotifications() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreOperation.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreOperation.kt new file mode 100644 index 0000000..c03620f --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreOperation.kt @@ -0,0 +1,20 @@ +package com.fsck.k9.notification + +import com.fsck.k9.controller.MessageReference + +sealed interface NotificationStoreOperation { + data class Add( + val messageReference: MessageReference, + val notificationId: Int, + val timestamp: Long + ) : NotificationStoreOperation + + data class Remove(val messageReference: MessageReference) : NotificationStoreOperation + + data class ChangeToInactive(val messageReference: MessageReference) : NotificationStoreOperation + + data class ChangeToActive( + val messageReference: MessageReference, + val notificationId: Int + ) : NotificationStoreOperation +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreProvider.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreProvider.kt new file mode 100644 index 0000000..44d0409 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreProvider.kt @@ -0,0 +1,7 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account + +interface NotificationStoreProvider { + fun getNotificationStore(account: Account): NotificationStore +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationStrategy.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationStrategy.kt new file mode 100644 index 0000000..a6fb5ed --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationStrategy.kt @@ -0,0 +1,15 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.mailstore.LocalFolder +import com.fsck.k9.mailstore.LocalMessage + +interface NotificationStrategy { + + fun shouldNotifyForMessage( + account: Account, + localFolder: LocalFolder, + message: LocalMessage, + isOldMessage: Boolean + ): Boolean +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationVibrationDecoder.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationVibrationDecoder.kt new file mode 100644 index 0000000..248b58f --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationVibrationDecoder.kt @@ -0,0 +1,26 @@ +package com.fsck.k9.notification + +import com.fsck.k9.NotificationVibration +import com.fsck.k9.VibratePattern + +/** + * Converts the vibration values read from a `NotificationChannel` into [NotificationVibration]. + */ +class NotificationVibrationDecoder { + fun decode(isVibrationEnabled: Boolean, systemPattern: List?): NotificationVibration { + if (systemPattern == null || systemPattern.size < 2 || systemPattern.size % 2 != 0) { + return NotificationVibration(isVibrationEnabled, VibratePattern.Default, repeatCount = 1) + } + + val systemPatternArray = systemPattern.toLongArray() + val repeatCount = systemPattern.size / 2 + val pattern = VibratePattern.values() + .firstOrNull { vibratePattern -> + val testPattern = NotificationVibration.getSystemPattern(vibratePattern, repeatCount) + + testPattern.contentEquals(systemPatternArray) + } ?: VibratePattern.Default + + return NotificationVibration(isVibrationEnabled, pattern, repeatCount) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/PushNotificationManager.kt b/app/core/src/main/java/com/fsck/k9/notification/PushNotificationManager.kt new file mode 100644 index 0000000..9b562b0 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/PushNotificationManager.kt @@ -0,0 +1,79 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.CoreResourceProvider +import com.fsck.k9.helper.PendingIntentCompat.FLAG_IMMUTABLE + +private const val PUSH_INFO_ACTION = "app.k9mail.action.PUSH_INFO" + +internal class PushNotificationManager( + private val context: Context, + private val resourceProvider: CoreResourceProvider, + private val notificationChannelManager: NotificationChannelManager, + private val notificationManager: NotificationManagerCompat +) { + val notificationId = NotificationIds.PUSH_NOTIFICATION_ID + + @get:Synchronized + @set:Synchronized + var notificationState = PushNotificationState.INITIALIZING + set(value) { + field = value + + if (isForegroundServiceStarted) { + updateNotification() + } + } + + private var isForegroundServiceStarted = false + + @Synchronized + fun createForegroundNotification(): Notification { + isForegroundServiceStarted = true + return createNotification() + } + + @Synchronized + fun setForegroundServiceStopped() { + isForegroundServiceStarted = false + } + + private fun updateNotification() { + val notification = createNotification() + notificationManager.notify(notificationId, notification) + } + + private fun createNotification(): Notification { + val intent = Intent(PUSH_INFO_ACTION).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + setPackage(context.packageName) + } + val contentIntent = PendingIntent.getActivity(context, 1, intent, FLAG_IMMUTABLE) + + return NotificationCompat.Builder(context, notificationChannelManager.pushChannelId) + .setSmallIcon(resourceProvider.iconPushNotification) + .setContentTitle(resourceProvider.pushNotificationText(notificationState)) + .setContentText(resourceProvider.pushNotificationInfoText()) + .setContentIntent(contentIntent) + .setOngoing(true) + .setNotificationSilent() + .setPriority(NotificationCompat.PRIORITY_MIN) + .setBadgeIconType(NotificationCompat.BADGE_ICON_NONE) + .setLocalOnly(true) + .setShowWhen(false) + .build() + } +} + +enum class PushNotificationState { + INITIALIZING, + LISTENING, + WAIT_BACKGROUND_SYNC, + WAIT_NETWORK +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/RemoveNotificationsResult.kt b/app/core/src/main/java/com/fsck/k9/notification/RemoveNotificationsResult.kt new file mode 100644 index 0000000..f3c2d44 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/RemoveNotificationsResult.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.notification + +internal data class RemoveNotificationsResult( + val notificationData: NotificationData, + val notificationStoreOperations: List, + val notificationHolders: List, + val cancelNotificationIds: List +) diff --git a/app/core/src/main/java/com/fsck/k9/notification/SendFailedNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/SendFailedNotificationController.kt new file mode 100644 index 0000000..2f82eab --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SendFailedNotificationController.kt @@ -0,0 +1,63 @@ +package com.fsck.k9.notification + +import android.app.Notification +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account +import com.fsck.k9.helper.ExceptionHelper + +internal class SendFailedNotificationController( + private val notificationHelper: NotificationHelper, + private val actionBuilder: NotificationActionCreator, + private val resourceProvider: NotificationResourceProvider +) { + fun showSendFailedNotification(account: Account, exception: Exception) { + val title = resourceProvider.sendFailedTitle() + val text = ExceptionHelper.getRootCauseMessage(exception) + + val notificationId = NotificationIds.getSendFailedNotificationId(account) + + val pendingIntent = account.outboxFolderId.let { outboxFolderId -> + if (outboxFolderId != null) { + actionBuilder.createViewFolderPendingIntent(account, outboxFolderId) + } else { + actionBuilder.createViewFolderListPendingIntent(account) + } + } + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(pendingIntent) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setPublicVersion(createLockScreenNotification(account)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setErrorAppearance() + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun clearSendFailedNotification(account: Account) { + val notificationId = NotificationIds.getSendFailedNotificationId(account) + notificationManager.cancel(notificationId) + } + + private fun createLockScreenNotification(account: Account): Notification { + return notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setContentTitle(resourceProvider.sendFailedTitle()) + .build() + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt new file mode 100644 index 0000000..d6d5e36 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt @@ -0,0 +1,173 @@ +package com.fsck.k9.notification + +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.WearableExtender +import com.fsck.k9.notification.NotificationChannelManager.ChannelType +import timber.log.Timber +import androidx.core.app.NotificationCompat.Builder as NotificationBuilder + +internal class SingleMessageNotificationCreator( + private val notificationHelper: NotificationHelper, + private val actionCreator: NotificationActionCreator, + private val resourceProvider: NotificationResourceProvider, + private val lockScreenNotificationCreator: LockScreenNotificationCreator +) { + fun createSingleNotification( + baseNotificationData: BaseNotificationData, + singleNotificationData: SingleNotificationData, + isGroupSummary: Boolean = false + ) { + val account = baseNotificationData.account + val notificationId = singleNotificationData.notificationId + val content = singleNotificationData.content + + val notification = notificationHelper.createNotificationBuilder(account, ChannelType.MESSAGES) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setGroup(baseNotificationData.groupKey) + .setGroupSummary(isGroupSummary) + .setSmallIcon(resourceProvider.iconNewMail) + .setColor(baseNotificationData.color) + .setWhen(singleNotificationData.timestamp) + .setTicker(content.summary) + .setContentTitle(content.sender) + .setContentText(content.subject) + .setSubText(baseNotificationData.accountName) + .setBigText(content.preview) + .setContentIntent(actionCreator.createViewMessagePendingIntent(content.messageReference)) + .setDeleteIntent(actionCreator.createDismissMessagePendingIntent(content.messageReference)) + .setDeviceActions(singleNotificationData) + .setWearActions(singleNotificationData) + .setAppearance(singleNotificationData.isSilent, baseNotificationData.appearance) + .setLockScreenNotification(baseNotificationData, singleNotificationData.addLockScreenNotification) + .build() + + if (isGroupSummary) { + Timber.v( + "Creating single summary notification (silent=%b): %s", + singleNotificationData.isSilent, + notification + ) + } + notificationHelper.notify(account, notificationId, notification) + } + + private fun NotificationBuilder.setBigText(text: CharSequence) = apply { + setStyle(NotificationCompat.BigTextStyle().bigText(text)) + } + + private fun NotificationBuilder.setDeviceActions(notificationData: SingleNotificationData) = apply { + val actions = notificationData.actions + for (action in actions) { + when (action) { + NotificationAction.Reply -> addReplyAction(notificationData) + NotificationAction.MarkAsRead -> addMarkAsReadAction(notificationData) + NotificationAction.Delete -> addDeleteAction(notificationData) + } + } + } + + private fun NotificationBuilder.addReplyAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.iconReply + val title = resourceProvider.actionReply() + val content = notificationData.content + val messageReference = content.messageReference + val replyToMessagePendingIntent = actionCreator.createReplyPendingIntent(messageReference) + + addAction(icon, title, replyToMessagePendingIntent) + } + + private fun NotificationBuilder.addMarkAsReadAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.iconMarkAsRead + val title = resourceProvider.actionMarkAsRead() + val content = notificationData.content + val messageReference = content.messageReference + val action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference) + + addAction(icon, title, action) + } + + private fun NotificationBuilder.addDeleteAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.iconDelete + val title = resourceProvider.actionDelete() + val content = notificationData.content + val messageReference = content.messageReference + val action = actionCreator.createDeleteMessagePendingIntent(messageReference) + + addAction(icon, title, action) + } + + private fun NotificationBuilder.setWearActions(notificationData: SingleNotificationData) = apply { + val wearableExtender = WearableExtender().apply { + for (action in notificationData.wearActions) { + when (action) { + WearNotificationAction.Reply -> addReplyAction(notificationData) + WearNotificationAction.MarkAsRead -> addMarkAsReadAction(notificationData) + WearNotificationAction.Delete -> addDeleteAction(notificationData) + WearNotificationAction.Archive -> addArchiveAction(notificationData) + WearNotificationAction.Spam -> addMarkAsSpamAction(notificationData) + } + } + } + + extend(wearableExtender) + } + + private fun WearableExtender.addReplyAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.wearIconReplyAll + val title = resourceProvider.actionReply() + val messageReference = notificationData.content.messageReference + val action = actionCreator.createReplyPendingIntent(messageReference) + val replyAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(replyAction) + } + + private fun WearableExtender.addMarkAsReadAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.wearIconMarkAsRead + val title = resourceProvider.actionMarkAsRead() + val messageReference = notificationData.content.messageReference + val action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference) + val markAsReadAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(markAsReadAction) + } + + private fun WearableExtender.addDeleteAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.wearIconDelete + val title = resourceProvider.actionDelete() + val messageReference = notificationData.content.messageReference + val action = actionCreator.createDeleteMessagePendingIntent(messageReference) + val deleteAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(deleteAction) + } + + private fun WearableExtender.addArchiveAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.wearIconArchive + val title = resourceProvider.actionArchive() + val messageReference = notificationData.content.messageReference + val action = actionCreator.createArchiveMessagePendingIntent(messageReference) + val archiveAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(archiveAction) + } + + private fun WearableExtender.addMarkAsSpamAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.wearIconMarkAsSpam + val title = resourceProvider.actionMarkAsSpam() + val messageReference = notificationData.content.messageReference + val action = actionCreator.createMarkMessageAsSpamPendingIntent(messageReference) + val spamAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(spamAction) + } + + private fun NotificationBuilder.setLockScreenNotification( + notificationData: BaseNotificationData, + addLockScreenNotification: Boolean + ) = apply { + if (addLockScreenNotification) { + lockScreenNotificationCreator.configureLockScreenNotification(this, notificationData) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt new file mode 100644 index 0000000..f693ab3 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt @@ -0,0 +1,87 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.K9 + +internal class SingleMessageNotificationDataCreator { + + fun createSingleNotificationData( + account: Account, + notificationId: Int, + content: NotificationContent, + timestamp: Long, + addLockScreenNotification: Boolean + ): SingleNotificationData { + return SingleNotificationData( + notificationId = notificationId, + isSilent = true, + timestamp = timestamp, + content = content, + actions = createSingleNotificationActions(), + wearActions = createSingleNotificationWearActions(account), + addLockScreenNotification = addLockScreenNotification + ) + } + + fun createSummarySingleNotificationData( + data: NotificationData, + timestamp: Long, + silent: Boolean + ): SummarySingleNotificationData { + return SummarySingleNotificationData( + SingleNotificationData( + notificationId = NotificationIds.getNewMailSummaryNotificationId(data.account), + isSilent = silent, + timestamp = timestamp, + content = data.activeNotifications.first().content, + actions = createSingleNotificationActions(), + wearActions = createSingleNotificationWearActions(data.account), + addLockScreenNotification = false + ) + ) + } + + private fun createSingleNotificationActions(): List { + return buildList { + add(NotificationAction.Reply) + add(NotificationAction.MarkAsRead) + + if (isDeleteActionEnabled()) { + add(NotificationAction.Delete) + } + } + } + + private fun createSingleNotificationWearActions(account: Account): List { + return buildList { + add(WearNotificationAction.Reply) + add(WearNotificationAction.MarkAsRead) + + if (isDeleteActionAvailableForWear()) { + add(WearNotificationAction.Delete) + } + + if (account.hasArchiveFolder()) { + add(WearNotificationAction.Archive) + } + + if (isSpamActionAvailableForWear(account)) { + add(WearNotificationAction.Spam) + } + } + } + + private fun isDeleteActionEnabled(): Boolean { + return K9.notificationQuickDeleteBehaviour != K9.NotificationQuickDelete.NEVER + } + + // We don't support confirming actions on Wear devices. So don't show the action when confirmation is enabled. + private fun isDeleteActionAvailableForWear(): Boolean { + return isDeleteActionEnabled() && !K9.isConfirmDeleteFromNotification + } + + // We don't support confirming actions on Wear devices. So don't show the action when confirmation is enabled. + private fun isSpamActionAvailableForWear(account: Account): Boolean { + return account.hasSpamFolder() && !K9.isConfirmSpam + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationCreator.kt new file mode 100644 index 0000000..c780415 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationCreator.kt @@ -0,0 +1,194 @@ +package com.fsck.k9.notification + +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.WearableExtender +import com.fsck.k9.Account +import com.fsck.k9.notification.NotificationChannelManager.ChannelType +import timber.log.Timber +import androidx.core.app.NotificationCompat.Builder as NotificationBuilder + +internal class SummaryNotificationCreator( + private val notificationHelper: NotificationHelper, + private val actionCreator: NotificationActionCreator, + private val lockScreenNotificationCreator: LockScreenNotificationCreator, + private val singleMessageNotificationCreator: SingleMessageNotificationCreator, + private val resourceProvider: NotificationResourceProvider +) { + fun createSummaryNotification( + baseNotificationData: BaseNotificationData, + summaryNotificationData: SummaryNotificationData + ) { + when (summaryNotificationData) { + is SummarySingleNotificationData -> { + createSingleMessageNotification(baseNotificationData, summaryNotificationData.singleNotificationData) + } + is SummaryInboxNotificationData -> { + createInboxStyleSummaryNotification(baseNotificationData, summaryNotificationData) + } + } + } + + private fun createSingleMessageNotification( + baseNotificationData: BaseNotificationData, + singleNotificationData: SingleNotificationData + ) { + singleMessageNotificationCreator.createSingleNotification( + baseNotificationData, + singleNotificationData, + isGroupSummary = true + ) + } + + private fun createInboxStyleSummaryNotification( + baseNotificationData: BaseNotificationData, + notificationData: SummaryInboxNotificationData + ) { + val account = baseNotificationData.account + val accountName = baseNotificationData.accountName + val newMessagesCount = baseNotificationData.newMessagesCount + val title = resourceProvider.newMessagesTitle(newMessagesCount) + val summary = buildInboxSummaryText(accountName, notificationData) + + val notification = notificationHelper.createNotificationBuilder(account, ChannelType.MESSAGES) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setGroup(baseNotificationData.groupKey) + .setGroupSummary(true) + .setSmallIcon(resourceProvider.iconNewMail) + .setColor(baseNotificationData.color) + .setWhen(notificationData.timestamp) + .setNumber(notificationData.additionalMessagesCount) + .setTicker(notificationData.content.firstOrNull()) + .setContentTitle(title) + .setSubText(accountName) + .setInboxStyle(title, summary, notificationData.content) + .setContentIntent(createViewIntent(account, notificationData)) + .setDeleteIntent(actionCreator.createDismissAllMessagesPendingIntent(account)) + .setDeviceActions(account, notificationData) + .setWearActions(account, notificationData) + .setAppearance(notificationData.isSilent, baseNotificationData.appearance) + .setLockScreenNotification(baseNotificationData) + .build() + + Timber.v("Creating inbox-style summary notification (silent=%b): %s", notificationData.isSilent, notification) + notificationHelper.notify(account, notificationData.notificationId, notification) + } + + private fun buildInboxSummaryText(accountName: String, notificationData: SummaryInboxNotificationData): String { + return if (notificationData.additionalMessagesCount > 0) { + resourceProvider.additionalMessages(notificationData.additionalMessagesCount, accountName) + } else { + accountName + } + } + + private fun NotificationBuilder.setInboxStyle( + title: String, + summary: String, + contentLines: List + ) = apply { + val style = NotificationCompat.InboxStyle() + .setBigContentTitle(title) + .setSummaryText(summary) + + for (line in contentLines) { + style.addLine(line) + } + + setStyle(style) + } + + private fun createViewIntent(account: Account, notificationData: SummaryInboxNotificationData): PendingIntent { + return actionCreator.createViewMessagesPendingIntent(account, notificationData.messageReferences) + } + + private fun NotificationBuilder.setDeviceActions( + account: Account, + notificationData: SummaryInboxNotificationData + ) = apply { + for (action in notificationData.actions) { + when (action) { + SummaryNotificationAction.MarkAsRead -> addMarkAllAsReadAction(account, notificationData) + SummaryNotificationAction.Delete -> addDeleteAllAction(account, notificationData) + } + } + } + + private fun NotificationBuilder.addMarkAllAsReadAction( + account: Account, + notificationData: SummaryInboxNotificationData + ) { + val icon = resourceProvider.iconMarkAsRead + val title = resourceProvider.actionMarkAsRead() + val messageReferences = notificationData.messageReferences + val markAllAsReadPendingIntent = actionCreator.createMarkAllAsReadPendingIntent(account, messageReferences) + + addAction(icon, title, markAllAsReadPendingIntent) + } + + private fun NotificationBuilder.addDeleteAllAction( + account: Account, + notificationData: SummaryInboxNotificationData + ) { + val icon = resourceProvider.iconDelete + val title = resourceProvider.actionDelete() + val messageReferences = notificationData.messageReferences + val action = actionCreator.createDeleteAllPendingIntent(account, messageReferences) + + addAction(icon, title, action) + } + + private fun NotificationBuilder.setWearActions( + account: Account, + notificationData: SummaryInboxNotificationData + ) = apply { + val wearableExtender = WearableExtender().apply { + for (action in notificationData.wearActions) { + when (action) { + SummaryWearNotificationAction.MarkAsRead -> addMarkAllAsReadAction(account, notificationData) + SummaryWearNotificationAction.Delete -> addDeleteAllAction(account, notificationData) + SummaryWearNotificationAction.Archive -> addArchiveAllAction(account, notificationData) + } + } + } + + extend(wearableExtender) + } + + private fun WearableExtender.addMarkAllAsReadAction( + account: Account, + notificationData: SummaryInboxNotificationData + ) { + val icon = resourceProvider.wearIconMarkAsRead + val title = resourceProvider.actionMarkAllAsRead() + val messageReferences = notificationData.messageReferences + val action = actionCreator.createMarkAllAsReadPendingIntent(account, messageReferences) + val markAsReadAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(markAsReadAction) + } + + private fun WearableExtender.addDeleteAllAction(account: Account, notificationData: SummaryInboxNotificationData) { + val icon = resourceProvider.wearIconDelete + val title = resourceProvider.actionDeleteAll() + val messageReferences = notificationData.messageReferences + val action = actionCreator.createDeleteAllPendingIntent(account, messageReferences) + val deleteAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(deleteAction) + } + + private fun WearableExtender.addArchiveAllAction(account: Account, notificationData: SummaryInboxNotificationData) { + val icon = resourceProvider.wearIconArchive + val title = resourceProvider.actionArchiveAll() + val messageReferences = notificationData.messageReferences + val action = actionCreator.createArchiveAllPendingIntent(account, messageReferences) + val archiveAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(archiveAction) + } + + private fun NotificationBuilder.setLockScreenNotification(notificationData: BaseNotificationData) = apply { + lockScreenNotificationCreator.configureLockScreenNotification(this, notificationData) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt new file mode 100644 index 0000000..34d82ef --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt @@ -0,0 +1,92 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.K9 + +private const val MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION = 5 + +internal class SummaryNotificationDataCreator( + private val singleMessageNotificationDataCreator: SingleMessageNotificationDataCreator +) { + fun createSummaryNotificationData(data: NotificationData, silent: Boolean): SummaryNotificationData { + val timestamp = data.latestTimestamp + val shouldBeSilent = silent || K9.isQuietTime + return if (data.isSingleMessageNotification) { + createSummarySingleNotificationData(data, timestamp, shouldBeSilent) + } else { + createSummaryInboxNotificationData(data, timestamp, shouldBeSilent) + } + } + + private fun createSummarySingleNotificationData( + data: NotificationData, + timestamp: Long, + silent: Boolean + ): SummaryNotificationData { + return singleMessageNotificationDataCreator.createSummarySingleNotificationData(data, timestamp, silent) + } + + private fun createSummaryInboxNotificationData( + data: NotificationData, + timestamp: Long, + silent: Boolean + ): SummaryNotificationData { + return SummaryInboxNotificationData( + notificationId = NotificationIds.getNewMailSummaryNotificationId(data.account), + isSilent = silent, + timestamp = timestamp, + content = data.summaryContent, + additionalMessagesCount = data.additionalMessagesCount, + messageReferences = data.activeMessageReferences, + actions = createSummaryNotificationActions(), + wearActions = createSummaryWearNotificationActions(data.account) + ) + } + + private fun createSummaryNotificationActions(): List { + return buildList { + add(SummaryNotificationAction.MarkAsRead) + + if (isDeleteActionEnabled()) { + add(SummaryNotificationAction.Delete) + } + } + } + + private fun createSummaryWearNotificationActions(account: Account): List { + return buildList { + add(SummaryWearNotificationAction.MarkAsRead) + + if (isDeleteActionAvailableForWear()) { + add(SummaryWearNotificationAction.Delete) + } + + if (account.hasArchiveFolder()) { + add(SummaryWearNotificationAction.Archive) + } + } + } + + private fun isDeleteActionEnabled(): Boolean { + return K9.notificationQuickDeleteBehaviour == K9.NotificationQuickDelete.ALWAYS + } + + // We don't support confirming actions on Wear devices. So don't show the action when confirmation is enabled. + private fun isDeleteActionAvailableForWear(): Boolean { + return isDeleteActionEnabled() && !K9.isConfirmDeleteFromNotification + } + + private val NotificationData.latestTimestamp: Long + get() = activeNotifications.first().timestamp + + private val NotificationData.summaryContent: List + get() { + return activeNotifications.asSequence() + .map { it.content.summary } + .take(MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION) + .toList() + } + + private val NotificationData.additionalMessagesCount: Int + get() = (newMessagesCount - MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION).coerceAtLeast(0) +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SyncNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/SyncNotificationController.kt new file mode 100644 index 0000000..441d569 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SyncNotificationController.kt @@ -0,0 +1,118 @@ +package com.fsck.k9.notification + +import android.app.Notification +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account +import com.fsck.k9.mailstore.LocalFolder + +internal class SyncNotificationController( + private val notificationHelper: NotificationHelper, + private val actionBuilder: NotificationActionCreator, + private val resourceProvider: NotificationResourceProvider +) { + fun showSendingNotification(account: Account) { + val accountName = account.displayName + val title = resourceProvider.sendingMailTitle() + val tickerText = resourceProvider.sendingMailBody(accountName) + + val notificationId = NotificationIds.getFetchingMailNotificationId(account) + val outboxFolderId = account.outboxFolderId ?: error("Outbox folder not configured") + val showMessageListPendingIntent = actionBuilder.createViewFolderPendingIntent(account, outboxFolderId) + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconSendingMail) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setTicker(tickerText) + .setContentTitle(title) + .setContentText(accountName) + .setContentIntent(showMessageListPendingIntent) + .setPublicVersion(createSendingLockScreenNotification(account)) + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun clearSendingNotification(account: Account) { + val notificationId = NotificationIds.getFetchingMailNotificationId(account) + notificationManager.cancel(notificationId) + } + + fun showFetchingMailNotification(account: Account, folder: LocalFolder) { + val accountName = account.displayName + val folderId = folder.databaseId + val folderName = folder.name + val tickerText = resourceProvider.checkingMailTicker(accountName, folderName) + val title = resourceProvider.checkingMailTitle() + + // TODO: Use format string from resources + val text = accountName + resourceProvider.checkingMailSeparator() + folderName + + val notificationId = NotificationIds.getFetchingMailNotificationId(account) + val showMessageListPendingIntent = actionBuilder.createViewFolderPendingIntent(account, folderId) + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconCheckingMail) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setTicker(tickerText) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(showMessageListPendingIntent) + .setPublicVersion(createFetchingMailLockScreenNotification(account)) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun showEmptyFetchingMailNotification(account: Account) { + val title = resourceProvider.checkingMailTitle() + val text = account.displayName + val notificationId = NotificationIds.getFetchingMailNotificationId(account) + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconCheckingMail) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setContentTitle(title) + .setContentText(text) + .setPublicVersion(createFetchingMailLockScreenNotification(account)) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun clearFetchingMailNotification(account: Account) { + val notificationId = NotificationIds.getFetchingMailNotificationId(account) + notificationManager.cancel(notificationId) + } + + private fun createSendingLockScreenNotification(account: Account): Notification { + return notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconSendingMail) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setContentTitle(resourceProvider.sendingMailTitle()) + .build() + } + + private fun createFetchingMailLockScreenNotification(account: Account): Notification { + return notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconCheckingMail) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setContentTitle(resourceProvider.checkingMailTitle()) + .build() + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfiguration.kt b/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfiguration.kt new file mode 100644 index 0000000..d3a11ed --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfiguration.kt @@ -0,0 +1,9 @@ +package com.fsck.k9.oauth + +data class OAuthConfiguration( + val clientId: String, + val scopes: List, + val authorizationEndpoint: String, + val tokenEndpoint: String, + val redirectUri: String +) diff --git a/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfigurationProvider.kt b/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfigurationProvider.kt new file mode 100644 index 0000000..f2b8b31 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfigurationProvider.kt @@ -0,0 +1,22 @@ +package com.fsck.k9.oauth + +class OAuthConfigurationProvider( + private val configurations: Map, OAuthConfiguration>, + private val googleConfiguration: OAuthConfiguration +) { + private val hostnameMapping: Map = buildMap { + for ((hostnames, configuration) in configurations) { + for (hostname in hostnames) { + put(hostname.lowercase(), configuration) + } + } + } + + fun getConfiguration(hostname: String): OAuthConfiguration? { + return hostnameMapping[hostname.lowercase()] + } + + fun isGoogle(hostname: String): Boolean { + return getConfiguration(hostname) == googleConfiguration + } +} diff --git a/app/core/src/main/java/com/fsck/k9/power/AndroidPowerManager.kt b/app/core/src/main/java/com/fsck/k9/power/AndroidPowerManager.kt new file mode 100644 index 0000000..5154628 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/power/AndroidPowerManager.kt @@ -0,0 +1,93 @@ +package com.fsck.k9.power + +import android.annotation.SuppressLint +import android.os.SystemClock +import com.fsck.k9.mail.power.PowerManager +import com.fsck.k9.mail.power.WakeLock +import java.util.concurrent.atomic.AtomicInteger +import timber.log.Timber +import android.os.PowerManager as SystemPowerManager +import android.os.PowerManager.WakeLock as SystemWakeLock + +internal class AndroidPowerManager(private val systemPowerManager: SystemPowerManager) : PowerManager { + override fun newWakeLock(tag: String): WakeLock { + return AndroidWakeLock(SystemPowerManager.PARTIAL_WAKE_LOCK, tag) + } + + inner class AndroidWakeLock(flags: Int, val tag: String?) : WakeLock { + private val wakeLock: SystemWakeLock = systemPowerManager.newWakeLock(flags, tag) + private val id = wakeLockId.getAndIncrement() + + @Volatile + private var startTime: Long? = null + + @Volatile + private var timeout: Long? = null + + init { + Timber.v("AndroidWakeLock for tag %s / id %d: Create", tag, id) + } + + override fun acquire(timeout: Long) { + synchronized(wakeLock) { + wakeLock.acquire(timeout) + } + + Timber.v("AndroidWakeLock for tag %s / id %d for %d ms: acquired", tag, id, timeout) + + if (startTime == null) { + startTime = SystemClock.elapsedRealtime() + } + + this.timeout = timeout + } + + @SuppressLint("WakelockTimeout") + override fun acquire() { + synchronized(wakeLock) { + wakeLock.acquire() + } + + Timber.v("AndroidWakeLock for tag %s / id %d: acquired with no timeout.", tag, id) + + if (startTime == null) { + startTime = SystemClock.elapsedRealtime() + } + + timeout = null + } + + override fun setReferenceCounted(counted: Boolean) { + synchronized(wakeLock) { + wakeLock.setReferenceCounted(counted) + } + } + + override fun release() { + val startTime = this.startTime + if (startTime != null) { + val endTime = SystemClock.elapsedRealtime() + + Timber.v( + "AndroidWakeLock for tag %s / id %d: releasing after %d ms, timeout = %d ms", + tag, + id, + endTime - startTime, + timeout + ) + } else { + Timber.v("AndroidWakeLock for tag %s / id %d, timeout = %d ms: releasing", tag, id, timeout) + } + + synchronized(wakeLock) { + wakeLock.release() + } + + this.startTime = null + } + } + + companion object { + private val wakeLockId = AtomicInteger(0) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/power/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/power/KoinModule.kt new file mode 100644 index 0000000..e5e0122 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/power/KoinModule.kt @@ -0,0 +1,10 @@ +package com.fsck.k9.power + +import android.content.Context +import com.fsck.k9.mail.power.PowerManager +import org.koin.dsl.module + +val powerModule = module { + factory { get().getSystemService(Context.POWER_SERVICE) as android.os.PowerManager } + single { AndroidPowerManager(systemPowerManager = get()) } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/AccountManager.kt b/app/core/src/main/java/com/fsck/k9/preferences/AccountManager.kt new file mode 100644 index 0000000..4037932 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/AccountManager.kt @@ -0,0 +1,17 @@ +package com.fsck.k9.preferences + +import com.fsck.k9.Account +import com.fsck.k9.AccountRemovedListener +import com.fsck.k9.AccountsChangeListener +import kotlinx.coroutines.flow.Flow + +interface AccountManager { + fun getAccountsFlow(): Flow> + fun getAccount(accountUuid: String): Account? + fun getAccountFlow(accountUuid: String): Flow + fun addAccountRemovedListener(listener: AccountRemovedListener) + fun moveAccount(account: Account, newPosition: Int) + fun addOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) + fun removeOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) + fun saveAccount(account: Account) +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/AccountSettingsDescriptions.java b/app/core/src/main/java/com/fsck/k9/preferences/AccountSettingsDescriptions.java new file mode 100644 index 0000000..97fcd96 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/AccountSettingsDescriptions.java @@ -0,0 +1,574 @@ +package com.fsck.k9.preferences; + + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import android.content.Context; + +import com.fsck.k9.Account; +import com.fsck.k9.Account.DeletePolicy; +import com.fsck.k9.Account.Expunge; +import com.fsck.k9.Account.FolderMode; +import com.fsck.k9.Account.MessageFormat; +import com.fsck.k9.Account.QuoteStyle; +import com.fsck.k9.Account.Searchable; +import com.fsck.k9.Account.ShowPictures; +import com.fsck.k9.Account.SortType; +import com.fsck.k9.Account.SpecialFolderSelection; +import com.fsck.k9.AccountPreferenceSerializer; +import com.fsck.k9.DI; +import com.fsck.k9.K9; +import com.fsck.k9.NotificationLight; +import com.fsck.k9.core.R; +import com.fsck.k9.mailstore.StorageManager; +import com.fsck.k9.notification.NotificationLightDecoder; +import com.fsck.k9.preferences.Settings.BooleanSetting; +import com.fsck.k9.preferences.Settings.ColorSetting; +import com.fsck.k9.preferences.Settings.EnumSetting; +import com.fsck.k9.preferences.Settings.IntegerRangeSetting; +import com.fsck.k9.preferences.Settings.InvalidSettingValueException; +import com.fsck.k9.preferences.Settings.PseudoEnumSetting; +import com.fsck.k9.preferences.Settings.SettingsDescription; +import com.fsck.k9.preferences.Settings.SettingsUpgrader; +import com.fsck.k9.preferences.Settings.StringSetting; +import com.fsck.k9.preferences.Settings.V; +import kotlin.collections.SetsKt; + + +public class AccountSettingsDescriptions { + static final Map> SETTINGS; + private static final Map UPGRADERS; + + static { + Map> s = new LinkedHashMap<>(); + + /* + * When adding new settings here, be sure to increment {@link Settings.VERSION} + * and use that for whatever you add here. + */ + + s.put("alwaysBcc", Settings.versions( + new V(11, new StringSetting("")) + )); + s.put("alwaysShowCcBcc", Settings.versions( + new V(13, new BooleanSetting(false)) + )); + s.put("archiveFolderName", Settings.versions( + new V(1, new StringSetting(SettingsUpgraderV53.FOLDER_NONE)), + new V(53, new StringSetting(null)) + )); + s.put("autoExpandFolderName", Settings.versions( + new V(1, new StringSetting("INBOX")), + new V(78, new StringSetting(null)) + )); + s.put("automaticCheckIntervalMinutes", Settings.versions( + new V(1, new IntegerResourceSetting(-1, R.array.check_frequency_values)), + new V(61, new IntegerResourceSetting(60, R.array.check_frequency_values)) + )); + s.put("chipColor", Settings.versions( + new V(1, new ColorSetting(0xFF0000FF)) + )); + s.put("defaultQuotedTextShown", Settings.versions( + new V(1, new BooleanSetting(AccountPreferenceSerializer.DEFAULT_QUOTED_TEXT_SHOWN)) + )); + s.put("deletePolicy", Settings.versions( + new V(1, new DeletePolicySetting(DeletePolicy.NEVER)) + )); + s.put("displayCount", Settings.versions( + new V(1, new IntegerResourceSetting(K9.DEFAULT_VISIBLE_LIMIT, + R.array.display_count_values)) + )); + s.put("draftsFolderName", Settings.versions( + new V(1, new StringSetting(SettingsUpgraderV53.FOLDER_NONE)), + new V(53, new StringSetting(null)) + )); + s.put("expungePolicy", Settings.versions( + new V(1, new StringResourceSetting(Expunge.EXPUNGE_IMMEDIATELY.name(), + R.array.expunge_policy_values)) + )); + s.put("folderDisplayMode", Settings.versions( + new V(1, new EnumSetting<>(FolderMode.class, FolderMode.NOT_SECOND_CLASS)) + )); + s.put("folderPushMode", Settings.versions( + new V(1, new EnumSetting<>(FolderMode.class, FolderMode.FIRST_CLASS)), + new V(72, new EnumSetting<>(FolderMode.class, FolderMode.NONE)) + )); + s.put("folderSyncMode", Settings.versions( + new V(1, new EnumSetting<>(FolderMode.class, FolderMode.FIRST_CLASS)) + )); + s.put("folderTargetMode", Settings.versions( + new V(1, new EnumSetting<>(FolderMode.class, FolderMode.NOT_SECOND_CLASS)) + )); + s.put("idleRefreshMinutes", Settings.versions( + new V(1, new IntegerArraySetting(24, new int[] { 1, 2, 3, 6, 12, 24, 36, 48, 60 })), + new V(74, new IntegerResourceSetting(24, R.array.idle_refresh_period_values)) + )); + s.put("led", Settings.versions( + new V(1, new BooleanSetting(true)), + new V(80, null) + )); + s.put("ledColor", Settings.versions( + new V(1, new ColorSetting(0xFF0000FF)), + new V(80, null) + )); + s.put("localStorageProvider", Settings.versions( + new V(1, new StorageProviderSetting()) + )); + s.put("markMessageAsReadOnView", Settings.versions( + new V(7, new BooleanSetting(true)) + )); + s.put("markMessageAsReadOnDelete", Settings.versions( + new V(63, new BooleanSetting(true)) + )); + s.put("maxPushFolders", Settings.versions( + new V(1, new IntegerRangeSetting(0, 100, 10)) + )); + s.put("maximumAutoDownloadMessageSize", Settings.versions( + new V(1, new IntegerResourceSetting(32768, R.array.autodownload_message_size_values)) + )); + s.put("maximumPolledMessageAge", Settings.versions( + new V(1, new IntegerResourceSetting(-1, R.array.message_age_values)) + )); + s.put("messageFormat", Settings.versions( + new V(1, new EnumSetting<>(MessageFormat.class, AccountPreferenceSerializer.DEFAULT_MESSAGE_FORMAT)) + )); + s.put("messageFormatAuto", Settings.versions( + new V(2, new BooleanSetting(AccountPreferenceSerializer.DEFAULT_MESSAGE_FORMAT_AUTO)) + )); + s.put("messageReadReceipt", Settings.versions( + new V(1, new BooleanSetting(AccountPreferenceSerializer.DEFAULT_MESSAGE_READ_RECEIPT)) + )); + s.put("notifyMailCheck", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("notifyNewMail", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("folderNotifyNewMailMode", Settings.versions( + new V(34, new EnumSetting<>(FolderMode.class, FolderMode.ALL)) + )); + s.put("notifySelfNewMail", Settings.versions( + new V(1, new BooleanSetting(true)) + )); + s.put("quotePrefix", Settings.versions( + new V(1, new StringSetting(AccountPreferenceSerializer.DEFAULT_QUOTE_PREFIX)) + )); + s.put("quoteStyle", Settings.versions( + new V(1, new EnumSetting<>(QuoteStyle.class, AccountPreferenceSerializer.DEFAULT_QUOTE_STYLE)) + )); + s.put("replyAfterQuote", Settings.versions( + new V(1, new BooleanSetting(AccountPreferenceSerializer.DEFAULT_REPLY_AFTER_QUOTE)) + )); + s.put("ring", Settings.versions( + new V(1, new BooleanSetting(true)) + )); + s.put("ringtone", Settings.versions( + new V(1, new RingtoneSetting("content://settings/system/notification_sound")) + )); + s.put("searchableFolders", Settings.versions( + new V(1, new EnumSetting<>(Searchable.class, Searchable.ALL)) + )); + s.put("sentFolderName", Settings.versions( + new V(1, new StringSetting(SettingsUpgraderV53.FOLDER_NONE)), + new V(53, new StringSetting(null)) + )); + s.put("sortTypeEnum", Settings.versions( + new V(9, new EnumSetting<>(SortType.class, Account.DEFAULT_SORT_TYPE)) + )); + s.put("sortAscending", Settings.versions( + new V(9, new BooleanSetting(Account.DEFAULT_SORT_ASCENDING)) + )); + s.put("showPicturesEnum", Settings.versions( + new V(1, new EnumSetting<>(ShowPictures.class, ShowPictures.NEVER)) + )); + s.put("signatureBeforeQuotedText", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("spamFolderName", Settings.versions( + new V(1, new StringSetting(SettingsUpgraderV53.FOLDER_NONE)), + new V(53, new StringSetting(null)) + )); + s.put("stripSignature", Settings.versions( + new V(2, new BooleanSetting(AccountPreferenceSerializer.DEFAULT_STRIP_SIGNATURE)) + )); + s.put("subscribedFoldersOnly", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("syncRemoteDeletions", Settings.versions( + new V(1, new BooleanSetting(true)) + )); + s.put("trashFolderName", Settings.versions( + new V(1, new StringSetting(SettingsUpgraderV53.FOLDER_NONE)), + new V(53, new StringSetting(null)) + )); + s.put("useCompression.MOBILE", Settings.versions( + new V(1, new BooleanSetting(true)), + new V(81, null) + )); + s.put("useCompression.OTHER", Settings.versions( + new V(1, new BooleanSetting(true)), + new V(81, null) + )); + s.put("useCompression.WIFI", Settings.versions( + new V(1, new BooleanSetting(true)), + new V(81, null) + )); + s.put("vibrate", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("vibratePattern", Settings.versions( + new V(1, new IntegerResourceSetting(0, R.array.vibrate_pattern_values)) + )); + s.put("vibrateTimes", Settings.versions( + new V(1, new IntegerRangeSetting(1, 10, 5)) + )); + s.put("remoteSearchNumResults", Settings.versions( + new V(18, new IntegerResourceSetting(AccountPreferenceSerializer.DEFAULT_REMOTE_SEARCH_NUM_RESULTS, + R.array.remote_search_num_results_values)) + )); + s.put("remoteSearchFullText", Settings.versions( + new V(18, new BooleanSetting(false)) + )); + s.put("notifyContactsMailOnly", Settings.versions( + new V(42, new BooleanSetting(false)) + )); + s.put("openPgpHideSignOnly", Settings.versions( + new V(50, new BooleanSetting(true)) + )); + s.put("openPgpEncryptSubject", Settings.versions( + new V(51, new BooleanSetting(true)) + )); + s.put("openPgpEncryptAllDrafts", Settings.versions( + new V(55, new BooleanSetting(true)) + )); + s.put("autocryptMutualMode", Settings.versions( + new V(50, new BooleanSetting(false)) + )); + s.put("uploadSentMessages", Settings.versions( + new V(52, new BooleanSetting(true)) + )); + s.put("archiveFolderSelection", Settings.versions( + new V(54, new EnumSetting<>(SpecialFolderSelection.class, SpecialFolderSelection.AUTOMATIC)) + )); + s.put("draftsFolderSelection", Settings.versions( + new V(54, new EnumSetting<>(SpecialFolderSelection.class, SpecialFolderSelection.AUTOMATIC)) + )); + s.put("sentFolderSelection", Settings.versions( + new V(54, new EnumSetting<>(SpecialFolderSelection.class, SpecialFolderSelection.AUTOMATIC)) + )); + s.put("spamFolderSelection", Settings.versions( + new V(54, new EnumSetting<>(SpecialFolderSelection.class, SpecialFolderSelection.AUTOMATIC)) + )); + s.put("trashFolderSelection", Settings.versions( + new V(54, new EnumSetting<>(SpecialFolderSelection.class, SpecialFolderSelection.AUTOMATIC)) + )); + s.put("ignoreChatMessages", Settings.versions( + new V(76, new BooleanSetting(false)) + )); + s.put("notificationLight", Settings.versions( + new V(80, new EnumSetting<>(NotificationLight.class, NotificationLight.Disabled)) + )); + s.put("useCompression", Settings.versions( + new V(81, new BooleanSetting(true)) + )); + // note that there is no setting for openPgpProvider, because this will have to be set up together + // with the actual provider after import anyways. + + SETTINGS = Collections.unmodifiableMap(s); + + Map u = new HashMap<>(); + u.put(53, new SettingsUpgraderV53()); + u.put(54, new SettingsUpgraderV54()); + u.put(74, new SettingsUpgraderV74()); + u.put(80, new SettingsUpgraderV80()); + u.put(81, new SettingsUpgraderV81()); + + UPGRADERS = Collections.unmodifiableMap(u); + } + + static Map validate(int version, Map importedSettings, boolean useDefaultValues) { + return Settings.validate(version, SETTINGS, importedSettings, useDefaultValues); + } + + public static Set upgrade(int version, Map validatedSettings) { + return Settings.upgrade(version, UPGRADERS, SETTINGS, validatedSettings); + } + + public static Map convert(Map settings) { + return Settings.convert(settings, SETTINGS); + } + + static Map getAccountSettings(Storage storage, String uuid) { + Map result = new HashMap<>(); + String prefix = uuid + "."; + for (String key : SETTINGS.keySet()) { + String value = storage.getString(prefix + key, null); + if (value != null) { + result.put(key, value); + } + } + return result; + } + + private static class IntegerResourceSetting extends PseudoEnumSetting { + private final Context context = DI.get(Context.class); + private final Map mapping; + + IntegerResourceSetting(int defaultValue, int resId) { + super(defaultValue); + + Map mapping = new HashMap<>(); + String[] values = context.getResources().getStringArray(resId); + for (String value : values) { + int intValue = Integer.parseInt(value); + mapping.put(intValue, value); + } + this.mapping = Collections.unmodifiableMap(mapping); + } + + @Override + protected Map getMapping() { + return mapping; + } + + @Override + public Integer fromString(String value) throws InvalidSettingValueException { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new InvalidSettingValueException(); + } + } + } + + private static class IntegerArraySetting extends SettingsDescription { + private final int[] values; + + IntegerArraySetting(int defaultValue, int[] values) { + super(defaultValue); + this.values = values; + } + + @Override + public Integer fromString(String value) throws InvalidSettingValueException { + try { + int number = Integer.parseInt(value); + for (int validValue : values) { + if (number == validValue) { + return number; + } + } + + throw new InvalidSettingValueException(); + } catch (NumberFormatException e) { + throw new InvalidSettingValueException(); + } + } + } + + private static class StringResourceSetting extends PseudoEnumSetting { + private final Context context = DI.get(Context.class); + private final Map mapping; + + StringResourceSetting(String defaultValue, int resId) { + super(defaultValue); + + Map mapping = new HashMap<>(); + String[] values = context.getResources().getStringArray(resId); + for (String value : values) { + mapping.put(value, value); + } + this.mapping = Collections.unmodifiableMap(mapping); + } + + @Override + protected Map getMapping() { + return mapping; + } + + @Override + public String fromString(String value) throws InvalidSettingValueException { + if (!mapping.containsKey(value)) { + throw new InvalidSettingValueException(); + } + return value; + } + } + + private static class RingtoneSetting extends SettingsDescription { + RingtoneSetting(String defaultValue) { + super(defaultValue); + } + + @Override + public String fromString(String value) { + //TODO: add validation + return value; + } + } + + private static class StorageProviderSetting extends SettingsDescription { + private final Context context = DI.get(Context.class); + + StorageProviderSetting() { + super(null); + } + + @Override + public String getDefaultValue() { + return StorageManager.getInstance(context).getDefaultProviderId(); + } + + @Override + public String fromString(String value) { + StorageManager storageManager = StorageManager.getInstance(context); + Set providers = storageManager.getAvailableProviders(); + if (providers.contains(value)) { + return value; + } + throw new RuntimeException("Validation failed"); + } + } + + private static class DeletePolicySetting extends PseudoEnumSetting { + private Map mapping; + + DeletePolicySetting(DeletePolicy defaultValue) { + super(defaultValue.setting); + Map mapping = new HashMap<>(); + mapping.put(DeletePolicy.NEVER.setting, "NEVER"); + mapping.put(DeletePolicy.ON_DELETE.setting, "DELETE"); + mapping.put(DeletePolicy.MARK_AS_READ.setting, "MARK_AS_READ"); + this.mapping = Collections.unmodifiableMap(mapping); + } + + @Override + protected Map getMapping() { + return mapping; + } + + @Override + public Integer fromString(String value) throws InvalidSettingValueException { + try { + Integer deletePolicy = Integer.parseInt(value); + if (mapping.containsKey(deletePolicy)) { + return deletePolicy; + } + } catch (NumberFormatException e) { /* do nothing */ } + + throw new InvalidSettingValueException(); + } + } + + /** + * Upgrades settings from version 52 to 53 + * + * Replace folder entries of "-NONE-" with {@code null}. + */ + private static class SettingsUpgraderV53 implements SettingsUpgrader { + private static final String FOLDER_NONE = "-NONE-"; + + @Override + public Set upgrade(Map settings) { + upgradeFolderEntry(settings, "archiveFolderName"); + upgradeFolderEntry(settings, "autoExpandFolderName"); + upgradeFolderEntry(settings, "draftsFolderName"); + upgradeFolderEntry(settings, "sentFolderName"); + upgradeFolderEntry(settings, "spamFolderName"); + upgradeFolderEntry(settings, "trashFolderName"); + + return null; + } + + private void upgradeFolderEntry(Map settings, String key) { + String archiveFolderName = (String) settings.get(key); + if (FOLDER_NONE.equals(archiveFolderName)) { + settings.put(key, null); + } + } + } + + /** + * Upgrades settings from version 53 to 54 + * + * Inserts folder selection entries with a value of "MANUAL" + */ + private static class SettingsUpgraderV54 implements SettingsUpgrader { + private static final String FOLDER_SELECTION_MANUAL = "MANUAL"; + + @Override + public Set upgrade(Map settings) { + settings.put("archiveFolderSelection", FOLDER_SELECTION_MANUAL); + settings.put("draftsFolderSelection", FOLDER_SELECTION_MANUAL); + settings.put("sentFolderSelection", FOLDER_SELECTION_MANUAL); + settings.put("spamFolderSelection", FOLDER_SELECTION_MANUAL); + settings.put("trashFolderSelection", FOLDER_SELECTION_MANUAL); + + return null; + } + } + + /** + * Upgrades settings from version 73 to 74 + * + * Rewrites 'idleRefreshMinutes' from '1' to '2' if necessary + */ + private static class SettingsUpgraderV74 implements SettingsUpgrader { + @Override + public Set upgrade(Map settings) { + Integer idleRefreshMinutes = (Integer) settings.get("idleRefreshMinutes"); + if (idleRefreshMinutes == 1) { + settings.put("idleRefreshMinutes", 2); + } + + return null; + } + } + + /** + * Upgrades settings from version 79 to 80 + * + * Rewrites 'led' and 'lecColor' to 'notificationLight'. + */ + private static class SettingsUpgraderV80 implements SettingsUpgrader { + private final NotificationLightDecoder notificationLightDecoder = DI.get(NotificationLightDecoder.class); + + @Override + public Set upgrade(Map settings) { + Boolean isLedEnabled = (Boolean) settings.get("led"); + Integer ledColor = (Integer) settings.get("ledColor"); + Integer chipColor = (Integer) settings.get("chipColor"); + + if (isLedEnabled != null && ledColor != null) { + int accountColor = chipColor != null ? chipColor : 0; + NotificationLight light = notificationLightDecoder.decode(isLedEnabled, ledColor, accountColor); + settings.put("notificationLight", light.name()); + } + + return SetsKt.setOf("led", "ledColor"); + } + } + + /** + * Rewrite the per-network type IMAP compression settings to a single setting. + */ + private static class SettingsUpgraderV81 implements SettingsUpgrader { + @Override + public Set upgrade(Map settings) { + Boolean useCompressionWifi = (Boolean) settings.get("useCompression.WIFI"); + Boolean useCompressionMobile = (Boolean) settings.get("useCompression.MOBILE"); + Boolean useCompressionOther = (Boolean) settings.get("useCompression.OTHER"); + + boolean useCompression = useCompressionWifi != null && useCompressionMobile != null && + useCompressionOther != null && useCompressionWifi && useCompressionMobile && useCompressionOther; + settings.put("useCompression", useCompression); + + return SetsKt.setOf("useCompression.WIFI", "useCompression.MOBILE", "useCompression.OTHER"); + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsDescriptions.java b/app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsDescriptions.java new file mode 100644 index 0000000..4a52056 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsDescriptions.java @@ -0,0 +1,81 @@ +package com.fsck.k9.preferences; + + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import com.fsck.k9.mail.FolderClass; +import com.fsck.k9.preferences.Settings.BooleanSetting; +import com.fsck.k9.preferences.Settings.EnumSetting; +import com.fsck.k9.preferences.Settings.SettingsDescription; +import com.fsck.k9.preferences.Settings.SettingsUpgrader; +import com.fsck.k9.preferences.Settings.V; + + +class FolderSettingsDescriptions { + static final Map> SETTINGS; + private static final Map UPGRADERS; + + static { + Map> s = new LinkedHashMap<>(); + + /* + * When adding new settings here, be sure to increment {@link Settings.VERSION} + * and use that for whatever you add here. + */ + + s.put("displayMode", Settings.versions( + new V(1, new EnumSetting<>(FolderClass.class, FolderClass.NO_CLASS)) + )); + s.put("notifyMode", Settings.versions( + new V(34, new EnumSetting<>(FolderClass.class, FolderClass.INHERITED)) + )); + s.put("syncMode", Settings.versions( + new V(1, new EnumSetting<>(FolderClass.class, FolderClass.INHERITED)) + )); + s.put("pushMode", Settings.versions( + new V(1, new EnumSetting<>(FolderClass.class, FolderClass.INHERITED)), + new V(66, new EnumSetting<>(FolderClass.class, FolderClass.SECOND_CLASS)) + )); + s.put("inTopGroup", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("integrate", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + + SETTINGS = Collections.unmodifiableMap(s); + + // noinspection MismatchedQueryAndUpdateOfCollection, this map intentionally left blank + Map u = new HashMap<>(); + UPGRADERS = Collections.unmodifiableMap(u); + } + + static Map validate(int version, Map importedSettings, boolean useDefaultValues) { + return Settings.validate(version, SETTINGS, importedSettings, useDefaultValues); + } + + public static Set upgrade(int version, Map validatedSettings) { + return Settings.upgrade(version, UPGRADERS, SETTINGS, validatedSettings); + } + + public static Map convert(Map settings) { + return Settings.convert(settings, SETTINGS); + } + + static Map getFolderSettings(Storage storage, String uuid, String folderName) { + Map result = new HashMap<>(); + String prefix = uuid + "." + folderName + "."; + for (String key : SETTINGS.keySet()) { + String value = storage.getString(prefix + key, null); + if (value != null) { + result.put(key, value); + } + } + return result; + } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsProvider.kt b/app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsProvider.kt new file mode 100644 index 0000000..559a4ce --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsProvider.kt @@ -0,0 +1,52 @@ +package com.fsck.k9.preferences + +import com.fsck.k9.Account +import com.fsck.k9.mail.FolderClass +import com.fsck.k9.mailstore.FolderRepository +import com.fsck.k9.mailstore.RemoteFolderDetails + +class FolderSettingsProvider(private val folderRepository: FolderRepository) { + fun getFolderSettings(account: Account): List { + return folderRepository.getRemoteFolderDetails(account) + .filterNot { it.containsOnlyDefaultValues() } + .map { it.toFolderSettings() } + } + + private fun RemoteFolderDetails.containsOnlyDefaultValues(): Boolean { + return isInTopGroup == getDefaultValue("inTopGroup") && + isIntegrate == getDefaultValue("integrate") && + syncClass == getDefaultValue("syncMode") && + displayClass == getDefaultValue("displayMode") && + notifyClass == getDefaultValue("notifyMode") && + pushClass == getDefaultValue("pushMode") + } + + private fun getDefaultValue(key: String): Any? { + val versionedSetting = FolderSettingsDescriptions.SETTINGS[key] ?: error("Key not found: $key") + val highestVersion = versionedSetting.lastKey() + val setting = versionedSetting[highestVersion] ?: error("Setting description not found: $key") + return setting.defaultValue + } + + private fun RemoteFolderDetails.toFolderSettings(): FolderSettings { + return FolderSettings( + folder.serverId, + isInTopGroup, + isIntegrate, + syncClass, + displayClass, + notifyClass, + pushClass + ) + } +} + +data class FolderSettings( + val serverId: String, + val isInTopGroup: Boolean, + val isIntegrate: Boolean, + val syncClass: FolderClass, + val displayClass: FolderClass, + val notifyClass: FolderClass, + val pushClass: FolderClass +) diff --git a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettings.kt b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettings.kt new file mode 100644 index 0000000..927f04a --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettings.kt @@ -0,0 +1,38 @@ +package com.fsck.k9.preferences + +/** + * Stores a snapshot of the app's general settings. + * + * When adding a setting here, make sure to also add it in these places: + * - [GeneralSettingsManager] (write function) + * - [RealGeneralSettingsManager.loadGeneralSettings] + * - [RealGeneralSettingsManager.writeSettings] + * - [GeneralSettingsDescriptions] + */ +// TODO: Move over settings from K9 +data class GeneralSettings( + val backgroundSync: BackgroundSync, + val showRecentChanges: Boolean, + val appTheme: AppTheme, + val messageViewTheme: SubTheme, + val messageComposeTheme: SubTheme, + val fixedMessageViewTheme: Boolean +) + +enum class BackgroundSync { + ALWAYS, + NEVER, + FOLLOW_SYSTEM_AUTO_SYNC +} + +enum class AppTheme { + LIGHT, + DARK, + FOLLOW_SYSTEM +} + +enum class SubTheme { + LIGHT, + DARK, + USE_GLOBAL +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java new file mode 100644 index 0000000..14819d4 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java @@ -0,0 +1,635 @@ +package com.fsck.k9.preferences; + + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import android.content.Context; + +import com.fsck.k9.Account; +import com.fsck.k9.Account.SortType; +import com.fsck.k9.DI; +import com.fsck.k9.FontSizes; +import com.fsck.k9.K9; +import com.fsck.k9.K9.BACKGROUND_OPS; +import com.fsck.k9.K9.NotificationQuickDelete; +import com.fsck.k9.K9.SplitViewMode; +import com.fsck.k9.SwipeAction; +import com.fsck.k9.UiDensity; +import com.fsck.k9.core.R; +import com.fsck.k9.preferences.Settings.BooleanSetting; +import com.fsck.k9.preferences.Settings.ColorSetting; +import com.fsck.k9.preferences.Settings.EnumSetting; +import com.fsck.k9.preferences.Settings.FontSizeSetting; +import com.fsck.k9.preferences.Settings.IntegerRangeSetting; +import com.fsck.k9.preferences.Settings.InvalidSettingValueException; +import com.fsck.k9.preferences.Settings.PseudoEnumSetting; +import com.fsck.k9.preferences.Settings.SettingsDescription; +import com.fsck.k9.preferences.Settings.SettingsUpgrader; +import com.fsck.k9.preferences.Settings.V; +import com.fsck.k9.preferences.Settings.WebFontSizeSetting; + +import static com.fsck.k9.K9.LockScreenNotificationVisibility; + + +public class GeneralSettingsDescriptions { + static final Map> SETTINGS; + private static final Map UPGRADERS; + + static { + Map> s = new LinkedHashMap<>(); + + /* + * When adding new settings here, be sure to increment {@link Settings.VERSION} + * and use that for whatever you add here. + */ + + s.put("animations", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("backgroundOperations", Settings.versions( + new V(1, new EnumSetting<>(K9.BACKGROUND_OPS.class, K9.BACKGROUND_OPS.WHEN_CHECKED_AUTO_SYNC)), + new V(83, new EnumSetting<>(K9.BACKGROUND_OPS.class, BACKGROUND_OPS.ALWAYS)) + )); + s.put("changeRegisteredNameColor", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("confirmDelete", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("confirmDeleteStarred", Settings.versions( + new V(2, new BooleanSetting(false)) + )); + s.put("confirmSpam", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("confirmMarkAllRead", Settings.versions( + new V(44, new BooleanSetting(true)) + )); + s.put("enableDebugLogging", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("enableSensitiveLogging", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("fontSizeMessageComposeInput", Settings.versions( + new V(5, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageListDate", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageListPreview", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageListSender", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageListSubject", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageViewAdditionalHeaders", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageViewCC", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageViewContent", Settings.versions( + new V(1, new WebFontSizeSetting(3)), + new V(31, null) + )); + s.put("fontSizeMessageViewDate", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageViewSender", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageViewSubject", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageViewTime", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("fontSizeMessageViewTo", Settings.versions( + new V(1, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("hideSpecialAccounts", Settings.versions( + new V(1, new BooleanSetting(false)), + new V(69, null) + )); + s.put("language", Settings.versions( + new V(1, new LanguageSetting()) + )); + s.put("messageListPreviewLines", Settings.versions( + new V(1, new IntegerRangeSetting(1, 100, 2)) + )); + s.put("messageListStars", Settings.versions( + new V(1, new BooleanSetting(true)) + )); + s.put("messageViewFixedWidthFont", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("messageViewReturnToList", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("messageViewShowNext", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("quietTimeEnabled", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("quietTimeEnds", Settings.versions( + new V(1, new TimeSetting("7:00")) + )); + s.put("quietTimeStarts", Settings.versions( + new V(1, new TimeSetting("21:00")) + )); + s.put("registeredNameColor", Settings.versions( + new V(1, new ColorSetting(0xFF00008F)), + new V(79, new ColorSetting(0xFF1093F5)) + )); + s.put("showContactName", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("showCorrespondentNames", Settings.versions( + new V(1, new BooleanSetting(true)) + )); + s.put("showUnifiedInbox", Settings.versions( + new V(69, new BooleanSetting(true)) + )); + s.put("sortTypeEnum", Settings.versions( + new V(10, new EnumSetting<>(SortType.class, Account.DEFAULT_SORT_TYPE)) + )); + s.put("sortAscending", Settings.versions( + new V(10, new BooleanSetting(Account.DEFAULT_SORT_ASCENDING)) + )); + s.put("theme", Settings.versions( + new V(1, new LegacyThemeSetting(AppTheme.LIGHT)), + new V(58, new ThemeSetting(AppTheme.FOLLOW_SYSTEM)) + )); + s.put("messageViewTheme", Settings.versions( + new V(16, new LegacyThemeSetting(AppTheme.LIGHT)), + new V(24, new SubThemeSetting(SubTheme.USE_GLOBAL)) + )); + s.put("useVolumeKeysForNavigation", Settings.versions( + new V(1, new BooleanSetting(false)) + )); + s.put("useBackgroundAsUnreadIndicator", Settings.versions( + new V(19, new BooleanSetting(true)), + new V(59, new BooleanSetting(false)) + )); + s.put("threadedView", Settings.versions( + new V(20, new BooleanSetting(true)) + )); + s.put("splitViewMode", Settings.versions( + new V(23, new EnumSetting<>(SplitViewMode.class, SplitViewMode.NEVER)) + )); + s.put("messageComposeTheme", Settings.versions( + new V(24, new SubThemeSetting(SubTheme.USE_GLOBAL)) + )); + s.put("fixedMessageViewTheme", Settings.versions( + new V(24, new BooleanSetting(true)) + )); + s.put("showContactPicture", Settings.versions( + new V(25, new BooleanSetting(true)) + )); + s.put("autofitWidth", Settings.versions( + new V(28, new BooleanSetting(true)) + )); + s.put("colorizeMissingContactPictures", Settings.versions( + new V(29, new BooleanSetting(true)) + )); + s.put("messageViewDeleteActionVisible", Settings.versions( + new V(30, new BooleanSetting(true)) + )); + s.put("messageViewArchiveActionVisible", Settings.versions( + new V(30, new BooleanSetting(false)) + )); + s.put("messageViewMoveActionVisible", Settings.versions( + new V(30, new BooleanSetting(false)) + )); + s.put("messageViewCopyActionVisible", Settings.versions( + new V(30, new BooleanSetting(false)) + )); + s.put("messageViewSpamActionVisible", Settings.versions( + new V(30, new BooleanSetting(false)) + )); + s.put("fontSizeMessageViewContentPercent", Settings.versions( + new V(31, new IntegerRangeSetting(40, 250, 100)) + )); + s.put("hideUserAgent", Settings.versions( + new V(32, new BooleanSetting(false)) + )); + s.put("hideTimeZone", Settings.versions( + new V(32, new BooleanSetting(false)) + )); + s.put("lockScreenNotificationVisibility", Settings.versions( + new V(37, new EnumSetting<>(LockScreenNotificationVisibility.class, + LockScreenNotificationVisibility.MESSAGE_COUNT)) + )); + s.put("confirmDeleteFromNotification", Settings.versions( + new V(38, new BooleanSetting(true)) + )); + s.put("messageListSenderAboveSubject", Settings.versions( + new V(38, new BooleanSetting(false)) + )); + s.put("notificationQuickDelete", Settings.versions( + new V(38, new EnumSetting<>(NotificationQuickDelete.class, NotificationQuickDelete.NEVER)), + new V(67, new EnumSetting<>(NotificationQuickDelete.class, NotificationQuickDelete.ALWAYS)) + )); + s.put("notificationDuringQuietTimeEnabled", Settings.versions( + new V(39, new BooleanSetting(true)) + )); + s.put("confirmDiscardMessage", Settings.versions( + new V(40, new BooleanSetting(true)) + )); + s.put("pgpInlineDialogCounter", Settings.versions( + new V(43, new IntegerRangeSetting(0, Integer.MAX_VALUE, 0)) + )); + s.put("pgpSignOnlyDialogCounter", Settings.versions( + new V(45, new IntegerRangeSetting(0, Integer.MAX_VALUE, 0)) + )); + s.put("fontSizeMessageViewBCC", Settings.versions( + new V(48, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + s.put("hideHostnameWhenConnecting", Settings.versions( + new V(49, new BooleanSetting(false)), + new V(56, null) + )); + s.put("showRecentChanges", Settings.versions( + new V(73, new BooleanSetting(true)) + )); + s.put("showStarredCount", Settings.versions( + new V(75, new BooleanSetting(false)) + )); + s.put("swipeRightAction", Settings.versions( + new V(83, new EnumSetting<>(SwipeAction.class, SwipeAction.ToggleSelection)) + )); + s.put("swipeLeftAction", Settings.versions( + new V(83, new EnumSetting<>(SwipeAction.class, SwipeAction.ToggleRead)) + )); + s.put("showComposeButtonOnMessageList", Settings.versions( + new V(85, new BooleanSetting(true)) + )); + s.put("messageListDensity", Settings.versions( + new V(86, new EnumSetting(UiDensity.class, UiDensity.Default)) + )); + s.put("fontSizeMessageViewAccountName", Settings.versions( + new V(87, new FontSizeSetting(FontSizes.FONT_DEFAULT)) + )); + + SETTINGS = Collections.unmodifiableMap(s); + + Map u = new HashMap<>(); + u.put(24, new SettingsUpgraderV24()); + u.put(31, new SettingsUpgraderV31()); + u.put(58, new SettingsUpgraderV58()); + u.put(69, new SettingsUpgraderV69()); + u.put(79, new SettingsUpgraderV79()); + + UPGRADERS = Collections.unmodifiableMap(u); + } + + static Map validate(int version, Map importedSettings) { + return Settings.validate(version, SETTINGS, importedSettings, false); + } + + public static Set upgrade(int version, Map validatedSettings) { + return Settings.upgrade(version, UPGRADERS, SETTINGS, validatedSettings); + } + + public static Map convert(Map settings) { + return Settings.convert(settings, SETTINGS); + } + + static Map getGlobalSettings(Storage storage) { + Map result = new HashMap<>(); + for (String key : SETTINGS.keySet()) { + String value = storage.getString(key, null); + if (value != null) { + result.put(key, value); + } + } + return result; + } + + /** + * Upgrades the settings from version 23 to 24. + * + *

    + * Set messageViewTheme to {@link SubTheme#USE_GLOBAL} if messageViewTheme has + * the same value as theme. + *

    + */ + private static class SettingsUpgraderV24 implements SettingsUpgrader { + + @Override + public Set upgrade(Map settings) { + SubTheme messageViewTheme = (SubTheme) settings.get("messageViewTheme"); + AppTheme theme = (AppTheme) settings.get("theme"); + if ((theme == AppTheme.LIGHT && messageViewTheme == SubTheme.LIGHT) || + (theme == AppTheme.DARK && messageViewTheme == SubTheme.DARK)) { + settings.put("messageViewTheme", SubTheme.USE_GLOBAL); + } + + return null; + } + } + + /** + * Upgrades the settings from version 30 to 31. + * + *

    + * Convert value from fontSizeMessageViewContent to + * fontSizeMessageViewContentPercent. + *

    + */ + public static class SettingsUpgraderV31 implements SettingsUpgrader { + + @Override + public Set upgrade(Map settings) { + int oldSize = (Integer) settings.get("fontSizeMessageViewContent"); + + int newSize = convertFromOldSize(oldSize); + + settings.put("fontSizeMessageViewContentPercent", newSize); + + return new HashSet<>(Collections.singletonList("fontSizeMessageViewContent")); + } + + public static int convertFromOldSize(int oldSize) { + switch (oldSize) { + case 1: { + return 40; + } + case 2: { + return 75; + } + case 4: { + return 175; + } + case 5: { + return 250; + } + case 3: + default: { + return 100; + } + } + } + } + + /** + * Upgrades the settings from version 57 to 58. + * + *

    + * Set theme to {@link AppTheme#FOLLOW_SYSTEM} if theme has the value {@link AppTheme#LIGHT}. + *

    + */ + private static class SettingsUpgraderV58 implements SettingsUpgrader { + + @Override + public Set upgrade(Map settings) { + AppTheme theme = (AppTheme) settings.get("theme"); + if (theme == AppTheme.LIGHT) { + settings.put("theme", AppTheme.FOLLOW_SYSTEM); + } + + return null; + } + } + + /** + * Upgrades the settings from version 68 to 69. + * + *

    + * Renames {@code hideSpecialAccounts} to {@code showUnifiedInbox}. + *

    + */ + private static class SettingsUpgraderV69 implements SettingsUpgrader { + + @Override + public Set upgrade(Map settings) { + Boolean hideSpecialAccounts = (Boolean) settings.get("hideSpecialAccounts"); + boolean showUnifiedInbox = hideSpecialAccounts == null || !hideSpecialAccounts; + settings.put("showUnifiedInbox", showUnifiedInbox); + + return new HashSet<>(Collections.singleton("hideSpecialAccounts")); + } + } + + /** + * Upgrades the settings from version 78 to 79. + * + *

    + * Change default value of {@code registeredNameColor} to have enough contrast in both the light and dark theme. + *

    + */ + private static class SettingsUpgraderV79 implements SettingsUpgrader { + + @Override + public Set upgrade(Map settings) { + final Integer registeredNameColorValue = (Integer) settings.get("registeredNameColor"); + + if (registeredNameColorValue != null && registeredNameColorValue == 0xFF00008F) { + settings.put("registeredNameColor", 0xFF1093F5); + } + + return null; + } + } + + private static class LanguageSetting extends PseudoEnumSetting { + private final Context context = DI.get(Context.class); + private final Map mapping; + + LanguageSetting() { + super(""); + + Map mapping = new HashMap<>(); + String[] values = context.getResources().getStringArray(R.array.language_values); + for (String value : values) { + if (value.length() == 0) { + mapping.put("", "default"); + } else { + mapping.put(value, value); + } + } + this.mapping = Collections.unmodifiableMap(mapping); + } + + @Override + protected Map getMapping() { + return mapping; + } + + @Override + public String fromString(String value) throws InvalidSettingValueException { + if (mapping.containsKey(value)) { + return value; + } + + throw new InvalidSettingValueException(); + } + } + + static class LegacyThemeSetting extends SettingsDescription { + private static final String THEME_LIGHT = "light"; + private static final String THEME_DARK = "dark"; + + LegacyThemeSetting(AppTheme defaultValue) { + super(defaultValue); + } + + @Override + public AppTheme fromString(String value) throws InvalidSettingValueException { + try { + return AppTheme.valueOf(value); + } catch (IllegalArgumentException e) { + throw new InvalidSettingValueException(); + } + } + + @Override + public AppTheme fromPrettyString(String value) throws InvalidSettingValueException { + if (THEME_LIGHT.equals(value)) { + return AppTheme.LIGHT; + } else if (THEME_DARK.equals(value)) { + return AppTheme.DARK; + } + + throw new InvalidSettingValueException(); + } + + @Override + public String toPrettyString(AppTheme value) { + switch (value) { + case LIGHT: return THEME_LIGHT; + case DARK: return THEME_DARK; + } + + throw new AssertionError("Unexpected case: " + value); + } + + @Override + public String toString(AppTheme value) { + return value.name(); + } + } + + private static class ThemeSetting extends SettingsDescription { + private static final String THEME_LIGHT = "light"; + private static final String THEME_DARK = "dark"; + private static final String THEME_FOLLOW_SYSTEM = "follow_system"; + + ThemeSetting(AppTheme defaultValue) { + super(defaultValue); + } + + @Override + public AppTheme fromString(String value) throws InvalidSettingValueException { + try { + return AppTheme.valueOf(value); + } catch (IllegalArgumentException e) { + throw new InvalidSettingValueException(); + } + } + + @Override + public AppTheme fromPrettyString(String value) throws InvalidSettingValueException { + if (THEME_LIGHT.equals(value)) { + return AppTheme.LIGHT; + } else if (THEME_DARK.equals(value)) { + return AppTheme.DARK; + } else if (THEME_FOLLOW_SYSTEM.equals(value)) { + return AppTheme.FOLLOW_SYSTEM; + } + + throw new InvalidSettingValueException(); + } + + @Override + public String toPrettyString(AppTheme value) { + switch (value) { + case LIGHT: return THEME_LIGHT; + case DARK: return THEME_DARK; + case FOLLOW_SYSTEM: return THEME_FOLLOW_SYSTEM; + } + + throw new AssertionError("Unexpected case: " + value); + } + + @Override + public String toString(AppTheme value) { + return value.name(); + } + } + + private static class SubThemeSetting extends SettingsDescription { + private static final String THEME_LIGHT = "light"; + private static final String THEME_DARK = "dark"; + private static final String THEME_USE_GLOBAL = "use_global"; + + SubThemeSetting(SubTheme defaultValue) { + super(defaultValue); + } + + @Override + public SubTheme fromString(String value) throws InvalidSettingValueException { + try { + return SubTheme.valueOf(value); + } catch (IllegalArgumentException e) { + throw new InvalidSettingValueException(); + } + } + + @Override + public SubTheme fromPrettyString(String value) throws InvalidSettingValueException { + if (THEME_LIGHT.equals(value)) { + return SubTheme.LIGHT; + } else if (THEME_DARK.equals(value)) { + return SubTheme.DARK; + } else if (THEME_USE_GLOBAL.equals(value)) { + return SubTheme.USE_GLOBAL; + } + + throw new InvalidSettingValueException(); + } + + @Override + public String toPrettyString(SubTheme value) { + switch (value) { + case LIGHT: return THEME_LIGHT; + case DARK: return THEME_DARK; + case USE_GLOBAL: return THEME_USE_GLOBAL; + } + + throw new AssertionError("Unexpected case: " + value); + } + + @Override + public String toString(SubTheme value) { + return value.name(); + } + } + + private static class TimeSetting extends SettingsDescription { + private static final String VALIDATION_EXPRESSION = "[0-2]*[0-9]:[0-5]*[0-9]"; + + TimeSetting(String defaultValue) { + super(defaultValue); + } + + @Override + public String fromString(String value) throws InvalidSettingValueException { + if (!value.matches(VALIDATION_EXPRESSION)) { + throw new InvalidSettingValueException(); + } + return value; + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsManager.kt b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsManager.kt new file mode 100644 index 0000000..2c9c4e2 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsManager.kt @@ -0,0 +1,19 @@ +package com.fsck.k9.preferences + +import kotlinx.coroutines.flow.Flow + +/** + * Retrieve and modify general settings. + * + * TODO: Add more settings as needed. + */ +interface GeneralSettingsManager { + fun getSettings(): GeneralSettings + fun getSettingsFlow(): Flow + + fun setShowRecentChanges(showRecentChanges: Boolean) + fun setAppTheme(appTheme: AppTheme) + fun setMessageViewTheme(subTheme: SubTheme) + fun setMessageComposeTheme(subTheme: SubTheme) + fun setFixedMessageViewTheme(fixedMessageViewTheme: Boolean) +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/IdentitySettingsDescriptions.java b/app/core/src/main/java/com/fsck/k9/preferences/IdentitySettingsDescriptions.java new file mode 100644 index 0000000..79182f2 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/IdentitySettingsDescriptions.java @@ -0,0 +1,130 @@ +package com.fsck.k9.preferences; + + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import com.fsck.k9.CoreResourceProvider; +import com.fsck.k9.DI; +import com.fsck.k9.EmailAddressValidator; +import com.fsck.k9.preferences.Settings.BooleanSetting; +import com.fsck.k9.preferences.Settings.InvalidSettingValueException; +import com.fsck.k9.preferences.Settings.SettingsDescription; +import com.fsck.k9.preferences.Settings.SettingsUpgrader; +import com.fsck.k9.preferences.Settings.V; + + +class IdentitySettingsDescriptions { + static final Map> SETTINGS; + private static final Map UPGRADERS; + + static { + Map> s = new LinkedHashMap<>(); + + /* + * When adding new settings here, be sure to increment {@link Settings.VERSION} + * and use that for whatever you add here. + */ + + s.put("signature", Settings.versions( + new V(1, new SignatureSetting()) + )); + s.put("signatureUse", Settings.versions( + new V(1, new BooleanSetting(true)), + new V(68, new BooleanSetting(false)) + )); + s.put("replyTo", Settings.versions( + new V(1, new OptionalEmailAddressSetting()) + )); + + SETTINGS = Collections.unmodifiableMap(s); + + // noinspection MismatchedQueryAndUpdateOfCollection, this map intentionally left blank + Map u = new HashMap<>(); + UPGRADERS = Collections.unmodifiableMap(u); + } + + static Map validate(int version, Map importedSettings, boolean useDefaultValues) { + return Settings.validate(version, SETTINGS, importedSettings, useDefaultValues); + } + + public static Set upgrade(int version, Map validatedSettings) { + return Settings.upgrade(version, UPGRADERS, SETTINGS, validatedSettings); + } + + public static Map convert(Map settings) { + return Settings.convert(settings, SETTINGS); + } + + static Map getIdentitySettings(Storage storage, String uuid, int identityIndex) { + Map result = new HashMap<>(); + String prefix = uuid + "."; + String suffix = "." + Integer.toString(identityIndex); + for (String key : SETTINGS.keySet()) { + String value = storage.getString(prefix + key + suffix, null); + if (value != null) { + result.put(key, value); + } + } + return result; + } + + + static boolean isEmailAddressValid(String email) { + return new EmailAddressValidator().isValidAddressOnly(email); + } + + private static class SignatureSetting extends SettingsDescription { + private final CoreResourceProvider resourceProvider = DI.get(CoreResourceProvider.class); + + SignatureSetting() { + super(null); + } + + @Override + public String getDefaultValue() { + return resourceProvider.defaultSignature(); + } + + @Override + public String fromString(String value) throws InvalidSettingValueException { + return value; + } + } + + private static class OptionalEmailAddressSetting extends SettingsDescription { + private EmailAddressValidator validator; + + OptionalEmailAddressSetting() { + super(null); + validator = new EmailAddressValidator(); + } + + @Override + public String fromString(String value) throws InvalidSettingValueException { + if (value != null && !validator.isValidAddressOnly(value)) { + throw new InvalidSettingValueException(); + } + return value; + } + + @Override + public String toString(String value) { + return value; + } + + @Override + public String toPrettyString(String value) { + return (value == null) ? "" : value; + } + + @Override + public String fromPrettyString(String value) throws InvalidSettingValueException { + return "".equals(value) ? null : fromString(value); + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt new file mode 100644 index 0000000..0d92a2a --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt @@ -0,0 +1,26 @@ +package com.fsck.k9.preferences + +import com.fsck.k9.Preferences +import org.koin.core.qualifier.named +import org.koin.dsl.bind +import org.koin.dsl.module + +val preferencesModule = module { + factory { + SettingsExporter( + contentResolver = get(), + preferences = get(), + folderSettingsProvider = get(), + folderRepository = get(), + notificationSettingsUpdater = get() + ) + } + factory { FolderSettingsProvider(folderRepository = get()) } + factory { get() } + single { + RealGeneralSettingsManager( + preferences = get(), + coroutineScope = get(named("AppCoroutineScope")) + ) + } bind GeneralSettingsManager::class +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/Protocols.kt b/app/core/src/main/java/com/fsck/k9/preferences/Protocols.kt new file mode 100644 index 0000000..ae7d1a1 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/Protocols.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.preferences + +object Protocols { + const val IMAP = "imap" + const val POP3 = "pop3" + const val WEBDAV = "webdav" + const val SMTP = "smtp" +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt b/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt new file mode 100644 index 0000000..adb2c53 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt @@ -0,0 +1,176 @@ +@file:Suppress("DEPRECATION") + +package com.fsck.k9.preferences + +import com.fsck.k9.K9 +import com.fsck.k9.Preferences +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Retrieve and modify general settings. + * + * Currently general settings are split between [K9] and [GeneralSettings]. The goal is to move everything over to + * [GeneralSettings] and get rid of [K9]. + * + * The [GeneralSettings] instance managed by this class is updated with state from [K9] when [K9.saveSettingsAsync] is + * called. + */ +internal class RealGeneralSettingsManager( + private val preferences: Preferences, + private val coroutineScope: CoroutineScope, + private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO +) : GeneralSettingsManager { + private val settingsFlow = MutableSharedFlow(replay = 1) + private var generalSettings: GeneralSettings? = null + + @Deprecated("This only exists for collaboration with the K9 class") + val storage: Storage + get() = preferences.storage + + @Synchronized + override fun getSettings(): GeneralSettings { + return generalSettings ?: loadGeneralSettings().also { generalSettings = it } + } + + override fun getSettingsFlow(): Flow { + // Make sure to load settings now if they haven't been loaded already. This will also update settingsFlow. + getSettings() + + return settingsFlow.distinctUntilChanged() + } + + @Synchronized + fun loadSettings() { + K9.loadPrefs(preferences.storage) + generalSettings = loadGeneralSettings() + } + + private fun updateSettingsFlow(settings: GeneralSettings) { + coroutineScope.launch { + settingsFlow.emit(settings) + } + } + + @Deprecated("This only exists for collaboration with the K9 class") + fun saveSettingsAsync() { + coroutineScope.launch(backgroundDispatcher) { + val settings = updateGeneralSettingsWithStateFromK9() + settingsFlow.emit(settings) + + saveSettings(settings) + } + } + + @Synchronized + private fun updateGeneralSettingsWithStateFromK9(): GeneralSettings { + return getSettings().copy( + backgroundSync = K9.backgroundOps.toBackgroundSync() + ).also { generalSettings -> + this.generalSettings = generalSettings + } + } + + @Synchronized + private fun saveSettings(settings: GeneralSettings) { + val editor = preferences.createStorageEditor() + K9.save(editor) + writeSettings(editor, settings) + editor.commit() + } + + @Synchronized + private fun GeneralSettings.persist() { + generalSettings = this + updateSettingsFlow(this) + saveSettingsAsync(this) + } + + private fun saveSettingsAsync(generalSettings: GeneralSettings) { + coroutineScope.launch(backgroundDispatcher) { + saveSettings(generalSettings) + } + } + + @Synchronized + override fun setShowRecentChanges(showRecentChanges: Boolean) { + getSettings().copy(showRecentChanges = showRecentChanges).persist() + } + + @Synchronized + override fun setAppTheme(appTheme: AppTheme) { + getSettings().copy(appTheme = appTheme).persist() + } + + @Synchronized + override fun setMessageViewTheme(subTheme: SubTheme) { + getSettings().copy(messageViewTheme = subTheme).persist() + } + + @Synchronized + override fun setMessageComposeTheme(subTheme: SubTheme) { + getSettings().copy(messageComposeTheme = subTheme).persist() + } + + @Synchronized + override fun setFixedMessageViewTheme(fixedMessageViewTheme: Boolean) { + getSettings().copy(fixedMessageViewTheme = fixedMessageViewTheme).persist() + } + + private fun writeSettings(editor: StorageEditor, settings: GeneralSettings) { + editor.putBoolean("showRecentChanges", settings.showRecentChanges) + editor.putEnum("theme", settings.appTheme) + editor.putEnum("messageViewTheme", settings.messageViewTheme) + editor.putEnum("messageComposeTheme", settings.messageComposeTheme) + editor.putBoolean("fixedMessageViewTheme", settings.fixedMessageViewTheme) + } + + private fun loadGeneralSettings(): GeneralSettings { + val storage = preferences.storage + + val settings = GeneralSettings( + backgroundSync = K9.backgroundOps.toBackgroundSync(), + showRecentChanges = storage.getBoolean("showRecentChanges", true), + appTheme = storage.getEnum("theme", AppTheme.FOLLOW_SYSTEM), + messageViewTheme = storage.getEnum("messageViewTheme", SubTheme.USE_GLOBAL), + messageComposeTheme = storage.getEnum("messageComposeTheme", SubTheme.USE_GLOBAL), + fixedMessageViewTheme = storage.getBoolean("fixedMessageViewTheme", true) + ) + + updateSettingsFlow(settings) + + return settings + } +} + +private fun K9.BACKGROUND_OPS.toBackgroundSync(): BackgroundSync { + return when (this) { + K9.BACKGROUND_OPS.ALWAYS -> BackgroundSync.ALWAYS + K9.BACKGROUND_OPS.NEVER -> BackgroundSync.NEVER + K9.BACKGROUND_OPS.WHEN_CHECKED_AUTO_SYNC -> BackgroundSync.FOLLOW_SYSTEM_AUTO_SYNC + } +} + +private inline fun > Storage.getEnum(key: String, defaultValue: T): T { + return try { + val value = getString(key, null) + if (value != null) { + enumValueOf(value) + } else { + defaultValue + } + } catch (e: Exception) { + Timber.e("Couldn't read setting '%s'. Using default value instead.", key) + defaultValue + } +} + +private fun > StorageEditor.putEnum(key: String, value: T) { + putString(key, value.name) +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/ServerTypeConverter.kt b/app/core/src/main/java/com/fsck/k9/preferences/ServerTypeConverter.kt new file mode 100644 index 0000000..9d64def --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/ServerTypeConverter.kt @@ -0,0 +1,15 @@ +package com.fsck.k9.preferences + +object ServerTypeConverter { + @JvmStatic + fun toServerSettingsType(exportType: String): String = exportType.lowercase() + + @JvmStatic + fun fromServerSettingsType(serverSettingsType: String): String = when (serverSettingsType) { + Protocols.IMAP -> "IMAP" + Protocols.POP3 -> "POP3" + Protocols.WEBDAV -> "WebDAV" + Protocols.SMTP -> "SMTP" + else -> throw AssertionError("Unsupported type: $serverSettingsType") + } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/Settings.java b/app/core/src/main/java/com/fsck/k9/preferences/Settings.java new file mode 100644 index 0000000..ea45b4d --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/Settings.java @@ -0,0 +1,580 @@ +package com.fsck.k9.preferences; + + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; + +import timber.log.Timber; + +import com.fsck.k9.FontSizes; +import com.fsck.k9.K9; + +/* + * TODO: + * - use the default values defined in GlobalSettings and AccountSettings when creating new + * accounts + * - think of a better way to validate enums than to use the resource arrays (i.e. get rid of + * ResourceArrayValidator); maybe even use the settings description for the settings UI + * - add unit test that validates the default values are actually valid according to the validator + */ + +public class Settings { + /** + * Version number of global and account settings. + * + *

    + * This value is used as "version" attribute in the export file. It needs to be incremented + * when a global or account setting is added or removed, or when the format of a setting + * is changed (e.g. add a value to an enum). + *

    + * + * @see SettingsExporter + */ + public static final int VERSION = 87; + + static Map validate(int version, Map> settings, + Map importedSettings, boolean useDefaultValues) { + + Map validatedSettings = new HashMap<>(); + for (Map.Entry> versionedSetting : settings.entrySet()) { + + // Get the setting description with the highest version lower than or equal to the + // supplied content version. + TreeMap versions = versionedSetting.getValue(); + SortedMap headMap = versions.headMap(version + 1); + + // Skip this setting if it was introduced after 'version' + if (headMap.isEmpty()) { + continue; + } + + Integer settingVersion = headMap.lastKey(); + SettingsDescription desc = versions.get(settingVersion); + + // Skip this setting if it is no longer used in 'version' + if (desc == null) { + continue; + } + + String key = versionedSetting.getKey(); + + boolean useDefaultValue; + if (!importedSettings.containsKey(key)) { + Timber.v("Key \"%s\" wasn't found in the imported file.%s", + key, + (useDefaultValues) ? " Using default value." : ""); + + useDefaultValue = useDefaultValues; + } else { + String prettyValue = importedSettings.get(key); + try { + Object internalValue = desc.fromPrettyString(prettyValue); + validatedSettings.put(key, internalValue); + useDefaultValue = false; + } catch (InvalidSettingValueException e) { + Timber.v("Key \"%s\" has invalid value \"%s\" in imported file. %s", + key, + prettyValue, + (useDefaultValues) ? "Using default value." : "Skipping."); + + useDefaultValue = useDefaultValues; + } + } + + if (useDefaultValue) { + Object defaultValue = desc.getDefaultValue(); + validatedSettings.put(key, defaultValue); + } + } + + return validatedSettings; + } + + /** + * Upgrade settings using the settings structure and/or special upgrade code. + * + * @param version + * The content version of the settings in {@code validatedSettingsMutable}. + * @param customUpgraders + * A map of {@link SettingsUpgrader}s for nontrivial settings upgrades. + * @param settings + * The structure describing the different settings, possibly containing multiple + * versions. + * @param validatedSettingsMutable + * The settings as returned by {@link Settings#validate(int, Map, Map, boolean)}. + * This map is modified and contains the upgraded settings when this method returns. + * + * @return A set of setting names that were removed during the upgrade process or {@code null} + * if none were removed. + */ + public static Set upgrade(int version, Map customUpgraders, + Map> settings, Map validatedSettingsMutable) { + Set deletedSettings = null; + + for (int toVersion = version + 1; toVersion <= VERSION; toVersion++) { + if (customUpgraders.containsKey(toVersion)) { + SettingsUpgrader upgrader = customUpgraders.get(toVersion); + deletedSettings = upgrader.upgrade(validatedSettingsMutable); + } + + deletedSettings = upgradeSettingsGeneric(settings, validatedSettingsMutable, deletedSettings, toVersion); + } + + return deletedSettings; + } + + private static Set upgradeSettingsGeneric(Map> settings, + Map validatedSettingsMutable, Set deletedSettingsMutable, int toVersion) { + for (Entry> versions : settings.entrySet()) { + String settingName = versions.getKey(); + TreeMap versionedSettings = versions.getValue(); + + boolean isNewlyAddedSetting = versionedSettings.firstKey() == toVersion; + if (isNewlyAddedSetting) { + boolean wasHandledByCustomUpgrader = validatedSettingsMutable.containsKey(settingName); + if (wasHandledByCustomUpgrader) { + continue; + } + + SettingsDescription setting = versionedSettings.get(toVersion); + if (setting == null) { + throw new AssertionError("First version of a setting must be non-null!"); + } + upgradeSettingInsertDefault(validatedSettingsMutable, settingName, setting); + } + + Integer highestVersion = versionedSettings.lastKey(); + boolean isRemovedSetting = (highestVersion == toVersion && versionedSettings.get(highestVersion) == null); + if (isRemovedSetting) { + if (deletedSettingsMutable == null) { + deletedSettingsMutable = new HashSet<>(); + } + upgradeSettingRemove(validatedSettingsMutable, deletedSettingsMutable, settingName); + } + } + return deletedSettingsMutable; + } + + private static void upgradeSettingInsertDefault(Map validatedSettingsMutable, + String settingName, SettingsDescription setting) { + T defaultValue = setting.getDefaultValue(); + validatedSettingsMutable.put(settingName, defaultValue); + + if (K9.isDebugLoggingEnabled()) { + String prettyValue = setting.toPrettyString(defaultValue); + Timber.v("Added new setting \"%s\" with default value \"%s\"", settingName, prettyValue); + } + } + + private static void upgradeSettingRemove(Map validatedSettingsMutable, + Set deletedSettingsMutable, String settingName) { + validatedSettingsMutable.remove(settingName); + deletedSettingsMutable.add(settingName); + + Timber.v("Removed setting \"%s\"", settingName); + } + + /** + * Convert settings from the internal representation to the string representation used in the + * preference storage. + * + * @param settings + * The map of settings to convert. + * @param settingDescriptions + * The structure containing the {@link SettingsDescription} objects that will be used + * to convert the setting values. + * + * @return The settings converted to the string representation used in the preference storage. + */ + public static Map convert(Map settings, + Map> settingDescriptions) { + Map serializedSettings = new HashMap<>(); + + for (Entry setting : settings.entrySet()) { + String settingName = setting.getKey(); + Object internalValue = setting.getValue(); + + TreeMap versionedSetting = settingDescriptions.get(settingName); + Integer highestVersion = versionedSetting.lastKey(); + SettingsDescription settingDesc = versionedSetting.get(highestVersion); + + if (settingDesc != null) { + String stringValue = settingDesc.toString(internalValue); + + serializedSettings.put(settingName, stringValue); + } else { + Timber.w("Settings.convert() called with a setting that should have been removed: %s", settingName); + } + } + + return serializedSettings; + } + + /** + * Creates a {@link TreeMap} linking version numbers to {@link SettingsDescription} instances. + * + *

    + * This {@code TreeMap} is used to quickly find the {@code SettingsDescription} belonging to a + * content version as read by {@link SettingsImporter}. See e.g. + * {@link Settings#validate(int, Map, Map, boolean)}. + *

    + * + * @param versionDescriptions + * A list of descriptions for a specific setting mapped to version numbers. Never + * {@code null}. + * + * @return A {@code TreeMap} using the version number as key, the {@code SettingsDescription} + * as value. + */ + static TreeMap versions(V... versionDescriptions) { + TreeMap map = new TreeMap<>(); + for (V v : versionDescriptions) { + map.put(v.version, v.description); + } + return map; + } + + + static class InvalidSettingValueException extends Exception { + private static final long serialVersionUID = 1L; + } + + /** + * Describes a setting. + * + *

    + * Instances of this class are used to convert the string representations of setting values to + * an internal representation (e.g. an integer) and back. + *

    + * Currently we use two different string representations: + *

    + *
      + *
    1. + * The one that is used by the internal preference {@link Storage}. It is usually obtained by + * calling {@code toString()} on the internal representation of the setting value (see e.g. + * {@link K9#save(StorageEditor)}). + *
    2. + *
    3. + * The "pretty" version that is used by the import/export settings file (e.g. colors are + * exported in #rrggbb format instead of a integer string like "-8734021"). + *
    4. + *
    + *

    + * Note: + * For the future we should aim to get rid of the "internal" string representation. The + * "pretty" version makes reading a database dump easier and the performance impact should be + * negligible. + *

    + */ + abstract static class SettingsDescription { + /** + * The setting's default value (internal representation). + */ + T defaultValue; + + SettingsDescription(T defaultValue) { + this.defaultValue = defaultValue; + } + + /** + * Get the default value. + * + * @return The internal representation of the default value. + */ + public T getDefaultValue() { + return defaultValue; + } + + /** + * Convert a setting's value to the string representation. + * + * @param value + * The internal representation of a setting's value. + * + * @return The string representation of {@code value}. + */ + public String toString(T value) { + return value.toString(); + } + + /** + * Parse the string representation of a setting's value . + * + * @param value + * The string representation of a setting's value. + * + * @return The internal representation of the setting's value. + * + * @throws InvalidSettingValueException + * If {@code value} contains an invalid value. + */ + public abstract T fromString(String value) throws InvalidSettingValueException; + + /** + * Convert a setting value to the "pretty" string representation. + * + * @param value + * The setting's value. + * + * @return A pretty-printed version of the setting's value. + */ + public String toPrettyString(T value) { + return toString(value); + } + + /** + * Convert the pretty-printed version of a setting's value to the internal representation. + * + * @param value + * The pretty-printed version of the setting's value. See + * {@link #toPrettyString(Object)}. + * + * @return The internal representation of the setting's value. + * + * @throws InvalidSettingValueException + * If {@code value} contains an invalid value. + */ + public T fromPrettyString(String value) throws InvalidSettingValueException { + return fromString(value); + } + } + + public static class V { + public final Integer version; + public final SettingsDescription description; + + V(Integer version, SettingsDescription description) { + this.version = version; + this.description = description; + } + } + + /** + * Used for a nontrivial settings upgrade. + * + * @see Settings#upgrade(int, Map, Map, Map) + */ + interface SettingsUpgrader { + /** + * Upgrade the provided settings. + * + * @param settings + * The settings to upgrade. This map is modified and contains the upgraded + * settings when this method returns. + * + * @return A set of setting names that were removed during the upgrade process or + * {@code null} if none were removed. + */ + Set upgrade(Map settings); + } + + + static class StringSetting extends SettingsDescription { + StringSetting(String defaultValue) { + super(defaultValue); + } + + @Override + public String fromString(String value) { + return value; + } + + @Override + public String toString(String value) { + return value; + } + } + + static class BooleanSetting extends SettingsDescription { + BooleanSetting(boolean defaultValue) { + super(defaultValue); + } + + @Override + public Boolean fromString(String value) throws InvalidSettingValueException { + if (Boolean.TRUE.toString().equals(value)) { + return true; + } else if (Boolean.FALSE.toString().equals(value)) { + return false; + } + throw new InvalidSettingValueException(); + } + } + + static class ColorSetting extends SettingsDescription { + ColorSetting(int defaultValue) { + super(defaultValue); + } + + @Override + public Integer fromString(String value) throws InvalidSettingValueException { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new InvalidSettingValueException(); + } + } + + @Override + public String toPrettyString(Integer value) { + int color = value & 0x00FFFFFF; + return String.format("#%06x", color); + } + + @Override + public Integer fromPrettyString(String value) throws InvalidSettingValueException { + try { + if (value.length() == 7) { + return Integer.parseInt(value.substring(1), 16) | 0xFF000000; + } + } catch (NumberFormatException e) { /* do nothing */ } + + throw new InvalidSettingValueException(); + } + } + + static class EnumSetting> extends SettingsDescription { + private Class enumClass; + + EnumSetting(Class enumClass, T defaultValue) { + super(defaultValue); + this.enumClass = enumClass; + } + + @Override + public T fromString(String value) throws InvalidSettingValueException { + try { + return Enum.valueOf(enumClass, value); + } catch (Exception e) { + throw new InvalidSettingValueException(); + } + } + } + + /** + * A setting that has multiple valid values but doesn't use an {@link Enum} internally. + * + * @param + * The type of the internal representation (e.g. {@code Integer}). + */ + abstract static class PseudoEnumSetting extends SettingsDescription { + PseudoEnumSetting(T defaultValue) { + super(defaultValue); + } + + protected abstract Map getMapping(); + + @Override + public String toPrettyString(T value) { + return getMapping().get(value); + } + + @Override + public T fromPrettyString(String value) throws InvalidSettingValueException { + for (Entry entry : getMapping().entrySet()) { + if (entry.getValue().equals(value)) { + return entry.getKey(); + } + } + + throw new InvalidSettingValueException(); + } + } + + static class FontSizeSetting extends PseudoEnumSetting { + private final Map mapping; + + FontSizeSetting(int defaultValue) { + super(defaultValue); + + Map mapping = new HashMap<>(); + mapping.put(FontSizes.FONT_10SP, "tiniest"); + mapping.put(FontSizes.FONT_12SP, "tiny"); + mapping.put(FontSizes.SMALL, "smaller"); + mapping.put(FontSizes.FONT_16SP, "small"); + mapping.put(FontSizes.MEDIUM, "medium"); + mapping.put(FontSizes.FONT_20SP, "large"); + mapping.put(FontSizes.LARGE, "larger"); + this.mapping = Collections.unmodifiableMap(mapping); + } + + @Override + protected Map getMapping() { + return mapping; + } + + @Override + public Integer fromString(String value) throws InvalidSettingValueException { + try { + Integer fontSize = Integer.parseInt(value); + if (mapping.containsKey(fontSize)) { + return fontSize; + } + } catch (NumberFormatException e) { /* do nothing */ } + + throw new InvalidSettingValueException(); + } + } + + static class WebFontSizeSetting extends PseudoEnumSetting { + private final Map mapping; + + WebFontSizeSetting(int defaultValue) { + super(defaultValue); + + Map mapping = new HashMap<>(); + mapping.put(1, "smallest"); + mapping.put(2, "smaller"); + mapping.put(3, "normal"); + mapping.put(4, "larger"); + mapping.put(5, "largest"); + this.mapping = Collections.unmodifiableMap(mapping); + } + + @Override + protected Map getMapping() { + return mapping; + } + + @Override + public Integer fromString(String value) throws InvalidSettingValueException { + try { + Integer fontSize = Integer.parseInt(value); + if (mapping.containsKey(fontSize)) { + return fontSize; + } + } catch (NumberFormatException e) { /* do nothing */ } + + throw new InvalidSettingValueException(); + } + } + + static class IntegerRangeSetting extends SettingsDescription { + private int start; + private int end; + + IntegerRangeSetting(int start, int end, int defaultValue) { + super(defaultValue); + this.start = start; + this.end = end; + } + + @Override + public Integer fromString(String value) throws InvalidSettingValueException { + try { + int intValue = Integer.parseInt(value); + if (start <= intValue && intValue <= end) { + return intValue; + } + } catch (NumberFormatException e) { /* do nothing */ } + + throw new InvalidSettingValueException(); + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/SettingsExporter.kt b/app/core/src/main/java/com/fsck/k9/preferences/SettingsExporter.kt new file mode 100644 index 0000000..0098813 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/SettingsExporter.kt @@ -0,0 +1,524 @@ +package com.fsck.k9.preferences + +import android.content.ContentResolver +import android.net.Uri +import android.util.Xml +import com.fsck.k9.Account +import com.fsck.k9.AccountPreferenceSerializer.Companion.ACCOUNT_DESCRIPTION_KEY +import com.fsck.k9.AccountPreferenceSerializer.Companion.IDENTITY_DESCRIPTION_KEY +import com.fsck.k9.AccountPreferenceSerializer.Companion.IDENTITY_EMAIL_KEY +import com.fsck.k9.AccountPreferenceSerializer.Companion.IDENTITY_NAME_KEY +import com.fsck.k9.Preferences +import com.fsck.k9.mailstore.FolderRepository +import com.fsck.k9.notification.NotificationSettingsUpdater +import com.fsck.k9.preferences.ServerTypeConverter.fromServerSettingsType +import com.fsck.k9.preferences.Settings.InvalidSettingValueException +import com.fsck.k9.preferences.Settings.SettingsDescription +import java.io.OutputStream +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import org.xmlpull.v1.XmlSerializer +import timber.log.Timber + +class SettingsExporter( + private val contentResolver: ContentResolver, + private val preferences: Preferences, + private val folderSettingsProvider: FolderSettingsProvider, + private val folderRepository: FolderRepository, + private val notificationSettingsUpdater: NotificationSettingsUpdater +) { + @Throws(SettingsImportExportException::class) + fun exportToUri(includeGlobals: Boolean, accountUuids: Set, uri: Uri) { + updateNotificationSettings(accountUuids) + + try { + contentResolver.openOutputStream(uri, "wt")!!.use { outputStream -> + exportPreferences(outputStream, includeGlobals, accountUuids) + } + } catch (e: Exception) { + throw SettingsImportExportException(e) + } + } + + private fun updateNotificationSettings(accountUuids: Set) { + try { + notificationSettingsUpdater.updateNotificationSettings(accountUuids) + } catch (e: Exception) { + // An error here could mean we export notification settings that don't reflect the current configuration + // of the notification channels. But we prefer stale data over failing the export. + Timber.w(e, "Error while updating accounts with notification configuration from system") + } + } + + @Throws(SettingsImportExportException::class) + fun exportPreferences(outputStream: OutputStream, includeGlobals: Boolean, accountUuids: Set) { + try { + val serializer = Xml.newSerializer() + serializer.setOutput(outputStream, "UTF-8") + + serializer.startDocument(null, true) + + // Output with indentation + serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true) + + serializer.startTag(null, ROOT_ELEMENT) + serializer.attribute(null, VERSION_ATTRIBUTE, Settings.VERSION.toString()) + serializer.attribute(null, FILE_FORMAT_ATTRIBUTE, FILE_FORMAT_VERSION.toString()) + + Timber.i("Exporting preferences") + + val storage = preferences.storage + + val prefs: Map = storage.all.toSortedMap() + if (includeGlobals) { + serializer.startTag(null, GLOBAL_ELEMENT) + writeSettings(serializer, prefs) + serializer.endTag(null, GLOBAL_ELEMENT) + } + + serializer.startTag(null, ACCOUNTS_ELEMENT) + for (accountUuid in accountUuids) { + preferences.getAccount(accountUuid)?.let { account -> + writeAccount(serializer, account, prefs) + } + } + serializer.endTag(null, ACCOUNTS_ELEMENT) + + serializer.endTag(null, ROOT_ELEMENT) + serializer.endDocument() + serializer.flush() + } catch (e: Exception) { + throw SettingsImportExportException(e.localizedMessage, e) + } + } + + private fun writeSettings(serializer: XmlSerializer, prefs: Map) { + for ((key, versions) in GeneralSettingsDescriptions.SETTINGS) { + val valueString = prefs[key] as String? + val highestVersion = versions.lastKey() + val setting = versions[highestVersion] ?: continue // Setting was removed + + if (valueString != null) { + try { + writeKeyAndPrettyValueFromSetting(serializer, key, setting, valueString) + } catch (e: InvalidSettingValueException) { + Timber.w( + "Global setting \"%s\" has invalid value \"%s\" in preference storage. This shouldn't happen!", + key, + valueString + ) + } + } else { + Timber.d("Couldn't find key \"%s\" in preference storage. Using default value.", key) + writeKeyAndDefaultValueFromSetting(serializer, key, setting) + } + } + } + + private fun writeAccount(serializer: XmlSerializer, account: Account, prefs: Map) { + val identities = mutableSetOf() + val accountUuid = account.uuid + + serializer.startTag(null, ACCOUNT_ELEMENT) + serializer.attribute(null, UUID_ATTRIBUTE, accountUuid) + + val name = prefs["$accountUuid.$ACCOUNT_DESCRIPTION_KEY"] as String? + if (name != null) { + serializer.startTag(null, NAME_ELEMENT) + serializer.text(name) + serializer.endTag(null, NAME_ELEMENT) + } + + // Write incoming server settings + val incoming = account.incomingServerSettings + serializer.startTag(null, INCOMING_SERVER_ELEMENT) + serializer.attribute(null, TYPE_ATTRIBUTE, fromServerSettingsType(incoming.type)) + + writeElement(serializer, HOST_ELEMENT, incoming.host) + if (incoming.port != -1) { + writeElement(serializer, PORT_ELEMENT, incoming.port.toString()) + } + writeElement(serializer, CONNECTION_SECURITY_ELEMENT, incoming.connectionSecurity.name) + writeElement(serializer, AUTHENTICATION_TYPE_ELEMENT, incoming.authenticationType.name) + writeElement(serializer, USERNAME_ELEMENT, incoming.username) + writeElement(serializer, CLIENT_CERTIFICATE_ALIAS_ELEMENT, incoming.clientCertificateAlias) + // XXX For now we don't export the password + // writeElement(serializer, PASSWORD_ELEMENT, incoming.password); + + var extras = incoming.extra + if (!extras.isNullOrEmpty()) { + serializer.startTag(null, EXTRA_ELEMENT) + for ((key, value) in extras) { + writeKeyAndPrettyValueFromSetting(serializer, key, value) + } + serializer.endTag(null, EXTRA_ELEMENT) + } + serializer.endTag(null, INCOMING_SERVER_ELEMENT) + + // Write outgoing server settings + val outgoing = account.outgoingServerSettings + serializer.startTag(null, OUTGOING_SERVER_ELEMENT) + serializer.attribute(null, TYPE_ATTRIBUTE, fromServerSettingsType(outgoing.type)) + + writeElement(serializer, HOST_ELEMENT, outgoing.host) + if (outgoing.port != -1) { + writeElement(serializer, PORT_ELEMENT, outgoing.port.toString()) + } + writeElement(serializer, CONNECTION_SECURITY_ELEMENT, outgoing.connectionSecurity.name) + writeElement(serializer, AUTHENTICATION_TYPE_ELEMENT, outgoing.authenticationType.name) + writeElement(serializer, USERNAME_ELEMENT, outgoing.username) + writeElement(serializer, CLIENT_CERTIFICATE_ALIAS_ELEMENT, outgoing.clientCertificateAlias) + // XXX For now we don't export the password + // writeElement(serializer, PASSWORD_ELEMENT, outgoing.password); + + extras = outgoing.extra + if (!extras.isNullOrEmpty()) { + serializer.startTag(null, EXTRA_ELEMENT) + for ((key, value) in extras) { + writeKeyAndPrettyValueFromSetting(serializer, key, value) + } + serializer.endTag(null, EXTRA_ELEMENT) + } + serializer.endTag(null, OUTGOING_SERVER_ELEMENT) + + // Write account settings + serializer.startTag(null, SETTINGS_ELEMENT) + for ((key, value) in prefs) { + val valueString = value.toString() + val comps = key.split(".", limit = 2) + + if (comps.size < 2) { + // Skip global settings + continue + } + + val keyUuid = comps[0] + val keyPart = comps[1] + + if (keyUuid != accountUuid) { + // Setting doesn't belong to the account we're currently writing. + continue + } + + val indexOfLastDot = keyPart.lastIndexOf(".") + val hasThirdPart = indexOfLastDot != -1 && indexOfLastDot < keyPart.length - 1 + if (hasThirdPart) { + val secondPart = keyPart.substring(0, indexOfLastDot) + val thirdPart = keyPart.substring(indexOfLastDot + 1) + if (secondPart == IDENTITY_DESCRIPTION_KEY) { + // This is an identity key. Save identity index for later... + thirdPart.toIntOrNull()?.let { + identities.add(it) + } + // ... but don't write it now. + continue + } + + if (FolderSettingsDescriptions.SETTINGS.containsKey(thirdPart)) { + // This is a folder key. Ignore it. + continue + } + } + + if (keyPart !in FOLDER_NAME_KEYS) { + writeAccountSettingIfValid(serializer, keyPart, valueString, account) + } + } + + writeFolderNameSettings(account, folderRepository, serializer) + + serializer.endTag(null, SETTINGS_ELEMENT) + + if (identities.isNotEmpty()) { + serializer.startTag(null, IDENTITIES_ELEMENT) + + // Sort identity indices (that's why we store them as Integers) + val sortedIdentities = identities.sorted() + for (identityIndex in sortedIdentities) { + writeIdentity(serializer, accountUuid, identityIndex.toString(), prefs) + } + serializer.endTag(null, IDENTITIES_ELEMENT) + } + + val folders = folderSettingsProvider.getFolderSettings(account) + if (folders.isNotEmpty()) { + serializer.startTag(null, FOLDERS_ELEMENT) + for (folder in folders) { + writeFolder(serializer, folder) + } + serializer.endTag(null, FOLDERS_ELEMENT) + } + + serializer.endTag(null, ACCOUNT_ELEMENT) + } + + private fun writeAccountSettingIfValid( + serializer: XmlSerializer, + keyPart: String, + valueString: String, + account: Account + ) { + val versionedSetting = AccountSettingsDescriptions.SETTINGS[keyPart] + if (versionedSetting != null) { + val highestVersion = versionedSetting.lastKey() + + val setting = versionedSetting[highestVersion] + if (setting != null) { + // Only export account settings that can be found in AccountSettings.SETTINGS + try { + writeKeyAndPrettyValueFromSetting(serializer, keyPart, setting, valueString) + } catch (e: InvalidSettingValueException) { + Timber.w( + "Account setting \"%s\" (%s) has invalid value \"%s\" in preference storage. " + + "This shouldn't happen!", + keyPart, + account, + valueString + ) + } + } + } + } + + private fun writeFolderNameSettings( + account: Account, + folderRepository: FolderRepository, + serializer: XmlSerializer + ) { + fun writeFolderNameSetting( + key: String, + folderId: Long?, + importedFolderServerId: String?, + writeEmptyValue: Boolean = false + ) { + val folderServerId = folderId?.let { + folderRepository.getFolderServerId(account, folderId) + } ?: importedFolderServerId + + if (folderServerId != null) { + writeAccountSettingIfValid(serializer, key, folderServerId, account) + } else if (writeEmptyValue) { + writeAccountSettingIfValid(serializer, key, valueString = "", account) + } + } + + writeFolderNameSetting( + "autoExpandFolderName", + account.autoExpandFolderId, + account.importedAutoExpandFolder, + writeEmptyValue = true + ) + writeFolderNameSetting("archiveFolderName", account.archiveFolderId, account.importedArchiveFolder) + writeFolderNameSetting("draftsFolderName", account.draftsFolderId, account.importedDraftsFolder) + writeFolderNameSetting("sentFolderName", account.sentFolderId, account.importedSentFolder) + writeFolderNameSetting("spamFolderName", account.spamFolderId, account.importedSpamFolder) + writeFolderNameSetting("trashFolderName", account.trashFolderId, account.importedTrashFolder) + } + + private fun writeIdentity( + serializer: XmlSerializer, + accountUuid: String, + identity: String, + prefs: Map + ) { + serializer.startTag(null, IDENTITY_ELEMENT) + + val prefix = "$accountUuid." + val suffix = ".$identity" + + // Write name belonging to the identity + val name = prefs[prefix + IDENTITY_NAME_KEY + suffix] as String? + serializer.startTag(null, NAME_ELEMENT) + serializer.text(name) + serializer.endTag(null, NAME_ELEMENT) + + // Write email address belonging to the identity + val email = prefs[prefix + IDENTITY_EMAIL_KEY + suffix] as String? + serializer.startTag(null, EMAIL_ELEMENT) + serializer.text(email) + serializer.endTag(null, EMAIL_ELEMENT) + + // Write identity description + val description = prefs[prefix + IDENTITY_DESCRIPTION_KEY + suffix] as String? + if (description != null) { + serializer.startTag(null, DESCRIPTION_ELEMENT) + serializer.text(description) + serializer.endTag(null, DESCRIPTION_ELEMENT) + } + + // Write identity settings + serializer.startTag(null, SETTINGS_ELEMENT) + for ((key, value) in prefs) { + val valueString = value.toString() + + val comps = key.split(".") + if (comps.size < 3) { + // Skip non-identity config entries + continue + } + + val keyUuid = comps[0] + val identityKey = comps[1] + val identityIndex = comps[2] + if (keyUuid != accountUuid || identityIndex != identity) { + // Skip entries that belong to another identity + continue + } + + val versionedSetting = IdentitySettingsDescriptions.SETTINGS[identityKey] + if (versionedSetting != null) { + val highestVersion = versionedSetting.lastKey() + val setting = versionedSetting[highestVersion] + if (setting != null) { + // Only write settings that have an entry in IdentitySettings.SETTINGS + try { + writeKeyAndPrettyValueFromSetting(serializer, identityKey, setting, valueString) + } catch (e: InvalidSettingValueException) { + Timber.w( + "Identity setting \"%s\" has invalid value \"%s\" in preference storage. " + + "This shouldn't happen!", + identityKey, + valueString + ) + } + } + } + } + serializer.endTag(null, SETTINGS_ELEMENT) + + serializer.endTag(null, IDENTITY_ELEMENT) + } + + private fun writeFolder(serializer: XmlSerializer, folder: FolderSettings) { + serializer.startTag(null, FOLDER_ELEMENT) + serializer.attribute(null, NAME_ATTRIBUTE, folder.serverId) + + // Write folder settings + writeFolderSetting(serializer, "integrate", folder.isIntegrate.toString()) + writeFolderSetting(serializer, "inTopGroup", folder.isInTopGroup.toString()) + writeFolderSetting(serializer, "syncMode", folder.syncClass.name) + writeFolderSetting(serializer, "displayMode", folder.displayClass.name) + writeFolderSetting(serializer, "notifyMode", folder.notifyClass.name) + writeFolderSetting(serializer, "pushMode", folder.pushClass.name) + + serializer.endTag(null, FOLDER_ELEMENT) + } + + private fun writeFolderSetting(serializer: XmlSerializer, key: String, value: String) { + val versionedSetting = FolderSettingsDescriptions.SETTINGS[key] + if (versionedSetting != null) { + val highestVersion = versionedSetting.lastKey() + val setting = versionedSetting[highestVersion] + if (setting != null) { + // Only write settings that have an entry in FolderSettings.SETTINGS + try { + writeKeyAndPrettyValueFromSetting(serializer, key, setting, value) + } catch (e: InvalidSettingValueException) { + Timber.w( + "Folder setting \"%s\" has invalid value \"%s\" in preference storage. This shouldn't happen!", + key, + value + ) + } + } + } + } + + private fun writeElement(serializer: XmlSerializer, elementName: String, value: String?) { + if (value != null) { + serializer.startTag(null, elementName) + serializer.text(value) + serializer.endTag(null, elementName) + } + } + + private fun writeKeyAndPrettyValueFromSetting( + serializer: XmlSerializer, + key: String, + setting: SettingsDescription, + valueString: String + ) { + val value = setting.fromString(valueString) + val outputValue = setting.toPrettyString(value) + writeKeyAndPrettyValueFromSetting(serializer, key, outputValue) + } + + private fun writeKeyAndDefaultValueFromSetting( + serializer: XmlSerializer, + key: String, + setting: SettingsDescription + ) { + val value = setting.getDefaultValue() + val outputValue = setting.toPrettyString(value) + writeKeyAndPrettyValueFromSetting(serializer, key, outputValue) + } + + private fun writeKeyAndPrettyValueFromSetting(serializer: XmlSerializer, key: String, literalValue: String?) { + serializer.startTag(null, VALUE_ELEMENT) + serializer.attribute(null, KEY_ATTRIBUTE, key) + if (literalValue != null) { + serializer.text(literalValue) + } + serializer.endTag(null, VALUE_ELEMENT) + } + + fun generateDatedExportFileName(): String { + val now = Calendar.getInstance() + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) + return String.format("%s_%s.%s", EXPORT_FILENAME_PREFIX, dateFormat.format(now.time), EXPORT_FILENAME_SUFFIX) + } + + companion object { + private const val EXPORT_FILENAME_PREFIX = "k9_settings_export" + private const val EXPORT_FILENAME_SUFFIX = "k9s" + + /** + * File format version number. + * + * Increment this if you need to change the structure of the settings file. When you do this + * remember that we also have to be able to handle old file formats. So have fun adding support + * for that to [SettingsImporter] :) + */ + const val FILE_FORMAT_VERSION = 1 + + const val ROOT_ELEMENT = "k9settings" + const val VERSION_ATTRIBUTE = "version" + const val FILE_FORMAT_ATTRIBUTE = "format" + const val GLOBAL_ELEMENT = "global" + const val SETTINGS_ELEMENT = "settings" + const val ACCOUNTS_ELEMENT = "accounts" + const val ACCOUNT_ELEMENT = "account" + const val UUID_ATTRIBUTE = "uuid" + const val INCOMING_SERVER_ELEMENT = "incoming-server" + const val OUTGOING_SERVER_ELEMENT = "outgoing-server" + const val TYPE_ATTRIBUTE = "type" + const val HOST_ELEMENT = "host" + const val PORT_ELEMENT = "port" + const val CONNECTION_SECURITY_ELEMENT = "connection-security" + const val AUTHENTICATION_TYPE_ELEMENT = "authentication-type" + const val USERNAME_ELEMENT = "username" + const val CLIENT_CERTIFICATE_ALIAS_ELEMENT = "client-cert-alias" + const val PASSWORD_ELEMENT = "password" + const val EXTRA_ELEMENT = "extra" + const val IDENTITIES_ELEMENT = "identities" + const val IDENTITY_ELEMENT = "identity" + const val FOLDERS_ELEMENT = "folders" + const val FOLDER_ELEMENT = "folder" + const val NAME_ATTRIBUTE = "name" + const val VALUE_ELEMENT = "value" + const val KEY_ATTRIBUTE = "key" + const val NAME_ELEMENT = "name" + const val EMAIL_ELEMENT = "email" + const val DESCRIPTION_ELEMENT = "description" + + private val FOLDER_NAME_KEYS = setOf( + "autoExpandFolderName", + "archiveFolderName", + "draftsFolderName", + "sentFolderName", + "spamFolderName", + "trashFolderName" + ) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/SettingsImportExportException.java b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImportExportException.java new file mode 100644 index 0000000..b2a6a5b --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImportExportException.java @@ -0,0 +1,22 @@ +package com.fsck.k9.preferences; + +public class SettingsImportExportException extends Exception { + private static final long serialVersionUID = -6042736634079588513L; + + public SettingsImportExportException() { + super(); + } + + public SettingsImportExportException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } + + public SettingsImportExportException(String detailMessage) { + super(detailMessage); + } + + public SettingsImportExportException(Throwable throwable) { + super(throwable); + } + +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java new file mode 100644 index 0000000..4ed1e02 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java @@ -0,0 +1,1154 @@ +package com.fsck.k9.preferences; + + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.TextUtils; + +import androidx.annotation.VisibleForTesting; +import com.fsck.k9.Account; +import com.fsck.k9.AccountPreferenceSerializer; +import com.fsck.k9.Core; +import com.fsck.k9.DI; +import com.fsck.k9.Identity; +import com.fsck.k9.K9; +import com.fsck.k9.Preferences; +import com.fsck.k9.ServerSettingsSerializer; +import com.fsck.k9.mail.AuthType; +import com.fsck.k9.mail.ConnectionSecurity; +import com.fsck.k9.mail.ServerSettings; +import com.fsck.k9.mailstore.SpecialLocalFoldersCreator; +import com.fsck.k9.preferences.Settings.InvalidSettingValueException; +import kotlinx.datetime.Clock; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; +import timber.log.Timber; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.unmodifiableMap; + + +public class SettingsImporter { + + /** + * Class to list the contents of an import file/stream. + * + * @see SettingsImporter#getImportStreamContents(InputStream) + */ + public static class ImportContents { + /** + * True, if the import file contains global settings. + */ + public final boolean globalSettings; + + /** + * The list of accounts found in the import file. Never {@code null}. + */ + public final List accounts; + + private ImportContents(boolean globalSettings, List accounts) { + this.globalSettings = globalSettings; + this.accounts = accounts; + } + } + + public static class AccountDescription { + public final String name; + public final String uuid; + + private AccountDescription(String name, String uuid) { + this.name = name; + this.uuid = uuid; + } + } + + public static class AccountDescriptionPair { + public final AccountDescription original; + public final AccountDescription imported; + public final boolean overwritten; + public final boolean authorizationNeeded; + public final boolean incomingPasswordNeeded; + public final boolean outgoingPasswordNeeded; + public final String incomingServerName; + public final String outgoingServerName; + + private AccountDescriptionPair(AccountDescription original, AccountDescription imported, + boolean overwritten, boolean authorizationNeeded, boolean incomingPasswordNeeded, + boolean outgoingPasswordNeeded, String incomingServerName, String outgoingServerName) { + this.original = original; + this.imported = imported; + this.overwritten = overwritten; + this.authorizationNeeded = authorizationNeeded; + this.incomingPasswordNeeded = incomingPasswordNeeded; + this.outgoingPasswordNeeded = outgoingPasswordNeeded; + this.incomingServerName = incomingServerName; + this.outgoingServerName = outgoingServerName; + } + } + + public static class ImportResults { + public final boolean globalSettings; + public final List importedAccounts; + public final List erroneousAccounts; + + private ImportResults(boolean globalSettings, List importedAccounts, + List erroneousAccounts) { + this.globalSettings = globalSettings; + this.importedAccounts = importedAccounts; + this.erroneousAccounts = erroneousAccounts; + } + } + + /** + * Parses an import {@link InputStream} and returns information on whether it contains global + * settings and/or account settings. For all account configurations found, the name of the + * account along with the account UUID is returned. + * + * @param inputStream + * An {@code InputStream} to read the settings from. + * + * @return An {@link ImportContents} instance containing information about the contents of the + * settings file. + * + * @throws SettingsImportExportException + * In case of an error. + */ + public static ImportContents getImportStreamContents(InputStream inputStream) + throws SettingsImportExportException { + + try { + // Parse the import stream but don't save individual settings (overview=true) + Imported imported = parseSettings(inputStream, false, null, true); + + // If the stream contains global settings the "globalSettings" member will not be null + boolean globalSettings = (imported.globalSettings != null); + + final List accounts = new ArrayList<>(); + // If the stream contains at least one account configuration the "accounts" member + // will not be null. + if (imported.accounts != null) { + for (ImportedAccount account : imported.accounts.values()) { + String accountName = getAccountDisplayName(account); + accounts.add(new AccountDescription(accountName, account.uuid)); + } + } + + //TODO: throw exception if neither global settings nor account settings could be found + + return new ImportContents(globalSettings, accounts); + + } catch (SettingsImportExportException e) { + throw e; + } catch (Exception e) { + throw new SettingsImportExportException(e); + } + } + + /** + * Reads an import {@link InputStream} and imports the global settings and/or account + * configurations specified by the arguments. + * + * @param context + * A {@link Context} instance. + * @param inputStream + * The {@code InputStream} to read the settings from. + * @param globalSettings + * {@code true} if global settings should be imported from the file. + * @param accountUuids + * A list of UUIDs of the accounts that should be imported. + * @param overwrite + * {@code true} if existing accounts should be overwritten when an account with the + * same UUID is found in the settings file.
    + * Note: This can have side-effects we currently don't handle, e.g. + * changing the account type from IMAP to POP3. So don't use this for now! + * @return An {@link ImportResults} instance containing information about errors and + * successfully imported accounts. + * + * @throws SettingsImportExportException + * In case of an error. + */ + public static ImportResults importSettings(Context context, InputStream inputStream, boolean globalSettings, + List accountUuids, boolean overwrite) throws SettingsImportExportException { + + try { + boolean globalSettingsImported = false; + List importedAccounts = new ArrayList<>(); + List erroneousAccounts = new ArrayList<>(); + + Imported imported = parseSettings(inputStream, globalSettings, accountUuids, false); + + Preferences preferences = Preferences.getPreferences(); + Storage storage = preferences.getStorage(); + + if (globalSettings) { + try { + StorageEditor editor = preferences.createStorageEditor(); + if (imported.globalSettings != null) { + importGlobalSettings(storage, editor, imported.contentVersion, imported.globalSettings); + } else { + Timber.w("Was asked to import global settings but none found."); + } + if (editor.commit()) { + Timber.v("Committed global settings to the preference storage."); + globalSettingsImported = true; + } else { + Timber.v("Failed to commit global settings to the preference storage"); + } + } catch (Exception e) { + Timber.e(e, "Exception while importing global settings"); + } + } + + if (accountUuids != null && accountUuids.size() > 0) { + if (imported.accounts != null) { + for (String accountUuid : accountUuids) { + if (imported.accounts.containsKey(accountUuid)) { + ImportedAccount account = imported.accounts.get(accountUuid); + try { + StorageEditor editor = preferences.createStorageEditor(); + + AccountDescriptionPair importResult = importAccount(context, editor, + imported.contentVersion, account, overwrite); + + if (editor.commit()) { + Timber.v("Committed settings for account \"%s\" to the settings database.", + importResult.imported.name); + + // Add UUID of the account we just imported to the list of + // account UUIDs + if (!importResult.overwritten) { + editor = preferences.createStorageEditor(); + + String newUuid = importResult.imported.uuid; + String oldAccountUuids = preferences.getStorage().getString("accountUuids", ""); + String newAccountUuids = (oldAccountUuids.length() > 0) ? + oldAccountUuids + "," + newUuid : newUuid; + + putString(editor, "accountUuids", newAccountUuids); + + if (!editor.commit()) { + throw new SettingsImportExportException("Failed to set account UUID list"); + } + } + + // Reload accounts + preferences.loadAccounts(); + + importedAccounts.add(importResult); + } else { + Timber.w("Error while committing settings for account \"%s\" to the settings " + + "database.", importResult.original.name); + + erroneousAccounts.add(importResult.original); + } + } catch (InvalidSettingValueException e) { + Timber.e(e, "Encountered invalid setting while importing account \"%s\"", + account.name); + + erroneousAccounts.add(new AccountDescription(account.name, account.uuid)); + } catch (Exception e) { + Timber.e(e, "Exception while importing account \"%s\"", account.name); + erroneousAccounts.add(new AccountDescription(account.name, account.uuid)); + } + } else { + Timber.w("Was asked to import account with UUID %s. But this account wasn't found.", + accountUuid); + } + } + + StorageEditor editor = preferences.createStorageEditor(); + + if (!editor.commit()) { + throw new SettingsImportExportException("Failed to set default account"); + } + } else { + Timber.w("Was asked to import at least one account but none found."); + } + } + + preferences.loadAccounts(); + + SpecialLocalFoldersCreator localFoldersCreator = DI.get(SpecialLocalFoldersCreator.class); + + // Create special local folders + for (AccountDescriptionPair importedAccount : importedAccounts) { + String accountUuid = importedAccount.imported.uuid; + Account account = preferences.getAccount(accountUuid); + + localFoldersCreator.createSpecialLocalFolders(account); + } + + DI.get(RealGeneralSettingsManager.class).loadSettings(); + Core.setServicesEnabled(context); + + return new ImportResults(globalSettingsImported, importedAccounts, erroneousAccounts); + + } catch (SettingsImportExportException e) { + throw e; + } catch (Exception e) { + throw new SettingsImportExportException(e); + } + } + + private static void importGlobalSettings(Storage storage, StorageEditor editor, int contentVersion, + ImportedSettings settings) { + + // Validate global settings + Map validatedSettings = GeneralSettingsDescriptions.validate(contentVersion, settings.settings); + + // Upgrade global settings to current content version + if (contentVersion != Settings.VERSION) { + GeneralSettingsDescriptions.upgrade(contentVersion, validatedSettings); + } + + // Convert global settings to the string representation used in preference storage + Map stringSettings = GeneralSettingsDescriptions.convert(validatedSettings); + + // Use current global settings as base and overwrite with validated settings read from the import file. + Map mergedSettings = new HashMap<>(GeneralSettingsDescriptions.getGlobalSettings(storage)); + mergedSettings.putAll(stringSettings); + + for (Map.Entry setting : mergedSettings.entrySet()) { + String key = setting.getKey(); + String value = setting.getValue(); + putString(editor, key, value); + } + } + + private static AccountDescriptionPair importAccount(Context context, StorageEditor editor, int contentVersion, + ImportedAccount account, boolean overwrite) throws InvalidSettingValueException { + + AccountDescription original = new AccountDescription(account.name, account.uuid); + + Preferences prefs = Preferences.getPreferences(); + List accounts = prefs.getAccounts(); + + String uuid = account.uuid; + Account existingAccount = prefs.getAccount(uuid); + boolean mergeImportedAccount = (overwrite && existingAccount != null); + + if (!overwrite && existingAccount != null) { + // An account with this UUID already exists, but we're not allowed to overwrite it. + // So generate a new UUID. + uuid = UUID.randomUUID().toString(); + } + + // Make sure the account name is unique + String accountName = account.name; + if (isAccountNameUsed(accountName, accounts)) { + // Account name is already in use. So generate a new one by appending " (x)", where x is the first + // number >= 1 that results in an unused account name. + for (int i = 1; i <= accounts.size(); i++) { + accountName = account.name + " (" + i + ")"; + if (!isAccountNameUsed(accountName, accounts)) { + break; + } + } + } + + // Write account name + String accountKeyPrefix = uuid + "."; + putString(editor, accountKeyPrefix + AccountPreferenceSerializer.ACCOUNT_DESCRIPTION_KEY, accountName); + + if (account.incoming == null) { + // We don't import accounts without incoming server settings + throw new InvalidSettingValueException(); + } + + // Write incoming server settings + ServerSettings incoming = createServerSettings(account.incoming); + ServerSettingsSerializer serverSettingsSerializer = DI.get(ServerSettingsSerializer.class); + String incomingServer = serverSettingsSerializer.serialize(incoming); + putString(editor, accountKeyPrefix + AccountPreferenceSerializer.INCOMING_SERVER_SETTINGS_KEY, incomingServer); + + String incomingServerName = incoming.host; + boolean incomingPasswordNeeded = AuthType.EXTERNAL != incoming.authenticationType && + AuthType.XOAUTH2 != incoming.authenticationType && + (incoming.password == null || incoming.password.isEmpty()); + + boolean authorizationNeeded = incoming.authenticationType == AuthType.XOAUTH2; + + String incomingServerType = ServerTypeConverter.toServerSettingsType(account.incoming.type); + if (account.outgoing == null && !incomingServerType.equals(Protocols.WEBDAV)) { + // All account types except WebDAV need to provide outgoing server settings + throw new InvalidSettingValueException(); + } + + String outgoingServerName = null; + boolean outgoingPasswordNeeded = false; + if (account.outgoing != null) { + // Write outgoing server settings + ServerSettings outgoing = createServerSettings(account.outgoing); + String outgoingServer = serverSettingsSerializer.serialize(outgoing); + putString(editor, accountKeyPrefix + AccountPreferenceSerializer.OUTGOING_SERVER_SETTINGS_KEY, outgoingServer); + + /* + * Mark account as disabled if the settings file contained a username but no password. However, no password + * is required for the outgoing server for WebDAV accounts, because incoming and outgoing servers are + * identical for this account type. Nor is a password required if the AuthType is EXTERNAL. + */ + String outgoingServerType = ServerTypeConverter.toServerSettingsType(outgoing.type); + outgoingPasswordNeeded = AuthType.EXTERNAL != outgoing.authenticationType && + AuthType.XOAUTH2 != outgoing.authenticationType && + !outgoingServerType.equals(Protocols.WEBDAV) && + outgoing.username != null && + !outgoing.username.isEmpty() && + (outgoing.password == null || outgoing.password.isEmpty()); + + authorizationNeeded |= outgoing.authenticationType == AuthType.XOAUTH2; + + outgoingServerName = outgoing.host; + } + + boolean createAccountDisabled = incomingPasswordNeeded || outgoingPasswordNeeded || authorizationNeeded; + if (createAccountDisabled) { + editor.putBoolean(accountKeyPrefix + "enabled", false); + } + + // Validate account settings + Map validatedSettings = + AccountSettingsDescriptions.validate(contentVersion, account.settings.settings, !mergeImportedAccount); + + // Upgrade account settings to current content version + if (contentVersion != Settings.VERSION) { + AccountSettingsDescriptions.upgrade(contentVersion, validatedSettings); + } + + // Convert account settings to the string representation used in preference storage + Map stringSettings = AccountSettingsDescriptions.convert(validatedSettings); + + // Merge account settings if necessary + Map writeSettings; + if (mergeImportedAccount) { + writeSettings = new HashMap<>(AccountSettingsDescriptions.getAccountSettings(prefs.getStorage(), uuid)); + writeSettings.putAll(stringSettings); + } else { + writeSettings = stringSettings; + } + + // Write account settings + for (Map.Entry setting : writeSettings.entrySet()) { + String key = accountKeyPrefix + setting.getKey(); + String value = setting.getValue(); + putString(editor, key, value); + } + + // If it's a new account generate and write a new "accountNumber" + if (!mergeImportedAccount) { + int newAccountNumber = prefs.generateAccountNumber(); + putString(editor, accountKeyPrefix + "accountNumber", Integer.toString(newAccountNumber)); + } + + // Write identities + if (account.identities != null) { + importIdentities(editor, contentVersion, uuid, account, overwrite, existingAccount, prefs); + } else if (!mergeImportedAccount) { + // Require accounts to at least have one identity + throw new InvalidSettingValueException(); + } + + // Write folder settings + if (account.folders != null) { + for (ImportedFolder folder : account.folders) { + importFolder(editor, contentVersion, uuid, folder, mergeImportedAccount, prefs); + } + } + + // When deleting an account and then restoring it using settings import, the same account UUID will be used. + // To avoid reusing a previously existing notification channel ID, we need to make sure to use a unique value + // for `messagesNotificationChannelVersion`. + Clock clock = DI.get(Clock.class); + String messageNotificationChannelVersion = Long.toString(clock.now().getEpochSeconds()); + putString(editor, accountKeyPrefix + "messagesNotificationChannelVersion", messageNotificationChannelVersion); + + AccountDescription imported = new AccountDescription(accountName, uuid); + return new AccountDescriptionPair(original, imported, mergeImportedAccount, authorizationNeeded, + incomingPasswordNeeded, outgoingPasswordNeeded, incomingServerName, outgoingServerName); + } + + private static void importFolder(StorageEditor editor, int contentVersion, String uuid, ImportedFolder folder, + boolean overwrite, Preferences prefs) { + + // Validate folder settings + Map validatedSettings = + FolderSettingsDescriptions.validate(contentVersion, folder.settings.settings, !overwrite); + + // Upgrade folder settings to current content version + if (contentVersion != Settings.VERSION) { + FolderSettingsDescriptions.upgrade(contentVersion, validatedSettings); + } + + // Convert folder settings to the string representation used in preference storage + Map stringSettings = FolderSettingsDescriptions.convert(validatedSettings); + + // Merge folder settings if necessary + Map writeSettings; + if (overwrite) { + writeSettings = FolderSettingsDescriptions.getFolderSettings(prefs.getStorage(), uuid, folder.name); + writeSettings.putAll(stringSettings); + } else { + writeSettings = stringSettings; + } + + // Write folder settings + String prefix = uuid + "." + folder.name + "."; + for (Map.Entry setting : writeSettings.entrySet()) { + String key = prefix + setting.getKey(); + String value = setting.getValue(); + putString(editor, key, value); + } + } + + private static void importIdentities(StorageEditor editor, int contentVersion, String uuid, ImportedAccount account, + boolean overwrite, Account existingAccount, Preferences prefs) throws InvalidSettingValueException { + + String accountKeyPrefix = uuid + "."; + + // Gather information about existing identities for this account (if any) + int nextIdentityIndex = 0; + final List existingIdentities; + if (overwrite && existingAccount != null) { + existingIdentities = existingAccount.getIdentities(); + nextIdentityIndex = existingIdentities.size(); + } else { + existingIdentities = new ArrayList<>(); + } + + // Write identities + for (ImportedIdentity identity : account.identities) { + int writeIdentityIndex = nextIdentityIndex; + boolean mergeSettings = false; + if (overwrite && existingIdentities.size() > 0) { + int identityIndex = findIdentity(identity, existingIdentities); + if (identityIndex != -1) { + writeIdentityIndex = identityIndex; + mergeSettings = true; + } + } + if (!mergeSettings) { + nextIdentityIndex++; + } + + String identityDescription = (identity.description == null) ? "Imported" : identity.description; + if (isIdentityDescriptionUsed(identityDescription, existingIdentities)) { + // Identity description is already in use. So generate a new one by appending + // " (x)", where x is the first number >= 1 that results in an unused identity + // description. + for (int i = 1; i <= existingIdentities.size(); i++) { + identityDescription = identity.description + " (" + i + ")"; + if (!isIdentityDescriptionUsed(identityDescription, existingIdentities)) { + break; + } + } + } + + String identitySuffix = "." + writeIdentityIndex; + + // Write name used in identity + String identityName = (identity.name == null) ? "" : identity.name; + putString(editor, accountKeyPrefix + AccountPreferenceSerializer.IDENTITY_NAME_KEY + identitySuffix, identityName); + + // Validate email address + if (!IdentitySettingsDescriptions.isEmailAddressValid(identity.email)) { + throw new InvalidSettingValueException(); + } + + // Write email address + putString(editor, accountKeyPrefix + AccountPreferenceSerializer.IDENTITY_EMAIL_KEY + identitySuffix, identity.email); + + // Write identity description + putString(editor, accountKeyPrefix + AccountPreferenceSerializer.IDENTITY_DESCRIPTION_KEY + identitySuffix, + identityDescription); + + if (identity.settings != null) { + // Validate identity settings + Map validatedSettings = IdentitySettingsDescriptions.validate( + contentVersion, identity.settings.settings, !mergeSettings); + + // Upgrade identity settings to current content version + if (contentVersion != Settings.VERSION) { + IdentitySettingsDescriptions.upgrade(contentVersion, validatedSettings); + } + + // Convert identity settings to the representation used in preference storage + Map stringSettings = IdentitySettingsDescriptions.convert(validatedSettings); + + // Merge identity settings if necessary + Map writeSettings; + if (mergeSettings) { + writeSettings = new HashMap<>(IdentitySettingsDescriptions.getIdentitySettings( + prefs.getStorage(), uuid, writeIdentityIndex)); + writeSettings.putAll(stringSettings); + } else { + writeSettings = stringSettings; + } + + // Write identity settings + for (Map.Entry setting : writeSettings.entrySet()) { + String key = accountKeyPrefix + setting.getKey() + identitySuffix; + String value = setting.getValue(); + putString(editor, key, value); + } + } + } + } + + private static boolean isAccountNameUsed(String name, List accounts) { + for (Account account : accounts) { + if (account == null) { + continue; + } + + if (account.getDisplayName().equals(name)) { + return true; + } + } + return false; + } + + private static boolean isIdentityDescriptionUsed(String description, List identities) { + for (Identity identity : identities) { + if (identity.getDescription().equals(description)) { + return true; + } + } + return false; + } + + private static int findIdentity(ImportedIdentity identity, List identities) { + for (int i = 0; i < identities.size(); i++) { + Identity existingIdentity = identities.get(i); + if (existingIdentity.getName().equals(identity.name) && + existingIdentity.getEmail().equals(identity.email)) { + return i; + } + } + return -1; + } + + /** + * Write to an {@link SharedPreferences.Editor} while logging what is written if debug logging + * is enabled. + * + * @param editor + * The {@code Editor} to write to. + * @param key + * The name of the preference to modify. + * @param value + * The new value for the preference. + */ + private static void putString(StorageEditor editor, String key, String value) { + if (K9.isDebugLoggingEnabled()) { + String outputValue = value; + if (!K9.isSensitiveDebugLoggingEnabled() && + (key.endsWith("." + AccountPreferenceSerializer.OUTGOING_SERVER_SETTINGS_KEY) || + key.endsWith("." + AccountPreferenceSerializer.INCOMING_SERVER_SETTINGS_KEY))) { + outputValue = "*sensitive*"; + } + Timber.v("Setting %s=%s", key, outputValue); + } + editor.putString(key, value); + } + + @VisibleForTesting + static Imported parseSettings(InputStream inputStream, boolean globalSettings, List accountUuids, + boolean overview) throws SettingsImportExportException { + + if (!overview && accountUuids == null) { + throw new IllegalArgumentException("Argument 'accountUuids' must not be null."); + } + + try { + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + //factory.setNamespaceAware(true); + XmlPullParser xpp = factory.newPullParser(); + + InputStreamReader reader = new InputStreamReader(inputStream); + xpp.setInput(reader); + + Imported imported = null; + int eventType = xpp.getEventType(); + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG) { + if (SettingsExporter.ROOT_ELEMENT.equals(xpp.getName())) { + imported = parseRoot(xpp, globalSettings, accountUuids, overview); + } else { + Timber.w("Unexpected start tag: %s", xpp.getName()); + } + } + eventType = xpp.next(); + } + + if (imported == null || (overview && imported.globalSettings == null && imported.accounts == null)) { + throw new SettingsImportExportException("Invalid import data"); + } + + return imported; + } catch (Exception e) { + throw new SettingsImportExportException(e); + } + } + + private static void skipToEndTag(XmlPullParser xpp, String endTag) throws XmlPullParserException, IOException { + int eventType = xpp.next(); + while (!(eventType == XmlPullParser.END_TAG && endTag.equals(xpp.getName()))) { + eventType = xpp.next(); + } + } + + private static String getText(XmlPullParser xpp) throws XmlPullParserException, IOException { + int eventType = xpp.next(); + if (eventType != XmlPullParser.TEXT) { + return ""; + } + return xpp.getText(); + } + + private static Imported parseRoot(XmlPullParser xpp, boolean globalSettings, List accountUuids, + boolean overview) throws XmlPullParserException, IOException, SettingsImportExportException { + + Imported result = new Imported(); + + String fileFormatVersionString = xpp.getAttributeValue(null, SettingsExporter.FILE_FORMAT_ATTRIBUTE); + validateFileFormatVersion(fileFormatVersionString); + + String contentVersionString = xpp.getAttributeValue(null, SettingsExporter.VERSION_ATTRIBUTE); + result.contentVersion = validateContentVersion(contentVersionString); + + int eventType = xpp.next(); + while (!(eventType == XmlPullParser.END_TAG && SettingsExporter.ROOT_ELEMENT.equals(xpp.getName()))) { + if (eventType == XmlPullParser.START_TAG) { + String element = xpp.getName(); + if (SettingsExporter.GLOBAL_ELEMENT.equals(element)) { + if (overview || globalSettings) { + if (result.globalSettings == null) { + if (overview) { + result.globalSettings = new ImportedSettings(); + skipToEndTag(xpp, SettingsExporter.GLOBAL_ELEMENT); + } else { + result.globalSettings = parseSettings(xpp, SettingsExporter.GLOBAL_ELEMENT); + } + } else { + skipToEndTag(xpp, SettingsExporter.GLOBAL_ELEMENT); + Timber.w("More than one global settings element. Only using the first one!"); + } + } else { + skipToEndTag(xpp, SettingsExporter.GLOBAL_ELEMENT); + Timber.i("Skipping global settings"); + } + } else if (SettingsExporter.ACCOUNTS_ELEMENT.equals(element)) { + if (result.accounts == null) { + result.accounts = parseAccounts(xpp, accountUuids, overview); + } else { + Timber.w("More than one accounts element. Only using the first one!"); + } + } else { + Timber.w("Unexpected start tag: %s", xpp.getName()); + } + } + eventType = xpp.next(); + } + + return result; + } + + private static int validateFileFormatVersion(String versionString) throws SettingsImportExportException { + if (versionString == null) { + throw new SettingsImportExportException("Missing file format version"); + } + + int version; + try { + version = Integer.parseInt(versionString); + } catch (NumberFormatException e) { + throw new SettingsImportExportException("Invalid file format version: " + versionString); + } + + if (version != SettingsExporter.FILE_FORMAT_VERSION) { + throw new SettingsImportExportException("Unsupported file format version: " + versionString); + } + + return version; + } + + private static int validateContentVersion(String versionString) throws SettingsImportExportException { + if (versionString == null) { + throw new SettingsImportExportException("Missing content version"); + } + + int version; + try { + version = Integer.parseInt(versionString); + } catch (NumberFormatException e) { + throw new SettingsImportExportException("Invalid content version: " + versionString); + } + + if (version < 1) { + throw new SettingsImportExportException("Unsupported content version: " + versionString); + } + + return version; + } + + private static ImportedSettings parseSettings(XmlPullParser xpp, String endTag) + throws XmlPullParserException, IOException { + + ImportedSettings result = null; + + int eventType = xpp.next(); + while (!(eventType == XmlPullParser.END_TAG && endTag.equals(xpp.getName()))) { + + if (eventType == XmlPullParser.START_TAG) { + String element = xpp.getName(); + if (SettingsExporter.VALUE_ELEMENT.equals(element)) { + String key = xpp.getAttributeValue(null, SettingsExporter.KEY_ATTRIBUTE); + String value = getText(xpp); + + if (result == null) { + result = new ImportedSettings(); + } + + if (result.settings.containsKey(key)) { + Timber.w("Already read key \"%s\". Ignoring value \"%s\"", key, value); + } else { + result.settings.put(key, value); + } + } else { + Timber.w("Unexpected start tag: %s", xpp.getName()); + } + } + eventType = xpp.next(); + } + + return result; + } + + private static Map parseAccounts(XmlPullParser xpp, List accountUuids, + boolean overview) throws XmlPullParserException, IOException { + + Map accounts = null; + + int eventType = xpp.next(); + while (!(eventType == XmlPullParser.END_TAG && SettingsExporter.ACCOUNTS_ELEMENT.equals(xpp.getName()))) { + if (eventType == XmlPullParser.START_TAG) { + String element = xpp.getName(); + if (SettingsExporter.ACCOUNT_ELEMENT.equals(element)) { + if (accounts == null) { + accounts = new LinkedHashMap<>(); + } + + ImportedAccount account = parseAccount(xpp, accountUuids, overview); + + if (account == null) { + // Do nothing - parseAccount() already logged a message + } else if (!accounts.containsKey(account.uuid)) { + accounts.put(account.uuid, account); + } else { + Timber.w("Duplicate account entries with UUID %s. Ignoring!", account.uuid); + } + } else { + Timber.w("Unexpected start tag: %s", xpp.getName()); + } + } + eventType = xpp.next(); + } + + return accounts; + } + + private static ImportedAccount parseAccount(XmlPullParser xpp, List accountUuids, boolean overview) + throws XmlPullParserException, IOException { + + String uuid = xpp.getAttributeValue(null, SettingsExporter.UUID_ATTRIBUTE); + + try { + UUID.fromString(uuid); + } catch (Exception e) { + skipToEndTag(xpp, SettingsExporter.ACCOUNT_ELEMENT); + Timber.w("Skipping account with invalid UUID %s", uuid); + return null; + } + + ImportedAccount account = new ImportedAccount(); + account.uuid = uuid; + + if (overview || accountUuids.contains(uuid)) { + int eventType = xpp.next(); + while (!(eventType == XmlPullParser.END_TAG && SettingsExporter.ACCOUNT_ELEMENT.equals(xpp.getName()))) { + if (eventType == XmlPullParser.START_TAG) { + String element = xpp.getName(); + if (SettingsExporter.NAME_ELEMENT.equals(element)) { + account.name = getText(xpp); + } else if (SettingsExporter.INCOMING_SERVER_ELEMENT.equals(element)) { + if (overview) { + skipToEndTag(xpp, SettingsExporter.INCOMING_SERVER_ELEMENT); + } else { + account.incoming = parseServerSettings(xpp, SettingsExporter.INCOMING_SERVER_ELEMENT); + } + } else if (SettingsExporter.OUTGOING_SERVER_ELEMENT.equals(element)) { + if (overview) { + skipToEndTag(xpp, SettingsExporter.OUTGOING_SERVER_ELEMENT); + } else { + account.outgoing = parseServerSettings(xpp, SettingsExporter.OUTGOING_SERVER_ELEMENT); + } + } else if (SettingsExporter.SETTINGS_ELEMENT.equals(element)) { + if (overview) { + skipToEndTag(xpp, SettingsExporter.SETTINGS_ELEMENT); + } else { + account.settings = parseSettings(xpp, SettingsExporter.SETTINGS_ELEMENT); + } + } else if (SettingsExporter.IDENTITIES_ELEMENT.equals(element)) { + account.identities = parseIdentities(xpp); + } else if (SettingsExporter.FOLDERS_ELEMENT.equals(element)) { + if (overview) { + skipToEndTag(xpp, SettingsExporter.FOLDERS_ELEMENT); + } else { + account.folders = parseFolders(xpp); + } + } else { + Timber.w("Unexpected start tag: %s", xpp.getName()); + } + } + eventType = xpp.next(); + } + } else { + skipToEndTag(xpp, SettingsExporter.ACCOUNT_ELEMENT); + Timber.i("Skipping account with UUID %s", uuid); + } + + // If we couldn't find an account name use the UUID + if (account.name == null) { + account.name = uuid; + } + + return account; + } + + private static ImportedServer parseServerSettings(XmlPullParser xpp, String endTag) + throws XmlPullParserException, IOException { + ImportedServer server = new ImportedServer(); + + server.type = xpp.getAttributeValue(null, SettingsExporter.TYPE_ATTRIBUTE); + + int eventType = xpp.next(); + while (!(eventType == XmlPullParser.END_TAG && endTag.equals(xpp.getName()))) { + if (eventType == XmlPullParser.START_TAG) { + String element = xpp.getName(); + if (SettingsExporter.HOST_ELEMENT.equals(element)) { + server.host = getText(xpp); + } else if (SettingsExporter.PORT_ELEMENT.equals(element)) { + server.port = getText(xpp); + } else if (SettingsExporter.CONNECTION_SECURITY_ELEMENT.equals(element)) { + server.connectionSecurity = getText(xpp); + } else if (SettingsExporter.AUTHENTICATION_TYPE_ELEMENT.equals(element)) { + String text = getText(xpp); + server.authenticationType = AuthType.valueOf(text); + } else if (SettingsExporter.USERNAME_ELEMENT.equals(element)) { + server.username = getText(xpp); + } else if (SettingsExporter.CLIENT_CERTIFICATE_ALIAS_ELEMENT.equals(element)) { + server.clientCertificateAlias = getText(xpp); + } else if (SettingsExporter.PASSWORD_ELEMENT.equals(element)) { + server.password = getText(xpp); + } else if (SettingsExporter.EXTRA_ELEMENT.equals(element)) { + server.extras = parseSettings(xpp, SettingsExporter.EXTRA_ELEMENT); + } else { + Timber.w("Unexpected start tag: %s", xpp.getName()); + } + } + eventType = xpp.next(); + } + + return server; + } + + private static List parseIdentities(XmlPullParser xpp) + throws XmlPullParserException, IOException { + List identities = null; + + int eventType = xpp.next(); + while (!(eventType == XmlPullParser.END_TAG && SettingsExporter.IDENTITIES_ELEMENT.equals(xpp.getName()))) { + if (eventType == XmlPullParser.START_TAG) { + String element = xpp.getName(); + if (SettingsExporter.IDENTITY_ELEMENT.equals(element)) { + if (identities == null) { + identities = new ArrayList<>(); + } + + ImportedIdentity identity = parseIdentity(xpp); + identities.add(identity); + } else { + Timber.w("Unexpected start tag: %s", xpp.getName()); + } + } + eventType = xpp.next(); + } + + return identities; + } + + private static ImportedIdentity parseIdentity(XmlPullParser xpp) throws XmlPullParserException, IOException { + ImportedIdentity identity = new ImportedIdentity(); + + int eventType = xpp.next(); + while (!(eventType == XmlPullParser.END_TAG && SettingsExporter.IDENTITY_ELEMENT.equals(xpp.getName()))) { + + if (eventType == XmlPullParser.START_TAG) { + String element = xpp.getName(); + if (SettingsExporter.NAME_ELEMENT.equals(element)) { + identity.name = getText(xpp); + } else if (SettingsExporter.EMAIL_ELEMENT.equals(element)) { + identity.email = getText(xpp); + } else if (SettingsExporter.DESCRIPTION_ELEMENT.equals(element)) { + identity.description = getText(xpp); + } else if (SettingsExporter.SETTINGS_ELEMENT.equals(element)) { + identity.settings = parseSettings(xpp, SettingsExporter.SETTINGS_ELEMENT); + } else { + Timber.w("Unexpected start tag: %s", xpp.getName()); + } + } + eventType = xpp.next(); + } + + return identity; + } + + private static List parseFolders(XmlPullParser xpp) throws XmlPullParserException, IOException { + List folders = null; + + int eventType = xpp.next(); + while (!(eventType == XmlPullParser.END_TAG && SettingsExporter.FOLDERS_ELEMENT.equals(xpp.getName()))) { + if (eventType == XmlPullParser.START_TAG) { + String element = xpp.getName(); + if (SettingsExporter.FOLDER_ELEMENT.equals(element)) { + if (folders == null) { + folders = new ArrayList<>(); + } + + ImportedFolder folder = parseFolder(xpp); + folders.add(folder); + } else { + Timber.w("Unexpected start tag: %s", xpp.getName()); + } + } + eventType = xpp.next(); + } + + return folders; + } + + private static ImportedFolder parseFolder(XmlPullParser xpp) throws XmlPullParserException, IOException { + ImportedFolder folder = new ImportedFolder(); + + folder.name = xpp.getAttributeValue(null, SettingsExporter.NAME_ATTRIBUTE); + + folder.settings = parseSettings(xpp, SettingsExporter.FOLDER_ELEMENT); + + return folder; + } + + private static String getAccountDisplayName(ImportedAccount account) { + String name = account.name; + if (TextUtils.isEmpty(name) && account.identities != null && account.identities.size() > 0) { + name = account.identities.get(0).email; + } + return name; + } + + private static ServerSettings createServerSettings(ImportedServer importedServer) { + String type = ServerTypeConverter.toServerSettingsType(importedServer.type); + int port = convertPort(importedServer.port); + ConnectionSecurity connectionSecurity = convertConnectionSecurity(importedServer.connectionSecurity); + String password = importedServer.authenticationType == AuthType.XOAUTH2 ? "" : importedServer.password; + Map extra = importedServer.extras != null ? + unmodifiableMap(importedServer.extras.settings) : emptyMap(); + + return new ServerSettings(type, importedServer.host, port, connectionSecurity, + importedServer.authenticationType, importedServer.username, password, + importedServer.clientCertificateAlias, extra); + } + + private static int convertPort(String port) { + try { + return Integer.parseInt(port); + } catch (NumberFormatException e) { + return -1; + } + } + + private static ConnectionSecurity convertConnectionSecurity(String connectionSecurity) { + try { + /* + * TODO: + * Add proper settings validation and upgrade capability for server settings. + * Once that exists, move this code into a SettingsUpgrader. + */ + if ("SSL_TLS_OPTIONAL".equals(connectionSecurity)) { + return ConnectionSecurity.SSL_TLS_REQUIRED; + } else if ("STARTTLS_OPTIONAL".equals(connectionSecurity)) { + return ConnectionSecurity.STARTTLS_REQUIRED; + } + return ConnectionSecurity.valueOf(connectionSecurity); + } catch (Exception e) { + return ConnectionSecurity.SSL_TLS_REQUIRED; + } + } + + @VisibleForTesting + static class Imported { + public int contentVersion; + public ImportedSettings globalSettings; + public Map accounts; + } + + private static class ImportedSettings { + public Map settings = new HashMap<>(); + } + + @VisibleForTesting + static class ImportedAccount { + public String uuid; + public String name; + public ImportedServer incoming; + public ImportedServer outgoing; + public ImportedSettings settings; + public List identities; + public List folders; + } + + @VisibleForTesting + static class ImportedServer { + public String type; + public String host; + public String port; + public String connectionSecurity; + public AuthType authenticationType; + public String username; + public String password; + public String clientCertificateAlias; + public ImportedSettings extras; + } + + @VisibleForTesting + static class ImportedIdentity { + public String name; + public String email; + public String description; + public ImportedSettings settings; + } + + private static class ImportedFolder { + public String name; + public ImportedSettings settings; + } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/Storage.java b/app/core/src/main/java/com/fsck/k9/preferences/Storage.java new file mode 100644 index 0000000..a515d58 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/Storage.java @@ -0,0 +1,69 @@ +package com.fsck.k9.preferences; + + +import java.util.Collections; +import java.util.Map; + +import timber.log.Timber; + +public class Storage { + private final Map values; + + public Storage(Map values) { + this.values = Collections.unmodifiableMap(values); + } + + public boolean isEmpty() { + return values.isEmpty(); + } + + public boolean contains(String key) { + return values.containsKey(key); + } + + public Map getAll() { + return values; + } + + public boolean getBoolean(String key, boolean defValue) { + String val = values.get(key); + if (val == null) { + return defValue; + } + return Boolean.parseBoolean(val); + } + + public int getInt(String key, int defValue) { + String val = values.get(key); + if (val == null) { + return defValue; + } + try { + return Integer.parseInt(val); + } catch (NumberFormatException nfe) { + Timber.e(nfe, "Could not parse int"); + return defValue; + } + } + + public long getLong(String key, long defValue) { + String val = values.get(key); + if (val == null) { + return defValue; + } + try { + return Long.parseLong(val); + } catch (NumberFormatException nfe) { + Timber.e(nfe, "Could not parse long"); + return defValue; + } + } + + public String getString(String key, String defValue) { + String val = values.get(key); + if (val == null) { + return defValue; + } + return val; + } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/StorageEditor.kt b/app/core/src/main/java/com/fsck/k9/preferences/StorageEditor.kt new file mode 100644 index 0000000..8ded820 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/StorageEditor.kt @@ -0,0 +1,12 @@ +package com.fsck.k9.preferences + +interface StorageEditor { + fun putBoolean(key: String, value: Boolean): StorageEditor + fun putInt(key: String, value: Int): StorageEditor + fun putLong(key: String, value: Long): StorageEditor + fun putString(key: String, value: String?): StorageEditor + + fun remove(key: String): StorageEditor + + fun commit(): Boolean +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/StoragePersister.kt b/app/core/src/main/java/com/fsck/k9/preferences/StoragePersister.kt new file mode 100644 index 0000000..5f14f6d --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/StoragePersister.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.preferences + +interface StoragePersister { + fun loadValues(): Storage + + fun createStorageEditor(storageUpdater: StorageUpdater): StorageEditor +} + +fun interface StorageUpdater { + fun updateStorage(updater: (currentStorage: Storage) -> Storage) +} diff --git a/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java b/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java new file mode 100644 index 0000000..36e0644 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java @@ -0,0 +1,189 @@ +package com.fsck.k9.provider; + + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.List; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.fsck.k9.DI; +import com.fsck.k9.helper.MimeTypeUtil; +import com.fsck.k9.mailstore.LocalStoreProvider; +import timber.log.Timber; + +import com.fsck.k9.Account; +import com.fsck.k9.Preferences; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mailstore.LocalStore; +import com.fsck.k9.mailstore.LocalStore.AttachmentInfo; +import org.openintents.openpgp.util.OpenPgpApi.OpenPgpDataSource; + + +/** + * A simple ContentProvider that allows file access to attachments. + */ +public class AttachmentProvider extends ContentProvider { + public static Uri CONTENT_URI; + + private static final String[] DEFAULT_PROJECTION = new String[] { + AttachmentProviderColumns._ID, + AttachmentProviderColumns.DATA, + }; + + public static class AttachmentProviderColumns { + public static final String _ID = "_id"; + public static final String DATA = "_data"; + public static final String DISPLAY_NAME = "_display_name"; + public static final String SIZE = "_size"; + } + + + public static Uri getAttachmentUri(String accountUuid, long id) { + return CONTENT_URI.buildUpon() + .appendPath(accountUuid) + .appendPath(Long.toString(id)) + .build(); + } + + @Override + public boolean onCreate() { + String packageName = getContext().getPackageName(); + String authority = packageName + ".attachmentprovider"; + CONTENT_URI = Uri.parse("content://" + authority); + + return true; + } + + @Override + public String getType(@NonNull Uri uri) { + List segments = uri.getPathSegments(); + String accountUuid = segments.get(0); + String id = segments.get(1); + String mimeType = (segments.size() < 3) ? null : segments.get(2); + + return getType(accountUuid, id, mimeType); + } + + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + List segments = uri.getPathSegments(); + String accountUuid = segments.get(0); + String attachmentId = segments.get(1); + + ParcelFileDescriptor parcelFileDescriptor = openAttachment(accountUuid, attachmentId); + if (parcelFileDescriptor == null) { + throw new FileNotFoundException("Attachment missing or cannot be opened!"); + } + return parcelFileDescriptor; + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + + String[] columnNames = (projection == null) ? DEFAULT_PROJECTION : projection; + + List segments = uri.getPathSegments(); + String accountUuid = segments.get(0); + String id = segments.get(1); + + final AttachmentInfo attachmentInfo; + try { + final Account account = Preferences.getPreferences().getAccount(accountUuid); + attachmentInfo = DI.get(LocalStoreProvider.class).getInstance(account).getAttachmentInfo(id); + } catch (MessagingException e) { + Timber.e(e, "Unable to retrieve attachment info from local store for ID: %s", id); + return null; + } + + if (attachmentInfo == null) { + Timber.d("No attachment info for ID: %s", id); + return null; + } + + MatrixCursor ret = new MatrixCursor(columnNames); + Object[] values = new Object[columnNames.length]; + for (int i = 0, count = columnNames.length; i < count; i++) { + String column = columnNames[i]; + if (AttachmentProviderColumns._ID.equals(column)) { + values[i] = id; + } else if (AttachmentProviderColumns.DATA.equals(column)) { + values[i] = uri.toString(); + } else if (AttachmentProviderColumns.DISPLAY_NAME.equals(column)) { + values[i] = attachmentInfo.name; + } else if (AttachmentProviderColumns.SIZE.equals(column)) { + values[i] = attachmentInfo.size; + } + } + ret.addRow(values); + return ret; + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + + @Override + public int delete(@NonNull Uri uri, String arg1, String[] arg2) { + throw new UnsupportedOperationException(); + } + + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + throw new UnsupportedOperationException(); + } + + private String getType(String accountUuid, String id, String mimeType) { + String type; + final Account account = Preferences.getPreferences().getAccount(accountUuid); + + try { + final LocalStore localStore = DI.get(LocalStoreProvider.class).getInstance(account); + + AttachmentInfo attachmentInfo = localStore.getAttachmentInfo(id); + if (mimeType != null) { + type = mimeType; + } else { + type = attachmentInfo.type; + } + } catch (MessagingException e) { + Timber.e(e, "Unable to retrieve LocalStore for %s", account); + type = MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE; + } + + return type; + } + + @Nullable + private ParcelFileDescriptor openAttachment(String accountUuid, String attachmentId) { + try { + OpenPgpDataSource openPgpDataSource = getAttachmentDataSource(accountUuid, attachmentId); + if (openPgpDataSource == null) { + Timber.e("Error getting data source for attachment (part doesn't exist?)"); + return null; + } + return openPgpDataSource.startPumpThread(); + } catch (MessagingException e) { + Timber.e(e, "Error getting InputStream for attachment"); + return null; + } catch (IOException e) { + Timber.e(e, "Error creating ParcelFileDescriptor"); + return null; + } + } + + @Nullable + private OpenPgpDataSource getAttachmentDataSource(String accountUuid, String attachmentId) throws MessagingException { + final Account account = Preferences.getPreferences().getAccount(accountUuid); + LocalStore localStore = DI.get(LocalStoreProvider.class).getInstance(account); + return localStore.getAttachmentDataSource(attachmentId); + } +} diff --git a/app/core/src/main/java/com/fsck/k9/provider/AttachmentTempFileProvider.java b/app/core/src/main/java/com/fsck/k9/provider/AttachmentTempFileProvider.java new file mode 100644 index 0000000..7b87d3c --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/provider/AttachmentTempFileProvider.java @@ -0,0 +1,211 @@ +package com.fsck.k9.provider; + + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.Uri; +import android.os.AsyncTask; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.content.FileProvider; +import timber.log.Timber; + +import com.fsck.k9.K9; +import okio.ByteString; +import org.apache.commons.io.IOUtils; + + +public class AttachmentTempFileProvider extends FileProvider { + private static final String CACHE_DIRECTORY = "temp"; + private static final long FILE_DELETE_THRESHOLD_MILLISECONDS = 3 * 60 * 1000; + private static final Object tempFileWriteMonitor = new Object(); + private static final Object cleanupReceiverMonitor = new Object(); + + private static String AUTHORITY; + private static AttachmentTempFileProviderCleanupReceiver cleanupReceiver = null; + + + @Override + public boolean onCreate() { + String packageName = getContext().getPackageName(); + AUTHORITY = packageName + ".tempfileprovider"; + return true; + } + + @WorkerThread + public static Uri createTempUriForContentUri(Context context, Uri uri) throws IOException { + Context applicationContext = context.getApplicationContext(); + + File tempFile = getTempFileForUri(uri, applicationContext); + writeUriContentToTempFileIfNotExists(context, uri, tempFile); + Uri tempFileUri = FileProvider.getUriForFile(context, AUTHORITY, tempFile); + + registerFileCleanupReceiver(applicationContext); + + return tempFileUri; + } + + @NonNull + private static File getTempFileForUri(Uri uri, Context context) { + Context applicationContext = context.getApplicationContext(); + + String tempFilename = getTempFilenameForUri(uri); + File tempDirectory = getTempFileDirectory(applicationContext); + return new File(tempDirectory, tempFilename); + } + + private static String getTempFilenameForUri(Uri uri) { + return ByteString.encodeUtf8(uri.toString()).sha1().hex(); + } + + private static void writeUriContentToTempFileIfNotExists(Context context, Uri uri, File tempFile) + throws IOException { + synchronized (tempFileWriteMonitor) { + if (tempFile.exists()) { + return; + } + + FileOutputStream outputStream = new FileOutputStream(tempFile); + InputStream inputStream = context.getContentResolver().openInputStream(uri); + if (inputStream == null) { + throw new IOException("Failed to resolve content at uri: " + uri); + } + IOUtils.copy(inputStream, outputStream); + + outputStream.close(); + IOUtils.closeQuietly(inputStream); + } + } + + public static Uri getMimeTypeUri(Uri contentUri, String mimeType) { + if (!AUTHORITY.equals(contentUri.getAuthority())) { + throw new IllegalArgumentException("Can only call this method for URIs within this authority!"); + } + if (contentUri.getQueryParameter("mime_type") != null) { + throw new IllegalArgumentException("Can only call this method for not yet typed URIs!"); + } + return contentUri.buildUpon().appendQueryParameter("mime_type", mimeType).build(); + } + + public static boolean deleteOldTemporaryFiles(Context context) { + File tempDirectory = getTempFileDirectory(context); + boolean allFilesDeleted = true; + long deletionThreshold = System.currentTimeMillis() - FILE_DELETE_THRESHOLD_MILLISECONDS; + for (File tempFile : tempDirectory.listFiles()) { + long lastModified = tempFile.lastModified(); + if (lastModified < deletionThreshold) { + boolean fileDeleted = tempFile.delete(); + if (!fileDeleted) { + Timber.e("Failed to delete temporary file"); + // TODO really do this? might cause our service to stay up indefinitely if a file can't be deleted + allFilesDeleted = false; + } + } else { + if (K9.isDebugLoggingEnabled()) { + String timeLeftStr = String.format( + Locale.ENGLISH, "%.2f", (lastModified - deletionThreshold) / 1000 / 60.0); + Timber.e("Not deleting temp file (for another %s minutes)", timeLeftStr); + } + allFilesDeleted = false; + } + } + + return allFilesDeleted; + } + + private static File getTempFileDirectory(Context context) { + File directory = new File(context.getCacheDir(), CACHE_DIRECTORY); + if (!directory.exists()) { + if (!directory.mkdir()) { + Timber.e("Error creating directory: %s", directory.getAbsolutePath()); + } + } + + return directory; + } + + + @Override + public String getType(Uri uri) { + return uri.getQueryParameter("mime_type"); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + + @Override + public void onTrimMemory(int level) { + if (level < TRIM_MEMORY_COMPLETE) { + return; + } + final Context context = getContext(); + if (context == null) { + return; + } + + new AsyncTask() { + @Override + protected Void doInBackground(Void... voids) { + deleteOldTemporaryFiles(context); + return null; + } + }.execute(); + + unregisterFileCleanupReceiver(context); + } + + private static void unregisterFileCleanupReceiver(Context context) { + synchronized (cleanupReceiverMonitor) { + if (cleanupReceiver == null) { + return; + } + + Timber.d("Unregistering temp file cleanup receiver"); + context.unregisterReceiver(cleanupReceiver); + cleanupReceiver = null; + } + } + + private static void registerFileCleanupReceiver(Context context) { + synchronized (cleanupReceiverMonitor) { + if (cleanupReceiver != null) { + return; + } + + Timber.d("Registering temp file cleanup receiver"); + cleanupReceiver = new AttachmentTempFileProviderCleanupReceiver(); + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(Intent.ACTION_SCREEN_OFF); + context.registerReceiver(cleanupReceiver, intentFilter); + } + } + + private static class AttachmentTempFileProviderCleanupReceiver extends BroadcastReceiver { + @Override + @MainThread + public void onReceive(Context context, Intent intent) { + if (!Intent.ACTION_SCREEN_OFF.equals(intent.getAction())) { + throw new IllegalArgumentException("onReceive called with action that isn't screen off!"); + } + + Timber.d("Cleaning up temp files"); + + boolean allFilesDeleted = deleteOldTemporaryFiles(context); + if (allFilesDeleted) { + unregisterFileCleanupReceiver(context); + } + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/provider/DecryptedFileProvider.java b/app/core/src/main/java/com/fsck/k9/provider/DecryptedFileProvider.java new file mode 100644 index 0000000..5514186 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/provider/DecryptedFileProvider.java @@ -0,0 +1,217 @@ +package com.fsck.k9.provider; + + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.ParcelFileDescriptor; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.FileProvider; +import android.text.TextUtils; +import timber.log.Timber; + +import com.fsck.k9.K9; +import com.fsck.k9.mailstore.util.FileFactory; +import org.apache.james.mime4j.codec.Base64InputStream; +import org.apache.james.mime4j.codec.QuotedPrintableInputStream; +import org.apache.james.mime4j.util.MimeUtil; +import org.openintents.openpgp.util.ParcelFileDescriptorUtil; + + +public class DecryptedFileProvider extends FileProvider { + private static final String DECRYPTED_CACHE_DIRECTORY = "decrypted"; + private static final long FILE_DELETE_THRESHOLD_MILLISECONDS = 3 * 60 * 1000; + private static final Object cleanupReceiverMonitor = new Object(); + + private static String AUTHORITY; + private static DecryptedFileProviderCleanupReceiver cleanupReceiver = null; + + + @Override + public boolean onCreate() { + String packageName = getContext().getPackageName(); + AUTHORITY = packageName + ".decryptedfileprovider"; + return true; + } + + public static FileFactory getFileFactory(Context context) { + final Context applicationContext = context.getApplicationContext(); + + return new FileFactory() { + @Override + public File createFile() throws IOException { + registerFileCleanupReceiver(applicationContext); + File decryptedTempDirectory = getDecryptedTempDirectory(applicationContext); + return File.createTempFile("decrypted-", null, decryptedTempDirectory); + } + }; + } + + @Nullable + public static Uri getUriForProvidedFile(@NonNull Context context, File file, + @Nullable String encoding, @Nullable String mimeType) { + try { + Uri.Builder uriBuilder = FileProvider.getUriForFile(context, AUTHORITY, file).buildUpon(); + if (mimeType != null) { + uriBuilder.appendQueryParameter("mime_type", mimeType); + } + if (encoding != null) { + uriBuilder.appendQueryParameter("encoding", encoding); + } + return uriBuilder.build(); + } catch (IllegalArgumentException e) { + return null; + } + } + + public static boolean deleteOldTemporaryFiles(Context context) { + File tempDirectory = getDecryptedTempDirectory(context); + boolean allFilesDeleted = true; + long deletionThreshold = System.currentTimeMillis() - FILE_DELETE_THRESHOLD_MILLISECONDS; + for (File tempFile : tempDirectory.listFiles()) { + long lastModified = tempFile.lastModified(); + if (lastModified < deletionThreshold) { + boolean fileDeleted = tempFile.delete(); + if (!fileDeleted) { + Timber.e("Failed to delete temporary file"); + // TODO really do this? might cause our service to stay up indefinitely if a file can't be deleted + allFilesDeleted = false; + } + } else { + if (K9.isDebugLoggingEnabled()) { + String timeLeftStr = String.format( + Locale.ENGLISH, "%.2f", (lastModified - deletionThreshold) / 1000 / 60.0); + Timber.e("Not deleting temp file (for another %s minutes)", timeLeftStr); + } + allFilesDeleted = false; + } + } + + return allFilesDeleted; + } + + private static File getDecryptedTempDirectory(Context context) { + File directory = new File(context.getCacheDir(), DECRYPTED_CACHE_DIRECTORY); + if (!directory.exists()) { + if (!directory.mkdir()) { + Timber.e("Error creating directory: %s", directory.getAbsolutePath()); + } + } + + return directory; + } + + + @Override + public String getType(Uri uri) { + return uri.getQueryParameter("mime_type"); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + ParcelFileDescriptor pfd = super.openFile(uri, "r"); + + InputStream decodedInputStream; + String encoding = uri.getQueryParameter("encoding"); + if (MimeUtil.isBase64Encoding(encoding)) { + InputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(pfd); + decodedInputStream = new Base64InputStream(inputStream); + } else if (MimeUtil.isQuotedPrintableEncoded(encoding)) { + InputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(pfd); + decodedInputStream = new QuotedPrintableInputStream(inputStream); + } else { // no or unknown encoding + if (!TextUtils.isEmpty(encoding)) { + Timber.e("unsupported encoding, returning raw stream"); + } + return pfd; + } + + try { + return ParcelFileDescriptorUtil.pipeFrom(decodedInputStream); + } catch (IOException e) { + // not strictly a FileNotFoundException, but failure to create a pipe is basically "can't access right now" + throw new FileNotFoundException(); + } + } + + @Override + public void onTrimMemory(int level) { + if (level < TRIM_MEMORY_COMPLETE) { + return; + } + final Context context = getContext(); + if (context == null) { + return; + } + + new AsyncTask() { + @Override + protected Void doInBackground(Void... voids) { + deleteOldTemporaryFiles(context); + return null; + } + }.execute(); + + unregisterFileCleanupReceiver(context); + } + + private static void unregisterFileCleanupReceiver(Context context) { + synchronized (cleanupReceiverMonitor) { + if (cleanupReceiver == null) { + return; + } + + Timber.d("Unregistering temp file cleanup receiver"); + context.unregisterReceiver(cleanupReceiver); + cleanupReceiver = null; + } + } + + private static void registerFileCleanupReceiver(Context context) { + synchronized (cleanupReceiverMonitor) { + if (cleanupReceiver != null) { + return; + } + + Timber.d("Registering temp file cleanup receiver"); + cleanupReceiver = new DecryptedFileProviderCleanupReceiver(); + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(Intent.ACTION_SCREEN_OFF); + context.registerReceiver(cleanupReceiver, intentFilter); + } + } + + private static class DecryptedFileProviderCleanupReceiver extends BroadcastReceiver { + @Override + @MainThread + public void onReceive(Context context, Intent intent) { + if (!Intent.ACTION_SCREEN_OFF.equals(intent.getAction())) { + throw new IllegalArgumentException("onReceive called with action that isn't screen off!"); + } + + Timber.d("Cleaning up temp files"); + + boolean allFilesDeleted = deleteOldTemporaryFiles(context); + if (allFilesDeleted) { + unregisterFileCleanupReceiver(context); + } + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java b/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java new file mode 100644 index 0000000..754bc12 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java @@ -0,0 +1,204 @@ +package com.fsck.k9.provider; + + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Collections; +import java.util.List; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.fsck.k9.Account; +import com.fsck.k9.DI; +import com.fsck.k9.Preferences; +import com.fsck.k9.controller.MessageReference; +import com.fsck.k9.mail.FetchProfile; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.filter.CountingOutputStream; +import com.fsck.k9.mailstore.LocalFolder; +import com.fsck.k9.mailstore.LocalMessage; +import com.fsck.k9.mailstore.LocalStore; +import com.fsck.k9.mailstore.LocalStoreProvider; +import org.openintents.openpgp.util.OpenPgpApi.OpenPgpDataSource; +import timber.log.Timber; + + +/** + * A simple ContentProvider that allows file access to a raw message. + */ +public class RawMessageProvider extends ContentProvider { + private static String AUTHORITY; + private static Uri CONTENT_URI; + + private static final String[] DEFAULT_PROJECTION = new String[] { + OpenableColumns.DISPLAY_NAME, + OpenableColumns.SIZE + }; + + + public static Uri getRawMessageUri(MessageReference messageReference) { + return CONTENT_URI.buildUpon() + .appendPath(messageReference.toIdentityString()) + .build(); + } + + @Override + public boolean onCreate() { + String packageName = getContext().getPackageName(); + AUTHORITY = packageName + ".rawmessageprovider"; + CONTENT_URI = Uri.parse("content://" + AUTHORITY); + return true; + } + + @Override + public String getType(@NonNull Uri uri) { + return "message/rfc822"; + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + + String[] columnNames = (projection == null) ? DEFAULT_PROJECTION : projection; + + List segments = uri.getPathSegments(); + String messageReferenceString = segments.get(0); + MessageReference messageReference = MessageReference.parse(messageReferenceString); + + LocalMessage message = loadMessage(messageReference); + if (message == null) { + return null; + } + + MatrixCursor ret = new MatrixCursor(columnNames); + Object[] values = new Object[columnNames.length]; + for (int i = 0, count = columnNames.length; i < count; i++) { + String column = columnNames[i]; + if (OpenableColumns.DISPLAY_NAME.equals(column)) { + values[i] = buildAttachmentFileName(message); + } else if (OpenableColumns.SIZE.equals(column)) { + values[i] = computeMessageSize(message); + } + } + ret.addRow(values); + return ret; + } + + private String buildAttachmentFileName(LocalMessage message) { + return message.getSubject() + ".eml"; + } + + private long computeMessageSize(LocalMessage message) { + // TODO: Store message size in database when saving message so this can be a simple lookup instead. + try (CountingOutputStream countingOutputStream = new CountingOutputStream()) { + message.writeTo(countingOutputStream); + return countingOutputStream.getCount(); + } catch (IOException | MessagingException e) { + Timber.w(e, "Unable to compute message size"); + return 0; + } + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + + @Override + public int delete(@NonNull Uri uri, String arg1, String[] arg2) { + throw new UnsupportedOperationException(); + } + + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + throw new UnsupportedOperationException(); + } + + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + List segments = uri.getPathSegments(); + String messageReferenceString = segments.get(0); + MessageReference messageReference = MessageReference.parse(messageReferenceString); + + ParcelFileDescriptor parcelFileDescriptor = openMessage(messageReference); + if (parcelFileDescriptor == null) { + throw new FileNotFoundException("Message missing or cannot be opened!"); + } + return parcelFileDescriptor; + } + + @Nullable + private ParcelFileDescriptor openMessage(MessageReference messageReference) { + try { + OpenPgpDataSource openPgpDataSource = getRawMessageDataSource(messageReference); + if (openPgpDataSource == null) { + return null; + } + return openPgpDataSource.startPumpThread(); + } catch (IOException e) { + Timber.e(e, "Error creating ParcelFileDescriptor"); + return null; + } + } + + @Nullable + private OpenPgpDataSource getRawMessageDataSource(MessageReference messageReference) { + final LocalMessage message = loadMessage(messageReference); + if (message == null) { + return null; + } + + return new OpenPgpDataSource() { + @Override + public void writeTo(OutputStream os) throws IOException { + try { + message.writeTo(os); + } catch (MessagingException e) { + throw new IOException(e); + } + } + }; + } + + private LocalMessage loadMessage(MessageReference messageReference) { + String accountUuid = messageReference.getAccountUuid(); + long folderId = messageReference.getFolderId(); + String uid = messageReference.getUid(); + + Account account = Preferences.getPreferences().getAccount(accountUuid); + if (account == null) { + Timber.w("Account not found: %s", accountUuid); + return null; + } + + try { + LocalStore localStore = DI.get(LocalStoreProvider.class).getInstance(account); + LocalFolder localFolder = localStore.getFolder(folderId); + localFolder.open(); + + LocalMessage message = localFolder.getMessage(uid); + if (message == null || message.getDatabaseId() == 0) { + Timber.w("Message not found: folder=%s, uid=%s", folderId, uid); + return null; + } + + FetchProfile fetchProfile = new FetchProfile(); + fetchProfile.add(FetchProfile.Item.BODY); + localFolder.fetch(Collections.singletonList(message), fetchProfile, null); + + return message; + } catch (MessagingException e) { + Timber.e(e, "Error loading message: folder=%d, uid=%s", folderId, uid); + return null; + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/search/AccountSearchConditions.kt b/app/core/src/main/java/com/fsck/k9/search/AccountSearchConditions.kt new file mode 100644 index 0000000..3a0beef --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/search/AccountSearchConditions.kt @@ -0,0 +1,77 @@ +package com.fsck.k9.search + +import com.fsck.k9.Account +import com.fsck.k9.Account.FolderMode +import com.fsck.k9.mail.FolderClass +import com.fsck.k9.search.SearchSpecification.Attribute +import com.fsck.k9.search.SearchSpecification.SearchCondition +import com.fsck.k9.search.SearchSpecification.SearchField + +/** + * Modify the supplied [LocalSearch] instance to limit the search to displayable folders. + * + * This method uses the current [folder display mode][Account.folderDisplayMode] to decide what folders to + * include/exclude. + */ +fun LocalSearch.limitToDisplayableFolders(account: Account) { + when (account.folderDisplayMode) { + FolderMode.FIRST_CLASS -> { + // Count messages in the INBOX and non-special first class folders + and(SearchField.DISPLAY_CLASS, FolderClass.FIRST_CLASS.name, Attribute.EQUALS) + } + FolderMode.FIRST_AND_SECOND_CLASS -> { + // Count messages in the INBOX and non-special first and second class folders + and(SearchField.DISPLAY_CLASS, FolderClass.FIRST_CLASS.name, Attribute.EQUALS) + + // TODO: Create a proper interface for creating arbitrary condition trees + val searchCondition = SearchCondition( + SearchField.DISPLAY_CLASS, + Attribute.EQUALS, + FolderClass.SECOND_CLASS.name + ) + val root = conditions + if (root.mRight != null) { + root.mRight.or(searchCondition) + } else { + or(searchCondition) + } + } + FolderMode.NOT_SECOND_CLASS -> { + // Count messages in the INBOX and non-special non-second-class folders + and(SearchField.DISPLAY_CLASS, FolderClass.SECOND_CLASS.name, Attribute.NOT_EQUALS) + } + FolderMode.ALL, FolderMode.NONE -> { + // Count messages in the INBOX and non-special folders + } + } +} + +/** + * Modify the supplied [LocalSearch] instance to exclude special folders. + * + * Currently the following folders are excluded: + * - Trash + * - Drafts + * - Spam + * - Outbox + * - Sent + * + * The Inbox will always be included even if one of the special folders is configured to point to the Inbox. + */ +fun LocalSearch.excludeSpecialFolders(account: Account) { + this.excludeSpecialFolder(account.trashFolderId) + this.excludeSpecialFolder(account.draftsFolderId) + this.excludeSpecialFolder(account.spamFolderId) + this.excludeSpecialFolder(account.outboxFolderId) + this.excludeSpecialFolder(account.sentFolderId) + + account.inboxFolderId?.let { inboxFolderId -> + or(SearchCondition(SearchField.FOLDER, Attribute.EQUALS, inboxFolderId.toString())) + } +} + +private fun LocalSearch.excludeSpecialFolder(folderId: Long?) { + if (folderId != null) { + and(SearchField.FOLDER, folderId.toString(), Attribute.NOT_EQUALS) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/search/ConditionsTreeNode.java b/app/core/src/main/java/com/fsck/k9/search/ConditionsTreeNode.java new file mode 100644 index 0000000..e915f53 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/search/ConditionsTreeNode.java @@ -0,0 +1,421 @@ +package com.fsck.k9.search; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Stack; +import java.util.Set; + +import android.database.Cursor; +import android.os.Parcel; +import android.os.Parcelable; + +import com.fsck.k9.search.SearchSpecification.Attribute; +import com.fsck.k9.search.SearchSpecification.SearchCondition; +import com.fsck.k9.search.SearchSpecification.SearchField; + + +/** + * This class stores search conditions. It's basically a boolean expression binary tree. + * The output will be SQL queries ( obtained by traversing inorder ). + * + * TODO removing conditions from the tree + * TODO implement NOT as a node again + */ +public class ConditionsTreeNode implements Parcelable { + + public enum Operator { + AND, OR, CONDITION + } + + public ConditionsTreeNode mLeft; + public ConditionsTreeNode mRight; + public ConditionsTreeNode mParent; + + /* + * If mValue isn't CONDITION then mCondition contains a real + * condition, otherwise it's null. + */ + public Operator mValue; + public SearchCondition mCondition; + + /* + * Used for storing and retrieving the tree to/from the database. + * The algorithm is called "modified preorder tree traversal". + */ + public int mLeftMPTTMarker; + public int mRightMPTTMarker; + + + /////////////////////////////////////////////////////////////// + // Static Helpers to restore a tree from a database cursor + /////////////////////////////////////////////////////////////// + /** + * Builds a condition tree starting from a database cursor. The cursor + * should point to rows representing the nodes of the tree. + * + * @param cursor Cursor pointing to the first of a bunch or rows. Each rows + * should contains 1 tree node. + * @return A condition tree. + */ + public static ConditionsTreeNode buildTreeFromDB(Cursor cursor) { + Stack stack = new Stack<>(); + ConditionsTreeNode tmp = null; + + // root node + if (cursor.moveToFirst()) { + tmp = buildNodeFromRow(cursor); + stack.push(tmp); + } + + // other nodes + while (cursor.moveToNext()) { + tmp = buildNodeFromRow(cursor); + if (tmp.mRightMPTTMarker < stack.peek().mRightMPTTMarker) { + stack.peek().mLeft = tmp; + stack.push(tmp); + } else { + while (stack.peek().mRightMPTTMarker < tmp.mRightMPTTMarker) { + stack.pop(); + } + stack.peek().mRight = tmp; + } + } + return tmp; + } + + /** + * Converts a single database row to a single condition node. + * + * @param cursor Cursor pointing to the row we want to convert. + * @return A single ConditionsTreeNode + */ + private static ConditionsTreeNode buildNodeFromRow(Cursor cursor) { + ConditionsTreeNode result = null; + SearchCondition condition = null; + + Operator tmpValue = ConditionsTreeNode.Operator.valueOf(cursor.getString(5)); + + if (tmpValue == Operator.CONDITION) { + condition = new SearchCondition(SearchField.valueOf(cursor.getString(0)), + Attribute.valueOf(cursor.getString(2)), cursor.getString(1)); + } + + result = new ConditionsTreeNode(condition); + result.mValue = tmpValue; + result.mLeftMPTTMarker = cursor.getInt(3); + result.mRightMPTTMarker = cursor.getInt(4); + + return result; + } + + + /////////////////////////////////////////////////////////////// + // Constructors + /////////////////////////////////////////////////////////////// + public ConditionsTreeNode(SearchCondition condition) { + mParent = null; + mCondition = condition; + mValue = Operator.CONDITION; + } + + public ConditionsTreeNode(ConditionsTreeNode parent, Operator op) { + mParent = parent; + mValue = op; + mCondition = null; + } + + + /* package */ ConditionsTreeNode cloneTree() { + if (mParent != null) { + throw new IllegalStateException("Can't call cloneTree() for a non-root node"); + } + + ConditionsTreeNode copy = new ConditionsTreeNode(mCondition.clone()); + + copy.mLeftMPTTMarker = mLeftMPTTMarker; + copy.mRightMPTTMarker = mRightMPTTMarker; + + copy.mLeft = (mLeft == null) ? null : mLeft.cloneNode(copy); + copy.mRight = (mRight == null) ? null : mRight.cloneNode(copy); + + return copy; + } + + private ConditionsTreeNode cloneNode(ConditionsTreeNode parent) { + ConditionsTreeNode copy = new ConditionsTreeNode(parent, mValue); + + copy.mCondition = mCondition.clone(); + copy.mLeftMPTTMarker = mLeftMPTTMarker; + copy.mRightMPTTMarker = mRightMPTTMarker; + + copy.mLeft = (mLeft == null) ? null : mLeft.cloneNode(copy); + copy.mRight = (mRight == null) ? null : mRight.cloneNode(copy); + + return copy; + } + + /////////////////////////////////////////////////////////////// + // Public modifiers + /////////////////////////////////////////////////////////////// + /** + * Adds the expression as the second argument of an AND + * clause to this node. + * + * @param expr Expression to 'AND' with. + * @return New top AND node. + */ + public ConditionsTreeNode and(ConditionsTreeNode expr) { + return add(expr, Operator.AND); + } + + /** + * Convenience method. + * Adds the provided condition as the second argument of an AND + * clause to this node. + * + * @param condition Condition to 'AND' with. + * @return New top AND node, new root. + */ + public ConditionsTreeNode and(SearchCondition condition) { + ConditionsTreeNode tmp = new ConditionsTreeNode(condition); + return and(tmp); + } + + /** + * Adds the expression as the second argument of an OR + * clause to this node. + * + * @param expr Expression to 'OR' with. + * @return New top OR node. + */ + public ConditionsTreeNode or(ConditionsTreeNode expr) { + return add(expr, Operator.OR); + } + + /** + * Convenience method. + * Adds the provided condition as the second argument of an OR + * clause to this node. + * + * @param condition Condition to 'OR' with. + * @return New top OR node, new root. + */ + public ConditionsTreeNode or(SearchCondition condition) { + ConditionsTreeNode tmp = new ConditionsTreeNode(condition); + return or(tmp); + } + + /** + * This applies the MPTT labeling to the subtree of which this node + * is the root node. + * + * For a description on MPTT see: + * http://www.sitepoint.com/hierarchical-data-database-2/ + */ + public void applyMPTTLabel() { + applyMPTTLabel(1); + } + + + /////////////////////////////////////////////////////////////// + // Public accessors + /////////////////////////////////////////////////////////////// + /** + * Returns the condition stored in this node. + * @return Condition stored in the node. + */ + public SearchCondition getCondition() { + return mCondition; + } + + /** + * Get a set of all the leaves in the tree. + * @return Set of all the leaves. + */ + public Set getLeafSet() { + Set leafSet = new HashSet<>(); + return getLeafSet(leafSet); + } + + /** + * Returns a list of all the nodes in the subtree of which this node + * is the root. The list contains the nodes in a pre traversal order. + * + * @return List of all nodes in subtree in preorder. + */ + public List preorder() { + List result = new ArrayList<>(); + Stack stack = new Stack<>(); + stack.push(this); + + while (!stack.isEmpty()) { + ConditionsTreeNode current = stack.pop(); + + if (current.mLeft != null) { + stack.push(current.mLeft); + } + + if (current.mRight != null) { + stack.push(current.mRight); + } + + result.add(current); + } + + return result; + } + + + /////////////////////////////////////////////////////////////// + // Private class logic + /////////////////////////////////////////////////////////////// + /** + * Adds two new ConditionTreeNodes, one for the operator and one for the + * new condition. The current node will end up on the same level as the + * one provided in the arguments, they will be siblings. Their common + * parent node will be one containing the operator provided in the arguments. + * The method will update all the required references so the tree ends up in + * a valid state. + * + * This method only supports node arguments with a null parent node. + * + * @param node Node to add. + * @param op Operator that will connect the new node with this one. + * @return New parent node, containing the operator. + * @throws IllegalArgumentException Throws when the provided new node does not have a null parent. + */ + private ConditionsTreeNode add(ConditionsTreeNode node, Operator op) { + if (node.mParent != null) { + throw new IllegalArgumentException("Can only add new expressions from root node down."); + } + + ConditionsTreeNode tmpNode = new ConditionsTreeNode(mParent, op); + tmpNode.mLeft = this; + tmpNode.mRight = node; + + if (mParent != null) { + mParent.updateChild(this, tmpNode); + } + + this.mParent = tmpNode; + node.mParent = tmpNode; + + return tmpNode; + } + + /** + * Helper method that replaces a child of the current node with a new node. + * If the provided old child node was the left one, left will be replaced with + * the new one. Same goes for the right one. + * + * @param oldChild Old child node to be replaced. + * @param newChild New child node. + */ + private void updateChild(ConditionsTreeNode oldChild, ConditionsTreeNode newChild) { + // we can compare objects id's because this is the desired behaviour in this case + if (mLeft == oldChild) { + mLeft = newChild; + } else if (mRight == oldChild) { + mRight = newChild; + } + } + + /** + * Recursive function to gather all the leaves in the subtree of which + * this node is the root. + * + * @param leafSet Leafset that's being built. + * @return Set of leaves being completed. + */ + private Set getLeafSet(Set leafSet) { + if (mLeft == null && mRight == null) { + // if we ended up in a leaf, add ourself and return + leafSet.add(this); + return leafSet; + } + + // we didn't end up in a leaf + if (mLeft != null) { + mLeft.getLeafSet(leafSet); + } + + if (mRight != null) { + mRight.getLeafSet(leafSet); + } + return leafSet; + } + + /** + * This applies the MPTT labeling to the subtree of which this node + * is the root node. + * + * For a description on MPTT see: + * http://www.sitepoint.com/hierarchical-data-database-2/ + */ + private int applyMPTTLabel(int label) { + mLeftMPTTMarker = label; + + if (mLeft != null) { + label = mLeft.applyMPTTLabel(label += 1); + } + + if (mRight != null) { + label = mRight.applyMPTTLabel(label += 1); + } + + ++label; + mRightMPTTMarker = label; + return label; + } + + + /////////////////////////////////////////////////////////////// + // Parcelable + // + // This whole class has to be parcelable because it's passed + // on through intents. + /////////////////////////////////////////////////////////////// + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mValue.ordinal()); + dest.writeParcelable(mCondition, flags); + dest.writeParcelable(mLeft, flags); + dest.writeParcelable(mRight, flags); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public ConditionsTreeNode createFromParcel(Parcel in) { + return new ConditionsTreeNode(in); + } + + @Override + public ConditionsTreeNode[] newArray(int size) { + return new ConditionsTreeNode[size]; + } + }; + + private ConditionsTreeNode(Parcel in) { + mValue = Operator.values()[in.readInt()]; + mCondition = in.readParcelable(ConditionsTreeNode.class.getClassLoader()); + mLeft = in.readParcelable(ConditionsTreeNode.class.getClassLoader()); + mRight = in.readParcelable(ConditionsTreeNode.class.getClassLoader()); + mParent = null; + + if (mLeft != null) { + mLeft.mParent = this; + } + + if (mRight != null) { + mRight.mParent = this; + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/search/LocalSearch.java b/app/core/src/main/java/com/fsck/k9/search/LocalSearch.java new file mode 100644 index 0000000..e538a97 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/search/LocalSearch.java @@ -0,0 +1,360 @@ +package com.fsck.k9.search; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * This class represents a local search. + * + * Removing conditions could be done through matching there unique id in the leafset and then + * removing them from the tree. + * + * TODO implement a complete addAllowedFolder method + * TODO conflicting conditions check on add + * TODO duplicate condition checking? + * TODO assign each node a unique id that's used to retrieve it from the leafset and remove. + * + */ + +public class LocalSearch implements SearchSpecification { + + private String id; + private boolean mPredefined; + private boolean mManualSearch = false; + + // since the uuid isn't in the message table it's not in the tree neither + private Set mAccountUuids = new HashSet<>(); + private ConditionsTreeNode mConditions = null; + private Set mLeafSet = new HashSet<>(); + + + /////////////////////////////////////////////////////////////// + // Constructors + /////////////////////////////////////////////////////////////// + /** + * Use this only if the search won't be saved. Saved searches need + * a name! + */ + public LocalSearch() {} + + /** + * Use this constructor when you know what you're doing. Normally it's only used + * when restoring these search objects from the database. + * + * @param searchConditions SearchConditions, may contains flags and folders + * @param accounts Relative Account's uuid's + * @param predefined Is this a predefined search or a user created one? + */ + protected LocalSearch(ConditionsTreeNode searchConditions, String accounts, boolean predefined) { + mConditions = searchConditions; + mPredefined = predefined; + mLeafSet = new HashSet<>(); + if (mConditions != null) { + mLeafSet.addAll(mConditions.getLeafSet()); + } + + // initialize accounts + if (accounts != null) { + for (String account : accounts.split(",")) { + mAccountUuids.add(account); + } + } else { + // impossible but still not unrecoverable + } + } + + @Override + public LocalSearch clone() { + ConditionsTreeNode conditions = (mConditions == null) ? null : mConditions.cloneTree(); + + LocalSearch copy = new LocalSearch(conditions, null, mPredefined); + copy.mManualSearch = mManualSearch; + copy.mAccountUuids = new HashSet<>(mAccountUuids); + + return copy; + } + + /////////////////////////////////////////////////////////////// + // Public manipulation methods + /////////////////////////////////////////////////////////////// + /** + * Set the ID of the search. This is used to identify a unified inbox + * search + * + * @param id ID to set + */ + public void setId(String id) { + this.id = id; + } + + /** + * Add a new account to the search. When no accounts are + * added manually we search all accounts on the device. + * + * @param uuid Uuid of the account to be added. + */ + public void addAccountUuid(String uuid) { + mAccountUuids.add(uuid); + } + + /** + * Adds all the account uuids in the provided array to + * be matched by the search. + * + * @param accountUuids + */ + public void addAccountUuids(String[] accountUuids) { + for (String acc : accountUuids) { + addAccountUuid(acc); + } + } + + /** + * Removes an account UUID from the current search. + * + * @param uuid Account UUID to remove. + * @return True if removed, false otherwise. + */ + public boolean removeAccountUuid(String uuid) { + return mAccountUuids.remove(uuid); + } + + /** + * Adds the provided node as the second argument of an AND + * clause to this node. + * + * @param field Message table field to match against. + * @param value Value to look for. + * @param attribute Attribute to use when matching. + */ + public void and(SearchField field, String value, Attribute attribute) { + and(new SearchCondition(field, attribute, value)); + } + + /** + * Adds the provided condition as the second argument of an AND + * clause to this node. + * + * @param condition Condition to 'AND' with. + * @return New top AND node, new root. + */ + public ConditionsTreeNode and(SearchCondition condition) { + ConditionsTreeNode tmp = new ConditionsTreeNode(condition); + return and(tmp); + } + + /** + * Adds the provided node as the second argument of an AND + * clause to this node. + * + * @param node Node to 'AND' with. + * @return New top AND node, new root. + */ + public ConditionsTreeNode and(ConditionsTreeNode node) { + mLeafSet.addAll(node.getLeafSet()); + + if (mConditions == null) { + mConditions = node; + return node; + } + + mConditions = mConditions.and(node); + return mConditions; + } + + /** + * Adds the provided condition as the second argument of an OR + * clause to this node. + * + * @param condition Condition to 'OR' with. + * @return New top OR node, new root. + */ + public ConditionsTreeNode or(SearchCondition condition) { + ConditionsTreeNode tmp = new ConditionsTreeNode(condition); + return or(tmp); + } + + /** + * Adds the provided node as the second argument of an OR + * clause to this node. + * + * @param node Node to 'OR' with. + * @return New top OR node, new root. + */ + public ConditionsTreeNode or(ConditionsTreeNode node) { + mLeafSet.addAll(node.getLeafSet()); + + if (mConditions == null) { + mConditions = node; + return node; + } + + mConditions = mConditions.or(node); + return mConditions; + } + + /** + * TODO + * FOR NOW: And the folder with the root. + * + * Add the folder as another folder to search in. The folder + * will be added AND to the root if no 'folder subtree' was found. + * Otherwise the folder will be added OR to that tree. + */ + public void addAllowedFolder(long folderId) { + /* + * TODO find folder sub-tree + * - do and on root of it & rest of search + * - do or between folder nodes + */ + mConditions = and(new SearchCondition(SearchField.FOLDER, Attribute.EQUALS, Long.toString(folderId))); + } + + /* + * TODO make this more advanced! + * This is a temporary solution that does NOT WORK for + * real searches because of possible extra conditions to a folder requirement. + */ + public List getFolderIds() { + List results = new ArrayList<>(); + for (ConditionsTreeNode node : mLeafSet) { + if (node.mCondition.field == SearchField.FOLDER && + node.mCondition.attribute == Attribute.EQUALS) { + results.add(Long.valueOf(node.mCondition.value)); + } + } + return results; + } + + /** + * Gets the leafset of the related condition tree. + * + * @return All the leaf conditions as a set. + */ + public Set getLeafSet() { + return mLeafSet; + } + + /////////////////////////////////////////////////////////////// + // Public accessor methods + /////////////////////////////////////////////////////////////// + /** + * TODO THIS HAS TO GO!!!! + * very dirty fix for remotesearch support atm + */ + public String getRemoteSearchArguments() { + Set leafSet = getLeafSet(); + if (leafSet == null) { + return null; + } + + for (ConditionsTreeNode node : leafSet) { + if (node.getCondition().field == SearchField.SUBJECT || + node.getCondition().field == SearchField.SENDER ) { + return node.getCondition().value; + } + } + return null; + } + + /** + * Returns the ID of the search + * + * @return The ID of the search + */ + public String getId() { + return (id == null) ? "" : id; + } + + /** + * Checks if this search was hard coded and shipped with K-9 + * + * @return True is search was shipped with K-9 + */ + public boolean isPredefined() { + return mPredefined; + } + + public boolean isManualSearch() { + return mManualSearch; + } + + public void setManualSearch(boolean manualSearch) { + mManualSearch = manualSearch; + } + + /** + * Returns all the account uuids that this search will try to match against. Might be an empty array, in which case + * all accounts should be included in the search. + */ + @Override + public String[] getAccountUuids() { + String[] tmp = new String[mAccountUuids.size()]; + mAccountUuids.toArray(tmp); + return tmp; + } + + /** + * Returns whether or not to search all accounts. + * + * @return {@code true} if all accounts should be searched. + */ + public boolean searchAllAccounts() { + return (mAccountUuids.isEmpty()); + } + + /** + * Get the condition tree. + * + * @return The root node of the related conditions tree. + */ + @Override + public ConditionsTreeNode getConditions() { + return mConditions; + } + + /////////////////////////////////////////////////////////////// + // Parcelable + /////////////////////////////////////////////////////////////// + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeByte((byte) (mPredefined ? 1 : 0)); + dest.writeByte((byte) (mManualSearch ? 1 : 0)); + dest.writeStringList(new ArrayList<>(mAccountUuids)); + dest.writeParcelable(mConditions, flags); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public LocalSearch createFromParcel(Parcel in) { + return new LocalSearch(in); + } + + @Override + public LocalSearch[] newArray(int size) { + return new LocalSearch[size]; + } + }; + + public LocalSearch(Parcel in) { + id = in.readString(); + mPredefined = (in.readByte() == 1); + mManualSearch = (in.readByte() == 1); + mAccountUuids.addAll(in.createStringArrayList()); + mConditions = in.readParcelable(LocalSearch.class.getClassLoader()); + mLeafSet = (mConditions == null) ? null : mConditions.getLeafSet(); + } +} diff --git a/app/core/src/main/java/com/fsck/k9/search/LocalSearchExtensions.kt b/app/core/src/main/java/com/fsck/k9/search/LocalSearchExtensions.kt new file mode 100644 index 0000000..d528446 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/search/LocalSearchExtensions.kt @@ -0,0 +1,33 @@ +@file:JvmName("LocalSearchExtensions") + +package com.fsck.k9.search + +import com.fsck.k9.Account +import com.fsck.k9.Preferences + +val LocalSearch.isUnifiedInbox: Boolean + get() = id == SearchAccount.UNIFIED_INBOX + +val LocalSearch.isNewMessages: Boolean + get() = id == SearchAccount.NEW_MESSAGES + +val LocalSearch.isSingleAccount: Boolean + get() = accountUuids.size == 1 + +val LocalSearch.isSingleFolder: Boolean + get() = isSingleAccount && folderIds.size == 1 + +@JvmName("getAccountsFromLocalSearch") +fun LocalSearch.getAccounts(preferences: Preferences): List { + val accounts = preferences.accounts + return if (searchAllAccounts()) { + accounts + } else { + val searchAccountUuids = accountUuids.toSet() + accounts.filter { it.uuid in searchAccountUuids } + } +} + +fun LocalSearch.getAccountUuids(preferences: Preferences): List { + return getAccounts(preferences).map { it.uuid } +} diff --git a/app/core/src/main/java/com/fsck/k9/search/SearchAccount.kt b/app/core/src/main/java/com/fsck/k9/search/SearchAccount.kt new file mode 100644 index 0000000..68a7abe --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/search/SearchAccount.kt @@ -0,0 +1,53 @@ +package com.fsck.k9.search + +import com.fsck.k9.BaseAccount +import com.fsck.k9.CoreResourceProvider +import com.fsck.k9.search.SearchSpecification.SearchField +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * This class is basically a wrapper around a LocalSearch. It allows to expose it as an account. + * This is a meta-account containing all the messages that match the search. + */ +class SearchAccount( + val id: String, + search: LocalSearch, + override val name: String, + override val email: String +) : BaseAccount { + /** + * Returns the ID of this `SearchAccount` instance. + * + * This isn't really a UUID. But since we don't expose this value to other apps and we only use the account UUID + * as opaque string (e.g. as key in a `Map`) we're fine. + * + * Using a constant string is necessary to identify the same search account even when the corresponding + * [SearchAccount] object has been recreated. + */ + override val uuid: String = id + + val relatedSearch: LocalSearch = search + + companion object : KoinComponent { + private val resourceProvider: CoreResourceProvider by inject() + + const val UNIFIED_INBOX = "unified_inbox" + const val NEW_MESSAGES = "new_messages" + + @JvmStatic + fun createUnifiedInboxAccount(): SearchAccount { + val tmpSearch = LocalSearch().apply { + id = UNIFIED_INBOX + and(SearchField.INTEGRATE, "1", SearchSpecification.Attribute.EQUALS) + } + + return SearchAccount( + id = UNIFIED_INBOX, + search = tmpSearch, + name = resourceProvider.searchUnifiedInboxTitle(), + email = resourceProvider.searchUnifiedInboxDetail() + ) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java b/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java new file mode 100644 index 0000000..f3ad2f0 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java @@ -0,0 +1,164 @@ +package com.fsck.k9.search; + +import android.os.Parcel; +import android.os.Parcelable; + +public interface SearchSpecification extends Parcelable { + + /** + * Get all the uuids of accounts this search acts on. + * @return Array of uuids. + */ + String[] getAccountUuids(); + + /** + * Returns the root node of the condition tree accompanying + * the search. + * + * @return Root node of conditions tree. + */ + ConditionsTreeNode getConditions(); + + /////////////////////////////////////////////////////////////// + // ATTRIBUTE enum + /////////////////////////////////////////////////////////////// + enum Attribute { + CONTAINS, + NOT_CONTAINS, + + EQUALS, + NOT_EQUALS, + + STARTSWITH, + NOT_STARTSWITH, + + ENDSWITH, + NOT_ENDSWITH + } + + /////////////////////////////////////////////////////////////// + // SEARCHFIELD enum + /////////////////////////////////////////////////////////////// + /* + * Using an enum in order to have more robust code. Users ( & coders ) + * are prevented from passing illegal fields. No database overhead + * when invalid fields passed. + * + * By result, only the fields in here are searchable. + * + * Fields not in here at this moment ( and by effect not searchable ): + * id, html_content, internal_date, message_id, + * preview, mime_type + * + */ + enum SearchField { + SUBJECT, + DATE, + UID, + FLAG, + SENDER, + TO, + CC, + FOLDER, + BCC, + REPLY_TO, + MESSAGE_CONTENTS, + ATTACHMENT_COUNT, + DELETED, + THREAD_ID, + ID, + INTEGRATE, + NEW_MESSAGE, + READ, + FLAGGED, + DISPLAY_CLASS + } + + + /////////////////////////////////////////////////////////////// + // SearchCondition class + /////////////////////////////////////////////////////////////// + /** + * This class represents 1 value for a certain search field. One + * value consists of three things: + * an attribute: equals, starts with, contains,... + * a searchfield: date, flags, sender, subject,... + * a value: "apple", "jesse",.. + * + * @author dzan + */ + class SearchCondition implements Parcelable { + public final String value; + public final Attribute attribute; + public final SearchField field; + + public SearchCondition(SearchField field, Attribute attribute, String value) { + this.value = value; + this.attribute = attribute; + this.field = field; + } + + private SearchCondition(Parcel in) { + this.value = in.readString(); + this.attribute = Attribute.values()[in.readInt()]; + this.field = SearchField.values()[in.readInt()]; + } + + @Override + public SearchCondition clone() { + return new SearchCondition(field, attribute, value); + } + + public String toHumanString() { + return field.toString() + attribute.toString(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof SearchCondition) { + SearchCondition tmp = (SearchCondition) o; + return tmp.attribute == attribute && + tmp.field == field && + tmp.value.equals(value); + } + + return false; + } + + @Override + public int hashCode() { + int result = 1; + result = 31 * result + attribute.hashCode(); + result = 31 * result + field.hashCode(); + result = 31 * result + value.hashCode(); + + return result; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(value); + dest.writeInt(attribute.ordinal()); + dest.writeInt(field.ordinal()); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public SearchCondition createFromParcel(Parcel in) { + return new SearchCondition(in); + } + + @Override + public SearchCondition[] newArray(int size) { + return new SearchCondition[size]; + } + }; + } +} diff --git a/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java b/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java new file mode 100644 index 0000000..088e118 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java @@ -0,0 +1,233 @@ +package com.fsck.k9.search; + +import java.util.List; + +import timber.log.Timber; + +import com.fsck.k9.search.SearchSpecification.Attribute; +import com.fsck.k9.search.SearchSpecification.SearchCondition; +import com.fsck.k9.search.SearchSpecification.SearchField; + + +public class SqlQueryBuilder { + public static void buildWhereClause(ConditionsTreeNode node, StringBuilder query, List selectionArgs) { + buildWhereClauseInternal(node, query, selectionArgs); + } + + private static void buildWhereClauseInternal(ConditionsTreeNode node, StringBuilder query, + List selectionArgs) { + + if (node == null) { + query.append("1"); + return; + } + + if (node.mLeft == null && node.mRight == null) { + SearchCondition condition = node.mCondition; + if (condition.field == SearchField.MESSAGE_CONTENTS) { + String fulltextQueryString = condition.value; + if (condition.attribute != Attribute.CONTAINS) { + Timber.e("message contents can only be matched!"); + } + query.append("messages.id IN (SELECT docid FROM messages_fulltext WHERE fulltext MATCH ?)"); + selectionArgs.add(fulltextQueryString); + } else { + appendCondition(condition, query, selectionArgs); + } + } else { + query.append("("); + buildWhereClauseInternal(node.mLeft, query, selectionArgs); + query.append(") "); + query.append(node.mValue.name()); + query.append(" ("); + buildWhereClauseInternal(node.mRight, query, selectionArgs); + query.append(")"); + } + } + + private static void appendCondition(SearchCondition condition, StringBuilder query, + List selectionArgs) { + query.append(getColumnName(condition)); + appendExprRight(condition, query, selectionArgs); + } + + private static String getColumnName(SearchCondition condition) { + String columnName = null; + switch (condition.field) { + case ATTACHMENT_COUNT: { + columnName = "attachment_count"; + break; + } + case BCC: { + columnName = "bcc_list"; + break; + } + case CC: { + columnName = "cc_list"; + break; + } + case FOLDER: { + columnName = "folder_id"; + break; + } + case DATE: { + columnName = "date"; + break; + } + case DELETED: { + columnName = "deleted"; + break; + } + case FLAG: { + columnName = "flags"; + break; + } + case ID: { + columnName = "id"; + break; + } + case REPLY_TO: { + columnName = "reply_to_list"; + break; + } + case SENDER: { + columnName = "sender_list"; + break; + } + case SUBJECT: { + columnName = "subject"; + break; + } + case TO: { + columnName = "to_list"; + break; + } + case UID: { + columnName = "uid"; + break; + } + case INTEGRATE: { + columnName = "integrate"; + break; + } + case NEW_MESSAGE: { + columnName = "new_message"; + break; + } + case READ: { + columnName = "read"; + break; + } + case FLAGGED: { + columnName = "flagged"; + break; + } + case DISPLAY_CLASS: { + columnName = "display_class"; + break; + } + case THREAD_ID: { + columnName = "threads.root"; + break; + } + case MESSAGE_CONTENTS: { + // Special case handled in buildWhereClauseInternal() + break; + } + } + + if (columnName == null) { + throw new RuntimeException("Unhandled case"); + } + + return columnName; + } + + private static void appendExprRight(SearchCondition condition, StringBuilder query, + List selectionArgs) { + String value = condition.value; + SearchField field = condition.field; + + query.append(" "); + String selectionArg = null; + switch (condition.attribute) { + case NOT_CONTAINS: + query.append("NOT "); + //$FALL-THROUGH$ + case CONTAINS: { + query.append("LIKE ?"); + selectionArg = "%" + value + "%"; + break; + } + case NOT_STARTSWITH: + query.append("NOT "); + //$FALL-THROUGH$ + case STARTSWITH: { + query.append("LIKE ?"); + selectionArg = "%" + value; + break; + } + case NOT_ENDSWITH: + query.append("NOT "); + //$FALL-THROUGH$ + case ENDSWITH: { + query.append("LIKE ?"); + selectionArg = value + "%"; + break; + } + case NOT_EQUALS: { + if (isNumberColumn(field)) { + query.append("!= ?"); + } else { + query.append("NOT LIKE ?"); + } + selectionArg = value; + break; + } + case EQUALS: { + if (isNumberColumn(field)) { + query.append("= ?"); + } else { + query.append("LIKE ?"); + } + selectionArg = value; + break; + } + } + + if (selectionArg == null) { + throw new RuntimeException("Unhandled case"); + } + + selectionArgs.add(selectionArg); + } + + private static boolean isNumberColumn(SearchField field) { + switch (field) { + case ATTACHMENT_COUNT: + case DATE: + case DELETED: + case FOLDER: + case ID: + case INTEGRATE: + case NEW_MESSAGE: + case THREAD_ID: + case READ: + case FLAGGED: { + return true; + } + default: { + return false; + } + } + } + + public static String addPrefixToSelection(String[] columnNames, String prefix, String selection) { + String result = selection; + for (String columnName : columnNames) { + result = result.replaceAll("(?<=^|[^\\.])\\b" + columnName + "\\b", prefix + columnName); + } + + return result; + } +} diff --git a/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java b/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java new file mode 100644 index 0000000..6befea4 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java @@ -0,0 +1,223 @@ +package com.fsck.k9.service; + + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.fsck.k9.Account; +import com.fsck.k9.DI; +import com.fsck.k9.K9; +import com.fsck.k9.Preferences; +import com.fsck.k9.mail.power.PowerManager; +import com.fsck.k9.mail.power.WakeLock; +import com.fsck.k9.mailstore.LocalStoreProvider; +import timber.log.Timber; + +/** + * Service used to upgrade the accounts' databases and/or track the progress of the upgrade. + * + *

    + * See {@code UpgradeDatabases} for a detailed explanation of the database upgrade process. + *

    + */ +public class DatabaseUpgradeService extends Service { + /** + * Broadcast intent reporting the current progress of the database upgrade. + * + *

    Extras:

    + *
      + *
    • {@link #EXTRA_ACCOUNT_UUID}
    • + *
    • {@link #EXTRA_PROGRESS}
    • + *
    • {@link #EXTRA_PROGRESS_END}
    • + *
    + */ + public static final String ACTION_UPGRADE_PROGRESS = "DatabaseUpgradeService.upgradeProgress"; + + /** + * Broadcast intent sent when the upgrade has been completed. + */ + public static final String ACTION_UPGRADE_COMPLETE = "DatabaseUpgradeService.upgradeComplete"; + + /** + * UUID of the account whose database is currently being upgraded. + */ + public static final String EXTRA_ACCOUNT_UUID = "account_uuid"; + + /** + * The current progress. + * + *

    Integer from {@code 0} (inclusive) to the value in {@link #EXTRA_PROGRESS_END} + * (exclusive).

    + */ + public static final String EXTRA_PROGRESS = "progress"; + + /** + * Number of items that will be upgraded. + * + *

    Currently this is the number of accounts.

    + */ + public static final String EXTRA_PROGRESS_END = "progress_end"; + + + /** + * Action used to start this service. + */ + private static final String ACTION_START_SERVICE = + "com.fsck.k9.service.DatabaseUpgradeService.startService"; + + private static final String WAKELOCK_TAG = "DatabaseUpgradeService"; + private static final long WAKELOCK_TIMEOUT = 10 * 60 * 1000; // 10 minutes + + + /** + * Start {@link DatabaseUpgradeService}. + * + * @param context + * The {@link Context} used to start this service. + */ + public static void startService(Context context) { + Intent i = new Intent(); + i.setClass(context, DatabaseUpgradeService.class); + i.setAction(DatabaseUpgradeService.ACTION_START_SERVICE); + context.startService(i); + } + + + /** + * Stores whether or not this service was already running when + * {@link #onStartCommand(Intent, int, int)} is executed. + */ + private AtomicBoolean mRunning = new AtomicBoolean(false); + + private LocalBroadcastManager mLocalBroadcastManager; + + private String mAccountUuid; + private int mProgress; + private int mProgressEnd; + + private WakeLock mWakeLock; + + + @Override + public IBinder onBind(Intent intent) { + // unused + return null; + } + + @Override + public void onCreate() { + mLocalBroadcastManager = LocalBroadcastManager.getInstance(this); + } + + @Override + public final int onStartCommand(Intent intent, int flags, int startId) { + boolean success = mRunning.compareAndSet(false, true); + if (success) { + // The service wasn't running yet. + Timber.i("DatabaseUpgradeService started"); + + acquireWakelock(); + + startUpgradeInBackground(); + } else { + // We're already running, so don't start the upgrade process again. But send the current + // progress via broadcast. + sendProgressBroadcast(mAccountUuid, mProgress, mProgressEnd); + } + + return START_STICKY; + } + + /** + * Acquire a partial wake lock so the CPU won't go to sleep when the screen is turned off. + */ + private void acquireWakelock() { + PowerManager pm = DI.get(PowerManager.class); + mWakeLock = pm.newWakeLock(WAKELOCK_TAG); + mWakeLock.setReferenceCounted(false); + mWakeLock.acquire(WAKELOCK_TIMEOUT); + } + + /** + * Release the wake lock. + */ + private void releaseWakelock() { + mWakeLock.release(); + } + + /** + * Stop this service. + */ + private void stopService() { + stopSelf(); + Timber.i("DatabaseUpgradeService stopped"); + + releaseWakelock(); + mRunning.set(false); + } + + /** + * Start a background thread for upgrading the databases. + */ + private void startUpgradeInBackground() { + new Thread("DatabaseUpgradeService") { + @Override + public void run() { + upgradeDatabases(); + stopService(); + } + }.start(); + } + + /** + * Upgrade the accounts' databases. + */ + private void upgradeDatabases() { + Preferences preferences = Preferences.getPreferences(); + + List accounts = preferences.getAccounts(); + mProgressEnd = accounts.size(); + mProgress = 0; + + for (Account account : accounts) { + mAccountUuid = account.getUuid(); + + sendProgressBroadcast(mAccountUuid, mProgress, mProgressEnd); + + try { + // Account.getLocalStore() is blocking and will upgrade the database if necessary + DI.get(LocalStoreProvider.class).getInstance(account); + } catch (Exception e) { + Timber.e(e, "Error while upgrading database"); + } + + mProgress++; + } + + K9.setDatabasesUpToDate(true); + sendUpgradeCompleteBroadcast(); + } + + private void sendProgressBroadcast(String accountUuid, int progress, int progressEnd) { + Intent intent = new Intent(); + intent.setAction(ACTION_UPGRADE_PROGRESS); + intent.putExtra(EXTRA_ACCOUNT_UUID, accountUuid); + intent.putExtra(EXTRA_PROGRESS, progress); + intent.putExtra(EXTRA_PROGRESS_END, progressEnd); + + mLocalBroadcastManager.sendBroadcast(intent); + } + + private void sendUpgradeCompleteBroadcast() { + Intent intent = new Intent(); + intent.setAction(ACTION_UPGRADE_COMPLETE); + + mLocalBroadcastManager.sendBroadcast(intent); + } +} diff --git a/app/core/src/main/java/com/fsck/k9/setup/ServerNameSuggester.kt b/app/core/src/main/java/com/fsck/k9/setup/ServerNameSuggester.kt new file mode 100644 index 0000000..376b73c --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/setup/ServerNameSuggester.kt @@ -0,0 +1,13 @@ +package com.fsck.k9.setup + +import com.fsck.k9.preferences.Protocols + +class ServerNameSuggester { + fun suggestServerName(serverType: String, domainPart: String): String = when (serverType) { + Protocols.IMAP -> "imap.$domainPart" + Protocols.SMTP -> "smtp.$domainPart" + Protocols.WEBDAV -> "exchange.$domainPart" + Protocols.POP3 -> "pop3.$domainPart" + else -> throw AssertionError("Missed case: $serverType") + } +} diff --git a/app/core/src/main/res/values/arrays_account_settings_values.xml b/app/core/src/main/res/values/arrays_account_settings_values.xml new file mode 100644 index 0000000..5b38df1 --- /dev/null +++ b/app/core/src/main/res/values/arrays_account_settings_values.xml @@ -0,0 +1,221 @@ + + + + + + @color/material_amber_600 + @color/material_orange_600 + @color/material_deep_orange_600 + @color/material_red_600 + + @color/material_lime_600 + @color/material_light_green_600 + @color/material_green_700 + @color/material_teal_600 + + @color/material_cyan_600 + @color/material_light_blue_600 + @color/material_blue_700 + @color/material_indigo_600 + + @color/material_blue_500 + @color/material_purple_600 + @color/material_deep_purple_600 + @color/material_blue_gray_700 + + + + @color/material_blue_700 + @color/material_blue_500 + @color/material_amber_600 + + + + -1 + 15 + 30 + 60 + 120 + 180 + 360 + 720 + 1440 + + + + 10 + 25 + 50 + 100 + 250 + 500 + 1000 + 2500 + 5000 + 10000 + 0 + + + + -1 + 0 + 1 + 2 + 7 + 14 + 21 + 28 + 56 + 84 + 168 + 365 + + + + 1024 + 2048 + 4096 + 8192 + 16384 + 32768 + 65536 + 131072 + 262144 + 524288 + 1048576 + 2097152 + 5242880 + 10485760 + 0 + + + + ALL + FIRST_CLASS + FIRST_AND_SECOND_CLASS + NOT_SECOND_CLASS + + + + NEVER + ONLY_FROM_CONTACTS + ALWAYS + + + + ALL + DISPLAYABLE + NONE + + + + ALL + FIRST_CLASS + FIRST_AND_SECOND_CLASS + NOT_SECOND_CLASS + NONE + + + + ALL + FIRST_CLASS + FIRST_AND_SECOND_CLASS + NOT_SECOND_CLASS + NONE + + + + 5 + 10 + 25 + 50 + 100 + 250 + 500 + 1000 + + + + ALL + FIRST_CLASS + FIRST_AND_SECOND_CLASS + NOT_SECOND_CLASS + NONE + + + + ALL + FIRST_CLASS + FIRST_AND_SECOND_CLASS + NOT_SECOND_CLASS + + + + NEVER + ON_DELETE + MARK_AS_READ + + + + EXPUNGE_IMMEDIATELY + EXPUNGE_ON_POLL + EXPUNGE_MANUALLY + + + + 2 + 3 + 6 + 12 + 24 + 36 + 48 + 60 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + Disabled + AccountColor + SystemDefaultColor + White + Red + Green + Blue + Yellow + Cyan + Magenta + + + + PREFIX + HEADER + + + + TEXT + HTML + AUTO + + + + 10 + 25 + 50 + 100 + 250 + 500 + 1000 + 0 + + + diff --git a/app/core/src/main/res/values/arrays_drawer.xml b/app/core/src/main/res/values/arrays_drawer.xml new file mode 100644 index 0000000..e648372 --- /dev/null +++ b/app/core/src/main/res/values/arrays_drawer.xml @@ -0,0 +1,27 @@ + + + + + + @color/material_amber_600 + @color/material_orange_500 + @color/material_deep_orange_400 + @color/material_red_400 + + @color/material_lime_600 + @color/material_light_green_600 + @color/material_green_500 + @color/material_teal_300 + + @color/material_cyan_600 + @color/material_light_blue_500 + @color/material_blue_400 + @color/material_indigo_200 + + @color/material_pink_200 + @color/material_purple_200 + @color/material_deep_purple_200 + @color/material_blue_gray_300 + + + diff --git a/app/core/src/main/res/values/arrays_general_settings_values.xml b/app/core/src/main/res/values/arrays_general_settings_values.xml new file mode 100644 index 0000000..95fca88 --- /dev/null +++ b/app/core/src/main/res/values/arrays_general_settings_values.xml @@ -0,0 +1,224 @@ + + + + + + in + br + ca + cs + cy + da + de + et + en + en_GB + es + eo + eu + fr + fy + gd + gl + hr + is + it + lv + lt + hu + nl + nb + pl + pt_PT + pt_BR + ru + ro + sq + sk + sl + fi + sv + tr + el + be + bg + sr + uk + iw + ar + fa + ml + ko + zh_CN + zh_TW + ja + + + + + af + az + in + ms + bm + br + ca + cs + cy + da + de + et + en + en_GB + es + eo + eu + ee + fr + fr_CA + fy + ff + ga + gd + gl + ha + hr + xs + zu + is + it + rw + rn + sw + lv + lt + hu + mt + nl + no + nb + uz + pl + pt_PT + pt_BR + ro + sq + sk + sl + so + fi + sv + vi + tr + wo + yo + el + ab + be + bg + ky + kk + mk + ru + sr + uk + hy + iw + ur + ar + ps + fa + am + hi + ml + te + kn + th + ko + zh_CN + zh_TW + ja + + + + follow_system + dark + + + + light + dark + follow_system + + + + light + dark + global + + + + WHEN_CHECKED_AUTO_SYNC + ALWAYS + NEVER + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + + + + NEVER + FOR_SINGLE_MSG + ALWAYS + + + + NOTHING + APP_NAME + MESSAGE_COUNT + SENDERS + EVERYTHING + + + + ALWAYS + NEVER + WHEN_IN_LANDSCAPE + + + + delete + delete_starred + delete_notif + spam + discard + mark_all_read + + + + delete + archive + move + copy + spam + + + + none + toggle_selection + toggle_read + toggle_star + archive + delete + spam + move + + + diff --git a/app/core/src/main/res/values/material_colors.xml b/app/core/src/main/res/values/material_colors.xml new file mode 100644 index 0000000..b069b24 --- /dev/null +++ b/app/core/src/main/res/values/material_colors.xml @@ -0,0 +1,211 @@ + + + #FFEBEE + #FFCDD2 + #EF9A9A + #E57373 + #EF5350 + #F44336 + #E53935 + #D32F2F + #C62828 + #B71C1C + + #EDE7F6 + #D1C4E9 + #B39DDB + #9575CD + #7E57C2 + #673AB7 + #5E35B1 + #512DA8 + #4527A0 + #311B92 + + #E1F5FE + #B3E5FC + #81D4FA + #4FC3F7 + #29B6F6 + #03A9F4 + #039BE5 + #0288D1 + #0277BD + #01579B + + #E8F5E9 + #C8E6C9 + #A5D6A7 + #81C784 + #66BB6A + #4CAF50 + #43A047 + #388E3C + #2E7D32 + #1B5E20 + + #FFFDE7 + #FFF9C4 + #FFF59D + #FFF176 + #FFEE58 + #FFEB3B + #FDD835 + #FBC02D + #F9A825 + #F57F17 + + #FBE9E7 + #FFCCBC + #FFAB91 + #FF8A65 + #FF7043 + #FF5722 + #F4511E + #E64A19 + #D84315 + #BF360C + + #ECEFF1 + #CFD8DC + #B0BEC5 + #90A4AE + #78909C + #607D8B + #546E7A + #455A64 + #37474F + #263238 + + #FCE4EC + #F8BBD0 + #F48FB1 + #F06292 + #EC407A + #E91E63 + #D81B60 + #C2185B + #AD1457 + #880E4F + + #E8EAF6 + #C5CAE9 + #9FA8DA + #7986CB + #5C6BC0 + #3F51B5 + #3949AB + #303F9F + #283593 + #1A237E + + #E0F7FA + #B2EBF2 + #80DEEA + #4DD0E1 + #26C6DA + #00BCD4 + #00ACC1 + #0097A7 + #00838F + #006064 + + #F1F8E9 + #DCEDC8 + #C5E1A5 + #AED581 + #9CCC65 + #8BC34A + #7CB342 + #689F38 + #558B2F + #33691E + + #FFF8E1 + #FFECB3 + #FFE082 + #FFD54F + #FFCA28 + #FFC107 + #FFB300 + #FFA000 + #FF8F00 + #FF6F00 + + #EFEBE9 + #D7CCC8 + #BCAAA4 + #A1887F + #8D6E63 + #795548 + #6D4C41 + #5D4037 + #4E342E + #3E2723 + + #F3E5F5 + #E1BEE7 + #CE93D8 + #BA68C8 + #AB47BC + #9C27B0 + #8E24AA + #7B1FA2 + #6A1B9A + #4A148C + + #E3F2FD + #BBDEFB + #90CAF9 + #64B5F6 + #42A5F5 + #7188C3 + #1E88E5 + #1976D2 + #1565C0 + #0D47A1 + + #E0F2F1 + #B2DFDB + #80CBC4 + #4DB6AC + #26A69A + #009688 + #00897B + #00796B + #00695C + #004D40 + + #F9FBE7 + #F0F4C3 + #E6EE9C + #DCE775 + #D4E157 + #CDDC39 + #C0CA33 + #AFB42B + #9E9D24 + #827717 + + #FFF3E0 + #FFE0B2 + #FFCC80 + #FFB74D + #FFA726 + #FF9800 + #FB8C00 + #F57C00 + #EF6C00 + #E65100 + + #FAFAFA + #F5F5F5 + #EEEEEE + #E0E0E0 + #BDBDBD + #9E9E9E + #757575 + #616161 + #424242 + #212121 + diff --git a/app/core/src/main/res/xml/decrypted_file_provider_paths.xml b/app/core/src/main/res/xml/decrypted_file_provider_paths.xml new file mode 100644 index 0000000..25ef4ea --- /dev/null +++ b/app/core/src/main/res/xml/decrypted_file_provider_paths.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/core/src/main/res/xml/temp_file_provider_paths.xml b/app/core/src/main/res/xml/temp_file_provider_paths.xml new file mode 100644 index 0000000..3e8d647 --- /dev/null +++ b/app/core/src/main/res/xml/temp_file_provider_paths.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/core/src/test/java/com/fsck/k9/EmailAddressValidatorTest.kt b/app/core/src/test/java/com/fsck/k9/EmailAddressValidatorTest.kt new file mode 100644 index 0000000..f839dec --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/EmailAddressValidatorTest.kt @@ -0,0 +1,36 @@ +package com.fsck.k9 + +import org.junit.Assert +import org.junit.Test + +class EmailAddressValidatorTest { + + @Test + fun testEmailValidation() { + // Most of the tests based on https://en.wikipedia.org/wiki/Email_address#Examples + val validator = EmailAddressValidator() + Assert.assertTrue(validator.isValidAddressOnly("simple@example.com")) + Assert.assertTrue(validator.isValidAddressOnly("very.common@example.com")) + Assert.assertTrue(validator.isValidAddressOnly("disposable.style.email.with+symbol@example.com")) + Assert.assertTrue(validator.isValidAddressOnly("other.email-with-hyphen@example.com")) + Assert.assertTrue(validator.isValidAddressOnly("fully-qualified-domain@example.com")) + Assert.assertTrue(validator.isValidAddressOnly("user.name+tag+sorting@example.com")) + Assert.assertTrue(validator.isValidAddressOnly("example-indeed@strange-example.com")) + Assert.assertTrue(validator.isValidAddressOnly("example-indeed@strange_example.com")) + Assert.assertTrue(validator.isValidAddressOnly("example@1.com")) + Assert.assertTrue(validator.isValidAddressOnly("admin@mailserver1")) + Assert.assertTrue(validator.isValidAddressOnly("user@localserver")) + Assert.assertTrue(validator.isValidAddressOnly("\"very.(),:;<>[]\\\".VERY.\\\"very@\\\\ \\\"very\\\".unusual\"@strange.example.com")) + Assert.assertTrue(validator.isValidAddressOnly("\"()<>[]:,;@\\\\\\\"!#$%&'-/=?^_`{}| ~.a\"@example.org")) + Assert.assertTrue(validator.isValidAddressOnly("\" \"@example.org")) + Assert.assertTrue(validator.isValidAddressOnly("x@example.com")) + + Assert.assertFalse(validator.isValidAddressOnly("Abc.example.com")) + Assert.assertFalse(validator.isValidAddressOnly("\"not\"right@example.com")) + Assert.assertFalse(validator.isValidAddressOnly("john.doe@example..com")) + Assert.assertFalse(validator.isValidAddressOnly("example@c.2")) + Assert.assertFalse(validator.isValidAddressOnly("this\\ still\\\"not\\\\allowed@example.com")) + Assert.assertFalse(validator.isValidAddressOnly("john..doe@example.com")) + Assert.assertFalse(validator.isValidAddressOnly("invalidperiod.@example.com")) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/K9RobolectricTest.kt b/app/core/src/test/java/com/fsck/k9/K9RobolectricTest.kt new file mode 100644 index 0000000..4eb912f --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/K9RobolectricTest.kt @@ -0,0 +1,16 @@ +package com.fsck.k9 + +import android.app.Application +import org.junit.runner.RunWith +import org.koin.test.AutoCloseKoinTest +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A Robolectric test that creates an instance of our [Application] test class [TestApp]. + * + * See also [RobolectricTest]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = TestApp::class) +abstract class K9RobolectricTest : AutoCloseKoinTest() diff --git a/app/core/src/test/java/com/fsck/k9/QuietTimeCheckerTest.kt b/app/core/src/test/java/com/fsck/k9/QuietTimeCheckerTest.kt new file mode 100644 index 0000000..854f3dc --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/QuietTimeCheckerTest.kt @@ -0,0 +1,118 @@ +package com.fsck.k9 + +import app.k9mail.core.testing.TestClock +import java.util.Calendar +import kotlinx.datetime.Instant +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QuietTimeCheckerTest { + private val clock = TestClock() + + @Test + fun endTimeBeforeStartTime_timeIsBeforeEndOfQuietTime() { + setClockTo("02:00") + val quietTimeChecker = QuietTimeChecker(clock, "22:30", "06:45") + + assertTrue(quietTimeChecker.isQuietTime) + } + + @Test + fun endTimeBeforeStartTime_timeIsAfterEndOfQuietTime() { + setClockTo("10:00") + val quietTimeChecker = QuietTimeChecker(clock, "22:30", "06:45") + + assertFalse(quietTimeChecker.isQuietTime) + } + + @Test + fun endTimeBeforeStartTime_timeIsAfterStartOfQuietTime() { + setClockTo("23:00") + val quietTimeChecker = QuietTimeChecker(clock, "22:30", "06:45") + + assertTrue(quietTimeChecker.isQuietTime) + } + + @Test + fun endTimeBeforeStartTime_timeIsStartOfQuietTime() { + setClockTo("22:30") + val quietTimeChecker = QuietTimeChecker(clock, "22:30", "06:45") + + assertTrue(quietTimeChecker.isQuietTime) + } + + @Test + fun endTimeBeforeStartTime_timeIsEndOfQuietTime() { + setClockTo("06:45") + val quietTimeChecker = QuietTimeChecker(clock, "22:30", "06:45") + + assertTrue(quietTimeChecker.isQuietTime) + } + + @Test + fun startTimeBeforeEndTime_timeIsBeforeStartOfQuietTime() { + setClockTo("02:00") + val quietTimeChecker = QuietTimeChecker(clock, "09:00", "17:00") + + assertFalse(quietTimeChecker.isQuietTime) + } + + @Test + fun startTimeBeforeEndTime_timeIsAfterStartOfQuietTime() { + setClockTo("10:00") + val quietTimeChecker = QuietTimeChecker(clock, "09:00", "17:00") + + assertTrue(quietTimeChecker.isQuietTime) + } + + @Test + fun startTimeBeforeEndTime_timeIsAfterEndOfQuietTime() { + setClockTo("20:00") + val quietTimeChecker = QuietTimeChecker(clock, "09:00", "17:00") + + assertFalse(quietTimeChecker.isQuietTime) + } + + @Test + fun startTimeBeforeEndTime_timeIsStartOfQuietTime() { + setClockTo("09:00") + val quietTimeChecker = QuietTimeChecker(clock, "09:00", "17:00") + + assertTrue(quietTimeChecker.isQuietTime) + } + + @Test + fun startTimeBeforeEndTime_timeIsEndOfQuietTime() { + setClockTo("17:00") + val quietTimeChecker = QuietTimeChecker(clock, "09:00", "17:00") + + assertTrue(quietTimeChecker.isQuietTime) + } + + @Test + fun startTimeEqualsEndTime_timeIsDifferentFromStartAndEndOfQuietTime_shouldReturnFalse() { + setClockTo("10:00") + val quietTimeChecker = QuietTimeChecker(clock, "06:00", "06:00") + + assertFalse(quietTimeChecker.isQuietTime) + } + + @Test + fun startTimeEqualsEndTime_timeIsEqualToStartAndEndOfQuietTime_shouldReturnFalse() { + setClockTo("06:00") + val quietTimeChecker = QuietTimeChecker(clock, "06:00", "06:00") + + assertFalse(quietTimeChecker.isQuietTime) + } + + private fun setClockTo(time: String) { + val (hourOfDay, minute) = time.split(':').map { it.toInt() } + + val calendar = Calendar.getInstance() + calendar.set(Calendar.HOUR_OF_DAY, hourOfDay) + calendar.set(Calendar.MINUTE, minute) + + clock.changeTimeTo(Instant.fromEpochMilliseconds(calendar.timeInMillis)) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/ServerSettingsSerializerTest.kt b/app/core/src/test/java/com/fsck/k9/ServerSettingsSerializerTest.kt new file mode 100644 index 0000000..9736684 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/ServerSettingsSerializerTest.kt @@ -0,0 +1,76 @@ +package com.fsck.k9 + +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isFailure +import assertk.assertions.isInstanceOf +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.store.imap.ImapStoreSettings +import org.junit.Test + +class ServerSettingsSerializerTest { + private val serverSettingsSerializer = ServerSettingsSerializer() + + @Test + fun `serialize and deserialize IMAP server settings`() { + val serverSettings = ServerSettings( + type = "imap", + host = "imap.domain.example", + port = 143, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = null, + clientCertificateAlias = "alias", + extra = ImapStoreSettings.createExtra(autoDetectNamespace = true, pathPrefix = null) + ) + + val json = serverSettingsSerializer.serialize(serverSettings) + val deserializedServerSettings = serverSettingsSerializer.deserialize(json) + + assertThat(deserializedServerSettings).isEqualTo(serverSettings) + } + + @Test + fun `serialize and deserialize POP3 server settings`() { + val serverSettings = ServerSettings( + type = "pop3", + host = "pop3.domain.example", + port = 995, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null + ) + + val json = serverSettingsSerializer.serialize(serverSettings) + val deserializedServerSettings = serverSettingsSerializer.deserialize(json) + + assertThat(deserializedServerSettings).isEqualTo(serverSettings) + } + + @Test + fun `deserialize JSON with missing type`() { + val json = """ + { + "host": "imap.domain.example", + "port": 993, + "connectionSecurity": "SSL_TLS_REQUIRED", + "authenticationType": "PLAIN", + "username": "user", + "password": "pass", + "clientCertificateAlias": null + } + """.trimIndent() + + assertThat { + serverSettingsSerializer.deserialize(json) + }.isFailure() + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("'type' must not be missing") + } +} diff --git a/app/core/src/test/java/com/fsck/k9/TestApp.kt b/app/core/src/test/java/com/fsck/k9/TestApp.kt new file mode 100644 index 0000000..7f6f0f8 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/TestApp.kt @@ -0,0 +1,41 @@ +package com.fsck.k9 + +import android.app.Application +import androidx.work.WorkManager +import com.fsck.k9.backend.BackendManager +import com.fsck.k9.controller.ControllerExtension +import com.fsck.k9.crypto.EncryptionExtractor +import com.fsck.k9.notification.NotificationActionCreator +import com.fsck.k9.notification.NotificationResourceProvider +import com.fsck.k9.notification.NotificationStrategy +import com.fsck.k9.preferences.InMemoryStoragePersister +import com.fsck.k9.preferences.StoragePersister +import com.fsck.k9.storage.storageModule +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.mockito.kotlin.mock + +class TestApp : Application() { + override fun onCreate() { + Core.earlyInit() + + super.onCreate() + DI.start(this, coreModules + storageModule + testModule) + + K9.init(this) + Core.init(this) + } +} + +val testModule = module { + single { AppConfig(emptyList()) } + single { mock() } + single { mock() } + single { InMemoryStoragePersister() } + single { mock() } + single { mock() } + single { mock() } + single { mock() } + single(named("controllerExtensions")) { emptyList() } + single { mock() } +} diff --git a/app/core/src/test/java/com/fsck/k9/TestCoreResourceProvider.kt b/app/core/src/test/java/com/fsck/k9/TestCoreResourceProvider.kt new file mode 100644 index 0000000..d13be3a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/TestCoreResourceProvider.kt @@ -0,0 +1,42 @@ +package com.fsck.k9 + +import com.fsck.k9.notification.PushNotificationState + +class TestCoreResourceProvider : CoreResourceProvider { + override fun defaultSignature() = "\n--\nbrevity!" + + override fun defaultIdentityDescription() = "initial identity" + + override fun contactDisplayNamePrefix() = "To:" + override fun contactUnknownSender() = "" + override fun contactUnknownRecipient() = "" + + override fun messageHeaderFrom() = "From:" + override fun messageHeaderTo() = "To:" + override fun messageHeaderCc() = "Cc:" + override fun messageHeaderDate() = "Sent:" + override fun messageHeaderSubject() = "Subject:" + override fun messageHeaderSeparator() = "-------- Original Message --------" + + override fun noSubject() = "(No subject)" + + override fun userAgent(): String = "K-9 Mail for Android" + override fun encryptedSubject(): String = "Encrypted message" + + override fun replyHeader(sender: String) = "$sender wrote:" + override fun replyHeader(sender: String, sentDate: String) = "On $sentDate, $sender wrote:" + + override fun searchUnifiedInboxTitle() = "Unified Inbox" + override fun searchUnifiedInboxDetail() = "All messages in unified folders" + + override fun outboxFolderName() = "Outbox" + + override val iconPushNotification: Int + get() = throw UnsupportedOperationException("not implemented") + + override fun pushNotificationText(notificationState: PushNotificationState): String { + throw UnsupportedOperationException("not implemented") + } + + override fun pushNotificationInfoText(): String = throw UnsupportedOperationException("not implemented") +} diff --git a/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptDraftStateHeaderParserTest.kt b/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptDraftStateHeaderParserTest.kt new file mode 100644 index 0000000..030ccc3 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptDraftStateHeaderParserTest.kt @@ -0,0 +1,57 @@ +package com.fsck.k9.autocrypt + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import com.fsck.k9.RobolectricTest +import org.junit.Test + +class AutocryptDraftStateHeaderParserTest : RobolectricTest() { + internal var autocryptHeaderParser = AutocryptDraftStateHeaderParser() + + @Test + fun testEncryptReplyByChoice() { + val draftStateHeader = AutocryptDraftStateHeader(true, false, true, true, false) + + val parsedHeader = autocryptHeaderParser.parseAutocryptDraftStateHeader(draftStateHeader.toHeaderValue()) + + assertThat(parsedHeader).isEqualTo(draftStateHeader) + } + + @Test + fun testSignOnly() { + val parsedHeader = autocryptHeaderParser.parseAutocryptDraftStateHeader("encrypt=no; _by-choice=yes; _sign-only=yes") + + with(parsedHeader!!) { + assertThat(isEncrypt).isFalse() + assertThat(isByChoice).isTrue() + assertThat(isSignOnly).isTrue() + assertThat(isPgpInline).isFalse() + assertThat(isReply).isFalse() + } + } + + @Test + fun badCritical() { + val parsedHeader = autocryptHeaderParser.parseAutocryptDraftStateHeader("encrypt=no; badcritical=value") + + assertThat(parsedHeader).isNull() + } + + @Test + fun missingEncrypt() { + val parsedHeader = autocryptHeaderParser.parseAutocryptDraftStateHeader("encrpt-with-typo=no; _non_critical=value") + + assertThat(parsedHeader).isNull() + } + + @Test + fun unknownNonCritical() { + val parsedHeader = autocryptHeaderParser.parseAutocryptDraftStateHeader("encrypt=no; _non-critical=value") + + assertThat(parsedHeader).isNotNull() + } +} diff --git a/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptGossipHeaderParserTest.kt b/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptGossipHeaderParserTest.kt new file mode 100644 index 0000000..f047a9d --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptGossipHeaderParserTest.kt @@ -0,0 +1,110 @@ +package com.fsck.k9.autocrypt + +import com.fsck.k9.mail.crlf +import com.fsck.k9.mail.filter.Base64 +import com.fsck.k9.mailstore.MimePartStreamParser +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class AutocryptGossipHeaderParserTest { + val GOSSIP_DATA_BOB = Base64.decodeBase64( + """ + mQGNBFoBt74BDAC8AMsjPY17kxodbfmHah38ZQipY0yfuo97WUBs2jeiFYlQdunPANi5VMgbAX+H + Mq1XoBRs6qW+WpX8Uj11mu22c57BTUXJRbRr4TnTuuOQmT0egwFDe3x8vHSFmcf9OzG8iKR9ftUE + +F2ewrzzmm3XY8hy7QeUgBfClZVA6A3rsX4gGawjDo6ZRBbYwckINgGX/vQk6rGs + """.trimIndent().toByteArray() + ) + + val GOSSIP_HEADER_BOB = + """ + |addr=bob@autocrypt.example; keydata= + | mQGNBFoBt74BDAC8AMsjPY17kxodbfmHah38ZQipY0yfuo97WUBs2jeiFYlQdunPANi5VMgbAX+H + | Mq1XoBRs6qW+WpX8Uj11mu22c57BTUXJRbRr4TnTuuOQmT0egwFDe3x8vHSFmcf9OzG8iKR9ftUE + | +F2ewrzzmm3XY8hy7QeUgBfClZVA6A3rsX4gGawjDo6ZRBbYwckINgGX/vQk6rGs + """.trimMargin() + + val GOSSIP_RAW_HEADER_BOB = "Autocrypt-Gossip: $GOSSIP_HEADER_BOB".crlf() + + // Example from Autocrypt 1.0 appendix + val GOSSIP_PART = + """ + |Autocrypt-Gossip: $GOSSIP_HEADER_BOB + |Autocrypt-Gossip: addr=carol@autocrypt.example; keydata= + | mQGNBFoBt8oBDADGqfZ6PqW05hUEO1dkKm+ixJXnbVriPz2tRkAqT7lTF4KBGitxo4IPv9RPIjJR + | UMUo89ddyqQfiwKxdFCMDqFDnVRWlDaM+r8sauNJoIFwtTFuvUpkFeCI5gYvneEIIbf1r3Xx1pf5 + | Iy9qsd5eg/4Vvc2AezUv+A6p2DUNHgFMX2FfDus+EPO0wgeWbNaV601aE7UhyugB + |Content-Type: text/plain + | + |Hi Bob and Carol, + | + |I wanted to introduce the two of you to each other. + | + |I hope you are both doing well! You can now both "reply all" here, + |and the thread will remain encrypted. + | + |Regards, + |Alice + """.trimMargin().crlf() + + private val autocryptGossipHeaderParser = AutocryptGossipHeaderParser.getInstance() + + @Test + fun parseFromPart() { + val gossipPart = MimePartStreamParser.parse(null, GOSSIP_PART.byteInputStream()) + val allAutocryptGossipHeaders = autocryptGossipHeaderParser.getAllAutocryptGossipHeaders(gossipPart) + + assertEquals("text/plain", gossipPart.mimeType) + assertEquals(2, allAutocryptGossipHeaders.size) + assertEquals("bob@autocrypt.example", allAutocryptGossipHeaders[0].addr) + assertEquals("carol@autocrypt.example", allAutocryptGossipHeaders[1].addr) + assertArrayEquals(GOSSIP_DATA_BOB, allAutocryptGossipHeaders[0].keyData) + } + + @Test + fun parseString() { + val gossipHeader = autocryptGossipHeaderParser.parseAutocryptGossipHeader(GOSSIP_HEADER_BOB) + + gossipHeader!! + assertArrayEquals(GOSSIP_DATA_BOB, gossipHeader.keyData) + assertEquals(GOSSIP_RAW_HEADER_BOB, gossipHeader.toRawHeaderString()) + } + + @Test + fun parseHeader_missingKeydata() { + val gossipHeader = autocryptGossipHeaderParser.parseAutocryptGossipHeader( + "addr=CDEF" + ) + + assertNull(gossipHeader) + } + + @Test + fun parseHeader_unknownCritical() { + val gossipHeader = autocryptGossipHeaderParser.parseAutocryptGossipHeader( + "addr=bawb; somecritical=value; keydata=aGk" + ) + + assertNull(gossipHeader) + } + + @Test + fun parseHeader_unknownNonCritical() { + val gossipHeader = autocryptGossipHeaderParser.parseAutocryptGossipHeader( + "addr=bawb; _somenoncritical=value; keydata=aGk" + ) + + assertNotNull(gossipHeader) + } + + @Test + fun parseHeader_brokenBase64() { + val gossipHeader = autocryptGossipHeaderParser.parseAutocryptGossipHeader( + "addr=bawb; _somenoncritical=value; keydata=X" + ) + + assertNull(gossipHeader) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderParserTest.java b/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderParserTest.java new file mode 100644 index 0000000..56f4556 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderParserTest.java @@ -0,0 +1,122 @@ +package com.fsck.k9.autocrypt; + + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +import com.fsck.k9.RobolectricTest; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.BinaryTempFileBody; +import com.fsck.k9.mail.internet.MimeMessage; +import org.junit.Before; +import org.junit.Test; +import org.robolectric.RuntimeEnvironment; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + + +public class AutocryptHeaderParserTest extends RobolectricTest { + AutocryptHeaderParser autocryptHeaderParser = AutocryptHeaderParser.getInstance(); + + @Before + public void setUp() throws Exception { + BinaryTempFileBody.setTempDirectory(RuntimeEnvironment.getApplication().getCacheDir()); + } + + // Test cases taken from: https://github.com/mailencrypt/autocrypt/tree/master/src/tests/data + + @Test + public void getValidAutocryptHeader__withNoHeader__shouldReturnNull() throws Exception { + MimeMessage message = parseFromResource("autocrypt/no_autocrypt.eml"); + + AutocryptHeader autocryptHeader = autocryptHeaderParser.getValidAutocryptHeader(message); + + assertNull(autocryptHeader); + } + + @Test + public void getValidAutocryptHeader__withBrokenBase64__shouldReturnNull() throws Exception { + MimeMessage message = parseFromResource("autocrypt/rsa2048-broken-base64.eml"); + + AutocryptHeader autocryptHeader = autocryptHeaderParser.getValidAutocryptHeader(message); + + assertNull(autocryptHeader); + } + + @Test + public void getValidAutocryptHeader__withSimpleAutocrypt() throws Exception { + MimeMessage message = parseFromResource("autocrypt/rsa2048-simple.eml"); + + AutocryptHeader autocryptHeader = autocryptHeaderParser.getValidAutocryptHeader(message); + + assertNotNull(autocryptHeader); + assertEquals("alice@testsuite.autocrypt.org", autocryptHeader.addr); + assertEquals(0, autocryptHeader.parameters.size()); + assertEquals(1225, autocryptHeader.keyData.length); + } + + @Test + public void getValidAutocryptHeader__withExplicitType() throws Exception { + MimeMessage message = parseFromResource("autocrypt/rsa2048-explicit-type.eml"); + + AutocryptHeader autocryptHeader = autocryptHeaderParser.getValidAutocryptHeader(message); + + assertNotNull(autocryptHeader); + assertEquals("alice@testsuite.autocrypt.org", autocryptHeader.addr); + assertEquals(0, autocryptHeader.parameters.size()); + } + + @Test + public void getValidAutocryptHeader__withUnknownType__shouldReturnNull() throws Exception { + MimeMessage message = parseFromResource("autocrypt/unknown-type.eml"); + + AutocryptHeader autocryptHeader = autocryptHeaderParser.getValidAutocryptHeader(message); + + assertNull(autocryptHeader); + } + + @Test + public void getValidAutocryptHeader__withUnknownCriticalHeader__shouldReturnNull() throws Exception { + MimeMessage message = parseFromResource("autocrypt/rsa2048-unknown-critical.eml"); + + AutocryptHeader autocryptHeader = autocryptHeaderParser.getValidAutocryptHeader(message); + + assertNull(autocryptHeader); + } + + @Test + public void getValidAutocryptHeader__withUnknownNonCriticalHeader() throws Exception { + MimeMessage message = parseFromResource("autocrypt/rsa2048-unknown-non-critical.eml"); + + AutocryptHeader autocryptHeader = autocryptHeaderParser.getValidAutocryptHeader(message); + + assertNotNull(autocryptHeader); + assertEquals("alice@testsuite.autocrypt.org", autocryptHeader.addr); + assertEquals(1, autocryptHeader.parameters.size()); + assertEquals("ignore", autocryptHeader.parameters.get("_monkey")); + } + + @Test + public void parseAutocryptHeader_toRawHeaderString() throws Exception { + MimeMessage message = parseFromResource("autocrypt/rsa2048-simple.eml"); + AutocryptHeader autocryptHeader = autocryptHeaderParser.getValidAutocryptHeader(message); + + String headerValue = autocryptHeader.toRawHeaderString(); + headerValue = headerValue.substring("Autocrypt: ".length()); + AutocryptHeader parsedAutocryptHeader = autocryptHeaderParser.parseAutocryptHeader(headerValue); + + assertEquals(autocryptHeader, parsedAutocryptHeader); + } + + private MimeMessage parseFromResource(String resourceName) throws IOException, MessagingException { + InputStream inputStream = readFromResourceFile(resourceName); + return MimeMessage.parseMimeMessage(inputStream, false); + } + + private InputStream readFromResourceFile(String name) throws FileNotFoundException { + return getClass().getResourceAsStream("/" + name); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderTest.java b/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderTest.java new file mode 100644 index 0000000..2440fc1 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderTest.java @@ -0,0 +1,43 @@ +package com.fsck.k9.autocrypt; + + +import java.util.HashMap; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +@SuppressWarnings("WeakerAccess") +public class AutocryptHeaderTest { + static final HashMap PARAMETERS = new HashMap<>(); + static final String ADDR = "addr"; + static final byte[] KEY_DATA = ("theseare120charactersxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx").getBytes(); + static final boolean IS_PREFER_ENCRYPT_MUTUAL = true; + + + @Test + public void toRawHeaderString_returnsExpected() throws Exception { + AutocryptHeader autocryptHeader = new AutocryptHeader(PARAMETERS, ADDR, KEY_DATA, IS_PREFER_ENCRYPT_MUTUAL); + String autocryptHeaderString = autocryptHeader.toRawHeaderString(); + + String expected = "Autocrypt: addr=addr; prefer-encrypt=mutual; keydata=\r\n" + + " dGhlc2VhcmUxMjBjaGFyYWN0ZXJzeHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4\r\n" + + " eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4\r\n" + + " eHh4eHh4"; + assertEquals(expected, autocryptHeaderString); + } + + @Test + public void gossip_toRawHeaderString_returnsExpected() throws Exception { + AutocryptGossipHeader autocryptHeader = new AutocryptGossipHeader(ADDR, KEY_DATA); + String autocryptHeaderString = autocryptHeader.toRawHeaderString(); + + String expected = "Autocrypt-Gossip: addr=addr; keydata=\r\n" + + " dGhlc2VhcmUxMjBjaGFyYWN0ZXJzeHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4\r\n" + + " eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4\r\n" + + " eHh4eHh4"; + assertEquals(expected, autocryptHeaderString); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/controller/DefaultMessageCountsProviderTest.kt b/app/core/src/test/java/com/fsck/k9/controller/DefaultMessageCountsProviderTest.kt new file mode 100644 index 0000000..62f0e55 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/controller/DefaultMessageCountsProviderTest.kt @@ -0,0 +1,48 @@ +package com.fsck.k9.controller + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.Account +import com.fsck.k9.Account.FolderMode +import com.fsck.k9.Preferences +import com.fsck.k9.mailstore.ListenableMessageStore +import com.fsck.k9.mailstore.MessageStoreManager +import com.fsck.k9.search.ConditionsTreeNode +import org.junit.Test +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +private const val ACCOUNT_UUID = "irrelevant" +private const val UNREAD_COUNT = 2 +private const val STARRED_COUNT = 3 + +class DefaultMessageCountsProviderTest { + private val preferences = mock() + private val account = Account(ACCOUNT_UUID) + private val messageStore = mock { + on { getUnreadMessageCount(anyOrNull()) } doReturn UNREAD_COUNT + on { getStarredMessageCount(anyOrNull()) } doReturn STARRED_COUNT + } + private val messageStoreManager = mock { + on { getMessageStore(account) } doReturn messageStore + } + + private val messageCountsProvider = DefaultMessageCountsProvider(preferences, messageStoreManager) + + @Test + fun `getMessageCounts() without any special folders and displayMode = ALL`() { + account.inboxFolderId = null + account.trashFolderId = null + account.draftsFolderId = null + account.spamFolderId = null + account.outboxFolderId = null + account.sentFolderId = null + account.folderDisplayMode = FolderMode.ALL + + val messageCounts = messageCountsProvider.getMessageCounts(account) + + assertThat(messageCounts.unread).isEqualTo(UNREAD_COUNT) + assertThat(messageCounts.starred).isEqualTo(STARRED_COUNT) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/controller/MessageReferenceTest.kt b/app/core/src/test/java/com/fsck/k9/controller/MessageReferenceTest.kt new file mode 100644 index 0000000..12dc9d0 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/controller/MessageReferenceTest.kt @@ -0,0 +1,64 @@ +package com.fsck.k9.controller + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import assertk.assertions.isTrue +import kotlin.test.assertNotNull +import org.junit.Test + +class MessageReferenceTest { + @Test + fun checkIdentityStringFromMessageReference() { + val messageReference = MessageReference("o hai!", 2, "10101010") + + val serialized = messageReference.toIdentityString() + + assertThat(serialized).isEqualTo("#:byBoYWkh:Mg==:MTAxMDEwMTA=") + } + + @Test + fun parseIdentityString() { + val result = MessageReference.parse("#:byBoYWkh:Mg==:MTAxMDEwMTA=") + + assertNotNull(result) { messageReference -> + assertThat(messageReference.accountUuid).isEqualTo("o hai!") + assertThat(messageReference.folderId).isEqualTo(2) + assertThat(messageReference.uid).isEqualTo("10101010") + } + } + + @Test + fun parseIdentityStringContainingBadVersionNumber() { + val messageReference = MessageReference.parse("@:byBoYWkh:MTAxMDEwMTA=") + + assertThat(messageReference).isNull() + } + + @Test + fun parseNullIdentityString() { + val messageReference = MessageReference.parse(null) + + assertThat(messageReference).isNull() + } + + @Test + fun checkMessageReferenceWithChangedUid() { + val messageReferenceOne = MessageReference("account", 1, "uid") + + val messageReference = messageReferenceOne.withModifiedUid("---") + + assertThat(messageReference.accountUuid).isEqualTo("account") + assertThat(messageReference.folderId).isEqualTo(1) + assertThat(messageReference.uid).isEqualTo("---") + } + + @Test + fun alternativeEquals() { + val messageReference = MessageReference("account", 1, "uid") + + val equalsResult = messageReference.equals("account", 1, "uid") + + assertThat(equalsResult).isTrue() + } +} diff --git a/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java b/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java new file mode 100644 index 0000000..3dbb7d3 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java @@ -0,0 +1,413 @@ +package com.fsck.k9.controller; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import android.content.Context; + +import com.fsck.k9.Account; +import com.fsck.k9.K9; +import com.fsck.k9.K9RobolectricTest; +import com.fsck.k9.Preferences; +import com.fsck.k9.backend.BackendManager; +import com.fsck.k9.backend.api.Backend; +import com.fsck.k9.mail.AuthType; +import com.fsck.k9.mail.AuthenticationFailedException; +import com.fsck.k9.mail.CertificateValidationException; +import com.fsck.k9.mail.ConnectionSecurity; +import com.fsck.k9.mail.Flag; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.ServerSettings; +import com.fsck.k9.mailstore.LocalFolder; +import com.fsck.k9.mailstore.LocalMessage; +import com.fsck.k9.mailstore.LocalStore; +import com.fsck.k9.mailstore.LocalStoreProvider; +import com.fsck.k9.mailstore.MessageStoreManager; +import com.fsck.k9.mailstore.OutboxState; +import com.fsck.k9.mailstore.OutboxStateRepository; +import com.fsck.k9.mailstore.SaveMessageDataCreator; +import com.fsck.k9.mailstore.SendState; +import com.fsck.k9.mailstore.SpecialLocalFoldersCreator; +import com.fsck.k9.notification.NotificationController; +import com.fsck.k9.notification.NotificationStrategy; +import com.fsck.k9.preferences.Protocols; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.shadows.ShadowLog; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + + +@SuppressWarnings("unchecked") +public class MessagingControllerTest extends K9RobolectricTest { + private static final long FOLDER_ID = 23; + private static final String FOLDER_NAME = "Folder"; + private static final long SENT_FOLDER_ID = 10; + private static final int MAXIMUM_SMALL_MESSAGE_SIZE = 1000; + + private MessagingController controller; + private Account account; + @Mock + private BackendManager backendManager; + @Mock + private Backend backend; + @Mock + private LocalStoreProvider localStoreProvider; + @Mock + private MessageStoreManager messageStoreManager; + @Mock + private SaveMessageDataCreator saveMessageDataCreator; + @Mock + private SpecialLocalFoldersCreator specialLocalFoldersCreator; + @Mock + private SimpleMessagingListener listener; + @Mock + private LocalFolder localFolder; + @Mock + private LocalFolder sentFolder; + @Mock + private LocalStore localStore; + @Mock + private NotificationController notificationController; + @Mock + private NotificationStrategy notificationStrategy; + + private Context appContext; + private Set reqFlags; + private Set forbiddenFlags; + + private List remoteMessages; + @Mock + private LocalMessage localNewMessage1; + @Mock + private LocalMessage localNewMessage2; + @Mock + private LocalMessage localMessageToSend1; + private volatile boolean hasFetchedMessage = false; + + private Preferences preferences; + private String accountUuid; + + + @Before + public void setUp() throws MessagingException { + ShadowLog.stream = System.out; + MockitoAnnotations.initMocks(this); + appContext = RuntimeEnvironment.getApplication(); + + preferences = Preferences.getPreferences(); + + controller = new MessagingController(appContext, notificationController, notificationStrategy, + localStoreProvider, backendManager, preferences, messageStoreManager, + saveMessageDataCreator, specialLocalFoldersCreator, Collections.emptyList()); + + configureAccount(); + configureBackendManager(); + configureLocalStore(); + } + + @After + public void tearDown() throws Exception { + removeAccountsFromPreferences(); + controller.stop(); + autoClose(); + } + + @Test + public void clearFolderSynchronous_shouldOpenFolderForWriting() throws MessagingException { + controller.clearFolderSynchronous(account, FOLDER_ID); + + verify(localFolder).open(); + } + + @Test + public void clearFolderSynchronous_shouldClearAllMessagesInTheFolder() throws MessagingException { + controller.clearFolderSynchronous(account, FOLDER_ID); + + verify(localFolder).clearAllMessages(); + } + + @Test + public void refreshRemoteSynchronous_shouldCallBackend() throws MessagingException { + controller.refreshFolderListSynchronous(account); + + verify(backend).refreshFolderList(); + } + + private void setupRemoteSearch() throws Exception { + remoteMessages = new ArrayList<>(); + Collections.addAll(remoteMessages, "oldMessageUid", "newMessageUid1", "newMessageUid2"); + List newRemoteMessages = new ArrayList<>(); + Collections.addAll(newRemoteMessages, "newMessageUid1", "newMessageUid2"); + + when(localNewMessage1.getUid()).thenReturn("newMessageUid1"); + when(localNewMessage2.getUid()).thenReturn("newMessageUid2"); + when(backend.search(eq(FOLDER_NAME), anyString(), nullable(Set.class), nullable(Set.class), eq(false))) + .thenReturn(remoteMessages); + when(localFolder.extractNewMessages(ArgumentMatchers.anyList())).thenReturn(newRemoteMessages); + when(localFolder.getMessage("newMessageUid1")).thenReturn(localNewMessage1); + when(localFolder.getMessage("newMessageUid2")).thenAnswer( + new Answer() { + @Override + public LocalMessage answer(InvocationOnMock invocation) throws Throwable { + if(hasFetchedMessage) { + return localNewMessage2; + } + else + return null; + } + } + ); + doAnswer((Answer) invocation -> { + hasFetchedMessage = true; + return null; + }).when(backend).downloadMessageStructure(eq(FOLDER_NAME), eq("newMessageUid2")); + reqFlags = Collections.singleton(Flag.ANSWERED); + forbiddenFlags = Collections.singleton(Flag.DELETED); + + account.setRemoteSearchNumResults(50); + } + + @Test + public void searchRemoteMessagesSynchronous_shouldNotifyStartedListingRemoteMessages() throws Exception { + setupRemoteSearch(); + + controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); + + verify(listener).remoteSearchStarted(FOLDER_ID); + } + + @Test + public void searchRemoteMessagesSynchronous_shouldQueryRemoteFolder() throws Exception { + setupRemoteSearch(); + + controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); + + verify(backend).search(FOLDER_NAME, "query", reqFlags, forbiddenFlags, false); + } + + @Test + public void searchRemoteMessagesSynchronous_shouldAskLocalFolderToDetermineNewMessages() throws Exception { + setupRemoteSearch(); + + controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); + + verify(localFolder).extractNewMessages(remoteMessages); + } + + @Test + public void searchRemoteMessagesSynchronous_shouldTryAndGetNewMessages() throws Exception { + setupRemoteSearch(); + + controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); + + verify(localFolder).getMessage("newMessageUid1"); + } + + @Test + public void searchRemoteMessagesSynchronous_shouldNotTryAndGetOldMessages() throws Exception { + setupRemoteSearch(); + + controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); + + verify(localFolder, never()).getMessage("oldMessageUid"); + } + + @Test + public void searchRemoteMessagesSynchronous_shouldFetchNewMessages() throws Exception { + setupRemoteSearch(); + + controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); + + verify(backend).downloadMessageStructure(eq(FOLDER_NAME), eq("newMessageUid2")); + } + + @Test + public void searchRemoteMessagesSynchronous_shouldNotFetchExistingMessages() throws Exception { + setupRemoteSearch(); + + controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); + + verify(backend, never()).downloadMessageStructure(eq(FOLDER_NAME), eq("newMessageUid1")); + } + + @Test + public void searchRemoteMessagesSynchronous_shouldNotifyOnFailure() throws Exception { + setupRemoteSearch(); + when(backend.search(anyString(), anyString(), nullable(Set.class), nullable(Set.class), eq(false))) + .thenThrow(new MessagingException("Test")); + + controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); + + verify(listener).remoteSearchFailed(null, "Test"); + } + + @Test + public void searchRemoteMessagesSynchronous_shouldNotifyOnFinish() throws Exception { + setupRemoteSearch(); + when(backend.search(anyString(), nullable(String.class), nullable(Set.class), nullable(Set.class), eq(false))) + .thenThrow(new MessagingException("Test")); + + controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); + + verify(listener).remoteSearchFinished(FOLDER_ID, 0, 50, Collections.emptyList()); + } + + @Test + public void sendPendingMessagesSynchronous_withNonExistentOutbox_shouldNotStartSync() throws MessagingException { + account.setOutboxFolderId(FOLDER_ID); + when(localFolder.exists()).thenReturn(false); + controller.addListener(listener); + + controller.sendPendingMessagesSynchronous(account); + + verifyNoMoreInteractions(listener); + } + + @Test + public void sendPendingMessagesSynchronous_shouldSetProgress() throws MessagingException { + setupAccountWithMessageToSend(); + + controller.sendPendingMessagesSynchronous(account); + + verify(listener).synchronizeMailboxProgress(account, FOLDER_ID, 0, 1); + } + + @Test + public void sendPendingMessagesSynchronous_shouldSendMessageUsingTransport() throws MessagingException { + setupAccountWithMessageToSend(); + + controller.sendPendingMessagesSynchronous(account); + + verify(backend).sendMessage(localMessageToSend1); + } + + @Test + public void sendPendingMessagesSynchronous_shouldSetAndRemoveSendInProgressFlag() throws MessagingException { + setupAccountWithMessageToSend(); + + controller.sendPendingMessagesSynchronous(account); + + InOrder ordering = inOrder(localMessageToSend1, backend); + ordering.verify(localMessageToSend1).setFlag(Flag.X_SEND_IN_PROGRESS, true); + ordering.verify(backend).sendMessage(localMessageToSend1); + ordering.verify(localMessageToSend1).setFlag(Flag.X_SEND_IN_PROGRESS, false); + } + + @Test + public void sendPendingMessagesSynchronous_shouldMarkSentMessageAsSeen() throws MessagingException { + setupAccountWithMessageToSend(); + + controller.sendPendingMessagesSynchronous(account); + + verify(localMessageToSend1).setFlag(Flag.SEEN, true); + } + + @Test + public void sendPendingMessagesSynchronous_whenMessageSentSuccesfully_shouldUpdateProgress() throws MessagingException { + setupAccountWithMessageToSend(); + + controller.sendPendingMessagesSynchronous(account); + + verify(listener).synchronizeMailboxProgress(account, FOLDER_ID, 1, 1); + } + + @Test + public void sendPendingMessagesSynchronous_shouldUpdateProgress() throws MessagingException { + setupAccountWithMessageToSend(); + + controller.sendPendingMessagesSynchronous(account); + + verify(listener).synchronizeMailboxProgress(account, FOLDER_ID, 1, 1); + } + + @Test + public void sendPendingMessagesSynchronous_withAuthenticationFailure_shouldNotify() throws MessagingException { + setupAccountWithMessageToSend(); + doThrow(new AuthenticationFailedException("Test")).when(backend).sendMessage(localMessageToSend1); + + controller.sendPendingMessagesSynchronous(account); + + verify(notificationController).showAuthenticationErrorNotification(account, false); + } + + @Test + public void sendPendingMessagesSynchronous_withCertificateFailure_shouldNotify() throws MessagingException { + setupAccountWithMessageToSend(); + doThrow(new CertificateValidationException("Test")).when(backend).sendMessage(localMessageToSend1); + + controller.sendPendingMessagesSynchronous(account); + + verify(notificationController).showCertificateErrorNotification(account, false); + } + + private void setupAccountWithMessageToSend() throws MessagingException { + account.setOutboxFolderId(FOLDER_ID); + account.setSentFolderId(SENT_FOLDER_ID); + when(localStore.getFolder(SENT_FOLDER_ID)).thenReturn(sentFolder); + when(sentFolder.getDatabaseId()).thenReturn(SENT_FOLDER_ID); + when(localFolder.exists()).thenReturn(true); + when(localFolder.getMessages()).thenReturn(Collections.singletonList(localMessageToSend1)); + when(localMessageToSend1.getUid()).thenReturn("localMessageToSend1"); + when(localMessageToSend1.getDatabaseId()).thenReturn(42L); + when(localMessageToSend1.getHeader(K9.IDENTITY_HEADER)).thenReturn(new String[]{}); + + OutboxState outboxState = new OutboxState(SendState.READY, 0, null, 0); + OutboxStateRepository outboxStateRepository = mock(OutboxStateRepository.class); + when(outboxStateRepository.getOutboxState(42L)).thenReturn(outboxState); + + when(localStore.getOutboxStateRepository()).thenReturn(outboxStateRepository); + controller.addListener(listener); + } + + private void configureBackendManager() { + when(backendManager.getBackend(account)).thenReturn(backend); + } + + private void configureAccount() { + account = preferences.newAccount(); + accountUuid = account.getUuid(); + + account.setIncomingServerSettings(new ServerSettings(Protocols.IMAP, "host", 993, + ConnectionSecurity.SSL_TLS_REQUIRED, AuthType.PLAIN, "username", "password", null)); + account.setOutgoingServerSettings(new ServerSettings(Protocols.SMTP, "host", 465, + ConnectionSecurity.SSL_TLS_REQUIRED, AuthType.PLAIN, "username", "password", null)); + account.setMaximumAutoDownloadMessageSize(MAXIMUM_SMALL_MESSAGE_SIZE); + account.setEmail("user@host.com"); + } + + private void configureLocalStore() throws MessagingException { + when(localStore.getFolder(FOLDER_NAME)).thenReturn(localFolder); + when(localStore.getFolder(FOLDER_ID)).thenReturn(localFolder); + when(localFolder.exists()).thenReturn(true); + when(localFolder.getDatabaseId()).thenReturn(FOLDER_ID); + when(localFolder.getServerId()).thenReturn(FOLDER_NAME); + when(localStore.getPersonalNamespaces(false)).thenReturn(Collections.singletonList(localFolder)); + when(localStoreProvider.getInstance(account)).thenReturn(localStore); + } + + private void removeAccountsFromPreferences() { + preferences.clearAccounts(); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/controller/PendingCommandSerializerTest.java b/app/core/src/test/java/com/fsck/k9/controller/PendingCommandSerializerTest.java new file mode 100644 index 0000000..85aede6 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/controller/PendingCommandSerializerTest.java @@ -0,0 +1,80 @@ +package com.fsck.k9.controller; + + +import java.util.HashMap; + +import com.fsck.k9.controller.MessagingControllerCommands.PendingAppend; +import com.fsck.k9.controller.MessagingControllerCommands.PendingCommand; +import com.fsck.k9.controller.MessagingControllerCommands.PendingEmptyTrash; +import com.fsck.k9.controller.MessagingControllerCommands.PendingMoveOrCopy; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + + +public class PendingCommandSerializerTest { + static final int DATABASE_ID = 123; + static final String UID = "uid"; + static final long SOURCE_FOLDER_ID = 42; + static final long DEST_FOLDER_ID = 23; + static final HashMap UID_MAP = new HashMap<>(); + public static final boolean IS_COPY = true; + + static { + UID_MAP.put("uid_1", "uid_other_1"); + UID_MAP.put("uid_2", "uid_other_2"); + } + + + PendingCommandSerializer pendingCommandSerializer = PendingCommandSerializer.getInstance(); + + + @Test + public void testSerializeDeserialize__withoutArguments() { + PendingCommand pendingCommand = PendingEmptyTrash.create(); + + String serializedCommand = pendingCommandSerializer.serialize(pendingCommand); + PendingEmptyTrash unserializedCommand = (PendingEmptyTrash) pendingCommandSerializer.unserialize( + DATABASE_ID, pendingCommand.getCommandName(), serializedCommand); + + assertEquals(DATABASE_ID, unserializedCommand.databaseId); + } + + @Test + public void testSerializeDeserialize__withArguments() { + PendingCommand pendingCommand = PendingAppend.create(SOURCE_FOLDER_ID, UID); + + String serializedCommand = pendingCommandSerializer.serialize(pendingCommand); + PendingAppend unserializedCommand = (PendingAppend) pendingCommandSerializer.unserialize( + DATABASE_ID, pendingCommand.getCommandName(), serializedCommand); + + assertEquals(DATABASE_ID, unserializedCommand.databaseId); + assertEquals(SOURCE_FOLDER_ID, unserializedCommand.folderId); + assertEquals(UID, unserializedCommand.uid); + } + + @Test + public void testSerializeDeserialize__withComplexArguments() { + PendingCommand pendingCommand = PendingMoveOrCopy.create( + SOURCE_FOLDER_ID, DEST_FOLDER_ID, IS_COPY, UID_MAP); + + String serializedCommand = pendingCommandSerializer.serialize(pendingCommand); + PendingMoveOrCopy unserializedCommand = (PendingMoveOrCopy) pendingCommandSerializer.unserialize( + DATABASE_ID, pendingCommand.getCommandName(), serializedCommand); + + assertEquals(DATABASE_ID, unserializedCommand.databaseId); + assertEquals(SOURCE_FOLDER_ID, unserializedCommand.srcFolderId); + assertEquals(DEST_FOLDER_ID, unserializedCommand.destFolderId); + assertEquals(UID_MAP, unserializedCommand.newUidMap); + } + + @Test(expected = IllegalArgumentException.class) + public void testDeserialize__withUnknownCommandName__shouldFail() { + PendingCommand pendingCommand = PendingEmptyTrash.create(); + + String serializedCommand = pendingCommandSerializer.serialize(pendingCommand); + pendingCommandSerializer.unserialize(DATABASE_ID, "BAD_COMMAND_NAME", serializedCommand); + } + +} diff --git a/app/core/src/test/java/com/fsck/k9/controller/UidReverseComparatorTest.java b/app/core/src/test/java/com/fsck/k9/controller/UidReverseComparatorTest.java new file mode 100644 index 0000000..fe0c218 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/controller/UidReverseComparatorTest.java @@ -0,0 +1,209 @@ +package com.fsck.k9.controller; + + +import androidx.annotation.NonNull; + +import com.fsck.k9.mail.Message; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + + +@SuppressWarnings("ConstantConditions") +public class UidReverseComparatorTest { + private UidReverseComparator comparator; + + + @Before + public void onBefore() { + comparator = new UidReverseComparator(); + } + + @Test + public void compare_withTwoNullArguments_shouldReturnZero() throws Exception { + Message messageLeft = null; + Message messageRight = null; + + int result = comparator.compare(messageLeft, messageRight); + + assertEquals("result must be 0 when both arguments are null", 0, result); + } + + @Test + public void compare_withNullArgumentAndMessageWithNullUid_shouldReturnZero() throws Exception { + Message messageLeft = null; + Message messageRight = createMessageWithNullUid(); + + int result = comparator.compare(messageLeft, messageRight); + + assertEquals("result must be 0 when both arguments are not a message with valid UID", 0, result); + } + + @Test + public void compare_withMessageWithNullUidAndNullArgument_shouldReturnZero() throws Exception { + Message messageLeft = createMessageWithNullUid(); + Message messageRight = null; + + int result = comparator.compare(messageLeft, messageRight); + + assertEquals("result must be 0 when both arguments are not a message with valid UID", 0, result); + } + + @Test + public void compare_withTwoMessagesWithNullUid_shouldReturnZero() throws Exception { + Message messageLeft = createMessageWithNullUid(); + Message messageRight = createMessageWithNullUid(); + + int result = comparator.compare(messageLeft, messageRight); + + assertEquals("result must be 0 when both arguments are a message with a null UID", 0, result); + } + + @Test + public void compare_withNullArgumentAndMessageWithInvalidUid_shouldReturnZero() throws Exception { + Message messageLeft = null; + Message messageRight = createMessageWithInvalidUid(); + + int result = comparator.compare(messageLeft, messageRight); + + assertEquals("result must be 0 when both arguments are not a message with valid UID", 0, result); + } + + @Test + public void compare_withMessageWithInvalidUidAndNullArgument_shouldReturnZero() throws Exception { + Message messageLeft = createMessageWithInvalidUid(); + Message messageRight = null; + + int result = comparator.compare(messageLeft, messageRight); + + assertEquals("result must be 0 when both arguments are not a message with valid UID", 0, result); + } + + @Test + public void compare_withTwoMessagesWithInvalidUid_shouldReturnZero() throws Exception { + Message messageLeft = createMessageWithInvalidUid(); + Message messageRight = createMessageWithInvalidUid(); + + int result = comparator.compare(messageLeft, messageRight); + + assertEquals("result must be 0 when both arguments are a message with an invalid UID", 0, result); + } + + @Test + public void compare_withMessageWithNullUidAndMessageWithInvalidUid_shouldReturnZero() throws Exception { + Message messageLeft = createMessageWithNullUid(); + Message messageRight = createMessageWithInvalidUid(); + + int result = comparator.compare(messageLeft, messageRight); + + assertEquals("result must be 0 when both arguments are not a message with valid UID", 0, result); + } + + @Test + public void compare_withMessageWithInvalidUidAndMessageWithNullUid_shouldReturnZero() throws Exception { + Message messageLeft = createMessageWithInvalidUid(); + Message messageRight = createMessageWithNullUid(); + + int result = comparator.compare(messageLeft, messageRight); + + assertEquals("result must be 0 when both arguments are not a message with valid UID", 0, result); + } + + @Test + public void compare_withLeftNullArgument_shouldReturnPositive() throws Exception { + Message messageLeft = null; + Message messageRight = createMessageWithUid(1); + + int result = comparator.compare(messageLeft, messageRight); + + assertTrue("result must be > 0 when left argument is null", result > 0); + } + + @Test + public void compare_withLeftMessageWithNullUid_shouldReturnPositive() throws Exception { + Message messageLeft = createMessageWithNullUid(); + Message messageRight = createMessageWithUid(1); + + int result = comparator.compare(messageLeft, messageRight); + + assertTrue("result must be > 0 when left argument is message with null UID", result > 0); + } + + @Test + public void compare_withLeftMessageWithInvalidUid_shouldReturnPositive() throws Exception { + Message messageLeft = createMessageWithInvalidUid(); + Message messageRight = createMessageWithUid(1); + + int result = comparator.compare(messageLeft, messageRight); + + assertTrue("result must be > 0 when left argument is message with invalid UID", result > 0); + } + + @Test + public void compare_withRightNullArgument_shouldReturnNegative() throws Exception { + Message messageLeft = createMessageWithUid(1); + Message messageRight = null; + + int result = comparator.compare(messageLeft, messageRight); + + assertTrue("result must be < 0 when right argument is null", result < 0); + } + + @Test + public void compare_withRightMessageWithNullUid_shouldReturnNegative() throws Exception { + Message messageLeft = createMessageWithUid(1); + Message messageRight = createMessageWithNullUid(); + + int result = comparator.compare(messageLeft, messageRight); + + assertTrue("result must be < 0 when right argument is message with null UID", result < 0); + } + + @Test + public void compare_withRightMessageWithInvalidUid_shouldReturnNegative() throws Exception { + Message messageLeft = createMessageWithUid(1); + Message messageRight = createMessageWithInvalidUid(); + + int result = comparator.compare(messageLeft, messageRight); + + assertTrue("result must be < 0 when right argument is message with invalid UID", result < 0); + } + + @Test + public void compare_twoMessages_shouldReturnOrderByUid() throws Exception { + Message messageSmall = createMessageWithUid(5); + Message messageLarge = createMessageWithUid(15); + + int resultOne = comparator.compare(messageSmall, messageLarge); + int resultTwo = comparator.compare(messageLarge, messageSmall); + + assertTrue("result must be > 0 when right message has larger UID than left message", resultOne > 0); + assertTrue("result must be < 0 when left message has larger UID than right message", resultTwo < 0); + } + + @NonNull + private Message createMessageWithUid(int uid) { + return createMessageWithUidString(Integer.toString(uid)); + } + + @NonNull + private Message createMessageWithNullUid() { + return createMessageWithUidString(null); + } + + @NonNull + private Message createMessageWithInvalidUid() { + return createMessageWithUidString("invalid"); + } + + private Message createMessageWithUidString(String uid) { + Message message = mock(Message.class); + when(message.getUid()).thenReturn(uid); + + return message; + } +} diff --git a/app/core/src/test/java/com/fsck/k9/crypto/MessageCryptoStructureDetectorTest.java b/app/core/src/test/java/com/fsck/k9/crypto/MessageCryptoStructureDetectorTest.java new file mode 100644 index 0000000..376363a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/crypto/MessageCryptoStructureDetectorTest.java @@ -0,0 +1,558 @@ +package com.fsck.k9.crypto; + + +import java.util.ArrayList; +import java.util.List; + +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MimeMessage; +import com.fsck.k9.mail.internet.MimeMessageHelper; +import com.fsck.k9.mail.internet.TextBody; +import com.fsck.k9.mailstore.MessageCryptoAnnotations; +import org.junit.Test; + +import static com.fsck.k9.mail.TestMessageConstructionUtils.bodypart; +import static com.fsck.k9.mail.TestMessageConstructionUtils.messageFromBody; +import static com.fsck.k9.mail.TestMessageConstructionUtils.multipart; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + + +@SuppressWarnings("WeakerAccess") +public class MessageCryptoStructureDetectorTest { + MessageCryptoAnnotations messageCryptoAnnotations = mock(MessageCryptoAnnotations.class); + static final String PGP_INLINE_DATA = "" + + "-----BEGIN PGP MESSAGE-----\n" + + "Header: Value\n" + + "\n" + + "base64base64base64base64\n" + + "-----END PGP MESSAGE-----\n"; + + + @Test + public void findPrimaryCryptoPart_withSimplePgpInline() throws Exception { + List outputExtraParts = new ArrayList<>(); + Message message = new MimeMessage(); + MimeMessageHelper.setBody(message, new TextBody(PGP_INLINE_DATA)); + + Part cryptoPart = MessageCryptoStructureDetector.findPrimaryEncryptedOrSignedPart(message, outputExtraParts); + + assertSame(message, cryptoPart); + } + + @Test + public void findPrimaryCryptoPart_withMultipartAlternativeContainingPgpInline() throws Exception { + List outputExtraParts = new ArrayList<>(); + BodyPart pgpInlinePart = bodypart("text/plain", PGP_INLINE_DATA); + Message message = messageFromBody( + multipart("alternative", + pgpInlinePart, + bodypart("text/html") + ) + ); + + Part cryptoPart = MessageCryptoStructureDetector.findPrimaryEncryptedOrSignedPart(message, outputExtraParts); + + assertSame(pgpInlinePart, cryptoPart); + } + + @Test + public void findPrimaryCryptoPart_withMultipartMixedContainingPgpInline() throws Exception { + List outputExtraParts = new ArrayList<>(); + BodyPart pgpInlinePart = bodypart("text/plain", PGP_INLINE_DATA); + Message message = messageFromBody( + multipart("mixed", + pgpInlinePart, + bodypart("application/octet-stream") + ) + ); + + Part cryptoPart = MessageCryptoStructureDetector.findPrimaryEncryptedOrSignedPart(message, outputExtraParts); + + assertSame(pgpInlinePart, cryptoPart); + } + + @Test + public void findPrimaryCryptoPart_withMultipartMixedContainingMultipartAlternativeContainingPgpInline() + throws Exception { + List outputExtraParts = new ArrayList<>(); + BodyPart pgpInlinePart = bodypart("text/plain", PGP_INLINE_DATA); + Message message = messageFromBody( + multipart("mixed", + multipart("alternative", + pgpInlinePart, + bodypart("text/html") + ), + bodypart("application/octet-stream") + ) + ); + + Part cryptoPart = MessageCryptoStructureDetector.findPrimaryEncryptedOrSignedPart(message, outputExtraParts); + + assertSame(pgpInlinePart, cryptoPart); + } + + @Test + public void findPrimaryCryptoPart_withEmptyMultipartAlternative_shouldReturnNull() throws Exception { + List outputExtraParts = new ArrayList<>(); + Message message = messageFromBody( + multipart("alternative") + ); + + Part cryptoPart = MessageCryptoStructureDetector.findPrimaryEncryptedOrSignedPart(message, outputExtraParts); + + assertNull(cryptoPart); + } + + @Test + public void findPrimaryCryptoPart_withEmptyMultipartMixed_shouldReturnNull() throws Exception { + List outputExtraParts = new ArrayList<>(); + Message message = messageFromBody( + multipart("mixed") + ); + + Part cryptoPart = MessageCryptoStructureDetector.findPrimaryEncryptedOrSignedPart(message, outputExtraParts); + + assertNull(cryptoPart); + } + + @Test + public void findPrimaryCryptoPart_withEmptyMultipartAlternativeInsideMultipartMixed_shouldReturnNull() + throws Exception { + List outputExtraParts = new ArrayList<>(); + Message message = messageFromBody( + multipart("mixed", + multipart("alternative") + ) + ); + + Part cryptoPart = MessageCryptoStructureDetector.findPrimaryEncryptedOrSignedPart(message, outputExtraParts); + + assertNull(cryptoPart); + } + + @Test + public void findEncryptedPartsShouldReturnEmptyListForEmptyMessage() throws Exception { + MimeMessage emptyMessage = new MimeMessage(); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(emptyMessage); + + assertEquals(0, encryptedParts.size()); + } + + @Test + public void findEncryptedPartsShouldReturnEmptyListForSimpleMessage() throws Exception { + MimeMessage message = new MimeMessage(); + message.setBody(new TextBody("message text")); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertEquals(0, encryptedParts.size()); + } + + @Test + public void findEncrypted__withMultipartEncrypted__shouldReturnRoot() throws Exception { + Message message = messageFromBody( + multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream") + ) + ); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertEquals(1, encryptedParts.size()); + assertSame(message, encryptedParts.get(0)); + } + + @Test + public void findEncrypted__withBadProtocol__shouldReturnEmpty() throws Exception { + Message message = messageFromBody( + multipart("encrypted", "protocol=\"application/not-pgp-encrypted\"", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream", "content") + ) + ); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertTrue(encryptedParts.isEmpty()); + } + + @Test + public void findEncrypted__withBadProtocolAndNoBody__shouldReturnRoot() throws Exception { + Message message = messageFromBody( + multipart("encrypted", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream") + ) + ); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertEquals(1, encryptedParts.size()); + assertSame(message, encryptedParts.get(0)); + } + + @Test + public void findEncrypted__withEmptyProtocol__shouldReturnEmpty() throws Exception { + Message message = messageFromBody( + multipart("encrypted", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream", "content") + ) + ); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertTrue(encryptedParts.isEmpty()); + } + + @Test + public void findEncrypted__withMissingEncryptedBody__shouldReturnEmpty() throws Exception { + Message message = messageFromBody( + multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/pgp-encrypted") + ) + ); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertTrue(encryptedParts.isEmpty()); + } + + @Test + public void findEncrypted__withBadStructure__shouldReturnEmpty() throws Exception { + Message message = messageFromBody( + multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/octet-stream") + ) + ); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertTrue(encryptedParts.isEmpty()); + } + + @Test + public void findEncrypted__withMultipartMixedSubEncrypted__shouldReturnRoot() throws Exception { + Message message = messageFromBody( + multipart("mixed", + multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream") + ) + ) + ); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertEquals(1, encryptedParts.size()); + assertSame(getPart(message, 0), encryptedParts.get(0)); + } + + @Test + public void findEncrypted__withMultipartMixedSubEncryptedAndEncrypted__shouldReturnBoth() + throws Exception { + Message message = messageFromBody( + multipart("mixed", + multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream") + ), + multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream") + ) + ) + ); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertEquals(2, encryptedParts.size()); + assertSame(getPart(message, 0), encryptedParts.get(0)); + assertSame(getPart(message, 1), encryptedParts.get(1)); + } + + @Test + public void findEncrypted__withMultipartMixedSubTextAndEncrypted__shouldReturnEncrypted() throws Exception { + Message message = messageFromBody( + multipart("mixed", + bodypart("text/plain"), + multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream") + ) + ) + ); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertEquals(1, encryptedParts.size()); + assertSame(getPart(message, 1), encryptedParts.get(0)); + } + + @Test + public void findEncrypted__withMultipartMixedSubEncryptedAndText__shouldReturnEncrypted() throws Exception { + Message message = messageFromBody( + multipart("mixed", + multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream") + ), + bodypart("text/plain") + ) + ); + + List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(message); + + assertEquals(1, encryptedParts.size()); + assertSame(getPart(message, 0), encryptedParts.get(0)); + } + + @Test + public void findSigned__withSimpleMultipartSigned__shouldReturnRoot() throws Exception { + Message message = messageFromBody( + multipart("signed", "protocol=\"application/pgp-signature\"", + bodypart("text/plain"), + bodypart("application/pgp-signature") + ) + ); + + List signedParts = MessageCryptoStructureDetector + .findMultipartSignedParts(message, messageCryptoAnnotations); + + assertEquals(1, signedParts.size()); + assertSame(message, signedParts.get(0)); + } + + @Test + public void findSigned__withNoProtocolAndNoBody__shouldReturnRoot() throws Exception { + Message message = messageFromBody( + multipart("signed", + bodypart("text/plain"), + bodypart("application/pgp-signature") + ) + ); + + List signedParts = MessageCryptoStructureDetector + .findMultipartSignedParts(message, messageCryptoAnnotations); + + assertEquals(1, signedParts.size()); + assertSame(message, signedParts.get(0)); + } + + @Test + public void findSigned__withBadProtocol__shouldReturnNothing() throws Exception { + Message message = messageFromBody( + multipart("signed", "protocol=\"application/not-pgp-signature\"", + bodypart("text/plain", "content"), + bodypart("application/pgp-signature") + ) + ); + + List signedParts = MessageCryptoStructureDetector + .findMultipartSignedParts(message, messageCryptoAnnotations); + + assertTrue(signedParts.isEmpty()); + } + + @Test + public void findSigned__withEmptyProtocol__shouldReturnRoot() throws Exception { + Message message = messageFromBody( + multipart("signed", + bodypart("text/plain", "content"), + bodypart("application/pgp-signature") + ) + ); + + List signedParts = MessageCryptoStructureDetector + .findMultipartSignedParts(message, messageCryptoAnnotations); + + assertTrue(signedParts.isEmpty()); + } + + @Test + public void findSigned__withMissingSignature__shouldReturnEmpty() throws Exception { + Message message = messageFromBody( + multipart("signed", "protocol=\"application/pgp-signature\"", + bodypart("text/plain") + ) + ); + + List signedParts = MessageCryptoStructureDetector + .findMultipartSignedParts(message, messageCryptoAnnotations); + + assertTrue(signedParts.isEmpty()); + } + + @Test + public void findSigned__withComplexMultipartSigned__shouldReturnRoot() throws Exception { + Message message = messageFromBody( + multipart("signed", "protocol=\"application/pgp-signature\"", + multipart("mixed", + bodypart("text/plain"), + bodypart("application/pdf") + ), + bodypart("application/pgp-signature") + ) + ); + + List signedParts = MessageCryptoStructureDetector + .findMultipartSignedParts(message, messageCryptoAnnotations); + + assertEquals(1, signedParts.size()); + assertSame(message, signedParts.get(0)); + } + + @Test + public void findEncrypted__withMultipartMixedSubSigned__shouldReturnSigned() throws Exception { + Message message = messageFromBody( + multipart("mixed", + multipart("signed", "protocol=\"application/pgp-signature\"", + bodypart("text/plain"), + bodypart("application/pgp-signature") + ) + ) + ); + + List signedParts = MessageCryptoStructureDetector + .findMultipartSignedParts(message, messageCryptoAnnotations); + + assertEquals(1, signedParts.size()); + assertSame(getPart(message, 0), signedParts.get(0)); + } + + @Test + public void findEncrypted__withMultipartMixedSubSignedAndText__shouldReturnSigned() throws Exception { + Message message = messageFromBody( + multipart("mixed", + multipart("signed", "application/pgp-signature", + bodypart("text/plain"), + bodypart("application/pgp-signature") + ), + bodypart("text/plain") + ) + ); + + List signedParts = MessageCryptoStructureDetector + .findMultipartSignedParts(message, messageCryptoAnnotations); + + assertEquals(1, signedParts.size()); + assertSame(getPart(message, 0), signedParts.get(0)); + } + + @Test + public void findEncrypted__withMultipartMixedSubTextAndSigned__shouldReturnSigned() throws Exception { + Message message = messageFromBody( + multipart("mixed", + bodypart("text/plain"), + multipart("signed", "application/pgp-signature", + bodypart("text/plain"), + bodypart("application/pgp-signature") + ) + ) + ); + + List signedParts = MessageCryptoStructureDetector + .findMultipartSignedParts(message, messageCryptoAnnotations); + + assertEquals(1, signedParts.size()); + assertSame(getPart(message, 1), signedParts.get(0)); + } + + @Test + public void isPgpInlineMethods__withPgpInlineData__shouldReturnTrue() throws Exception { + String pgpInlineData = "-----BEGIN PGP MESSAGE-----\n" + + "Header: Value\n" + + "\n" + + "base64base64base64base64\n" + + "-----END PGP MESSAGE-----\n"; + + MimeMessage message = new MimeMessage(); + message.setBody(new TextBody(pgpInlineData)); + + assertTrue(MessageCryptoStructureDetector.isPartPgpInlineEncrypted(message)); + } + + @Test + public void isPgpInlineMethods__withEncryptedDataAndLeadingWhitespace__shouldReturnTrue() throws Exception { + String pgpInlineData = "\n \n \n" + + "-----BEGIN PGP MESSAGE-----\n" + + "Header: Value\n" + + "\n" + + "base64base64base64base64\n" + + "-----END PGP MESSAGE-----\n"; + + MimeMessage message = new MimeMessage(); + message.setBody(new TextBody(pgpInlineData)); + + assertTrue(MessageCryptoStructureDetector.isPartPgpInlineEncryptedOrSigned(message)); + assertTrue(MessageCryptoStructureDetector.isPartPgpInlineEncrypted(message)); + } + + @Test + public void isPgpInlineMethods__withEncryptedDataAndLeadingGarbage__shouldReturnFalse() throws Exception { + String pgpInlineData = "garbage!" + + "-----BEGIN PGP MESSAGE-----\n" + + "Header: Value\n" + + "\n" + + "base64base64base64base64\n" + + "-----END PGP MESSAGE-----\n"; + + MimeMessage message = new MimeMessage(); + message.setBody(new TextBody(pgpInlineData)); + + assertFalse(MessageCryptoStructureDetector.isPartPgpInlineEncryptedOrSigned(message)); + assertFalse(MessageCryptoStructureDetector.isPartPgpInlineEncrypted(message)); + } + + @Test + public void isPartPgpInlineEncryptedOrSigned__withSignedData__shouldReturnTrue() throws Exception { + String pgpInlineData = "-----BEGIN PGP SIGNED MESSAGE-----\n" + + "Header: Value\n" + + "\n" + + "-----BEGIN PGP SIGNATURE-----\n" + + "Header: Value\n" + + "\n" + + "base64base64base64base64\n" + + "-----END PGP SIGNED MESSAGE-----\n"; + + MimeMessage message = new MimeMessage(); + message.setBody(new TextBody(pgpInlineData)); + + assertTrue(MessageCryptoStructureDetector.isPartPgpInlineEncryptedOrSigned(message)); + } + + @Test + public void isPartPgpInlineEncrypted__withSignedData__shouldReturnFalse() throws Exception { + String pgpInlineData = "-----BEGIN PGP SIGNED MESSAGE-----\n" + + "Header: Value\n" + + "\n" + + "-----BEGIN PGP SIGNATURE-----\n" + + "Header: Value\n" + + "\n" + + "base64base64base64base64\n" + + "-----END PGP SIGNED MESSAGE-----\n"; + + MimeMessage message = new MimeMessage(); + message.setBody(new TextBody(pgpInlineData)); + + assertFalse(MessageCryptoStructureDetector.isPartPgpInlineEncrypted(message)); + } + + static Part getPart(Part searchRootPart, int... indexes) { + Part part = searchRootPart; + for (int index : indexes) { + part = ((Multipart) part.getBody()).getBodyPart(index); + } + return part; + } +} diff --git a/app/core/src/test/java/com/fsck/k9/crypto/OpenPgpApiHelperTest.kt b/app/core/src/test/java/com/fsck/k9/crypto/OpenPgpApiHelperTest.kt new file mode 100644 index 0000000..20c4d2d --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/crypto/OpenPgpApiHelperTest.kt @@ -0,0 +1,31 @@ +package com.fsck.k9.crypto + +import com.fsck.k9.Identity +import org.junit.Assert.assertEquals +import org.junit.Test + +class OpenPgpApiHelperTest { + + @Test + fun buildUserId_withName_shouldCreateOpenPgpAccountName() { + val identity = Identity( + email = "user@domain.com", + name = "Name" + ) + + val result = OpenPgpApiHelper.buildUserId(identity) + + assertEquals("Name ", result) + } + + @Test + fun buildUserId_withoutName_shouldCreateOpenPgpAccountName() { + val identity = Identity( + email = "user@domain.com" + ) + + val result = OpenPgpApiHelper.buildUserId(identity) + + assertEquals("", result) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/helper/EmailHelperTest.java b/app/core/src/test/java/com/fsck/k9/helper/EmailHelperTest.java new file mode 100644 index 0000000..1769e6c --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/helper/EmailHelperTest.java @@ -0,0 +1,66 @@ +package com.fsck.k9.helper; + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class EmailHelperTest { + + @Test + public void getDomainFromEmailAddress_withRegularEmail_shouldReturnsDomain() { + String result = EmailHelper.getDomainFromEmailAddress("user@domain.com"); + + assertEquals("domain.com", result); + } + + @Test + public void getDomainFromEmailAddress_withInvalidEmail_shouldReturnNull() { + String result = EmailHelper.getDomainFromEmailAddress("user"); + + assertNull(result); + } + + @Test + public void getDomainFromEmailAddress_withTLD_shouldReturnDomain() { + String result = EmailHelper.getDomainFromEmailAddress("user@domain"); + + assertEquals("domain", result); + } + + @Test + public void getDomainFromEmailAddress_withEmptyDomain_shouldReturnNull() { + String result = EmailHelper.getDomainFromEmailAddress("user@"); + + assertNull(result); + } + + @Test + public void getLocalPartFromEmailAddress_withRegularEmail_shouldReturnLocalPart() { + String result = EmailHelper.getLocalPartFromEmailAddress("user@domain.com"); + + assertEquals("user", result); + } + + @Test + public void getLocalPartFromEmailAddress_withAtInLocalPart_shouldReturnLocalPart() { + String result = EmailHelper.getLocalPartFromEmailAddress("\"user@work\"@domain"); + + assertEquals("\"user@work\"", result); + } + + @Test + public void getLocalPartFromEmailAddress_withInvalidEmail_shouldReturnNull() { + String result = EmailHelper.getLocalPartFromEmailAddress("user"); + + assertNull(result); + } + + @Test + public void getLocalPartFromEmailAddress_withEmptyDomain_shouldReturnNull() { + String result = EmailHelper.getLocalPartFromEmailAddress("user@"); + + assertNull(result); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/helper/IdentityHelperTest.kt b/app/core/src/test/java/com/fsck/k9/helper/IdentityHelperTest.kt new file mode 100644 index 0000000..21fd985 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/helper/IdentityHelperTest.kt @@ -0,0 +1,162 @@ +package com.fsck.k9.helper + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.Account +import com.fsck.k9.Identity +import com.fsck.k9.RobolectricTest +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.Message.RecipientType +import com.fsck.k9.mail.internet.AddressHeaderBuilder +import com.fsck.k9.mail.internet.MimeMessage +import java.util.UUID +import org.junit.Test + +class IdentityHelperTest : RobolectricTest() { + private val account = createDummyAccount() + + @Test + fun getRecipientIdentityFromMessage_prefersToOverCc() { + val message = messageWithRecipients( + RecipientType.TO to IDENTITY_1_ADDRESS, + RecipientType.CC to IDENTITY_2_ADDRESS, + RecipientType.X_ORIGINAL_TO to IDENTITY_3_ADDRESS, + RecipientType.DELIVERED_TO to IDENTITY_4_ADDRESS, + RecipientType.X_ENVELOPE_TO to IDENTITY_5_ADDRESS + ) + + val identity = IdentityHelper.getRecipientIdentityFromMessage(account, message) + + assertThat(identity.email).isEqualTo(IDENTITY_1_ADDRESS) + } + + @Test + fun getRecipientIdentityFromMessage_prefersCcOverXOriginalTo() { + val message = messageWithRecipients( + RecipientType.TO to "unrelated1@example.org", + RecipientType.CC to IDENTITY_2_ADDRESS, + RecipientType.X_ORIGINAL_TO to IDENTITY_3_ADDRESS, + RecipientType.DELIVERED_TO to IDENTITY_4_ADDRESS, + RecipientType.X_ENVELOPE_TO to IDENTITY_5_ADDRESS + ) + + val identity = IdentityHelper.getRecipientIdentityFromMessage(account, message) + + assertThat(identity.email).isEqualTo(IDENTITY_2_ADDRESS) + } + + @Test + fun getRecipientIdentityFromMessage_prefersXOriginalToOverDeliveredTo() { + val message = messageWithRecipients( + RecipientType.TO to "unrelated1@example.org", + RecipientType.CC to "unrelated2@example.org", + RecipientType.X_ORIGINAL_TO to IDENTITY_3_ADDRESS, + RecipientType.DELIVERED_TO to IDENTITY_4_ADDRESS, + RecipientType.X_ENVELOPE_TO to IDENTITY_5_ADDRESS + ) + + val identity = IdentityHelper.getRecipientIdentityFromMessage(account, message) + + assertThat(identity.email).isEqualTo(IDENTITY_3_ADDRESS) + } + + @Test + fun getRecipientIdentityFromMessage_prefersDeliveredToOverXEnvelopeTo() { + val message = messageWithRecipients( + RecipientType.TO to "unrelated1@example.org", + RecipientType.CC to "unrelated2@example.org", + RecipientType.X_ORIGINAL_TO to "unrelated3@example.org", + RecipientType.DELIVERED_TO to IDENTITY_4_ADDRESS, + RecipientType.X_ENVELOPE_TO to IDENTITY_5_ADDRESS + ) + + val identity = IdentityHelper.getRecipientIdentityFromMessage(account, message) + + assertThat(identity.email).isEqualTo(IDENTITY_4_ADDRESS) + } + + @Test + fun getRecipientIdentityFromMessage_usesXEnvelopeToWhenPresent() { + val message = messageWithRecipients( + RecipientType.TO to "unrelated1@example.org", + RecipientType.CC to "unrelated2@example.org", + RecipientType.X_ORIGINAL_TO to "unrelated3@example.org", + RecipientType.DELIVERED_TO to "unrelated4@example.org", + RecipientType.X_ENVELOPE_TO to IDENTITY_5_ADDRESS + ) + + val identity = IdentityHelper.getRecipientIdentityFromMessage(account, message) + + assertThat(identity.email).isEqualTo(IDENTITY_5_ADDRESS) + } + + @Test + fun getRecipientIdentityFromMessage_withoutAnyIdentityAddresses_returnsFirstIdentity() { + val message = messageWithRecipients( + RecipientType.TO to "unrelated1@example.org", + RecipientType.CC to "unrelated2@example.org", + RecipientType.X_ORIGINAL_TO to "unrelated3@example.org", + RecipientType.DELIVERED_TO to "unrelated4@example.org", + RecipientType.X_ENVELOPE_TO to "unrelated5@example.org" + ) + + val identity = IdentityHelper.getRecipientIdentityFromMessage(account, message) + + assertThat(identity.email).isEqualTo(DEFAULT_ADDRESS) + } + + @Test + fun getRecipientIdentityFromMessage_withNoApplicableHeaders_returnsFirstIdentity() { + val emptyMessage = MimeMessage() + + val identity = IdentityHelper.getRecipientIdentityFromMessage(account, emptyMessage) + + assertThat(identity.email).isEqualTo(DEFAULT_ADDRESS) + } + + private fun createDummyAccount() = Account(UUID.randomUUID().toString()).apply { + replaceIdentities( + listOf( + newIdentity("Default", DEFAULT_ADDRESS), + newIdentity("Identity 1", IDENTITY_1_ADDRESS), + newIdentity("Identity 2", IDENTITY_2_ADDRESS), + newIdentity("Identity 3", IDENTITY_3_ADDRESS), + newIdentity("Identity 4", IDENTITY_4_ADDRESS), + newIdentity("Identity 5", IDENTITY_5_ADDRESS) + ) + ) + } + + private fun newIdentity(name: String, email: String) = Identity( + name = name, + email = email + ) + + private fun messageWithRecipients(vararg recipients: Pair): Message { + return MimeMessage().apply { + for ((recipientType, email) in recipients) { + val headerName = recipientType.toHeaderName() + addHeader(headerName, AddressHeaderBuilder.createHeaderValue(arrayOf(Address(email)))) + } + } + } + + private fun RecipientType.toHeaderName() = when (this) { + RecipientType.TO -> "To" + RecipientType.CC -> "Cc" + RecipientType.BCC -> "Bcc" + RecipientType.X_ORIGINAL_TO -> "X-Original-To" + RecipientType.DELIVERED_TO -> "Delivered-To" + RecipientType.X_ENVELOPE_TO -> "X-Envelope-To" + } + + companion object { + const val DEFAULT_ADDRESS = "default@example.org" + const val IDENTITY_1_ADDRESS = "identity1@example.org" + const val IDENTITY_2_ADDRESS = "identity2@example.org" + const val IDENTITY_3_ADDRESS = "identity3@example.org" + const val IDENTITY_4_ADDRESS = "identity4@example.org" + const val IDENTITY_5_ADDRESS = "identity5@example.org" + } +} diff --git a/app/core/src/test/java/com/fsck/k9/helper/ListHeadersTest.java b/app/core/src/test/java/com/fsck/k9/helper/ListHeadersTest.java new file mode 100644 index 0000000..1f0fdfc --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/helper/ListHeadersTest.java @@ -0,0 +1,123 @@ +package com.fsck.k9.helper; + + +import com.fsck.k9.RobolectricTest; +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.internet.MimeMessage; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + + +public class ListHeadersTest extends RobolectricTest { + private static final String[] TEST_EMAIL_ADDRESSES = new String[] { + "prettyandsimple@example.com", + "very.common@example.com", + "disposable.style.email.with+symbol@example.com", + "other.email-with-dash@example.com", + //TODO: Fix Address.parse() + /* + "\"much.more unusual\"@example.com", + "\"very.unusual.@.unusual.com\"@example.com", + //"very.(),:;<>[]\".VERY.\"very@\\ \"very\".unusual"@strange.example.com + "\"very.(),:;<>[]\\\".VERY.\\\"very@\\\\ \\\"very\\\".unusual\"@strange.example.com", + "admin@mailserver1", + "#!$%&'*+-/=?^_`{}|~@example.org", + "\"()<>[]:,;@\\\\\\\"!#$%&'*+-/=?^_`{}| ~.a\"@example.org", + "\" \"@example.org", + "example@localhost", + "example@s.solutions", + "user@com", + "user@localserver", + "user@[IPv6:2001:db8::1]" + */ + }; + + + @Test + public void getListPostAddresses_withMailTo_shouldReturnCorrectAddress() throws Exception { + for (String emailAddress : TEST_EMAIL_ADDRESSES) { + String headerValue = ""; + Message message = buildMimeMessageWithListPostValue(headerValue); + + Address[] result = ListHeaders.getListPostAddresses(message); + + assertExtractedAddressMatchesEmail(emailAddress, result); + } + } + + @Test + public void getListPostAddresses_withMailtoWithNote_shouldReturnCorrectAddress() throws Exception { + for (String emailAddress : TEST_EMAIL_ADDRESSES) { + String headerValue = " (Postings are Moderated)"; + Message message = buildMimeMessageWithListPostValue(headerValue); + + Address[] result = ListHeaders.getListPostAddresses(message); + + assertExtractedAddressMatchesEmail(emailAddress, result); + } + } + + @Test + public void getListPostAddresses_withMailtoWithSubject_shouldReturnCorrectAddress() throws Exception { + for (String emailAddress : TEST_EMAIL_ADDRESSES) { + String headerValue = ""; + Message message = buildMimeMessageWithListPostValue(headerValue); + + Address[] result = ListHeaders.getListPostAddresses(message); + + assertExtractedAddressMatchesEmail(emailAddress, result); + } + } + + @Test + public void getListPostAddresses_withMessageWithNo_shouldReturnEmptyList() throws Exception { + MimeMessage message = buildMimeMessageWithListPostValue("NO (posting not allowed on this list)"); + + Address[] result = ListHeaders.getListPostAddresses(message); + + assertEquals(0, result.length); + } + + @Test + public void getListPostAddresses_shouldProvideAllListPostHeaders() throws Exception { + MimeMessage message = buildMimeMessageWithListPostValue( + "", ""); + + Address[] result = ListHeaders.getListPostAddresses(message); + + assertNotNull(result); + assertEquals(2, result.length); + assertNotNull(result[0]); + assertEquals("list1@example.org", result[0].getAddress()); + assertNotNull(result[1]); + assertEquals("list2@example.org", result[1].getAddress()); + } + + @Test + public void getListPostAddresses_withoutMailtoUriInBrackets_shouldReturnEmptyList() throws Exception { + MimeMessage message = buildMimeMessageWithListPostValue(""); + + Address[] result = ListHeaders.getListPostAddresses(message); + + assertEquals(0, result.length); + } + + private void assertExtractedAddressMatchesEmail(String emailAddress, Address[] result) { + assertNotNull(result); + assertEquals(1, result.length); + assertNotNull(result[0]); + assertEquals(emailAddress, result[0].getAddress()); + } + + private MimeMessage buildMimeMessageWithListPostValue(String... values) { + MimeMessage message = new MimeMessage(); + for (String value : values) { + message.addHeader("List-Post", value); + } + + return message; + } +} diff --git a/app/core/src/test/java/com/fsck/k9/helper/ListUnsubscribeHelperTest.kt b/app/core/src/test/java/com/fsck/k9/helper/ListUnsubscribeHelperTest.kt new file mode 100644 index 0000000..3afd9c4 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/helper/ListUnsubscribeHelperTest.kt @@ -0,0 +1,96 @@ +package com.fsck.k9.helper + +import androidx.core.net.toUri +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.RobolectricTest +import com.fsck.k9.mail.internet.MimeMessage +import kotlin.test.assertNull +import org.junit.Test + +class ListUnsubscribeHelperTest : RobolectricTest() { + @Test + fun `get list unsubscribe url - should accept mailto`() { + val message = buildMimeMessageWithListUnsubscribeValue( + "" + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(MailtoUnsubscribeUri("mailto:unsubscribe@example.com".toUri())) + } + + @Test + fun `get list unsubscribe url - should prefer mailto 1`() { + val message = buildMimeMessageWithListUnsubscribeValue( + ", " + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(MailtoUnsubscribeUri("mailto:unsubscribe@example.com".toUri())) + } + + @Test + fun `get list unsubscribe url - should prefer mailto 2`() { + val message = buildMimeMessageWithListUnsubscribeValue( + ", " + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(MailtoUnsubscribeUri("mailto:unsubscribe@example.com".toUri())) + } + + @Test + fun `get list unsubscribe url - should allow https if no mailto`() { + val message = buildMimeMessageWithListUnsubscribeValue( + "" + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(HttpsUnsubscribeUri("https://example.com/unsubscribe".toUri())) + } + + @Test + fun `get list unsubscribe url - should correctly parse uncommon urls`() { + val message = buildMimeMessageWithListUnsubscribeValue( + "" + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(HttpsUnsubscribeUri("https://domain.example/one,two".toUri())) + } + + @Test + fun `get list unsubscribe url - should ignore unsafe entries 1`() { + val message = buildMimeMessageWithListUnsubscribeValue( + "" + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertNull(result) + } + + @Test + fun `get list unsubscribe url - should ignore unsafe entries 2`() { + val message = buildMimeMessageWithListUnsubscribeValue( + ", " + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(HttpsUnsubscribeUri("https://example.com/unsubscribe".toUri())) + } + + @Test + fun `get list unsubscribe url - should ignore empty`() { + val message = buildMimeMessageWithListUnsubscribeValue( + "" + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertNull(result) + } + + @Test + fun `get list unsubscribe url - should ignore missing header`() { + val message = MimeMessage() + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertNull(result) + } + + private fun buildMimeMessageWithListUnsubscribeValue(value: String): MimeMessage { + val message = MimeMessage() + message.addHeader("List-Unsubscribe", value) + return message + } +} diff --git a/app/core/src/test/java/com/fsck/k9/helper/MailToTest.java b/app/core/src/test/java/com/fsck/k9/helper/MailToTest.java new file mode 100644 index 0000000..d388d41 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/helper/MailToTest.java @@ -0,0 +1,235 @@ +package com.fsck.k9.helper; + + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import android.net.Uri; + +import com.fsck.k9.RobolectricTest; +import com.fsck.k9.helper.MailTo.CaseInsensitiveParamWrapper; +import com.fsck.k9.mail.Address; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertThat; + + +public class MailToTest extends RobolectricTest { + @Rule + public ExpectedException exception = ExpectedException.none(); + + + @Test + public void testIsMailTo_validMailToURI() { + Uri uri = Uri.parse("mailto:nobody"); + + boolean result = MailTo.isMailTo(uri); + + assertTrue(result); + } + + @Test + public void testIsMailTo_invalidMailToUri() { + Uri uri = Uri.parse("http://example.org/"); + + boolean result = MailTo.isMailTo(uri); + + assertFalse(result); + } + + @SuppressWarnings("ConstantConditions") + @Test + public void testIsMailTo_nullArgument() { + Uri uri = null; + + boolean result = MailTo.isMailTo(uri); + + assertFalse(result); + } + + @Test + public void parse_withNullArgument_shouldThrow() throws Exception { + exception.expect(NullPointerException.class); + exception.expectMessage("Argument 'uri' must not be null"); + + MailTo.parse(null); + } + + @Test + public void parse_withoutMailtoUri_shouldThrow() throws Exception { + exception.expect(IllegalArgumentException.class); + exception.expectMessage("Not a mailto scheme"); + + Uri uri = Uri.parse("http://example.org/"); + + MailTo.parse(uri); + } + + @Test + public void testGetTo_singleEmailAddress() { + Uri uri = Uri.parse("mailto:test@abc.com"); + MailTo mailToHelper = MailTo.parse(uri); + + Address[] emailAddressList = mailToHelper.getTo(); + + assertEquals(emailAddressList[0].getAddress(), "test@abc.com"); + } + + @Test + public void testGetTo_multipleEmailAddress() { + Uri uri = Uri.parse("mailto:test1@abc.com?to=test2@abc.com"); + MailTo mailToHelper = MailTo.parse(uri); + + Address[] emailAddressList = mailToHelper.getTo(); + + assertEquals(emailAddressList[0].getAddress(), "test1@abc.com"); + assertEquals(emailAddressList[1].getAddress(), "test2@abc.com"); + } + + @Test + public void testGetCc_singleEmailAddress() { + Uri uri = Uri.parse("mailto:test1@abc.com?cc=test3@abc.com"); + MailTo mailToHelper = MailTo.parse(uri); + + Address[] emailAddressList = mailToHelper.getCc(); + + assertEquals(emailAddressList[0].getAddress(), "test3@abc.com"); + } + + @Test + public void testGetCc_multipleEmailAddress() { + Uri uri = Uri.parse("mailto:test1@abc.com?cc=test3@abc.com,test4@abc.com"); + MailTo mailToHelper = MailTo.parse(uri); + + Address[] emailAddressList = mailToHelper.getCc(); + + assertEquals(emailAddressList[0].getAddress(), "test3@abc.com"); + assertEquals(emailAddressList[1].getAddress(), "test4@abc.com"); + } + + @Test + public void testGetBcc_singleEmailAddress() { + Uri uri = Uri.parse("mailto:?bcc=test3@abc.com"); + MailTo mailToHelper = MailTo.parse(uri); + + Address[] emailAddressList = mailToHelper.getBcc(); + + assertEquals(emailAddressList[0].getAddress(), "test3@abc.com"); + } + + @Test + public void testGetBcc_multipleEmailAddress() { + Uri uri = Uri.parse("mailto:?bcc=test3@abc.com&bcc=test4@abc.com"); + MailTo mailToHelper = MailTo.parse(uri); + + Address[] emailAddressList = mailToHelper.getBcc(); + + assertEquals(emailAddressList[0].getAddress(), "test3@abc.com"); + assertEquals(emailAddressList[1].getAddress(), "test4@abc.com"); + } + + @Test + public void testGetSubject() { + Uri uri = Uri.parse("mailto:?subject=Hello"); + MailTo mailToHelper = MailTo.parse(uri); + + String subject = mailToHelper.getSubject(); + + assertEquals(subject, "Hello"); + } + + @Test + public void testGetBody() { + Uri uri = Uri.parse("mailto:?body=Test%20Body&something=else"); + MailTo mailToHelper = MailTo.parse(uri); + + String subject = mailToHelper.getBody(); + + assertEquals(subject, "Test Body"); + } + + @Test + public void testCaseInsensitiveParamWrapper() { + Uri uri = Uri.parse("scheme://authority?a=one&b=two&c=three"); + CaseInsensitiveParamWrapper caseInsensitiveParamWrapper = new CaseInsensitiveParamWrapper(uri); + + List result = caseInsensitiveParamWrapper.getQueryParameters("b"); + + assertThat(Collections.singletonList("two"), is(result)); + } + + @Test + public void testCaseInsensitiveParamWrapper_multipleMatchingQueryParameters() { + Uri uri = Uri.parse("scheme://authority?xname=one&name=two&Name=Three&NAME=FOUR"); + CaseInsensitiveParamWrapper caseInsensitiveParamWrapper = new CaseInsensitiveParamWrapper(uri); + + List result = caseInsensitiveParamWrapper.getQueryParameters("name"); + + assertThat(Arrays.asList("two", "Three", "FOUR"), is(result)); + } + + @Test + public void testCaseInsensitiveParamWrapper_withoutQueryParameters() { + Uri uri = Uri.parse("scheme://authority"); + CaseInsensitiveParamWrapper caseInsensitiveParamWrapper = new CaseInsensitiveParamWrapper(uri); + + List result = caseInsensitiveParamWrapper.getQueryParameters("name"); + + assertThat(Collections.emptyList(), is(result)); + } + + @Test + public void testGetInReplyTo_singleMessageId() { + Uri uri = Uri.parse("mailto:?in-reply-to=%3C7C72B202-73F3@somewhere%3E"); + + MailTo mailToHelper = MailTo.parse(uri); + + assertEquals("<7C72B202-73F3@somewhere>", mailToHelper.getInReplyTo()); + } + + @Test + public void testGetInReplyTo_multipleMessageIds() { + Uri uri = Uri.parse("mailto:?in-reply-to=%3C7C72B202-73F3@somewhere%3E%3C8A39-1A87CB40C114@somewhereelse%3E"); + + MailTo mailToHelper = MailTo.parse(uri); + + assertEquals("<7C72B202-73F3@somewhere>", mailToHelper.getInReplyTo()); + } + + + @Test + public void testGetInReplyTo_RFC6068Example() { + Uri uri = Uri.parse("mailto:list@example.org?In-Reply-To=%3C3469A91.D10AF4C@example.com%3E"); + + MailTo mailToHelper = MailTo.parse(uri); + + assertEquals("<3469A91.D10AF4C@example.com>", mailToHelper.getInReplyTo()); + } + + @Test + public void testGetInReplyTo_invalid() { + Uri uri = Uri.parse("mailto:?in-reply-to=7C72B202-73F3somewhere"); + + MailTo mailToHelper = MailTo.parse(uri); + + assertEquals(null, mailToHelper.getInReplyTo()); + } + + @Test + public void testGetInReplyTo_empty() { + Uri uri = Uri.parse("mailto:?in-reply-to="); + + MailTo mailToHelper = MailTo.parse(uri); + + assertEquals(null, mailToHelper.getInReplyTo()); + + } + +} diff --git a/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.kt b/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.kt new file mode 100644 index 0000000..2f31e77 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.kt @@ -0,0 +1,147 @@ +package com.fsck.k9.helper + +import android.graphics.Color +import android.text.SpannableString +import app.k9mail.core.android.common.contact.Contact +import app.k9mail.core.android.common.contact.ContactRepository +import app.k9mail.core.common.mail.EmailAddress +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.fsck.k9.RobolectricTest +import com.fsck.k9.helper.MessageHelper.Companion.toFriendly +import com.fsck.k9.mail.Address +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub + +class MessageHelperTest : RobolectricTest() { + + private val contactRepository: ContactRepository = mock() + + @Test + fun testToFriendlyShowsPersonalPartIfItExists() { + val address = Address("test@testor.com", "Tim Testor") + assertThat(toFriendly(address, contactRepository)).isEqualTo("Tim Testor") + } + + @Test + fun testToFriendlyShowsEmailPartIfNoPersonalPartExists() { + val address = Address("test@testor.com") + assertThat(toFriendly(address, contactRepository)).isEqualTo("test@testor.com") + } + + @Test + fun testToFriendlyArray() { + val address1 = Address("test@testor.com", "Tim Testor") + val address2 = Address("foo@bar.com", "Foo Bar") + val addresses = arrayOf(address1, address2) + assertThat(toFriendly(addresses, contactRepository).toString()).isEqualTo("Tim Testor,Foo Bar") + } + + @Test + fun testToFriendlyWithContactLookup() { + val address = Address(EMAIL_ADDRESS.address) + setupContactRepositoryWithFakeContact(EMAIL_ADDRESS) + + assertThat(toFriendly(address, contactRepository)).isEqualTo("Tim Testor") + } + + @Test + fun testToFriendlyWithChangeContactColor() { + val address = Address(EMAIL_ADDRESS.address) + setupContactRepositoryWithFakeContact(EMAIL_ADDRESS) + + val friendly = toFriendly( + address = address, + contactRepository = contactRepository, + showCorrespondentNames = true, + changeContactNameColor = true, + contactNameColor = Color.RED, + ) + assertThat(friendly).isInstanceOf(SpannableString::class.java) + assertThat(friendly.toString()).isEqualTo("Tim Testor") + } + + @Test + fun testToFriendlyWithoutCorrespondentNames() { + val address = Address(EMAIL_ADDRESS.address, "Tim Testor") + setupContactRepositoryWithFakeContact(EMAIL_ADDRESS) + + val friendly = toFriendly( + address = address, + contactRepository = contactRepository, + showCorrespondentNames = false, + changeContactNameColor = false, + contactNameColor = 0, + ) + assertThat(friendly).isEqualTo("test@testor.com") + } + + @Test + fun toFriendly_spoofPreventionOverridesPersonal() { + val address = Address("test@testor.com", "potus@whitehouse.gov") + val friendly = toFriendly(address, contactRepository) + assertThat(friendly).isEqualTo("test@testor.com") + } + + @Test + fun toFriendly_atPrecededByOpeningParenthesisShouldNotTriggerSpoofPrevention() { + val address = Address("gitlab@gitlab.example", "username (@username)") + val friendly = toFriendly(address, contactRepository) + assertThat(friendly).isEqualTo("username (@username)") + } + + @Test + fun toFriendly_nameStartingWithAtShouldNotTriggerSpoofPrevention() { + val address = Address("address@domain.example", "@username") + val friendly = toFriendly(address, contactRepository) + assertThat(friendly).isEqualTo("@username") + } + + @Test + fun toFriendly_spoofPreventionDoesntOverrideContact() { + val address = Address(EMAIL_ADDRESS.address, "Tim Testor") + setupContactRepositoryWithSpoofContact(EMAIL_ADDRESS) + + val friendly = toFriendly( + address = address, + contactRepository = contactRepository, + showCorrespondentNames = true, + changeContactNameColor = false, + contactNameColor = 0, + ) + assertThat(friendly).isEqualTo("Tim@Testor") + } + + private fun setupContactRepositoryWithFakeContact(emailAddress: EmailAddress) { + contactRepository.stub { + on { getContactFor(emailAddress) } doReturn + Contact( + id = 1L, + name = "Tim Testor", + emailAddress = emailAddress, + uri = mock(), + photoUri = null, + ) + } + } + + private fun setupContactRepositoryWithSpoofContact(emailAddress: EmailAddress) { + contactRepository.stub { + on { getContactFor(emailAddress) } doReturn + Contact( + id = 1L, + name = "Tim@Testor", + emailAddress = emailAddress, + uri = mock(), + photoUri = null, + ) + } + } + + private companion object { + val EMAIL_ADDRESS = EmailAddress("test@testor.com") + } +} diff --git a/app/core/src/test/java/com/fsck/k9/helper/ReplyToParserTest.java b/app/core/src/test/java/com/fsck/k9/helper/ReplyToParserTest.java new file mode 100644 index 0000000..6093ed1 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/helper/ReplyToParserTest.java @@ -0,0 +1,182 @@ +package com.fsck.k9.helper; + + +import java.lang.reflect.Array; +import java.util.ArrayList; + +import com.fsck.k9.Account; +import com.fsck.k9.RobolectricTest; +import com.fsck.k9.helper.ReplyToParser.ReplyToAddresses; +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.Message.RecipientType; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +public class ReplyToParserTest extends RobolectricTest { + private static final Address[] REPLY_TO_ADDRESSES = Address.parse("replyTo1@example.com, replyTo2@example.com"); + private static final Address[] LIST_POST_ADDRESSES = Address.parse("listPost@example.com"); + private static final Address[] FROM_ADDRESSES = Address.parse("from@example.com"); + private static final Address[] TO_ADDRESSES = Address.parse("to1@example.com, to2@example.com"); + private static final Address[] CC_ADDRESSES = Address.parse("cc1@example.com, cc2@example.com"); + private static final String[] LIST_POST_HEADER_VALUES = new String[] { "" }; + public static final Address[] EMPTY_ADDRESSES = new Address[0]; + + + private ReplyToParser replyToParser; + private Message message; + private Account account; + + + @Before + public void setUp() throws Exception { + message = mock(Message.class); + account = mock(Account.class); + + replyToParser = new ReplyToParser(); + } + + @Test + public void getRecipientsToReplyTo_should_prefer_replyTo_over_any_other_field() throws Exception { + when(message.getReplyTo()).thenReturn(REPLY_TO_ADDRESSES); + when(message.getHeader(ListHeaders.LIST_POST_HEADER)).thenReturn(LIST_POST_HEADER_VALUES); + when(message.getFrom()).thenReturn(FROM_ADDRESSES); + + ReplyToAddresses result = replyToParser.getRecipientsToReplyTo(message, account); + + assertArrayEquals(REPLY_TO_ADDRESSES, result.to); + assertArrayEquals(EMPTY_ADDRESSES, result.cc); + verify(account).isAnIdentity(result.to); + } + + @Test + public void getRecipientsToReplyTo_should_prefer_from_ifOtherIsIdentity() throws Exception { + when(message.getReplyTo()).thenReturn(REPLY_TO_ADDRESSES); + when(message.getHeader(ListHeaders.LIST_POST_HEADER)).thenReturn(LIST_POST_HEADER_VALUES); + when(message.getFrom()).thenReturn(FROM_ADDRESSES); + when(message.getRecipients(RecipientType.TO)).thenReturn(TO_ADDRESSES); + when(account.isAnIdentity(any(Address[].class))).thenReturn(true); + + ReplyToAddresses result = replyToParser.getRecipientsToReplyTo(message, account); + + assertArrayEquals(TO_ADDRESSES, result.to); + assertArrayEquals(EMPTY_ADDRESSES, result.cc); + } + + @Test + public void getRecipientsToReplyTo_should_prefer_listPost_over_from_field() throws Exception { + when(message.getReplyTo()).thenReturn(EMPTY_ADDRESSES); + when(message.getHeader(ListHeaders.LIST_POST_HEADER)).thenReturn(LIST_POST_HEADER_VALUES); + when(message.getFrom()).thenReturn(FROM_ADDRESSES); + + ReplyToAddresses result = replyToParser.getRecipientsToReplyTo(message, account); + + assertArrayEquals(LIST_POST_ADDRESSES, result.to); + assertArrayEquals(EMPTY_ADDRESSES, result.cc); + verify(account).isAnIdentity(result.to); + } + + @Test + public void getRecipientsToReplyTo_should_return_from_otherwise() throws Exception { + when(message.getReplyTo()).thenReturn(EMPTY_ADDRESSES); + when(message.getHeader(ListHeaders.LIST_POST_HEADER)).thenReturn(new String[0]); + when(message.getFrom()).thenReturn(FROM_ADDRESSES); + + ReplyToAddresses result = replyToParser.getRecipientsToReplyTo(message, account); + + assertArrayEquals(FROM_ADDRESSES, result.to); + assertArrayEquals(EMPTY_ADDRESSES, result.cc); + verify(account).isAnIdentity(result.to); + } + + @Test + public void getRecipientsToReplyAllTo_should_returnFromAndToAndCcRecipients() throws Exception { + when(message.getReplyTo()).thenReturn(EMPTY_ADDRESSES); + when(message.getHeader(ListHeaders.LIST_POST_HEADER)).thenReturn(new String[0]); + when(message.getFrom()).thenReturn(FROM_ADDRESSES); + when(message.getRecipients(RecipientType.TO)).thenReturn(TO_ADDRESSES); + when(message.getRecipients(RecipientType.CC)).thenReturn(CC_ADDRESSES); + + ReplyToAddresses recipientsToReplyAllTo = replyToParser.getRecipientsToReplyAllTo(message, account); + + assertArrayEquals(arrayConcatenate(FROM_ADDRESSES, TO_ADDRESSES, Address.class), recipientsToReplyAllTo.to); + assertArrayEquals(CC_ADDRESSES, recipientsToReplyAllTo.cc); + } + + @Test + public void getRecipientsToReplyAllTo_should_excludeIdentityAddresses() throws Exception { + when(message.getReplyTo()).thenReturn(EMPTY_ADDRESSES); + when(message.getHeader(ListHeaders.LIST_POST_HEADER)).thenReturn(new String[0]); + when(message.getFrom()).thenReturn(EMPTY_ADDRESSES); + + when(message.getRecipients(RecipientType.TO)).thenReturn(TO_ADDRESSES); + when(message.getRecipients(RecipientType.CC)).thenReturn(CC_ADDRESSES); + Address excludedCcAddress = CC_ADDRESSES[1]; + Address excludedToAddress = TO_ADDRESSES[0]; + when(account.isAnIdentity(eq(excludedToAddress))).thenReturn(true); + when(account.isAnIdentity(eq(excludedCcAddress))).thenReturn(true); + + + ReplyToAddresses recipientsToReplyAllTo = replyToParser.getRecipientsToReplyAllTo(message, account); + + + assertArrayEquals(arrayExcept(TO_ADDRESSES, excludedToAddress), recipientsToReplyAllTo.to); + assertArrayEquals(arrayExcept(CC_ADDRESSES, excludedCcAddress), recipientsToReplyAllTo.cc); + } + + @Test + public void getRecipientsToReplyAllTo_should_excludeDuplicates() throws Exception { + when(message.getReplyTo()).thenReturn(REPLY_TO_ADDRESSES); + when(message.getFrom()).thenReturn(arrayConcatenate(FROM_ADDRESSES, REPLY_TO_ADDRESSES, Address.class)); + when(message.getRecipients(RecipientType.TO)).thenReturn(arrayConcatenate(FROM_ADDRESSES, TO_ADDRESSES, Address.class)); + when(message.getRecipients(RecipientType.CC)).thenReturn(arrayConcatenate(CC_ADDRESSES, TO_ADDRESSES, Address.class)); + when(message.getHeader(ListHeaders.LIST_POST_HEADER)).thenReturn(new String[0]); + + ReplyToAddresses recipientsToReplyAllTo = replyToParser.getRecipientsToReplyAllTo(message, account); + + assertArrayContainsAll(REPLY_TO_ADDRESSES, recipientsToReplyAllTo.to); + assertArrayContainsAll(FROM_ADDRESSES, recipientsToReplyAllTo.to); + assertArrayContainsAll(TO_ADDRESSES, recipientsToReplyAllTo.to); + int totalExpectedAddresses = REPLY_TO_ADDRESSES.length + FROM_ADDRESSES.length + TO_ADDRESSES.length; + assertEquals(totalExpectedAddresses, recipientsToReplyAllTo.to.length); + assertArrayEquals(CC_ADDRESSES, recipientsToReplyAllTo.cc); + } + + public void assertArrayContainsAll(T[] expecteds, T[] actual) { + for (T expected : expecteds) { + assertTrue("Element must be in array (" + expected + ")", Utility.arrayContains(actual, expected)); + } + } + + public T[] arrayConcatenate(T[] first, T[] second, Class cls) { + // noinspection unchecked + T[] result = (T[]) Array.newInstance(cls, first.length + second.length); + + System.arraycopy(first, 0, result, 0, first.length); + System.arraycopy(second, 0, result, first.length, second.length); + + return result; + } + + public T[] arrayExcept(T[] in, T except) { + ArrayList result = new ArrayList<>(); + for (T element : in) { + if (!element.equals(except)) { + result.add(element); + } + } + + // noinspection unchecked, it's a hack but it works ♪ + return result.toArray((T[]) new Object[result.size()]); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/helper/UtilityTest.java b/app/core/src/test/java/com/fsck/k9/helper/UtilityTest.java new file mode 100644 index 0000000..9c86071 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/helper/UtilityTest.java @@ -0,0 +1,82 @@ +package com.fsck.k9.helper; + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class UtilityTest { + + @Test + public void stripNewLines_removesLeadingCarriageReturns() { + String result = Utility.stripNewLines("\r\rTest"); + + assertEquals("Test", result); + } + + @Test + public void stripNewLines_removesLeadingLineFeeds() { + String result = Utility.stripNewLines("\n\nTest\n\n"); + + assertEquals("Test", result); + } + + @Test + public void stripNewLines_removesTrailingCarriageReturns() { + String result = Utility.stripNewLines("Test\r\r"); + + assertEquals("Test", result); + } + + @Test + public void stripNewLines_removesMidCarriageReturns() { + String result = Utility.stripNewLines("Test\rTest"); + + assertEquals("TestTest", result); + } + + @Test + public void stripNewLines_removesMidLineFeeds() { + String result = Utility.stripNewLines("Test\nTest"); + + assertEquals("TestTest", result); + } + + @Test + public void arrayContains_withObject_returnTrue() { + Object[] container = { 10, 20, 30, 40, 50, 60, 71, 80, 90, 91 }; + + boolean result = Utility.arrayContains(container, 10); + + assertTrue(result); + } + + @Test + public void arrayContains_withoutObject_returnFalse() { + Object[] container = { 10, 20, 30, 40, 50, 60, 71, 80, 90, 91 }; + + boolean result = Utility.arrayContains(container, 11); + + assertFalse(result); + } + + @Test + public void arrayContainsAny_withObject_returnsTrue() { + Object[] container = { 10, 20, 30, 40, 50, 60, 71, 80, 90, 91 }; + + boolean result = Utility.arrayContainsAny(container, 1, 2, 3, 10); + + assertTrue(result); + } + + @Test + public void arrayContainsAny_withoutObject_returnsFalse() { + Object[] container = { 10, 20, 30, 40, 50, 60, 71, 80, 90, 91 }; + + boolean result = Utility.arrayContainsAny(container, 1, 2, 3, 4); + + assertFalse(result); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/logging/LogcatLogFileWriterTest.kt b/app/core/src/test/java/com/fsck/k9/logging/LogcatLogFileWriterTest.kt new file mode 100644 index 0000000..c29606a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/logging/LogcatLogFileWriterTest.kt @@ -0,0 +1,86 @@ +package com.fsck.k9.logging + +import android.content.ContentResolver +import android.net.Uri +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class LogcatLogFileWriterTest { + private val contentUri = mock() + private val outputStream = ByteArrayOutputStream() + + @Test + fun `write log to contentUri`() = runBlocking { + val logData = "a".repeat(10_000) + val logFileWriter = LogcatLogFileWriter( + contentResolver = createContentResolver(), + processExecutor = createProcessExecutor(logData), + coroutineDispatcher = Dispatchers.Unconfined + ) + + logFileWriter.writeLogTo(contentUri) + + assertThat(outputStream.toByteArray().decodeToString()).isEqualTo(logData) + } + + @Test(expected = FileNotFoundException::class) + fun `contentResolver throws`() = runBlocking { + val logFileWriter = LogcatLogFileWriter( + contentResolver = createThrowingContentResolver(FileNotFoundException()), + processExecutor = createProcessExecutor("irrelevant"), + coroutineDispatcher = Dispatchers.Unconfined + ) + + logFileWriter.writeLogTo(contentUri) + } + + @Test(expected = IOException::class) + fun `processExecutor throws`() = runBlocking { + val logFileWriter = LogcatLogFileWriter( + contentResolver = createContentResolver(), + processExecutor = ThrowingProcessExecutor(IOException()), + coroutineDispatcher = Dispatchers.Unconfined + ) + + logFileWriter.writeLogTo(contentUri) + } + + private fun createContentResolver(): ContentResolver { + return mock { + on { openOutputStream(contentUri, "wt") } doReturn outputStream + } + } + + private fun createThrowingContentResolver(exception: Exception): ContentResolver { + return mock { + on { openOutputStream(contentUri, "wt") } doAnswer { throw exception } + } + } + + private fun createProcessExecutor(logData: String): DataProcessExecutor { + return DataProcessExecutor(logData.toByteArray(charset = Charsets.US_ASCII)) + } +} + +private class DataProcessExecutor(val data: ByteArray) : ProcessExecutor { + override fun exec(command: String): InputStream { + return ByteArrayInputStream(data) + } +} + +private class ThrowingProcessExecutor(val exception: Exception) : ProcessExecutor { + override fun exec(command: String): InputStream { + throw exception + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/AttachmentResolverTest.java b/app/core/src/test/java/com/fsck/k9/mailstore/AttachmentResolverTest.java new file mode 100644 index 0000000..3f5aa79 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/AttachmentResolverTest.java @@ -0,0 +1,99 @@ +package com.fsck.k9.mailstore; + + +import java.util.Map; + +import android.net.Uri; + +import com.fsck.k9.RobolectricTest; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.MimeHeader; +import com.fsck.k9.mail.internet.MimeMultipart; +import com.fsck.k9.message.extractors.AttachmentInfoExtractor; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +@SuppressWarnings("unchecked") +public class AttachmentResolverTest extends RobolectricTest { + public static final Uri ATTACHMENT_TEST_URI_1 = Uri.parse("uri://test/1"); + public static final Uri ATTACHMENT_TEST_URI_2 = Uri.parse("uri://test/2"); + + + private AttachmentInfoExtractor attachmentInfoExtractor; + + + @Before + public void setUp() throws Exception { + attachmentInfoExtractor = mock(AttachmentInfoExtractor.class); + } + + @Test + public void buildCidMap__onPartWithNoBody__shouldReturnEmptyMap() throws Exception { + Part part = new MimeBodyPart(); + + Map result = AttachmentResolver.buildCidToAttachmentUriMap(attachmentInfoExtractor, part); + + assertTrue(result.isEmpty()); + } + + @Test + public void buildCidMap__onMultipartWithNoParts__shouldReturnEmptyMap() throws Exception { + Multipart multipartBody = MimeMultipart.newInstance(); + Part multipartPart = new MimeBodyPart(multipartBody); + + Map result = AttachmentResolver.buildCidToAttachmentUriMap(attachmentInfoExtractor, multipartPart); + + assertTrue(result.isEmpty()); + } + + @Test + public void buildCidMap__onMultipartWithEmptyBodyPart__shouldReturnEmptyMap() throws Exception { + Multipart multipartBody = MimeMultipart.newInstance(); + BodyPart bodyPart = spy(new MimeBodyPart()); + Part multipartPart = new MimeBodyPart(multipartBody); + multipartBody.addBodyPart(bodyPart); + + Map result = AttachmentResolver.buildCidToAttachmentUriMap(attachmentInfoExtractor, multipartPart); + + verify(bodyPart).getContentId(); + assertTrue(result.isEmpty()); + } + + @Test + public void buildCidMap__onTwoPart__shouldReturnBothUris() throws Exception { + Multipart multipartBody = MimeMultipart.newInstance(); + Part multipartPart = new MimeBodyPart(multipartBody); + + BodyPart subPart1 = new MimeBodyPart(); + BodyPart subPart2 = new MimeBodyPart(); + multipartBody.addBodyPart(subPart1); + multipartBody.addBodyPart(subPart2); + + subPart1.setHeader(MimeHeader.HEADER_CONTENT_ID, "cid-1"); + subPart2.setHeader(MimeHeader.HEADER_CONTENT_ID, "cid-2"); + + when(attachmentInfoExtractor.extractAttachmentInfo(subPart1)).thenReturn(new AttachmentViewInfo( + null, null, AttachmentViewInfo.UNKNOWN_SIZE, ATTACHMENT_TEST_URI_1, false, subPart1, true)); + when(attachmentInfoExtractor.extractAttachmentInfo(subPart2)).thenReturn(new AttachmentViewInfo( + null, null, AttachmentViewInfo.UNKNOWN_SIZE, ATTACHMENT_TEST_URI_2, false, subPart2, true)); + + + Map result = AttachmentResolver.buildCidToAttachmentUriMap(attachmentInfoExtractor, multipartPart); + + + assertEquals(2, result.size()); + assertEquals(ATTACHMENT_TEST_URI_1, result.get("cid-1")); + assertEquals(ATTACHMENT_TEST_URI_2, result.get("cid-2")); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/DeferredFileBodyTest.java b/app/core/src/test/java/com/fsck/k9/mailstore/DeferredFileBodyTest.java new file mode 100644 index 0000000..9cf32ce --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/DeferredFileBodyTest.java @@ -0,0 +1,141 @@ +package com.fsck.k9.mailstore; + + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.fsck.k9.mailstore.util.FileFactory; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.output.ByteArrayOutputStream; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + + +public class DeferredFileBodyTest { + public static final String TEST_ENCODING = "test-encoding"; + public static final byte[] TEST_DATA_SHORT = "test data".getBytes(); + public static final byte[] TEST_DATA_LONG = "test data long enough to be file backed".getBytes(); + public static final int TEST_THRESHOLD = 15; + + + private File createdFile; + private DeferredFileBody deferredFileBody; + + + @Before + public void setUp() throws Exception { + FileFactory fileFactory = new FileFactory() { + @Override + public File createFile() throws IOException { + assertNull("only a single file should be created", createdFile); + createdFile = File.createTempFile("test", "tmp"); + createdFile.deleteOnExit(); + return createdFile; + } + }; + + deferredFileBody = new DeferredFileBody(TEST_THRESHOLD, fileFactory, TEST_ENCODING); + } + + @Test + public void withShortData__getLength__shouldReturnWrittenLength() throws Exception { + writeShortTestData(); + + assertNull(createdFile); + assertEquals(TEST_DATA_SHORT.length, deferredFileBody.getSize()); + } + + @Test + public void withLongData__getLength__shouldReturnWrittenLength() throws Exception { + writeLongTestData(); + + assertNotNull(createdFile); + assertEquals(TEST_DATA_LONG.length, deferredFileBody.getSize()); + } + + @Test + public void withShortData__shouldReturnData() throws Exception { + writeShortTestData(); + + InputStream inputStream = deferredFileBody.getInputStream(); + byte[] data = IOUtils.toByteArray(inputStream); + + assertNull(createdFile); + assertArrayEquals(TEST_DATA_SHORT, data); + } + + @Test + public void withLongData__shouldReturnData() throws Exception { + writeLongTestData(); + + InputStream inputStream = deferredFileBody.getInputStream(); + byte[] data = IOUtils.toByteArray(inputStream); + InputStream fileInputStream = new FileInputStream(createdFile); + byte[] dataFromFile = IOUtils.toByteArray(fileInputStream); + + assertArrayEquals(TEST_DATA_LONG, data); + assertArrayEquals(TEST_DATA_LONG, dataFromFile); + } + + @Test + public void withShortData__getFile__shouldWriteDataToFile() throws Exception { + writeShortTestData(); + + File returnedFile = deferredFileBody.getFile(); + InputStream fileInputStream = new FileInputStream(returnedFile); + byte[] dataFromFile = IOUtils.toByteArray(fileInputStream); + + assertSame(createdFile, returnedFile); + assertArrayEquals(TEST_DATA_SHORT, dataFromFile); + } + + @Test + public void withLongData__getFile__shouldReturnCreatedFile() throws Exception { + writeLongTestData(); + + File returnedFile = deferredFileBody.getFile(); + + assertSame(createdFile, returnedFile); + } + + @Test + public void withShortData__writeTo__shouldWriteData() throws Exception { + writeShortTestData(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + deferredFileBody.writeTo(baos); + + assertArrayEquals(TEST_DATA_SHORT, baos.toByteArray()); + } + + @Test(expected = UnsupportedOperationException.class) + public void setEncoding__shouldThrow() throws Exception { + deferredFileBody.setEncoding("anything"); + } + + @Test + public void getEncoding__shouldReturnEncoding() throws Exception { + assertEquals(TEST_ENCODING, deferredFileBody.getEncoding()); + } + + private void writeShortTestData() throws IOException { + OutputStream outputStream = deferredFileBody.getOutputStream(); + outputStream.write(TEST_DATA_SHORT); + outputStream.close(); + } + + private void writeLongTestData() throws IOException { + OutputStream outputStream = deferredFileBody.getOutputStream(); + outputStream.write(TEST_DATA_LONG); + outputStream.close(); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt new file mode 100644 index 0000000..ba1488f --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt @@ -0,0 +1,159 @@ +package com.fsck.k9.mailstore + +import android.database.sqlite.SQLiteDatabase +import androidx.core.content.contentValuesOf +import com.fsck.k9.Account +import com.fsck.k9.K9RobolectricTest +import com.fsck.k9.Preferences +import com.fsck.k9.backend.api.BackendFolder +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.backend.api.updateFolders +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.MessageDownloadState +import com.fsck.k9.mail.internet.MimeMessage +import com.fsck.k9.mail.internet.MimeMessageHelper +import com.fsck.k9.mail.internet.TextBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.koin.core.component.inject + +class K9BackendFolderTest : K9RobolectricTest() { + val preferences: Preferences by inject() + val localStoreProvider: LocalStoreProvider by inject() + val messageStoreManager: MessageStoreManager by inject() + val saveMessageDataCreator: SaveMessageDataCreator by inject() + + val account: Account = createAccount() + val backendFolder = createBackendFolder() + val database: LockableDatabase = localStoreProvider.getInstance(account).database + + @After + fun tearDown() { + preferences.deleteAccount(account) + } + + @Test + fun getMessageFlags() { + val flags = setOf(Flag.SEEN, Flag.DRAFT, Flag.X_DOWNLOADED_FULL) + createMessageInBackendFolder(MESSAGE_SERVER_ID, flags) + + val messageFlags = backendFolder.getMessageFlags(MESSAGE_SERVER_ID) + + assertEquals(flags, messageFlags) + } + + @Test + fun getMessageFlags_withFlagsColumnSetToNull_shouldBeTreatedAsEmpty() { + createMessageInBackendFolder(MESSAGE_SERVER_ID) + setFlagsColumnToNull() + + val messageFlags = backendFolder.getMessageFlags(MESSAGE_SERVER_ID) + + assertTrue(messageFlags.isEmpty()) + } + + @Test + fun getMessageFlags_withFlagsColumnSetToNull_shouldReadSpecialColumnFlags() { + val flags = setOf(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED) + createMessageInBackendFolder(MESSAGE_SERVER_ID, flags) + setFlagsColumnToNull() + + val messageFlags = backendFolder.getMessageFlags(MESSAGE_SERVER_ID) + + assertEquals(flags, messageFlags) + } + + @Test + fun saveCompleteMessage_withoutServerId_shouldThrow() { + val message = createMessage(messageServerId = null) + + try { + backendFolder.saveMessage(message, MessageDownloadState.FULL) + fail("Expected exception") + } catch (e: IllegalStateException) { + } + } + + @Test + fun savePartialMessage_withoutServerId_shouldThrow() { + val message = createMessage(messageServerId = null) + + try { + backendFolder.saveMessage(message, MessageDownloadState.PARTIAL) + fail("Expected exception") + } catch (e: IllegalStateException) { + } + } + + fun createAccount(): Account { + // FIXME: This is a hack to get Preferences into a state where it's safe to call newAccount() + preferences.clearAccounts() + + return preferences.newAccount() + } + + fun createBackendFolder(): BackendFolder { + val messageStore = messageStoreManager.getMessageStore(account) + val backendStorage = K9BackendStorage( + messageStore, + createFolderSettingsProvider(), + saveMessageDataCreator, + emptyList() + ) + backendStorage.updateFolders { + createFolders(listOf(FolderInfo(FOLDER_SERVER_ID, FOLDER_NAME, FOLDER_TYPE))) + } + + val folderServerIds = backendStorage.getFolderServerIds() + assertTrue(FOLDER_SERVER_ID in folderServerIds) + + return K9BackendFolder(messageStore, saveMessageDataCreator, FOLDER_SERVER_ID) + } + + fun createMessageInBackendFolder(messageServerId: String, flags: Set = emptySet()) { + val message = createMessage(messageServerId, flags) + backendFolder.saveMessage(message, MessageDownloadState.FULL) + + val messageServerIds = backendFolder.getMessageServerIds() + assertTrue(messageServerId in messageServerIds) + } + + private fun createMessage(messageServerId: String?, flags: Set = emptySet()): Message { + return MimeMessage().apply { + subject = "Test message" + setFrom(Address("alice@domain.example")) + setHeader("To", "bob@domain.example") + MimeMessageHelper.setBody(this, TextBody("Hello Bob!")) + + uid = messageServerId + setFlags(flags, true) + } + } + + private fun setFlagsColumnToNull() { + dbOperation { db -> + val numberOfUpdatedRows = db.update( + "messages", + contentValuesOf("flags" to null), + "uid = ?", + arrayOf(MESSAGE_SERVER_ID) + ) + assertEquals(1, numberOfUpdatedRows) + } + } + + private fun dbOperation(action: (SQLiteDatabase) -> Unit) = database.execute(false, action) + + companion object { + const val FOLDER_SERVER_ID = "testFolder" + const val FOLDER_NAME = "Test Folder" + val FOLDER_TYPE = FolderType.INBOX + const val MESSAGE_SERVER_ID = "msg001" + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt new file mode 100644 index 0000000..ccd2fee --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt @@ -0,0 +1,92 @@ +package com.fsck.k9.mailstore + +import com.fsck.k9.Account +import com.fsck.k9.K9RobolectricTest +import com.fsck.k9.Preferences +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.mail.FolderClass +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.koin.core.component.inject +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class K9BackendStorageTest : K9RobolectricTest() { + val preferences: Preferences by inject() + val localStoreProvider: LocalStoreProvider by inject() + val messageStoreManager: MessageStoreManager by inject() + val saveMessageDataCreator: SaveMessageDataCreator by inject() + + val account: Account = createAccount() + val database: LockableDatabase = localStoreProvider.getInstance(account).database + val backendStorage = createBackendStorage() + + @After + fun tearDown() { + preferences.deleteAccount(account) + } + + @Test + fun writeAndReadExtraString() { + backendStorage.setExtraString("testString", "someValue") + val value = backendStorage.getExtraString("testString") + + assertEquals("someValue", value) + } + + @Test + fun updateExtraString() { + backendStorage.setExtraString("testString", "oldValue") + backendStorage.setExtraString("testString", "newValue") + + val value = backendStorage.getExtraString("testString") + assertEquals("newValue", value) + } + + @Test + fun writeAndReadExtraInteger() { + backendStorage.setExtraNumber("testNumber", 42) + val value = backendStorage.getExtraNumber("testNumber") + + assertEquals(42L, value) + } + + @Test + fun updateExtraInteger() { + backendStorage.setExtraNumber("testNumber", 42) + backendStorage.setExtraNumber("testNumber", 23) + + val value = backendStorage.getExtraNumber("testNumber") + assertEquals(23L, value) + } + + fun createAccount(): Account { + // FIXME: This is a hack to get Preferences into a state where it's safe to call newAccount() + preferences.clearAccounts() + + return preferences.newAccount() + } + + private fun createBackendStorage(): BackendStorage { + val messageStore = messageStoreManager.getMessageStore(account) + val folderSettingsProvider = createFolderSettingsProvider() + return K9BackendStorage(messageStore, folderSettingsProvider, saveMessageDataCreator, emptyList()) + } +} + +internal fun createFolderSettingsProvider(): FolderSettingsProvider { + return mock { + on { getFolderSettings(any()) } doReturn + FolderSettings( + visibleLimit = 25, + displayClass = FolderClass.NO_CLASS, + syncClass = FolderClass.INHERITED, + notifyClass = FolderClass.INHERITED, + pushClass = FolderClass.SECOND_CLASS, + inTopGroup = false, + integrate = false + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/LocalStoreTest.java b/app/core/src/test/java/com/fsck/k9/mailstore/LocalStoreTest.java new file mode 100644 index 0000000..db28fd6 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/LocalStoreTest.java @@ -0,0 +1,82 @@ +package com.fsck.k9.mailstore; + + +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.MimeMultipart; +import org.junit.Test; + +import static org.junit.Assert.*; + + +public class LocalStoreTest { + + @Test + public void findPartById__withRootLocalBodyPart() throws Exception { + LocalBodyPart searchRoot = new LocalBodyPart(null, null, 123L, -1L); + + Part part = LocalStore.findPartById(searchRoot, 123L); + + assertSame(searchRoot, part); + } + + @Test + public void findPartById__withRootLocalMessage() throws Exception { + LocalMessage searchRoot = new LocalMessage(null, "uid", null); + searchRoot.setMessagePartId(123L); + + Part part = LocalStore.findPartById(searchRoot, 123L); + + assertSame(searchRoot, part); + } + + @Test + public void findPartById__withNestedLocalBodyPart() throws Exception { + LocalBodyPart searchRoot = new LocalBodyPart(null, null, 1L, -1L); + + LocalBodyPart needlePart = new LocalBodyPart(null, null, 123L, -1L); + MimeMultipart mimeMultipart = new MimeMultipart("boundary"); + mimeMultipart.addBodyPart(needlePart); + searchRoot.setBody(mimeMultipart); + + + Part part = LocalStore.findPartById(searchRoot, 123L); + + + assertSame(needlePart, part); + } + + @Test + public void findPartById__withNestedLocalMessagePart() throws Exception { + LocalBodyPart searchRoot = new LocalBodyPart(null, null, 1L, -1L); + + LocalMimeMessage needlePart = new LocalMimeMessage(null, null, 123L); + MimeMultipart mimeMultipart = new MimeMultipart("boundary"); + mimeMultipart.addBodyPart(new MimeBodyPart(needlePart)); + searchRoot.setBody(mimeMultipart); + + + Part part = LocalStore.findPartById(searchRoot, 123L); + + + assertSame(needlePart, part); + } + + @Test + public void findPartById__withTwoTimesNestedLocalMessagePart() throws Exception { + LocalBodyPart searchRoot = new LocalBodyPart(null, null, 1L, -1L); + + LocalMimeMessage needlePart = new LocalMimeMessage(null, null, 123L); + MimeMultipart mimeMultipartInner = new MimeMultipart("boundary"); + mimeMultipartInner.addBodyPart(new MimeBodyPart(needlePart)); + MimeMultipart mimeMultipart = new MimeMultipart("boundary"); + mimeMultipart.addBodyPart(new MimeBodyPart(mimeMultipartInner)); + searchRoot.setBody(mimeMultipart); + + + Part part = LocalStore.findPartById(searchRoot, 123L); + + + assertSame(needlePart, part); + } +} \ No newline at end of file diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MessageListCacheTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListCacheTest.kt new file mode 100644 index 0000000..8562b56 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListCacheTest.kt @@ -0,0 +1,147 @@ +package com.fsck.k9.mailstore + +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isNotNull +import assertk.assertions.isNotSameAs +import assertk.assertions.isNull +import assertk.assertions.isSameAs +import assertk.assertions.isTrue +import com.fsck.k9.mail.Flag +import java.util.UUID +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +private const val MESSAGE_ID = 1L +private const val FOLDER_ID = 2L + +class MessageListCacheTest { + private val localFolder = mock { + on { databaseId } doReturn FOLDER_ID + } + + private val localMessage = mock { + on { databaseId } doReturn MESSAGE_ID + on { folder } doReturn localFolder + } + + private val cache = MessageListCache.getCache(UUID.randomUUID().toString()) + + @Before + fun setUp() { + startKoin { + modules( + module { + single { mock() } + } + ) + } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun `getCache() returns different cache for each UUID`() { + val cache = MessageListCache.getCache("u001") + + val cache2 = MessageListCache.getCache("u002") + + assertThat(cache2).isNotSameAs(cache) + } + + @Test + fun `getCache() returns same cache for the same UUID`() { + val cache = MessageListCache.getCache("u001") + + val cache2 = MessageListCache.getCache("u001") + + assertThat(cache2).isSameAs(cache) + } + + @Test + fun `getFlagForMessage() returns value set for message`() { + cache.setFlagForMessages(listOf(1L), Flag.SEEN, true) + + val result = cache.getFlagForMessage(1L, Flag.SEEN) + + assertThat(result).isNotNull().isTrue() + } + + @Test + fun `getFlagForMessage() with unknown message ID returns null`() { + val result = cache.getFlagForMessage(1L, Flag.SEEN) + + assertThat(result).isNull() + } + + @Test + fun `getFlagForMessage() returns null when removed`() { + cache.setFlagForMessages(listOf(1L), Flag.FLAGGED, false) + cache.removeFlagForMessages(listOf(1L), Flag.FLAGGED) + + val result = cache.getFlagForMessage(1L, Flag.FLAGGED) + + assertThat(result).isNull() + } + + @Test + fun `getFlagForThread() returns value set for thread`() { + cache.setValueForThreads(listOf(1L), Flag.SEEN, false) + + val result = cache.getFlagForThread(1L, Flag.SEEN) + + assertThat(result).isNotNull().isFalse() + } + + @Test + fun `getFlagForThread() with unknown message ID returns null`() { + val result = cache.getFlagForThread(1L, Flag.ANSWERED) + + assertThat(result).isNull() + } + + @Test + fun `getFlagForThread() returns null when removed`() { + cache.setValueForThreads(listOf(1L), Flag.SEEN, true) + cache.removeFlagForThreads(listOf(1L), Flag.SEEN) + + val result = cache.getFlagForThread(1L, Flag.SEEN) + + assertThat(result).isNull() + } + + @Test + fun `isMessageHidden() returns true for hidden message`() { + cache.hideMessages(listOf(localMessage)) + + val result = cache.isMessageHidden(MESSAGE_ID, FOLDER_ID) + + assertThat(result).isTrue() + } + + @Test + fun `isMessageHidden() returns false for unknown message`() { + val result = cache.isMessageHidden(MESSAGE_ID, FOLDER_ID) + + assertThat(result).isFalse() + } + + @Test + fun `isMessageHidden() returns false for unhidden message`() { + cache.hideMessages(listOf(localMessage)) + cache.unhideMessages(listOf(localMessage)) + + val result = cache.isMessageHidden(MESSAGE_ID, FOLDER_ID) + + assertThat(result).isFalse() + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MessageListRepositoryTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListRepositoryTest.kt new file mode 100644 index 0000000..fc855bc --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListRepositoryTest.kt @@ -0,0 +1,435 @@ +package com.fsck.k9.mailstore + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Flag +import com.fsck.k9.message.extractors.PreviewResult +import java.util.UUID +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub + +private const val MESSAGE_ID = 1L +private const val MESSAGE_ID_2 = 2L +private const val MESSAGE_ID_3 = 3L +private const val FOLDER_ID = 20L +private const val FOLDER_ID_2 = 21L +private const val THREAD_ROOT = 30L +private const val THREAD_ROOT_2 = 31L + +private const val SELECTION = "irrelevant" +private val SELECTION_ARGS = arrayOf("irrelevant") +private const val SORT_ORDER = "irrelevant" + +class MessageListRepositoryTest { + private val accountUuid = UUID.randomUUID().toString() + + private val messageStore = mock() + private val messageStoreManager = mock { + on { getMessageStore(accountUuid) } doReturn messageStore + } + + private val messageListRepository = MessageListRepository(messageStoreManager) + + @Before + fun setUp() { + startKoin { + modules( + module { + single { messageListRepository } + } + ) + } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun `adding and removing listener`() { + var messageListChanged = 0 + val listener = MessageListChangedListener { + messageListChanged++ + } + messageListRepository.addListener(accountUuid, listener) + + messageListRepository.notifyMessageListChanged(accountUuid) + + assertThat(messageListChanged).isEqualTo(1) + + messageListRepository.removeListener(listener) + + messageListRepository.notifyMessageListChanged(accountUuid) + + assertThat(messageListChanged).isEqualTo(1) + } + + @Test + fun `only notify listener when account UUID matches`() { + var messageListChanged = 0 + val listener = MessageListChangedListener { + messageListChanged++ + } + messageListRepository.addListener(accountUuid, listener) + + messageListRepository.notifyMessageListChanged("otherAccountUuid") + + assertThat(messageListChanged).isEqualTo(0) + } + + @Test + fun `notifyMessageListChanged() without any listeners should not throw`() { + messageListRepository.notifyMessageListChanged(accountUuid) + } + + @Test + fun `getMessages() should use flag values from the cache`() { + addMessages( + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = false, + isStarred = true, + isAnswered = false, + isForwarded = true + ) + ) + MessageListCache.getCache(accountUuid).apply { + setFlagForMessages(listOf(MESSAGE_ID), Flag.SEEN, true) + setValueForThreads(listOf(THREAD_ROOT), Flag.FLAGGED, false) + } + + val result = messageListRepository.getMessages(accountUuid, SELECTION, SELECTION_ARGS, SORT_ORDER) { message -> + MessageData( + messageId = message.id, + folderId = message.folderId, + threadRoot = message.threadRoot, + isRead = message.isRead, + isStarred = message.isStarred, + isAnswered = message.isAnswered, + isForwarded = message.isForwarded + ) + } + + assertThat(result).containsExactly( + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = true, + isStarred = false, + isAnswered = false, + isForwarded = true + ) + ) + } + + @Test + fun `getMessages() should skip messages marked as hidden in the cache`() { + addMessages( + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT) + ) + hideMessage(MESSAGE_ID, FOLDER_ID) + + val result = messageListRepository.getMessages(accountUuid, SELECTION, SELECTION_ARGS, SORT_ORDER) { message -> + message.id + } + + assertThat(result).containsExactly(MESSAGE_ID_2) + } + + @Test + fun `getMessages() should not skip message when marked as hidden in a different folder`() { + addMessages( + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT) + ) + hideMessage(MESSAGE_ID, FOLDER_ID_2) + + val result = messageListRepository.getMessages(accountUuid, SELECTION, SELECTION_ARGS, SORT_ORDER) { message -> + message.id + } + + assertThat(result).containsExactly(MESSAGE_ID, MESSAGE_ID_2) + } + + @Test + fun `getThreadedMessages() should use flag values from the cache`() { + addThreadedMessages( + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = false, + isStarred = true, + isAnswered = false, + isForwarded = true + ) + ) + MessageListCache.getCache(accountUuid).apply { + setFlagForMessages(listOf(MESSAGE_ID), Flag.SEEN, true) + setValueForThreads(listOf(THREAD_ROOT), Flag.FLAGGED, false) + } + + val result = messageListRepository.getThreadedMessages( + accountUuid, + SELECTION, + SELECTION_ARGS, + SORT_ORDER + ) { message -> + MessageData( + messageId = message.id, + folderId = message.folderId, + threadRoot = message.threadRoot, + isRead = message.isRead, + isStarred = message.isStarred, + isAnswered = message.isAnswered, + isForwarded = message.isForwarded + ) + } + + assertThat(result).containsExactly( + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = true, + isStarred = false, + isAnswered = false, + isForwarded = true + ) + ) + } + + @Test + fun `getThreadedMessages() should skip messages marked as hidden in the cache`() { + addThreadedMessages( + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT_2) + ) + hideMessage(MESSAGE_ID, FOLDER_ID) + + val result = messageListRepository.getThreadedMessages( + accountUuid, + SELECTION, + SELECTION_ARGS, + SORT_ORDER + ) { message -> + message.id + } + + assertThat(result).containsExactly(MESSAGE_ID_2) + } + + @Test + fun `getThreadedMessages() should not skip message when marked as hidden in a different folder`() { + addThreadedMessages( + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT_2) + ) + hideMessage(MESSAGE_ID, FOLDER_ID_2) + + val result = messageListRepository.getThreadedMessages( + accountUuid, + SELECTION, + SELECTION_ARGS, + SORT_ORDER + ) { message -> + message.id + } + + assertThat(result).containsExactly(MESSAGE_ID, MESSAGE_ID_2) + } + + @Test + fun `getThread() should use flag values from the cache`() { + addMessagesToThread( + THREAD_ROOT, + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = false, + isStarred = true, + isAnswered = false, + isForwarded = true + ), + MessageData( + messageId = MESSAGE_ID_2, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = false, + isStarred = true, + isAnswered = true, + isForwarded = false + ) + ) + MessageListCache.getCache(accountUuid).apply { + setFlagForMessages(listOf(MESSAGE_ID), Flag.SEEN, true) + setValueForThreads(listOf(THREAD_ROOT), Flag.FLAGGED, false) + } + + val result = messageListRepository.getThread( + accountUuid, + THREAD_ROOT, + SORT_ORDER + ) { message -> + MessageData( + messageId = message.id, + folderId = message.folderId, + threadRoot = message.threadRoot, + isRead = message.isRead, + isStarred = message.isStarred, + isAnswered = message.isAnswered, + isForwarded = message.isForwarded + ) + } + + assertThat(result).containsExactly( + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = true, + isStarred = false, + isAnswered = false, + isForwarded = true + ), + MessageData( + messageId = MESSAGE_ID_2, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = false, + isStarred = false, + isAnswered = true, + isForwarded = false + ) + ) + } + + @Test + fun `getThread() should skip messages marked as hidden in the cache`() { + addMessagesToThread( + THREAD_ROOT, + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_3, folderId = FOLDER_ID, threadRoot = THREAD_ROOT) + ) + hideMessage(MESSAGE_ID, FOLDER_ID) + + val result = messageListRepository.getThread(accountUuid, THREAD_ROOT, SORT_ORDER) { message -> message.id } + + assertThat(result).containsExactly(MESSAGE_ID_2, MESSAGE_ID_3) + } + + @Test + fun `getThread() should not skip message when marked as hidden in a different folder`() { + addMessagesToThread( + THREAD_ROOT, + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_3, folderId = FOLDER_ID, threadRoot = THREAD_ROOT) + ) + hideMessage(MESSAGE_ID, FOLDER_ID_2) + + val result = messageListRepository.getThread(accountUuid, THREAD_ROOT, SORT_ORDER) { message -> message.id } + + assertThat(result).containsExactly(MESSAGE_ID, MESSAGE_ID_2, MESSAGE_ID_3) + } + + private fun addMessages(vararg messages: MessageData) { + messageStore.stub { + on { getMessages(eq(SELECTION), eq(SELECTION_ARGS), eq(SORT_ORDER), any()) } doAnswer { + val mapper: MessageMapper = it.getArgument(3) + + runMessageMapper(messages, mapper) + } + } + } + + private fun addThreadedMessages(vararg messages: MessageData) { + messageStore.stub { + on { getThreadedMessages(eq(SELECTION), eq(SELECTION_ARGS), eq(SORT_ORDER), any()) } doAnswer { + val mapper: MessageMapper = it.getArgument(3) + + runMessageMapper(messages, mapper) + } + } + } + + @Suppress("SameParameterValue") + private fun addMessagesToThread(threadRoot: Long, vararg messages: MessageData) { + messageStore.stub { + on { getThread(eq(threadRoot), eq(SORT_ORDER), any()) } doAnswer { + val mapper: MessageMapper = it.getArgument(2) + + runMessageMapper(messages, mapper) + } + } + } + + private fun runMessageMapper(messages: Array, mapper: MessageMapper): List { + return messages.mapNotNull { message -> + mapper.map(object : MessageDetailsAccessor { + override val id = message.messageId + override val messageServerId = "irrelevant" + override val folderId = message.folderId + override val fromAddresses = emptyList
    () + override val toAddresses = emptyList
    () + override val ccAddresses = emptyList
    () + override val messageDate = 0L + override val internalDate = 0L + override val subject = "irrelevant" + override val preview = PreviewResult.error() + override val isRead = message.isRead + override val isStarred = message.isStarred + override val isAnswered = message.isAnswered + override val isForwarded = message.isForwarded + override val hasAttachments = false + override val threadRoot = message.threadRoot + override val threadCount = 0 + }) + } + } + + @Suppress("SameParameterValue") + private fun hideMessage(messageId: Long, folderId: Long) { + val cache = MessageListCache.getCache(accountUuid) + + val localFolder = mock { + on { databaseId } doReturn folderId + } + + val localMessage = mock { + on { databaseId } doReturn messageId + on { folder } doReturn localFolder + } + + cache.hideMessages(listOf(localMessage)) + } +} + +private data class MessageData( + val messageId: Long, + val folderId: Long, + val threadRoot: Long, + val isRead: Boolean = false, + val isStarred: Boolean = false, + val isAnswered: Boolean = false, + val isForwarded: Boolean = false +) diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MessageStoreManagerTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/MessageStoreManagerTest.kt new file mode 100644 index 0000000..98c59cb --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/MessageStoreManagerTest.kt @@ -0,0 +1,51 @@ +package com.fsck.k9.mailstore + +import assertk.assertThat +import assertk.assertions.isSameAs +import com.fsck.k9.Account +import com.fsck.k9.AccountRemovedListener +import com.fsck.k9.preferences.AccountManager +import org.junit.Test +import org.mockito.kotlin.KStubbing +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class MessageStoreManagerTest { + private val account = Account("00000000-0000-4000-0000-000000000000") + private val messageStore1 = mock(name = "messageStore1") + private val messageStore2 = mock(name = "messageStore2") + private val messageStoreFactory = mock { + on { create(account) } doReturn messageStore1 doReturn messageStore2 + } + + @Test + fun `MessageStore instance is reused`() { + val accountManager = mock() + val messageStoreManager = MessageStoreManager(accountManager, messageStoreFactory) + + assertThat(messageStoreManager.getMessageStore(account)).isSameAs(messageStore1) + assertThat(messageStoreManager.getMessageStore(account)).isSameAs(messageStore1) + } + + @Test + fun `MessageStore instance is removed when account is removed`() { + val listenerCaptor = argumentCaptor() + val accountManager = mock { + doNothingOn { addAccountRemovedListener(listenerCaptor.capture()) } + } + val messageStoreManager = MessageStoreManager(accountManager, messageStoreFactory) + + assertThat(messageStoreManager.getMessageStore(account)).isSameAs(messageStore1) + + listenerCaptor.firstValue.onAccountRemoved(account) + + assertThat(messageStoreManager.getMessageStore(account)).isSameAs(messageStore2) + } + + private fun KStubbing.doNothingOn(block: T.() -> Any) { + doNothing().whenever(mock).block() + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java b/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java new file mode 100644 index 0000000..6de424c --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java @@ -0,0 +1,662 @@ +package com.fsck.k9.mailstore; + + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +import android.app.Application; +import android.content.res.Configuration; +import android.content.res.Resources; +import androidx.annotation.NonNull; + +import com.fsck.k9.DI; +import com.fsck.k9.K9RobolectricTest; +import com.fsck.k9.TestCoreResourceProvider; +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MessageExtractor; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.MimeHeader; +import com.fsck.k9.mail.internet.MimeMessage; +import com.fsck.k9.mail.internet.MimeMessageHelper; +import com.fsck.k9.mail.internet.MimeMultipart; +import com.fsck.k9.mail.internet.TextBody; +import com.fsck.k9.mail.internet.Viewable; +import com.fsck.k9.mail.internet.Viewable.MessageHeader; +import com.fsck.k9.mailstore.CryptoResultAnnotation.CryptoError; +import com.fsck.k9.mailstore.MessageViewInfoExtractor.ViewableExtractedText; +import com.fsck.k9.message.extractors.AttachmentInfoExtractor; +import app.k9mail.html.cleaner.HtmlProcessor; +import org.junit.Before; +import org.junit.Test; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.openintents.openpgp.OpenPgpDecryptionResult; +import org.robolectric.RuntimeEnvironment; + +import static com.fsck.k9.mail.TestMessageConstructionUtils.bodypart; +import static com.fsck.k9.mail.TestMessageConstructionUtils.messageFromBody; +import static com.fsck.k9.mail.TestMessageConstructionUtils.multipart; +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertSame; +import static junit.framework.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + + +@SuppressWarnings("WeakerAccess") +public class MessageViewInfoExtractorTest extends K9RobolectricTest { + public static final String BODY_TEXT = "K-9 Mail rocks :>"; + public static final String BODY_TEXT_HTML = "
    K-9 Mail rocks :>
    "; + public static final String BODY_TEXT_FLOWED = "K-9 Mail rocks :> \r\nflowed line\r\nnot flowed line"; + public static final String SUBJECT = "sabject"; + public static final String PROTECTED_SUBJECT = "protected subject"; + + + private MessageViewInfoExtractor messageViewInfoExtractor; + private Application context; + private AttachmentInfoExtractor attachmentInfoExtractor; + + + @Before + public void setUp() throws Exception { + context = RuntimeEnvironment.getApplication(); + + HtmlProcessor htmlProcessor = createFakeHtmlProcessor(); + attachmentInfoExtractor = spy(DI.get(AttachmentInfoExtractor.class)); + messageViewInfoExtractor = new MessageViewInfoExtractor(attachmentInfoExtractor, htmlProcessor, + new TestCoreResourceProvider()); + } + + @Test + public void testShouldSanitizeOutputHtml() throws MessagingException { + // Create text/plain body + TextBody body = new TextBody(BODY_TEXT); + + // Create message + MimeMessage message = new MimeMessage(); + MimeMessageHelper.setBody(message, body); + message.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain; format=flowed"); + + // Prepare fixture + HtmlProcessor htmlProcessor = mock(HtmlProcessor.class); + MessageViewInfoExtractor messageViewInfoExtractor = + new MessageViewInfoExtractor(null, htmlProcessor, + new TestCoreResourceProvider()); + String value = "--sanitized html--"; + when(htmlProcessor.processForDisplay(anyString())).thenReturn(value); + + // Extract text + List outputNonViewableParts = new ArrayList<>(); + ArrayList outputViewableParts = new ArrayList<>(); + MessageExtractor.findViewablesAndAttachments(message, outputViewableParts, outputNonViewableParts); + ViewableExtractedText viewableExtractedText = + messageViewInfoExtractor.extractTextFromViewables(outputViewableParts); + + assertSame(value, viewableExtractedText.html); + } + + @Test + public void testSimplePlainTextMessage() throws MessagingException { + // Create text/plain body + TextBody body = new TextBody(BODY_TEXT); + + // Create message + MimeMessage message = new MimeMessage(); + message.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain"); + MimeMessageHelper.setBody(message, body); + + // Extract text + List outputNonViewableParts = new ArrayList<>(); + ArrayList outputViewableParts = new ArrayList<>(); + MessageExtractor.findViewablesAndAttachments(message, outputViewableParts, outputNonViewableParts); + ViewableExtractedText container = messageViewInfoExtractor.extractTextFromViewables(outputViewableParts); + + String expectedHtml = + "
    " +
    +                BODY_TEXT_HTML +
    +                "
    "; + + assertEquals(BODY_TEXT, container.text); + assertEquals(expectedHtml, container.html); + } + + @Test + public void testTextPlainFormatFlowed() throws MessagingException { + // Create text/plain body + Body body = new BinaryMemoryBody(BODY_TEXT_FLOWED.getBytes(StandardCharsets.UTF_8), "utf-8"); + + // Create message + MimeMessage message = new MimeMessage(); + MimeMessageHelper.setBody(message, body); + message.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain; format=flowed"); + + // Extract text + List outputNonViewableParts = new ArrayList<>(); + ArrayList outputViewableParts = new ArrayList<>(); + MessageExtractor.findViewablesAndAttachments(message, outputViewableParts, outputNonViewableParts); + ViewableExtractedText container = messageViewInfoExtractor.extractTextFromViewables(outputViewableParts); + + String expectedText = "K-9 Mail rocks :> flowed line\r\n" + + "not flowed line"; + String expectedHtml = + "
    " +
    +                        "
    " + + "K-9 Mail rocks :> flowed line
    not flowed line" + + "
    " + + "
    "; + + assertEquals(expectedText, container.text); + assertEquals(expectedHtml, container.html); + } + + @Test + public void testSimpleHtmlMessage() throws MessagingException { + String bodyText = "K-9 Mail rocks :>"; + + // Create text/plain body + TextBody body = new TextBody(bodyText); + + // Create message + MimeMessage message = new MimeMessage(); + message.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/html"); + MimeMessageHelper.setBody(message, body); + + // Extract text + ArrayList outputViewableParts = new ArrayList<>(); + MessageExtractor.findViewablesAndAttachments(message, outputViewableParts, null); + assertEquals(outputViewableParts.size(), 1); + ViewableExtractedText container = messageViewInfoExtractor.extractTextFromViewables(outputViewableParts); + + assertEquals(BODY_TEXT, container.text); + assertEquals(bodyText, container.html); + } + + @Test + public void testMultipartPlainTextMessage() throws MessagingException { + String bodyText1 = "text body 1"; + String bodyText2 = "text body 2"; + + // Create text/plain bodies + TextBody body1 = new TextBody(bodyText1); + TextBody body2 = new TextBody(bodyText2); + + // Create multipart/mixed part + MimeMultipart multipart = MimeMultipart.newInstance(); + MimeBodyPart bodyPart1 = new MimeBodyPart(body1, "text/plain"); + MimeBodyPart bodyPart2 = new MimeBodyPart(body2, "text/plain"); + multipart.addBodyPart(bodyPart1); + multipart.addBodyPart(bodyPart2); + + // Create message + MimeMessage message = new MimeMessage(); + MimeMessageHelper.setBody(message, multipart); + + // Extract text + List outputNonViewableParts = new ArrayList<>(); + ArrayList outputViewableParts = new ArrayList<>(); + MessageExtractor.findViewablesAndAttachments(message, outputViewableParts, outputNonViewableParts); + ViewableExtractedText container = messageViewInfoExtractor.extractTextFromViewables(outputViewableParts); + + String expectedText = + bodyText1 + "\r\n\r\n" + + "------------------------------------------------------------------------\r\n\r\n" + + bodyText2; + String expectedHtml = + "
    " +
    +                "
    " + + bodyText1 + + "
    " + + "
    " + + "

    " + + "
    " +
    +                "
    " + + bodyText2 + + "
    " + + "
    "; + + + assertEquals(expectedText, container.text); + assertEquals(expectedHtml, container.html); + } + + @Test + public void testTextPlusRfc822Message() throws MessagingException { + setLanguage("en"); + Locale.setDefault(Locale.US); + TimeZone.setDefault(TimeZone.getTimeZone("GMT+01:00")); + + String innerBodyText = "Hey there. I'm inside a message/rfc822 (inline) attachment."; + + // Create text/plain body + TextBody textBody = new TextBody(BODY_TEXT); + + // Create inner text/plain body + TextBody innerBody = new TextBody(innerBodyText); + + // Create message/rfc822 body + MimeMessage innerMessage = new MimeMessage(); + innerMessage.addSentDate(new Date(112, 2, 17), false); + innerMessage.setHeader("To", "to@example.com"); + innerMessage.setSubject("Subject"); + innerMessage.setFrom(new Address("from@example.com")); + MimeMessageHelper.setBody(innerMessage, innerBody); + + // Create multipart/mixed part + MimeMultipart multipart = MimeMultipart.newInstance(); + MimeBodyPart bodyPart1 = new MimeBodyPart(textBody, "text/plain"); + MimeBodyPart bodyPart2 = new MimeBodyPart(innerMessage, "message/rfc822"); + bodyPart2.setHeader("Content-Disposition", "inline; filename=\"message.eml\""); + multipart.addBodyPart(bodyPart1); + multipart.addBodyPart(bodyPart2); + + // Create message + MimeMessage message = new MimeMessage(); + MimeMessageHelper.setBody(message, multipart); + + // Extract text + List outputNonViewableParts = new ArrayList<>(); + ArrayList outputViewableParts = new ArrayList<>(); + MessageExtractor.findViewablesAndAttachments(message, outputViewableParts, outputNonViewableParts); + ViewableExtractedText container = messageViewInfoExtractor.extractTextFromViewables(outputViewableParts); + + String expectedText = + BODY_TEXT + + "\r\n\r\n" + + "----- message.eml ------------------------------------------------------" + + "\r\n\r\n" + + "From: from@example.com" + "\r\n" + + "To: to@example.com" + "\r\n" + + "Sent: Sat Mar 17 00:00:00 GMT+01:00 2012" + "\r\n" + + "Subject: Subject" + "\r\n" + + "\r\n" + + innerBodyText; + String expectedHtml = + "
    " +
    +                        BODY_TEXT_HTML +
    +                "
    " + + "

    message.eml

    " + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
    From:from@example.com
    To:to@example.com
    Sent:Sat Mar 17 00:00:00 GMT+01:00 2012
    Subject:Subject
    " + + "
    " +
    +                "
    " + + innerBodyText + + "
    " + + "
    "; + + assertEquals(expectedText, container.text); + assertEquals(expectedHtml, container.html); + } + + @Test + public void testMultipartDigestWithMessages() throws Exception { + String data = "Content-Type: multipart/digest; boundary=\"bndry\"\r\n" + + "\r\n" + + "--bndry\r\n" + + "\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "text body of first message\r\n" + + "\r\n" + + "--bndry\r\n" + + "\r\n" + + "Subject: subject of second message\r\n" + + "Content-Type: multipart/alternative; boundary=\"bndry2\"\r\n" + + "\r\n" + + "--bndry2\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "text part of second message\r\n" + + "\r\n" + + "--bndry2\r\n" + + "Content-Type: text/html\"\r\n" + + "\r\n" + + "html part of second message\r\n" + + "\r\n" + + "--bndry2--\r\n" + + "\r\n" + + "--bndry--\r\n"; + MimeMessage message = MimeMessage.parseMimeMessage(new ByteArrayInputStream(data.getBytes()), false); + + // Extract text + List outputNonViewableParts = new ArrayList<>(); + ArrayList outputViewableParts = new ArrayList<>(); + MessageExtractor.findViewablesAndAttachments(message, outputViewableParts, outputNonViewableParts); + + String expectedExtractedText = "Subject: (No subject)\r\n" + + "\r\n" + + "text body of first message\r\n" + + "\r\n" + + "\r\n" + + "------------------------------------------------------------------------\r\n" + + "\r\n" + + "Subject: subject of second message\r\n" + + "\r\n" + + "text part of second message\r\n"; + String expectedHtmlText = "" + + "" + + "
    Subject:(No subject)
    " + + "
    " +
    +                "
    " + + "text body of first message
    " + + "
    " + + "
    " + + "

    " + + "" + + "" + + "
    Subject:subject of second message
    " + + "
    " +
    +                "
    " + + "text part of second message
    " + + "
    " + + "
    "; + + + assertEquals(4, outputViewableParts.size()); + assertEquals("subject of second message", ((MessageHeader) outputViewableParts.get(2)).getMessage().getSubject()); + + ViewableExtractedText firstMessageExtractedText = + messageViewInfoExtractor.extractTextFromViewables(outputViewableParts); + assertEquals(expectedExtractedText, firstMessageExtractedText.text); + assertEquals(expectedHtmlText, firstMessageExtractedText.html); + } + + @Test + public void extractMessage_withAttachment() throws Exception { + BodyPart attachmentPart = bodypart("application/octet-stream"); + Message message = messageFromBody(multipart("mixed", + bodypart("text/plain", "text"), + attachmentPart + )); + message.setSubject(SUBJECT); + AttachmentViewInfo attachmentViewInfo = mock(AttachmentViewInfo.class); + setupAttachmentInfoForPart(attachmentPart, attachmentViewInfo); + + + MessageViewInfo messageViewInfo = messageViewInfoExtractor.extractMessageForView(message, null, + false); + + + assertEquals("
    text
    ", messageViewInfo.text); + assertSame(attachmentViewInfo, messageViewInfo.attachments.get(0)); + assertNull(messageViewInfo.cryptoResultAnnotation); + assertTrue(messageViewInfo.extraAttachments.isEmpty()); + assertEquals(SUBJECT, messageViewInfo.subject); + } + + @Test + public void extractMessage_withCryptoAnnotation() throws Exception { + Message message = messageFromBody(SUBJECT, multipart("signed", "protocol=\"application/pgp-signature\"", + bodypart("text/plain", "text"), + bodypart("application/pgp-signature") + )); + CryptoResultAnnotation annotation = CryptoResultAnnotation.createOpenPgpResultAnnotation( + null, null, null, null, null, false); + MessageCryptoAnnotations messageCryptoAnnotations = createAnnotations(message, annotation); + + + MessageViewInfo messageViewInfo = messageViewInfoExtractor.extractMessageForView(message, messageCryptoAnnotations, + false); + + + assertEquals("
    text
    ", messageViewInfo.text); + assertSame(annotation, messageViewInfo.cryptoResultAnnotation); + assertSame(message, messageViewInfo.message); + assertSame(message, messageViewInfo.rootPart); + assertEquals(SUBJECT, messageViewInfo.subject); + assertTrue(messageViewInfo.attachments.isEmpty()); + assertTrue(messageViewInfo.extraAttachments.isEmpty()); + } + + @Test + public void extractMessage_withCryptoAnnotation_andReplacementPart() throws Exception { + Message message = messageFromBody(multipart("signed", "protocol=\"application/pgp-signature\"", + bodypart("text/plain", "text"), + bodypart("application/pgp-signature") + )); + MimeBodyPart replacementPart = bodypart("text/plain", "replacement text"); + CryptoResultAnnotation annotation = CryptoResultAnnotation.createOpenPgpResultAnnotation( + null, null, null, null, replacementPart, false); + MessageCryptoAnnotations messageCryptoAnnotations = createAnnotations(message, annotation); + + + MessageViewInfo messageViewInfo = messageViewInfoExtractor.extractMessageForView(message, messageCryptoAnnotations, + false); + + + assertEquals("
    replacement text
    ", messageViewInfo.text); + assertSame(annotation, messageViewInfo.cryptoResultAnnotation); + assertSame(message, messageViewInfo.message); + assertSame(replacementPart, messageViewInfo.rootPart); + assertTrue(messageViewInfo.attachments.isEmpty()); + assertTrue(messageViewInfo.extraAttachments.isEmpty()); + } + + @Test + public void extractMessage_withCryptoAnnotation_andExtraText() throws Exception { + MimeBodyPart signedPart = multipart("signed", "protocol=\"application/pgp-signature\"", + bodypart("text/plain", "text"), + bodypart("application/pgp-signature") + ); + BodyPart extraText = bodypart("text/plain", "extra text"); + Message message = messageFromBody(multipart("mixed", + signedPart, + extraText + )); + CryptoResultAnnotation annotation = CryptoResultAnnotation.createOpenPgpResultAnnotation( + null, null, null, null, null, false); + MessageCryptoAnnotations messageCryptoAnnotations = createAnnotations(signedPart, annotation); + + + MessageViewInfo messageViewInfo = messageViewInfoExtractor.extractMessageForView(message, messageCryptoAnnotations, + false); + + + assertEquals("
    text
    ", messageViewInfo.text); + assertSame(annotation, messageViewInfo.cryptoResultAnnotation); + assertEquals("extra text", messageViewInfo.extraText); + assertTrue(messageViewInfo.attachments.isEmpty()); + assertTrue(messageViewInfo.extraAttachments.isEmpty()); + } + + @Test + public void extractMessage_withCryptoAnnotation_andExtraAttachment() throws Exception { + MimeBodyPart signedPart = multipart("signed", "protocol=\"application/pgp-signature\"", + bodypart("text/plain", "text"), + bodypart("application/pgp-signature") + ); + BodyPart extraAttachment = bodypart("application/octet-stream"); + Message message = messageFromBody(multipart("mixed", + signedPart, + extraAttachment + )); + CryptoResultAnnotation annotation = CryptoResultAnnotation.createOpenPgpResultAnnotation( + null, null, null, null, null, false); + MessageCryptoAnnotations messageCryptoAnnotations = createAnnotations(signedPart, annotation); + + AttachmentViewInfo attachmentViewInfo = mock(AttachmentViewInfo.class); + setupAttachmentInfoForPart(extraAttachment, attachmentViewInfo); + + + MessageViewInfo messageViewInfo = messageViewInfoExtractor.extractMessageForView(message, messageCryptoAnnotations, + false); + + + assertEquals("
    text
    ", messageViewInfo.text); + assertSame(annotation, messageViewInfo.cryptoResultAnnotation); + assertSame(attachmentViewInfo, messageViewInfo.extraAttachments.get(0)); + assertTrue(messageViewInfo.attachments.isEmpty()); + } + + @Test + public void extractMessage_openPgpEncrypted_withoutAnnotations() throws Exception { + Message message = messageFromBody( + multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream") + ) + ); + + MessageViewInfo messageViewInfo = messageViewInfoExtractor.extractMessageForView(message, null, + false); + + assertEquals(CryptoError.OPENPGP_ENCRYPTED_NO_PROVIDER, messageViewInfo.cryptoResultAnnotation.getErrorType()); + assertNull(messageViewInfo.text); + assertNull(messageViewInfo.attachments); + assertNull(messageViewInfo.extraAttachments); + } + + @Test + public void extractMessage_openPgpEncrypted() throws Exception { + MimeBodyPart encryptedPayload = bodypart("text/plain", "encrypted text"); + Message message = messageFromBody(multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream") + )); + + MessageCryptoAnnotations cryptoAnnotations = new MessageCryptoAnnotations(); + CryptoResultAnnotation openPgpResultAnnotation = CryptoResultAnnotation.createOpenPgpResultAnnotation( + null, null, null, null, encryptedPayload, false); + cryptoAnnotations.put(message, openPgpResultAnnotation); + + MessageViewInfo messageViewInfo = messageViewInfoExtractor.extractMessageForView(message, cryptoAnnotations, + true); + + assertSame(openPgpResultAnnotation, messageViewInfo.cryptoResultAnnotation); + assertEquals("
    encrypted text
    ", messageViewInfo.text); + assertTrue(messageViewInfo.attachments.isEmpty()); + assertTrue(messageViewInfo.extraAttachments.isEmpty()); + } + + @Test + public void extractMessage_openPgpEncrypted_withProtectedSubject() throws Exception { + MimeBodyPart encryptedPayload = bodypart("text/plain", "encrypted text"); + Message message = messageFromBody(multipart("encrypted", "protocol=\"application/pgp-encrypted\"", + bodypart("application/pgp-encrypted"), + bodypart("application/octet-stream") + )); + + encryptedPayload.setHeader("Content-Type", + encryptedPayload.getHeader("Content-Type")[0] + "; protected-headers=v1"); + encryptedPayload.setHeader("Subject", PROTECTED_SUBJECT); + + MessageCryptoAnnotations cryptoAnnotations = new MessageCryptoAnnotations(); + OpenPgpDecryptionResult decryptionResult = new OpenPgpDecryptionResult(OpenPgpDecryptionResult.RESULT_ENCRYPTED); + CryptoResultAnnotation openPgpResultAnnotation = CryptoResultAnnotation.createOpenPgpResultAnnotation( + decryptionResult, null, null, null, encryptedPayload, false); + cryptoAnnotations.put(message, openPgpResultAnnotation); + + MessageViewInfo messageViewInfo = messageViewInfoExtractor.extractMessageForView(message, cryptoAnnotations, + true); + + assertSame(openPgpResultAnnotation, messageViewInfo.cryptoResultAnnotation); + assertEquals("
    encrypted text
    ", messageViewInfo.text); + assertEquals(PROTECTED_SUBJECT, messageViewInfo.subject); + assertTrue(messageViewInfo.attachments.isEmpty()); + assertTrue(messageViewInfo.extraAttachments.isEmpty()); + } + + @Test + public void extractMessage_multipartSigned_UnknownProtocol() throws Exception { + Message message = messageFromBody( + multipart("signed", "protocol=\"application/pkcs7-signature\"", + bodypart("text/plain", "text"), + bodypart("application/pkcs7-signature", "signature") + ) + ); + + MessageViewInfo messageViewInfo = messageViewInfoExtractor.extractMessageForView(message, null, + false); + + assertEquals("
    text
    ", messageViewInfo.text); + assertNull(messageViewInfo.cryptoResultAnnotation); + assertTrue(messageViewInfo.attachments.isEmpty()); + assertTrue(messageViewInfo.extraAttachments.isEmpty()); + } + + @Test + public void extractMessage_multipartSigned_UnknownProtocol_withExtraAttachments() throws Exception { + BodyPart extraAttachment = bodypart("application/octet-stream"); + Message message = messageFromBody( + multipart("mixed", + multipart("signed", "protocol=\"application/pkcs7-signature\"", + bodypart("text/plain", "text"), + bodypart("application/pkcs7-signature", "signature") + ), + extraAttachment + ) + ); + AttachmentViewInfo mock = mock(AttachmentViewInfo.class); + setupAttachmentInfoForPart(extraAttachment, mock); + + MessageViewInfo messageViewInfo = messageViewInfoExtractor.extractMessageForView(message, null, + false); + + assertEquals("
    text
    ", messageViewInfo.text); + assertNull(messageViewInfo.cryptoResultAnnotation); + assertSame(mock, messageViewInfo.attachments.get(0)); + assertTrue(messageViewInfo.extraAttachments.isEmpty()); + } + + void setupAttachmentInfoForPart(BodyPart extraAttachment, AttachmentViewInfo attachmentViewInfo) + throws MessagingException { + doReturn(attachmentViewInfo).when(attachmentInfoExtractor).extractAttachmentInfo(extraAttachment); + } + + @NonNull + MessageCryptoAnnotations createAnnotations(Part part, CryptoResultAnnotation annotation) { + MessageCryptoAnnotations messageCryptoAnnotations = new MessageCryptoAnnotations(); + messageCryptoAnnotations.put(part, annotation); + return messageCryptoAnnotations; + } + + HtmlProcessor createFakeHtmlProcessor() { + HtmlProcessor htmlProcessor = mock(HtmlProcessor.class); + + when(htmlProcessor.processForDisplay(anyString())).thenAnswer(new Answer() { + @Override + public String answer(InvocationOnMock invocation) throws Throwable { + return (String) invocation.getArguments()[0]; + } + }); + + return htmlProcessor; + } + + private void setLanguage(String language) { + Locale locale = new Locale(language); + + Resources resources = context.getResources(); + Configuration config = resources.getConfiguration(); + config.locale = locale; + resources.updateConfiguration(config, resources.getDisplayMetrics()); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MimePartStreamParserTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/MimePartStreamParserTest.kt new file mode 100644 index 0000000..2371b5a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/MimePartStreamParserTest.kt @@ -0,0 +1,83 @@ +package com.fsck.k9.mailstore + +import com.fsck.k9.mail.crlf +import com.fsck.k9.mail.internet.MimeBodyPart +import com.fsck.k9.mail.internet.MimeMessage +import com.fsck.k9.mail.internet.MimeMultipart +import java.io.ByteArrayInputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MimePartStreamParserTest { + @Test + fun innerMessage_DispositionInline() { + val messageContent = + """ + From: + To: + Subject: Testmail 1 + Content-Type: multipart/mixed; boundary=1 + + --1 + Content-Type: text/plain + + some text in the first part + --1 + Content-Type: message/rfc822; name="message" + + To: + Subject: Hi + Date: now + Content-Type: text/plain + + inner text + --1-- + """.trimIndent().crlf() + + val msg = MimePartStreamParser.parse(null, ByteArrayInputStream(messageContent.toByteArray())) + + val body = msg.body as MimeMultipart + assertEquals(2, body.count.toLong()) + + val messagePart = body.getBodyPart(1) as MimeBodyPart + assertEquals("message/rfc822", messagePart.mimeType) + assertTrue(messagePart.body is MimeMessage) + } + + @Test + fun innerMessage_dispositionAttachment() { + val messageContent = + """ + From: + To: + Subject: Testmail 2 + Content-Type: multipart/mixed; boundary=1 + + --1 + Content-Type: text/plain + + some text in the first part + --1 + Content-Type: message/rfc822; name="message" + Content-Disposition: attachment + + To: + Subject: Hi + Date: now + Content-Type: text/plain + + inner text + --1-- + """.trimIndent().crlf() + + val msg = MimePartStreamParser.parse(null, ByteArrayInputStream(messageContent.toByteArray())) + + val body = msg.body as MimeMultipart + assertEquals(2, body.count) + + val messagePart = body.getBodyPart(1) as MimeBodyPart + assertEquals("message/rfc822", messagePart.mimeType) + assertTrue(messagePart.body is DeferredFileBody) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MoreMessagesTest.java b/app/core/src/test/java/com/fsck/k9/mailstore/MoreMessagesTest.java new file mode 100644 index 0000000..4c8fd73 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/MoreMessagesTest.java @@ -0,0 +1,28 @@ +package com.fsck.k9.mailstore; + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class MoreMessagesTest { + private static final String ERROR_MESSAGE = "The return value of getDatabaseName() is used in the database and " + + "must not be changed without data migration."; + + + @Test + public void UNKNOWN_getDatabaseName_shouldReturnUnknown() throws Exception { + assertEquals(ERROR_MESSAGE, "unknown", MoreMessages.UNKNOWN.getDatabaseName()); + } + + @Test + public void TRUE_getDatabaseName_shouldReturnTrue() throws Exception { + assertEquals(ERROR_MESSAGE, "true", MoreMessages.TRUE.getDatabaseName()); + } + + @Test + public void FALSE_getDatabaseName_shouldReturnFalse() throws Exception { + assertEquals(ERROR_MESSAGE, "false", MoreMessages.FALSE.getDatabaseName()); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderBuilderTest.kt b/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderBuilderTest.kt new file mode 100644 index 0000000..65c0bbe --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderBuilderTest.kt @@ -0,0 +1,57 @@ +package com.fsck.k9.message + +import assertk.assertThat +import assertk.assertions.isGreaterThan +import com.fsck.k9.Account.QuoteStyle +import com.fsck.k9.Identity +import com.fsck.k9.RobolectricTest +import com.fsck.k9.mail.internet.MimeHeaderChecker +import com.fsck.k9.mail.internet.TextBody +import org.junit.Test + +private const val IDENTITY_HEADER = "X-K9mail-Identity" + +class IdentityHeaderBuilderTest : RobolectricTest() { + @Test + fun `valid unstructured header field value`() { + val signature = "a".repeat(1000) + + val identityHeader = IdentityHeaderBuilder() + .setCursorPosition(0) + .setIdentity(createIdentity(signatureUse = true)) + .setIdentityChanged(false) + .setMessageFormat(SimpleMessageFormat.TEXT) + .setMessageReference(null) + .setQuotedHtmlContent(null) + .setQuoteStyle(QuoteStyle.PREFIX) + .setQuoteTextMode(QuotedTextMode.NONE) + .setSignature(signature) + .setSignatureChanged(true) + .setBody(TextBody("irrelevant")) + .setBodyPlain(null) + .build() + + assertThat(identityHeader.length).isGreaterThan(1000) + assertIsValidHeader(identityHeader) + } + + private fun assertIsValidHeader(identityHeader: String) { + try { + MimeHeaderChecker.checkHeader(IDENTITY_HEADER, identityHeader) + } catch (e: Exception) { + println("$IDENTITY_HEADER: $identityHeader") + throw e + } + } + + private fun createIdentity( + description: String? = null, + name: String? = null, + email: String? = null, + signature: String? = null, + signatureUse: Boolean = false, + replyTo: String? = null + ): Identity { + return Identity(description, name, email, signature, signatureUse, replyTo) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderParserTest.kt b/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderParserTest.kt new file mode 100644 index 0000000..f9dd3b8 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderParserTest.kt @@ -0,0 +1,34 @@ +package com.fsck.k9.message + +import assertk.assertThat +import assertk.assertions.contains +import com.fsck.k9.RobolectricTest +import com.fsck.k9.helper.toCrLf +import org.junit.Test + +class IdentityHeaderParserTest : RobolectricTest() { + @Test + fun `folded header value`() { + val input = """ + |!l=10&o=0&qs=PREFIX&f=TEXT&s=aaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&p=0&q=NONE + """.trimMargin().toCrLf() + + val result = IdentityHeaderParser.parse(input) + + assertThat(result).contains(IdentityField.SIGNATURE, "a".repeat(1000)) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/MessageBuilderTest.java b/app/core/src/test/java/com/fsck/k9/message/MessageBuilderTest.java new file mode 100644 index 0000000..267e448 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/MessageBuilderTest.java @@ -0,0 +1,480 @@ +package com.fsck.k9.message; + + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fsck.k9.Account.QuoteStyle; +import com.fsck.k9.CoreResourceProvider; +import com.fsck.k9.Identity; +import com.fsck.k9.RobolectricTest; +import com.fsck.k9.TestCoreResourceProvider; +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.BoundaryGenerator; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.Message.RecipientType; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.MessageIdGenerator; +import com.fsck.k9.mail.internet.MimeHeader; +import com.fsck.k9.mail.internet.MimeMessage; +import com.fsck.k9.mail.internet.MimeMultipart; +import com.fsck.k9.message.MessageBuilder.Callback; +import com.fsck.k9.message.quote.InsertableHtmlContent; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.robolectric.Robolectric; +import org.robolectric.annotation.LooperMode; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + + +@LooperMode(LooperMode.Mode.LEGACY) +public class MessageBuilderTest extends RobolectricTest { + private static final String TEST_MESSAGE_TEXT = "soviet message\r\ntext ☭"; + private static final String TEST_ATTACHMENT_TEXT = "text data in attachment"; + private static final String TEST_SUBJECT = "test_subject"; + private static final Address TEST_IDENTITY_ADDRESS = new Address("test@example.org", "tester"); + private static final Address[] TEST_REPLY_TO = new Address[] { + new Address("reply-to1@example.org", "reply 1"), + new Address("reply-to2@example.org", "reply 2") + }; + private static final Address[] TEST_TO = new Address[] { + new Address("to1@example.org", "recip 1"), + new Address("to2@example.org", "recip 2") + }; + private static final Address[] TEST_CC = new Address[] { + new Address("cc@example.org", "cc recip") }; + private static final Address[] TEST_BCC = new Address[] { + new Address("bcc@example.org", "bcc recip") }; + private static final String TEST_MESSAGE_ID = "<00000000-0000-007B-0000-0000000000EA@example.org>"; + private static final Date SENT_DATE = new Date(10000000000L); + + private static final String BOUNDARY_1 = "----boundary1"; + private static final String BOUNDARY_2 = "----boundary2"; + private static final String BOUNDARY_3 = "----boundary3"; + + private static final String MESSAGE_HEADERS = "" + + "Date: Sun, 26 Apr 1970 17:46:40 +0000\r\n" + + "From: tester \r\n" + + "To: recip 1 , recip 2 \r\n" + + "CC: cc recip \r\n" + + "BCC: bcc recip \r\n" + + "Subject: test_subject\r\n" + + "User-Agent: K-9 Mail for Android\r\n" + + "Reply-to: reply 1 , reply 2 \r\n" + + "In-Reply-To: inreplyto\r\n" + + "References: references\r\n" + + "Message-ID: " + TEST_MESSAGE_ID + "\r\n" + + "MIME-Version: 1.0\r\n"; + + private static final String MESSAGE_CONTENT = "" + + "Content-Type: text/plain;\r\n" + + " charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n" + + "\r\n" + + "soviet message\r\n" + + "text =E2=98=AD"; + + private static final String MESSAGE_CONTENT_WITH_ATTACH = "" + + "Content-Type: multipart/mixed;\r\n" + + " boundary=" + BOUNDARY_1 + "\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "--" + BOUNDARY_1 + "\r\n" + + "Content-Type: text/plain;\r\n" + + " charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n" + + "\r\n" + + "soviet message\r\n" + + "text =E2=98=AD\r\n" + + "--" + BOUNDARY_1 + "\r\n" + + "Content-Type: text/plain;\r\n" + + " name=attach.txt\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "Content-Disposition: attachment;\r\n" + + " filename=attach.txt;\r\n" + + " size=23\r\n" + + "\r\n" + + "dGV4dCBkYXRhIGluIGF0dGFjaG1lbnQ=\r\n" + + "\r\n" + + "--" + BOUNDARY_1 + "--\r\n"; + + private static final String MESSAGE_CONTENT_WITH_LONG_FILE_NAME = + "Content-Type: multipart/mixed;\r\n" + + " boundary=" + BOUNDARY_1 + "\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "--" + BOUNDARY_1 + "\r\n" + + "Content-Type: text/plain;\r\n" + + " charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n" + + "\r\n" + + "soviet message\r\n" + + "text =E2=98=AD\r\n" + + "--" + BOUNDARY_1 + "\r\n" + + "Content-Type: text/plain;\r\n" + + " name*0*=UTF-8''~~~~~~~~~1~~~~~~~~~2~~~~~~~~~3~~~~~~~~~4~~~~~~~~~5~~~~~~~~~6~;\r\n" + + " name*1*=~~~~~~~~7.txt\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "Content-Disposition: attachment;\r\n" + + " filename*0*=UTF-8''~~~~~~~~~1~~~~~~~~~2~~~~~~~~~3~~~~~~~~~4~~~~~~~~~5~~~~~~~;\r\n" + + " filename*1*=~~6~~~~~~~~~7.txt;\r\n" + + " size=23\r\n" + + "\r\n" + + "dGV4dCBkYXRhIGluIGF0dGFjaG1lbnQ=\r\n" + + "\r\n" + + "--" + BOUNDARY_1 + "--\r\n"; + + private static final String ATTACHMENT_FILENAME_NON_ASCII = "テスト文書.txt"; + private static final String MESSAGE_CONTENT_WITH_ATTACH_NON_ASCII_FILENAME = "" + + "Content-Type: multipart/mixed;\r\n" + + " boundary=" + BOUNDARY_1 + "\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "--" + BOUNDARY_1 + "\r\n" + + "Content-Type: text/plain;\r\n" + + " charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n" + + "\r\n" + + "soviet message\r\n" + + "text =E2=98=AD\r\n" + + "--" + BOUNDARY_1 + "\r\n" + + "Content-Type: text/plain;\r\n" + + " name*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88%E6%96%87%E6%9B%B8.txt\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "Content-Disposition: attachment;\r\n" + + " filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88%E6%96%87%E6%9B%B8.txt;\r\n" + + " size=23\r\n" + + "\r\n" + + "dGV4dCBkYXRhIGluIGF0dGFjaG1lbnQ=\r\n" + + "\r\n" + + "--" + BOUNDARY_1 + "--\r\n"; + + private static final String MESSAGE_CONTENT_WITH_MESSAGE_ATTACH = "" + + "Content-Type: multipart/mixed;\r\n" + + " boundary=" + BOUNDARY_1 + "\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "--" + BOUNDARY_1 + "\r\n" + + "Content-Type: text/plain;\r\n" + + " charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n" + + "\r\n" + + "soviet message\r\n" + + "text =E2=98=AD\r\n" + + "--" + BOUNDARY_1 + "\r\n" + + "Content-Type: message/rfc822;\r\n" + + " name=attach.txt\r\n" + + "Content-Disposition: attachment;\r\n" + + " filename=attach.txt;\r\n" + + " size=23\r\n" + + "\r\n" + + "text data in attachment" + + "\r\n" + + "--" + BOUNDARY_1 + "--\r\n"; + + + private MessageIdGenerator messageIdGenerator; + private BoundaryGenerator boundaryGenerator; + private CoreResourceProvider resourceProvider = new TestCoreResourceProvider(); + private Callback callback; + + + @Before + public void setUp() throws Exception { + messageIdGenerator = mock(MessageIdGenerator.class); + when(messageIdGenerator.generateMessageId(any(Message.class))).thenReturn(TEST_MESSAGE_ID); + + boundaryGenerator = mock(BoundaryGenerator.class); + when(boundaryGenerator.generateBoundary()).thenReturn(BOUNDARY_1, BOUNDARY_2, BOUNDARY_3); + + callback = mock(Callback.class); + } + + @Test + public void build_shouldSucceed() throws Exception { + MessageBuilder messageBuilder = createSimpleMessageBuilder(); + + messageBuilder.buildAsync(callback); + + MimeMessage message = getMessageFromCallback(); + assertEquals("text/plain", message.getMimeType()); + assertEquals(TEST_SUBJECT, message.getSubject()); + assertEquals(TEST_IDENTITY_ADDRESS, message.getFrom()[0]); + assertArrayEquals(TEST_REPLY_TO, message.getReplyTo()); + assertArrayEquals(TEST_TO, message.getRecipients(RecipientType.TO)); + assertArrayEquals(TEST_CC, message.getRecipients(RecipientType.CC)); + assertArrayEquals(TEST_BCC, message.getRecipients(RecipientType.BCC)); + assertEquals(MESSAGE_HEADERS + MESSAGE_CONTENT, getMessageContents(message)); + } + + @Test + public void build_withAttachment_shouldSucceed() throws Exception { + MessageBuilder messageBuilder = createSimpleMessageBuilder(); + Attachment attachment = createAttachmentWithContent( + "text/plain", "attach.txt", TEST_ATTACHMENT_TEXT); + messageBuilder.setAttachments(Collections.singletonList(attachment)); + + messageBuilder.buildAsync(callback); + + MimeMessage message = getMessageFromCallback(); + assertEquals(MESSAGE_HEADERS + MESSAGE_CONTENT_WITH_ATTACH, getMessageContents(message)); + } + + @Test + public void build_withAttachment_longFileName() throws Exception { + MessageBuilder messageBuilder = createSimpleMessageBuilder(); + Attachment attachment = createAttachmentWithContent( + "text/plain", + "~~~~~~~~~1~~~~~~~~~2~~~~~~~~~3~~~~~~~~~4~~~~~~~~~5~~~~~~~~~6~~~~~~~~~7.txt", + TEST_ATTACHMENT_TEXT); + messageBuilder.setAttachments(Collections.singletonList(attachment)); + + messageBuilder.buildAsync(callback); + + MimeMessage message = getMessageFromCallback(); + assertEquals(MESSAGE_HEADERS + MESSAGE_CONTENT_WITH_LONG_FILE_NAME, + getMessageContents(message)); + } + + @Test + public void build_withAttachment_nonAscii_shouldSucceed() throws Exception { + MessageBuilder messageBuilder = createSimpleMessageBuilder(); + Attachment attachment = createAttachmentWithContent( + "text/plain", ATTACHMENT_FILENAME_NON_ASCII, TEST_ATTACHMENT_TEXT); + messageBuilder.setAttachments(Collections.singletonList(attachment)); + + messageBuilder.buildAsync(callback); + + MimeMessage message = getMessageFromCallback(); + assertEquals(MESSAGE_HEADERS + MESSAGE_CONTENT_WITH_ATTACH_NON_ASCII_FILENAME, + getMessageContents(message)); + } + + @Test + public void build_usingHtmlFormat_shouldUseMultipartAlternativeInCorrectOrder() { + MessageBuilder messageBuilder = createHtmlMessageBuilder(); + + messageBuilder.buildAsync(callback); + + MimeMessage message = getMessageFromCallback(); + assertEquals(MimeMultipart.class, message.getBody().getClass()); + assertEquals("multipart/alternative", ((MimeMultipart) message.getBody()).getMimeType()); + List parts = ((MimeMultipart) message.getBody()).getBodyParts(); + //RFC 2046 - 5.1.4. - Best type is last displayable + assertEquals("text/plain", parts.get(0).getMimeType()); + assertEquals("text/html", parts.get(1).getMimeType()); + } + + @Test + public void build_usingHtmlFormatWithInlineAttachment_shouldUseMultipartAlternativeInCorrectOrder() throws Exception { + String contentId = "contentId"; + String attachmentMimeType = "image/png"; + + Map inlineAttachments = new HashMap<>(); + inlineAttachments.put(contentId, createAttachmentWithContent(attachmentMimeType, "1x1.png", + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC")); + MessageBuilder messageBuilder = createHtmlMessageBuilder().setInlineAttachments(inlineAttachments); + + messageBuilder.buildAsync(callback); + + MimeMessage message = getMessageFromCallback(); + assertEquals(MimeMultipart.class, message.getBody().getClass()); + assertEquals("multipart/alternative", ((MimeMultipart) message.getBody()).getMimeType()); + List parts = ((MimeMultipart) message.getBody()).getBodyParts(); + //RFC 2046 - 5.1.4. - Best type is last displayable + assertEquals("text/plain", parts.get(0).getMimeType()); + assertEquals("multipart/related", parts.get(1).getMimeType()); + List partWithInlineAttachment = ((MimeMultipart) parts.get(1).getBody()).getBodyParts(); + assertEquals("text/html", partWithInlineAttachment.get(0).getMimeType()); + assertEquals(attachmentMimeType, partWithInlineAttachment.get(1).getMimeType()); + String[] attachmentHeaders = partWithInlineAttachment.get(1).getHeader(MimeHeader.HEADER_CONTENT_ID); + assertEquals(1, attachmentHeaders.length); + assertEquals(contentId, attachmentHeaders[0]); + } + + @Test + public void build_withMessageAttachment_shouldAttachAsMessageRfc822() throws Exception { + MessageBuilder messageBuilder = createSimpleMessageBuilder(); + Attachment attachment = createAttachmentWithContent( + "message/rfc822", "attach.txt", TEST_ATTACHMENT_TEXT); + messageBuilder.setAttachments(Collections.singletonList(attachment)); + + messageBuilder.buildAsync(callback); + + MimeMessage message = getMessageFromCallback(); + assertEquals(MESSAGE_HEADERS + MESSAGE_CONTENT_WITH_MESSAGE_ATTACH, + getMessageContents(message)); + } + + @Test + public void build_detachAndReattach_shouldSucceed() throws MessagingException { + MessageBuilder messageBuilder = createSimpleMessageBuilder(); + Callback anotherCallback = mock(Callback.class); + + Robolectric.getBackgroundThreadScheduler().pause(); + messageBuilder.buildAsync(callback); + messageBuilder.detachCallback(); + Robolectric.getBackgroundThreadScheduler().unPause(); + messageBuilder.reattachCallback(anotherCallback); + + verifyNoMoreInteractions(callback); + verify(anotherCallback).onMessageBuildSuccess(any(MimeMessage.class), eq(false)); + verifyNoMoreInteractions(anotherCallback); + } + + @Test + public void buildWithException_shouldThrow() throws MessagingException { + MessageBuilder messageBuilder = new SimpleMessageBuilder(messageIdGenerator, boundaryGenerator, resourceProvider) { + @Override + protected void buildMessageInternal() { + queueMessageBuildException(new MessagingException("expected error")); + } + }; + + messageBuilder.buildAsync(callback); + + verify(callback).onMessageBuildException(any(MessagingException.class)); + verifyNoMoreInteractions(callback); + } + + @Test + public void buildWithException_detachAndReattach_shouldThrow() throws MessagingException { + Callback anotherCallback = mock(Callback.class); + MessageBuilder messageBuilder = new SimpleMessageBuilder(messageIdGenerator, boundaryGenerator, resourceProvider) { + @Override + protected void buildMessageInternal() { + queueMessageBuildException(new MessagingException("expected error")); + } + }; + + Robolectric.getBackgroundThreadScheduler().pause(); + messageBuilder.buildAsync(callback); + messageBuilder.detachCallback(); + Robolectric.getBackgroundThreadScheduler().unPause(); + messageBuilder.reattachCallback(anotherCallback); + + verifyNoMoreInteractions(callback); + verify(anotherCallback).onMessageBuildException(any(MessagingException.class)); + verifyNoMoreInteractions(anotherCallback); + } + + private MimeMessage getMessageFromCallback() { + ArgumentCaptor mimeMessageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(callback).onMessageBuildSuccess(mimeMessageCaptor.capture(), eq(false)); + verifyNoMoreInteractions(callback); + + return mimeMessageCaptor.getValue(); + } + + private String getMessageContents(MimeMessage message) throws IOException, MessagingException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + message.writeTo(outputStream); + return outputStream.toString(); + } + + private Attachment createAttachmentWithContent(final String mimeType, final String filename, String content) throws Exception { + final byte[] bytes = content.getBytes(); + final File tempFile = File.createTempFile("pre", ".tmp"); + tempFile.deleteOnExit(); + FileOutputStream fileOutputStream = new FileOutputStream(tempFile); + fileOutputStream.write(bytes); + fileOutputStream.close(); + + return new Attachment() { + @Override + public Long getSize() { + return (long) bytes.length; + } + + @Override + public String getName() { + return filename; + } + + @Override + public String getContentType() { + return mimeType; + } + + @Override + public String getFileName() { + return tempFile.getAbsolutePath(); + } + + @Override + public LoadingState getState() { + return LoadingState.COMPLETE; + } + + @Override + public boolean isInternalAttachment() { + return true; + } + }; + } + + private MessageBuilder createSimpleMessageBuilder() { + Identity identity = createIdentity(); + return new SimpleMessageBuilder(messageIdGenerator, boundaryGenerator, resourceProvider) + .setSubject(TEST_SUBJECT) + .setSentDate(SENT_DATE) + .setHideTimeZone(true) + .setReplyTo(TEST_REPLY_TO) + .setTo(Arrays.asList(TEST_TO)) + .setCc(Arrays.asList(TEST_CC)) + .setBcc(Arrays.asList(TEST_BCC)) + .setInReplyTo("inreplyto") + .setReferences("references") + .setRequestReadReceipt(false) + .setIdentity(identity) + .setMessageFormat(SimpleMessageFormat.TEXT) + .setText(TEST_MESSAGE_TEXT) + .setAttachments(new ArrayList<>()) + .setSignature("signature") + .setQuoteStyle(QuoteStyle.PREFIX) + .setQuotedTextMode(QuotedTextMode.NONE) + .setQuotedText("quoted text") + .setQuotedHtmlContent(new InsertableHtmlContent()) + .setReplyAfterQuote(false) + .setSignatureBeforeQuotedText(false) + .setIdentityChanged(false) + .setSignatureChanged(false) + .setCursorPosition(0) + .setMessageReference(null) + .setDraft(false); + } + + private MessageBuilder createHtmlMessageBuilder() { + return createSimpleMessageBuilder().setMessageFormat(SimpleMessageFormat.HTML); + } + + private Identity createIdentity() { + return new Identity( + "test identity", + TEST_IDENTITY_ADDRESS.getPersonal(), + TEST_IDENTITY_ADDRESS.getAddress(), + null, + false, + null + ); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/MessageCreationHelper.java b/app/core/src/test/java/com/fsck/k9/message/MessageCreationHelper.java new file mode 100644 index 0000000..cf33fbd --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/MessageCreationHelper.java @@ -0,0 +1,69 @@ +package com.fsck.k9.message; + + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.MimeHeader; +import com.fsck.k9.mail.internet.MimeMessage; +import com.fsck.k9.mail.internet.MimeMultipart; +import com.fsck.k9.mail.internet.TextBody; +import com.fsck.k9.mailstore.BinaryMemoryBody; + + +public class MessageCreationHelper { + public static BodyPart createTextPart(String mimeType) throws MessagingException { + return createTextPart(mimeType, ""); + } + + public static BodyPart createTextPart(String mimeType, String text) throws MessagingException { + TextBody body = new TextBody(text); + return new MimeBodyPart(body, mimeType); + } + + public static BodyPart createEmptyPart(String mimeType) throws MessagingException { + return new MimeBodyPart(null, mimeType); + } + + public static BodyPart createPart(String mimeType) throws MessagingException { + BinaryMemoryBody body = new BinaryMemoryBody(new byte[0], "utf-8"); + return new MimeBodyPart(body, mimeType); + } + + public static BodyPart createMultipart(String mimeType, BodyPart... parts) throws MessagingException { + MimeMultipart multipart = createMultipartBody(mimeType, parts); + return new MimeBodyPart(multipart, mimeType); + } + + public static Message createTextMessage(String mimeType, String text) throws MessagingException { + TextBody body = new TextBody(text); + return createMessage(mimeType, body); + } + + public static Message createMultipartMessage(String mimeType, BodyPart... parts) throws MessagingException { + MimeMultipart body = createMultipartBody(mimeType, parts); + return createMessage(mimeType, body); + } + + public static Message createMessage(String mimeType) throws MessagingException { + return createMessage(mimeType, null); + } + + private static Message createMessage(String mimeType, Body body) { + MimeMessage message = new MimeMessage(); + message.setBody(body); + message.setHeader(MimeHeader.HEADER_CONTENT_TYPE, mimeType); + + return message; + } + + private static MimeMultipart createMultipartBody(String mimeType, BodyPart[] parts) { + MimeMultipart multipart = new MimeMultipart(mimeType, "boundary"); + for (BodyPart part : parts) { + multipart.addBodyPart(part); + } + return multipart; + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt b/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt new file mode 100644 index 0000000..868ab07 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt @@ -0,0 +1,115 @@ +package com.fsck.k9.message + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import com.fsck.k9.Account +import com.fsck.k9.Identity +import com.fsck.k9.helper.ReplyToParser +import com.fsck.k9.mail.buildMessage +import org.junit.Test + +private const val IDENTITY_EMAIL_ADDRESS = "myself@domain.example" + +class ReplyActionStrategyTest { + private val account = createAccount() + private val replyActionStrategy = ReplyActionStrategy(ReplyToParser()) + + @Test + fun `message sent to only our identity`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("To", IDENTITY_EMAIL_ADDRESS) + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY) + assertThat(replyActions.additionalActions).isEmpty() + } + + @Test + fun `message sent to our identity and others`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("To", "$IDENTITY_EMAIL_ADDRESS, other@domain.example") + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL) + assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY) + } + + @Test + fun `message sent to our identity and others (CC)`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("Cc", "$IDENTITY_EMAIL_ADDRESS, other@domain.example") + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL) + assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY) + } + + @Test + fun `message sent to our identity and others (To+CC)`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("To", IDENTITY_EMAIL_ADDRESS) + header("Cc", "other@domain.example") + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL) + assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY) + } + + @Test + fun `message sent to our identity and others (CC+To)`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("To", "other@domain.example") + header("Cc", IDENTITY_EMAIL_ADDRESS) + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL) + assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY) + } + + @Test + fun `message where neither sender nor recipient addresses belong to account`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("To", "recipient@domain.example") + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL) + assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY) + } + + @Test + fun `message without any sender or recipient headers`() { + val message = buildMessage {} + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isNull() + assertThat(replyActions.additionalActions).isEmpty() + } + + private fun createAccount(): Account { + return Account("00000000-0000-4000-0000-000000000000").apply { + identities += Identity(name = "Myself", email = IDENTITY_EMAIL_ADDRESS) + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/TextBodyBuilderTest.kt b/app/core/src/test/java/com/fsck/k9/message/TextBodyBuilderTest.kt new file mode 100644 index 0000000..655784f --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/TextBodyBuilderTest.kt @@ -0,0 +1,223 @@ +package com.fsck.k9.message + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.message.quote.InsertableHtmlContent +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class TextBodyBuilderTest(val testData: TestData) { + + companion object { + + private const val MESSAGE_TEXT = "my message\r\nwith two lines" + private const val MESSAGE_TEXT_HTML = "
    my message
    with two lines
    " + private const val QUOTED_TEXT = ">quoted text\r\n>-- \r\n>Other signature" + private const val QUOTED_HTML_BODY = "
    quoted text
    " + private const val QUOTED_HTML_TAGS_END = "\n" + private const val QUOTED_HTML_TAGS_START = "" + private const val SIGNATURE_TEXT = "-- \r\n\r\nsignature\r\n indented second line" + private const val SIGNATURE_TEXT_HTML = "
    --
    " + + "
    signature
    \u00A0 indented second line
    " + + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection { + return listOf( + TestData( + appendSignature = false, + includeQuotedText = false, + insertSeparator = false, + replyAfterQuote = false, + signatureBeforeQuotedText = false, + expectedPlainTextMessage = MESSAGE_TEXT, + expectedHtmlTextMessage = TextBodyBuilder.HTML_AND_BODY_START + MESSAGE_TEXT_HTML + + TextBodyBuilder.HTML_AND_BODY_END + ), + TestData( + appendSignature = true, + includeQuotedText = false, + insertSeparator = false, + replyAfterQuote = false, + signatureBeforeQuotedText = false, + expectedPlainTextMessage = MESSAGE_TEXT + "\r\n" + SIGNATURE_TEXT, + expectedHtmlTextMessage = TextBodyBuilder.HTML_AND_BODY_START + MESSAGE_TEXT_HTML + + SIGNATURE_TEXT_HTML + TextBodyBuilder.HTML_AND_BODY_END + ), + TestData( + appendSignature = false, + includeQuotedText = true, + insertSeparator = false, + replyAfterQuote = false, + signatureBeforeQuotedText = false, + expectedPlainTextMessage = MESSAGE_TEXT + "\r\n\r\n" + QUOTED_TEXT, + expectedHtmlTextMessage = QUOTED_HTML_TAGS_START + MESSAGE_TEXT_HTML + QUOTED_HTML_BODY + + QUOTED_HTML_TAGS_END + ), + TestData( + appendSignature = false, + includeQuotedText = true, + insertSeparator = true, + replyAfterQuote = false, + signatureBeforeQuotedText = false, + expectedPlainTextMessage = MESSAGE_TEXT + "\r\n\r\n" + QUOTED_TEXT, + expectedHtmlTextMessage = QUOTED_HTML_TAGS_START + MESSAGE_TEXT_HTML + "

    " + + QUOTED_HTML_BODY + QUOTED_HTML_TAGS_END + ), + TestData( + appendSignature = false, + includeQuotedText = true, + insertSeparator = false, + replyAfterQuote = true, + signatureBeforeQuotedText = false, + expectedPlainTextMessage = QUOTED_TEXT + "\r\n" + MESSAGE_TEXT, + expectedHtmlTextMessage = QUOTED_HTML_TAGS_START + QUOTED_HTML_BODY + MESSAGE_TEXT_HTML + + QUOTED_HTML_TAGS_END + ), + TestData( + appendSignature = false, + includeQuotedText = true, + insertSeparator = true, + replyAfterQuote = true, + signatureBeforeQuotedText = false, + expectedPlainTextMessage = QUOTED_TEXT + "\r\n" + MESSAGE_TEXT, + expectedHtmlTextMessage = QUOTED_HTML_TAGS_START + QUOTED_HTML_BODY + "
    " + + MESSAGE_TEXT_HTML + QUOTED_HTML_TAGS_END + ), + TestData( + appendSignature = true, + includeQuotedText = true, + insertSeparator = false, + replyAfterQuote = false, + signatureBeforeQuotedText = false, + expectedPlainTextMessage = MESSAGE_TEXT + "\r\n\r\n" + QUOTED_TEXT + "\r\n" + SIGNATURE_TEXT, + expectedHtmlTextMessage = QUOTED_HTML_TAGS_START + MESSAGE_TEXT_HTML + QUOTED_HTML_BODY + + SIGNATURE_TEXT_HTML + QUOTED_HTML_TAGS_END + ), + TestData( + appendSignature = true, + includeQuotedText = true, + insertSeparator = true, + replyAfterQuote = false, + signatureBeforeQuotedText = false, + expectedPlainTextMessage = MESSAGE_TEXT + "\r\n\r\n" + QUOTED_TEXT + "\r\n" + SIGNATURE_TEXT, + expectedHtmlTextMessage = QUOTED_HTML_TAGS_START + MESSAGE_TEXT_HTML + "

    " + + QUOTED_HTML_BODY + SIGNATURE_TEXT_HTML + QUOTED_HTML_TAGS_END + ), + TestData( + appendSignature = true, + includeQuotedText = true, + insertSeparator = false, + replyAfterQuote = true, + signatureBeforeQuotedText = false, + expectedPlainTextMessage = QUOTED_TEXT + "\r\n" + MESSAGE_TEXT + "\r\n" + SIGNATURE_TEXT, + expectedHtmlTextMessage = QUOTED_HTML_TAGS_START + QUOTED_HTML_BODY + MESSAGE_TEXT_HTML + + SIGNATURE_TEXT_HTML + QUOTED_HTML_TAGS_END + ), + TestData( + appendSignature = true, + includeQuotedText = true, + insertSeparator = true, + replyAfterQuote = true, + signatureBeforeQuotedText = false, + expectedPlainTextMessage = QUOTED_TEXT + "\r\n" + MESSAGE_TEXT + "\r\n" + SIGNATURE_TEXT, + expectedHtmlTextMessage = QUOTED_HTML_TAGS_START + QUOTED_HTML_BODY + "
    " + + MESSAGE_TEXT_HTML + SIGNATURE_TEXT_HTML + QUOTED_HTML_TAGS_END + ), + TestData( + appendSignature = true, + includeQuotedText = true, + insertSeparator = false, + replyAfterQuote = false, + signatureBeforeQuotedText = true, + expectedPlainTextMessage = MESSAGE_TEXT + "\r\n" + SIGNATURE_TEXT + "\r\n\r\n" + QUOTED_TEXT, + expectedHtmlTextMessage = QUOTED_HTML_TAGS_START + MESSAGE_TEXT_HTML + SIGNATURE_TEXT_HTML + + QUOTED_HTML_BODY + QUOTED_HTML_TAGS_END + ), + TestData( + appendSignature = true, + includeQuotedText = true, + insertSeparator = true, + replyAfterQuote = false, + signatureBeforeQuotedText = true, + expectedPlainTextMessage = MESSAGE_TEXT + "\r\n" + SIGNATURE_TEXT + "\r\n\r\n" + QUOTED_TEXT, + expectedHtmlTextMessage = QUOTED_HTML_TAGS_START + MESSAGE_TEXT_HTML + SIGNATURE_TEXT_HTML + + "

    " + QUOTED_HTML_BODY + QUOTED_HTML_TAGS_END + ) + ) + } + } + + private val toTest: TextBodyBuilder + + init { + toTest = TextBodyBuilder(MESSAGE_TEXT) + toTest.setAppendSignature(testData.appendSignature) + toTest.setIncludeQuotedText(testData.includeQuotedText) + toTest.setInsertSeparator(testData.insertSeparator) + toTest.setReplyAfterQuote(testData.replyAfterQuote) + toTest.setSignatureBeforeQuotedText(testData.signatureBeforeQuotedText) + toTest.setQuotedText(QUOTED_TEXT) + val quotedHtmlContent = InsertableHtmlContent() + quotedHtmlContent.setQuotedContent( + StringBuilder(QUOTED_HTML_TAGS_START + QUOTED_HTML_BODY + QUOTED_HTML_TAGS_END) + ) + quotedHtmlContent.setHeaderInsertionPoint(QUOTED_HTML_TAGS_START.length) + quotedHtmlContent.footerInsertionPoint = + QUOTED_HTML_TAGS_START.length + QUOTED_HTML_BODY.length + toTest.setQuotedTextHtml(quotedHtmlContent) + toTest.setSignature(SIGNATURE_TEXT) + } + + @Test + fun plainTextBody_expectCorrectRawText() { + val textBody = toTest.buildTextPlain() + + assertThat(textBody.rawText).isEqualTo(testData.expectedPlainTextMessage) + } + + @Test + fun plainTextBodySubstring_expectMessage() { + val textBody = toTest.buildTextPlain() + + val startIndex = textBody.composedMessageOffset!! + val endIndex = startIndex + textBody.composedMessageLength!! + assertThat(textBody.rawText.substring(startIndex, endIndex)).isEqualTo(MESSAGE_TEXT) + } + + @Test + fun htmlTextBody_expectCorrectRawText() { + val textBody = toTest.buildTextHtml() + + assertThat(textBody.rawText).isEqualTo(testData.expectedHtmlTextMessage) + } + + @Test + fun htmlTextBodySubstring_expectMessage() { + val textBody = toTest.buildTextHtml() + + val startIndex = textBody.composedMessageOffset!! + val endIndex = startIndex + textBody.composedMessageLength!! + assertThat(textBody.rawText.substring(startIndex, endIndex)).isEqualTo(MESSAGE_TEXT_HTML) + } + + class TestData( + val appendSignature: Boolean, + val includeQuotedText: Boolean, + val insertSeparator: Boolean, + val replyAfterQuote: Boolean, + val signatureBeforeQuotedText: Boolean, + val expectedPlainTextMessage: String, + val expectedHtmlTextMessage: String + ) { + override fun toString(): String { + return "appendSignature=$appendSignature," + + "includeQuotedText=$includeQuotedText," + + "insertSeparator=$insertSeparator," + + "replyAfterQuote=$replyAfterQuote," + + "signatureBeforeQuotedText=$signatureBeforeQuotedText" + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/extractors/AttachmentInfoExtractorTest.java b/app/core/src/test/java/com/fsck/k9/message/extractors/AttachmentInfoExtractorTest.java new file mode 100644 index 0000000..c83476a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/extractors/AttachmentInfoExtractorTest.java @@ -0,0 +1,222 @@ +package com.fsck.k9.message.extractors; + + +import android.content.Context; +import android.net.Uri; +import androidx.annotation.Nullable; + +import com.fsck.k9.RobolectricTest; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.MimeHeader; +import com.fsck.k9.mail.internet.TextBody; +import com.fsck.k9.mailstore.AttachmentViewInfo; +import com.fsck.k9.mailstore.DeferredFileBody; +import com.fsck.k9.mailstore.LocalBodyPart; +import com.fsck.k9.provider.AttachmentProvider; +import org.junit.Before; +import org.junit.Test; +import org.robolectric.RuntimeEnvironment; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + + +public class AttachmentInfoExtractorTest extends RobolectricTest { + public static final Uri TEST_URI = Uri.parse("uri://test"); + public static final String TEST_MIME_TYPE = "text/plain"; + public static final long TEST_SIZE = 123L; + public static final String TEST_ACCOUNT_UUID = "uuid"; + public static final long TEST_ID = 234L; + public static final String TEST_CONTENT_ID = "test-content-id"; + + + private AttachmentInfoExtractor attachmentInfoExtractor; + private Context context; + + + @Before + public void setUp() throws Exception { + AttachmentProvider.CONTENT_URI = Uri.parse("content://test.attachmentprovider"); + context = RuntimeEnvironment.getApplication(); + attachmentInfoExtractor = new AttachmentInfoExtractor(context); + } + + @Test(expected = IllegalArgumentException.class) + public void extractInfo__withGenericPart_shouldThrow() throws Exception { + Part part = mock(Part.class); + + attachmentInfoExtractor.extractAttachmentInfo(part); + } + + @Test + public void extractInfo__fromLocalBodyPart__shouldReturnProvidedValues() throws Exception { + LocalBodyPart part = new LocalBodyPart(TEST_ACCOUNT_UUID, null, TEST_ID, TEST_SIZE); + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, TEST_MIME_TYPE); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfo(part); + + assertEquals(AttachmentProvider.getAttachmentUri(TEST_ACCOUNT_UUID, TEST_ID), attachmentViewInfo.internalUri); + assertEquals(TEST_SIZE, attachmentViewInfo.size); + assertEquals(TEST_MIME_TYPE, attachmentViewInfo.mimeType); + } + + @Test + public void extractInfoForDb__withNoHeaders__shouldReturnEmptyValues() throws Exception { + MimeBodyPart part = new MimeBodyPart(); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + assertEquals(Uri.EMPTY, attachmentViewInfo.internalUri); + assertEquals(AttachmentViewInfo.UNKNOWN_SIZE, attachmentViewInfo.size); + assertEquals("noname.txt", attachmentViewInfo.displayName); + assertEquals("text/plain", attachmentViewInfo.mimeType); + assertFalse(attachmentViewInfo.inlineAttachment); + } + + @Test + public void extractInfoForDb__withTextMimeType__shouldReturnTxtExtension() throws Exception { + MimeBodyPart part = new MimeBodyPart(); + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain"); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + // MimeUtility.getExtensionByMimeType("text/plain"); -> "txt" + assertEquals("noname.txt", attachmentViewInfo.displayName); + assertEquals("text/plain", attachmentViewInfo.mimeType); + } + + @Test + public void extractInfoForDb__withContentTypeAndName__shouldReturnNamedAttachment() throws Exception { + MimeBodyPart part = new MimeBodyPart(); + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, TEST_MIME_TYPE + "; name=\"filename.ext\""); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + assertEquals(Uri.EMPTY, attachmentViewInfo.internalUri); + assertEquals(TEST_MIME_TYPE, attachmentViewInfo.mimeType); + assertEquals("filename.ext", attachmentViewInfo.displayName); + assertFalse(attachmentViewInfo.inlineAttachment); + } + + @Test + public void extractInfoForDb__withContentTypeAndEncodedWordName__shouldReturnDecodedName() throws Exception { + Part part = new MimeBodyPart(); + part.addRawHeader(MimeHeader.HEADER_CONTENT_TYPE, + MimeHeader.HEADER_CONTENT_TYPE + ": " +TEST_MIME_TYPE + "; name=\"=?ISO-8859-1?Q?Sm=F8rrebr=F8d?=\""); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + assertEquals("Smørrebrød", attachmentViewInfo.displayName); + } + + @Test + public void extractInfoForDb__withDispositionAttach__shouldReturnNamedAttachment() throws Exception { + MimeBodyPart part = new MimeBodyPart(); + part.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, + "attachment" + "; filename=\"filename.ext\"; meaningless=\"dummy\""); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + assertEquals(Uri.EMPTY, attachmentViewInfo.internalUri); + assertEquals("filename.ext", attachmentViewInfo.displayName); + assertFalse(attachmentViewInfo.inlineAttachment); + } + + @Test + public void extractInfoForDb__withDispositionInlineAndContentIdAndMissingMimeType__shouldNotReturnInlineAttachment() + throws Exception { + Part part = new MimeBodyPart(); + part.addRawHeader(MimeHeader.HEADER_CONTENT_ID, MimeHeader.HEADER_CONTENT_ID + ": " + TEST_CONTENT_ID); + part.addRawHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, MimeHeader.HEADER_CONTENT_DISPOSITION + ": " + + "inline" + ";\n filename=\"filename.ext\";\n meaningless=\"dummy\""); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + assertFalse(attachmentViewInfo.inlineAttachment); + } + + @Test + public void extractInfoForDb__withDispositionInlineAndContentIdAndImageMimeType__shouldReturnInlineAttachment() + throws Exception { + Part part = new MimeBodyPart(); + part.addRawHeader(MimeHeader.HEADER_CONTENT_TYPE, MimeHeader.HEADER_CONTENT_TYPE + ": image/png"); + part.addRawHeader(MimeHeader.HEADER_CONTENT_ID, MimeHeader.HEADER_CONTENT_ID + ": " + TEST_CONTENT_ID); + part.addRawHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, MimeHeader.HEADER_CONTENT_DISPOSITION + ": " + + "inline" + ";\n filename=\"filename.ext\";\n meaningless=\"dummy\""); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + assertTrue(attachmentViewInfo.inlineAttachment); + } + + @Test + public void extractInfoForDb__withDispositionSizeParam__shouldReturnThatSize() throws Exception { + MimeBodyPart part = new MimeBodyPart(); + part.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, "attachment" + "; size=\"" + TEST_SIZE + "\""); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + assertEquals(TEST_SIZE, attachmentViewInfo.size); + } + + @Test + public void extractInfoForDb__withDispositionInvalidSizeParam__shouldReturnUnknownSize() throws Exception { + MimeBodyPart part = new MimeBodyPart(); + part.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, "attachment" + "; size=\"notanint\""); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + assertEquals(AttachmentViewInfo.UNKNOWN_SIZE, attachmentViewInfo.size); + } + + @Test + public void extractInfoForDb__withNoBody__shouldReturnContentNotAvailable() throws Exception { + MimeBodyPart part = new MimeBodyPart(); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + assertFalse(attachmentViewInfo.isContentAvailable()); + } + + @Test + public void extractInfoForDb__withNoBody__shouldReturnContentAvailable() throws Exception { + MimeBodyPart part = new MimeBodyPart(); + part.setBody(new TextBody("data")); + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfoForDatabase(part); + + assertTrue(attachmentViewInfo.isContentAvailable()); + } + + @Test + public void extractInfo__withDeferredFileBody() throws Exception { + attachmentInfoExtractor = new AttachmentInfoExtractor(context) { + @Nullable + @Override + protected Uri getDecryptedFileProviderUri(DeferredFileBody decryptedTempFileBody, String mimeType) { + return TEST_URI; + } + }; + + DeferredFileBody body = mock(DeferredFileBody.class); + when(body.getSize()).thenReturn(TEST_SIZE); + + MimeBodyPart part = new MimeBodyPart(); + part.setBody(body); + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, TEST_MIME_TYPE); + + + AttachmentViewInfo attachmentViewInfo = attachmentInfoExtractor.extractAttachmentInfo(part); + + + assertEquals(TEST_URI, attachmentViewInfo.internalUri); + assertEquals(TEST_SIZE, attachmentViewInfo.size); + assertEquals(TEST_MIME_TYPE, attachmentViewInfo.mimeType); + assertFalse(attachmentViewInfo.inlineAttachment); + assertTrue(attachmentViewInfo.isContentAvailable()); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/extractors/BasicPartInfoExtractorTest.kt b/app/core/src/test/java/com/fsck/k9/message/extractors/BasicPartInfoExtractorTest.kt new file mode 100644 index 0000000..cce9cba --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/extractors/BasicPartInfoExtractorTest.kt @@ -0,0 +1,122 @@ +package com.fsck.k9.message.extractors + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import com.fsck.k9.mail.Part +import com.fsck.k9.mail.internet.MimeBodyPart +import org.junit.Test + +class BasicPartInfoExtractorTest { + private val basicPartInfoExtractor = BasicPartInfoExtractor() + + @Test + fun `extractPartInfo with 'filename' parameter in Content-Disposition header`() { + val part = createPart( + contentType = "application/octet-stream", + contentDisposition = "attachment; filename=\"attachment_name.txt\"; size=23" + ) + + val partInfo = basicPartInfoExtractor.extractPartInfo(part) + + assertThat(partInfo.displayName).isEqualTo("attachment_name.txt") + assertThat(partInfo.size).isEqualTo(23L) + } + + @Test + fun `extractPartInfo with 'name' parameter in Content-Type header`() { + val part = createPart( + contentType = "image/jpeg; name=\"attachment.jpeg\"", + contentDisposition = "attachment; size=42" + ) + + val partInfo = basicPartInfoExtractor.extractPartInfo(part) + + assertThat(partInfo.displayName).isEqualTo("attachment.jpeg") + assertThat(partInfo.size).isEqualTo(42L) + } + + @Test + fun `extractPartInfo without display name and size`() { + val part = createPart(contentType = "text/plain", contentDisposition = "attachment") + + val partInfo = basicPartInfoExtractor.extractPartInfo(part) + + assertThat(partInfo.displayName).isEqualTo("noname.txt") + assertThat(partInfo.size).isNull() + } + + @Test + fun `extractPartInfo without display name and unknown mime type`() { + val part = createPart(contentType = "x-made-up/unknown", contentDisposition = "attachment") + + val partInfo = basicPartInfoExtractor.extractPartInfo(part) + + assertThat(partInfo.displayName).isEqualTo("noname") + assertThat(partInfo.size).isNull() + } + + @Test + fun `extractPartInfo with missing Content-Disposition header`() { + val part = createPart( + contentType = "application/octet-stream; name=\"attachment.dat\"", + contentDisposition = null + ) + + val partInfo = basicPartInfoExtractor.extractPartInfo(part) + + assertThat(partInfo.displayName).isEqualTo("attachment.dat") + assertThat(partInfo.size).isNull() + } + + @Test + fun `extractPartInfo with missing Content-Disposition header and name`() { + val part = createPart( + contentType = "application/octet-stream", + contentDisposition = null + ) + + val partInfo = basicPartInfoExtractor.extractPartInfo(part) + + assertThat(partInfo.displayName).isEqualTo("noname") + assertThat(partInfo.size).isNull() + } + + @Test + fun `extractPartInfo without any relevant headers`() { + val part = createPart( + contentType = null, + contentDisposition = null + ) + + val partInfo = basicPartInfoExtractor.extractPartInfo(part) + + assertThat(partInfo.displayName).isEqualTo("noname.txt") + assertThat(partInfo.size).isNull() + } + + @Test + fun `extractPartInfo with invalid Content-Disposition header`() { + val part = createPart( + contentType = "application/octet-stream", + contentDisposition = "something; " + ) + + val partInfo = basicPartInfoExtractor.extractPartInfo(part) + + assertThat(partInfo.displayName).isEqualTo("noname") + assertThat(partInfo.size).isNull() + } + + private fun createPart(contentType: String?, contentDisposition: String?): Part { + return MimeBodyPart().apply { + if (contentType != null) { + addHeader("Content-Type", contentType) + } + + if (contentDisposition != null) { + addHeader("Content-Disposition", contentDisposition) + } + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/extractors/MessagePreviewCreatorTest.java b/app/core/src/test/java/com/fsck/k9/message/extractors/MessagePreviewCreatorTest.java new file mode 100644 index 0000000..911711d --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/extractors/MessagePreviewCreatorTest.java @@ -0,0 +1,102 @@ +package com.fsck.k9.message.extractors; + + +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MimeMessage; +import com.fsck.k9.message.extractors.PreviewResult.PreviewType; +import org.junit.Before; +import org.junit.Test; + +import static com.fsck.k9.message.MessageCreationHelper.createEmptyPart; +import static com.fsck.k9.message.MessageCreationHelper.createTextPart; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + + +public class MessagePreviewCreatorTest { + private TextPartFinder textPartFinder; + private PreviewTextExtractor previewTextExtractor; + private MessagePreviewCreator previewCreator; + + @Before + public void setUp() throws Exception { + textPartFinder = mock(TextPartFinder.class); + previewTextExtractor = mock(PreviewTextExtractor.class); + + previewCreator = new MessagePreviewCreator(textPartFinder, previewTextExtractor); + } + + @Test + public void createPreview_withoutTextPart() { + Message message = createDummyMessage(); + when(textPartFinder.findFirstTextPart(message)).thenReturn(null); + + PreviewResult result = previewCreator.createPreview(message); + + assertFalse(result.isPreviewTextAvailable()); + assertEquals(PreviewType.NONE, result.getPreviewType()); + verifyNoMoreInteractions(previewTextExtractor); + } + + @Test + public void createPreview_withEmptyTextPart() throws Exception { + Message message = createDummyMessage(); + Part textPart = createEmptyPart("text/plain"); + when(textPartFinder.findFirstTextPart(message)).thenReturn(textPart); + + PreviewResult result = previewCreator.createPreview(message); + + assertFalse(result.isPreviewTextAvailable()); + assertEquals(PreviewType.NONE, result.getPreviewType()); + verifyNoMoreInteractions(previewTextExtractor); + } + + @Test + public void createPreview_withTextPart() throws Exception { + Message message = createDummyMessage(); + Part textPart = createTextPart("text/plain"); + when(textPartFinder.findFirstTextPart(message)).thenReturn(textPart); + when(previewTextExtractor.extractPreview(textPart)).thenReturn("expected"); + + PreviewResult result = previewCreator.createPreview(message); + + assertTrue(result.isPreviewTextAvailable()); + assertEquals(PreviewType.TEXT, result.getPreviewType()); + assertEquals("expected", result.getPreviewText()); + } + + @Test + public void createPreview_withPreviewTextExtractorThrowing() throws Exception { + Message message = createDummyMessage(); + Part textPart = createTextPart("text/plain"); + when(textPartFinder.findFirstTextPart(message)).thenReturn(textPart); + when(previewTextExtractor.extractPreview(textPart)).thenThrow(new PreviewExtractionException("")); + + PreviewResult result = previewCreator.createPreview(message); + + assertFalse(result.isPreviewTextAvailable()); + assertEquals(PreviewType.ERROR, result.getPreviewType()); + } + + @Test + public void createPreview_withPreviewTextExtractorThrowingUnexpectedException() throws Exception { + Message message = createDummyMessage(); + Part textPart = createTextPart("text/plain"); + when(textPartFinder.findFirstTextPart(message)).thenReturn(textPart); + when(previewTextExtractor.extractPreview(textPart)).thenThrow(new IllegalStateException("")); + + PreviewResult result = previewCreator.createPreview(message); + + assertFalse(result.isPreviewTextAvailable()); + assertEquals(PreviewType.ERROR, result.getPreviewType()); + } + + private Message createDummyMessage() { + return new MimeMessage(); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/extractors/PreviewTextExtractorTest.kt b/app/core/src/test/java/com/fsck/k9/message/extractors/PreviewTextExtractorTest.kt new file mode 100644 index 0000000..e45345a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/extractors/PreviewTextExtractorTest.kt @@ -0,0 +1,219 @@ +package com.fsck.k9.message.extractors + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.internet.MimeBodyPart +import com.fsck.k9.message.MessageCreationHelper +import org.junit.Test + +class PreviewTextExtractorTest { + private val previewTextExtractor = PreviewTextExtractor() + + @Test(expected = PreviewExtractionException::class) + fun extractPreview_withEmptyBody_shouldThrow() { + val part = MimeBodyPart(null, "text/plain") + + previewTextExtractor.extractPreview(part) + } + + @Test + fun extractPreview_withSimpleTextPlain() { + val text = "The quick brown fox jumps over the lazy dog" + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo(text) + } + + @Test + fun extractPreview_withSimpleTextHtml() { + val text = "The quick brown fox jumps over the lazy dog" + val part = MessageCreationHelper.createTextPart("text/html", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("The quick brown fox jumps over the lazy dog") + } + + @Test + fun extractPreview_withLongTextPlain() { + val text = "" + + "10--------20--------30--------40--------50--------" + + "60--------70--------80--------90--------100-------" + + "110-------120-------130-------140-------150-------" + + "160-------170-------180-------190-------200-------" + + "210-------220-------230-------240-------250-------" + + "260-------270-------280-------290-------300-------" + + "310-------320-------330-------340-------350-------" + + "360-------370-------380-------390-------400-------" + + "410-------420-------430-------440-------450-------" + + "460-------470-------480-------490-------500-------" + + "510-------520-------" + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo(text.substring(0, 511) + "…") + } + + @Test + fun extractPreview_shouldStripSignature() { + val text = + """ + Some text + -- + Signature + """.trimIndent() + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("Some text") + } + + @Test + fun extractPreview_shouldStripHorizontalLine() { + val text = + """ + line 1 + ---- + line 2 + """.trimIndent() + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("line 1 line 2") + } + + @Test + fun extractPreview_shouldStripQuoteHeaderAndQuotedText() { + val text = + """ + some text + + On 01/02/03 someone wrote: + > some quoted text + > some other quoted text + """.trimIndent() + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("some text") + } + + @Test + fun extractPreview_shouldStripGenericQuoteHeader() { + val text = + """ + Am 13.12.2015 um 23:42 schrieb Hans: + > hallo + hi there + + """.trimIndent() + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("hi there") + } + + @Test + fun extractPreview_shouldStripHorizontalRules() { + val text = + """ + line 1------------------------------ + line 2 + """.trimIndent() + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("line 1 line 2") + } + + @Test + fun extractPreview_shouldReplaceUrl() { + val text = "some url: https://k9mail.org/" + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("some url: ...") + } + + @Test + fun extractPreview_shouldCollapseAndTrimWhitespace() { + val text = " whitespace is\t\tfun " + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("whitespace is fun") + } + + @Test + fun extractPreview_lineEndingWithColon() { + val text = + """ + Here's a list: + - item 1 + - item 2 + """.trimIndent() + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("Here's a list: - item 1 - item 2") + } + + @Test + fun extractPreview_inlineReplies() { + val text = + """ + On 2020-09-30 at 03:12 Bob wrote: + > Hi Alice + Hi Bob + + > How are you? + I'm fine. Thanks for asking. + + > Bye + See you tomorrow + """.trimIndent() + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("Hi Bob […] I'm fine. Thanks for asking. […] See you tomorrow") + } + + @Test + fun extractPreview_quoteHeaderContainingLineBreak() { + val text = + """ + Reply text + + On 2020-09-30 at 03:12 + Bob wrote: + > Quoted text + """.trimIndent() + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("Reply text") + } + + @Test + fun extractPreview_emptyBody() { + val text = "" + val part = MessageCreationHelper.createTextPart("text/plain", text) + + val preview = previewTextExtractor.extractPreview(part) + + assertThat(preview).isEqualTo("") + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/extractors/TextPartFinderTest.kt b/app/core/src/test/java/com/fsck/k9/message/extractors/TextPartFinderTest.kt new file mode 100644 index 0000000..2fb5f48 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/extractors/TextPartFinderTest.kt @@ -0,0 +1,256 @@ +package com.fsck.k9.message.extractors + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import com.fsck.k9.message.MessageCreationHelper.createEmptyPart +import com.fsck.k9.message.MessageCreationHelper.createMultipart +import com.fsck.k9.message.MessageCreationHelper.createPart +import com.fsck.k9.message.MessageCreationHelper.createTextPart +import org.junit.Test + +class TextPartFinderTest { + private val textPartFinder = TextPartFinder() + + @Test + fun `text_plain part`() { + val part = createTextPart("text/plain") + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(part) + } + + @Test + fun `text_html part`() { + val part = createTextPart("text/html") + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(part) + } + + @Test + fun `without text part`() { + val part = createPart("image/jpeg") + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isNull() + } + + @Test + fun `multipart_alternative text_plain and text_html`() { + val expected = createTextPart("text/plain") + val part = createMultipart( + "multipart/alternative", + expected, + createTextPart("text/html") + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_alternative containing text_html and text_plain`() { + val expected = createTextPart("text/plain") + val part = createMultipart( + "multipart/alternative", + createTextPart("text/html"), + expected + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_alternative containing multiple text_html parts`() { + val expected = createTextPart("text/html") + val part = createMultipart( + "multipart/alternative", + createPart("image/gif"), + expected, + createTextPart("text/html") + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_alternative not containing any text parts`() { + val part = createMultipart( + "multipart/alternative", + createPart("image/gif"), + createPart("application/pdf") + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isNull() + } + + @Test + fun `multipart_alternative containing multipart_related containing text_plain`() { + val expected = createTextPart("text/plain") + val part = createMultipart( + "multipart/alternative", + createMultipart( + "multipart/related", + expected, + createPart("image/jpeg") + ), + createTextPart("text/html") + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_alternative containing multipart_related and text_plain`() { + val expected = createTextPart("text/plain") + val part = createMultipart( + "multipart/alternative", + createMultipart( + "multipart/related", + createTextPart("text/html"), + createPart("image/jpeg") + ), + expected + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_mixed containing text_plain`() { + val expected = createTextPart("text/plain") + val part = createMultipart( + "multipart/mixed", + createPart("image/jpeg"), + expected + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_mixed containing text_html and text_plain`() { + val expected = createTextPart("text/html") + val part = createMultipart( + "multipart/mixed", + expected, + createTextPart("text/plain") + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_mixed not containing any text parts`() { + val part = createMultipart( + "multipart/mixed", + createPart("image/jpeg"), + createPart("image/gif") + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isNull() + } + + @Test + fun `multipart_mixed containing multipart_alternative containing text_plain and text_html`() { + val expected = createTextPart("text/plain") + val part = createMultipart( + "multipart/mixed", + createPart("image/jpeg"), + createMultipart( + "multipart/alternative", + expected, + createTextPart("text/html") + ), + createTextPart("text/plain") + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_mixed containing multipart_alternative containing text_html and text_plain`() { + val expected = createTextPart("text/plain") + val part = createMultipart( + "multipart/mixed", + createMultipart( + "multipart/alternative", + createTextPart("text/html"), + expected + ) + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_alternative containing empty text_plain and text_html`() { + val expected = createEmptyPart("text/plain") + val part = createMultipart( + "multipart/alternative", + expected, + createTextPart("text/html") + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_mixed containing empty text_html and text_plain`() { + val expected = createEmptyPart("text/html") + val part = createMultipart( + "multipart/mixed", + expected, + createTextPart("text/plain") + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `multipart_mixed containing multipart_alternative and text_plain`() { + val expected = createEmptyPart("text/plain") + val part = createMultipart( + "multipart/mixed", + createMultipart( + "multipart/alternative", + createPart("image/jpeg"), + createPart("image/png") + ), + expected + ) + + val result = textPartFinder.findFirstTextPart(part) + + assertThat(result).isEqualTo(expected) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/html/DisplayHtmlTest.kt b/app/core/src/test/java/com/fsck/k9/message/html/DisplayHtmlTest.kt new file mode 100644 index 0000000..080355a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/html/DisplayHtmlTest.kt @@ -0,0 +1,58 @@ +package com.fsck.k9.message.html + +import org.jsoup.Jsoup +import org.junit.Assert.assertEquals +import org.junit.Test + +class DisplayHtmlTest { + val displayHtml = DisplayHtml(HtmlSettings(useDarkMode = false, useFixedWidthFont = false)) + + @Test + fun wrapMessageContent_addsViewportMetaElement() { + val html = displayHtml.wrapMessageContent("Some text") + + assertHtmlContainsElement(html, "head > meta[name=viewport]") + } + + @Test + fun wrapMessageContent_setsDirToAuto() { + val html = displayHtml.wrapMessageContent("Some text") + + assertHtmlContainsElement(html, "html[dir=auto]") + } + + @Test + fun wrapMessageContent_addsPreCSS() { + val html = displayHtml.wrapMessageContent("Some text") + + assertHtmlContainsElement(html, "head > style") + } + + @Test + fun wrapMessageContent_whenDarkMessageViewTheme_addsDarkThemeCSS() { + val darkModeDisplayHtml = DisplayHtml(HtmlSettings(useDarkMode = true, useFixedWidthFont = false)) + + val html = darkModeDisplayHtml.wrapMessageContent("Some text") + + assertHtmlContainsElement(html, "head > style", 2) + } + + @Test + fun wrapMessageContent_putsMessageContentInBody() { + val content = "Some text" + + val html = displayHtml.wrapMessageContent(content) + + assertEquals(content, Jsoup.parse(html).body().text()) + } + + private fun assertHtmlContainsElement(html: String, cssQuery: String, numberOfExpectedOccurrences: Int = 1) { + val document = Jsoup.parse(html) + val numberOfFoundElements = document.select(cssQuery).size + assertEquals( + "Expected to find '$cssQuery' $numberOfExpectedOccurrences time(s) in:\n$html", + numberOfExpectedOccurrences, + numberOfFoundElements + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/html/EmailSectionExtractorTest.kt b/app/core/src/test/java/com/fsck/k9/message/html/EmailSectionExtractorTest.kt new file mode 100644 index 0000000..2a182e7 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/html/EmailSectionExtractorTest.kt @@ -0,0 +1,189 @@ +package com.fsck.k9.message.html + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class EmailSectionExtractorTest { + @Test + fun simpleMessageWithoutQuotes() { + val message = + """ + Hi Alice, + + are we still on for new Thursday? + + Best + Bob + """.trimIndent() + + val sections = EmailSectionExtractor.extract(message) + + assertThat(sections.size).isEqualTo(1) + with(sections[0]) { + assertThat(quoteDepth).isEqualTo(0) + assertThat(toString()).isEqualTo(message) + } + } + + @Test + fun simpleMessageEndingWithTwoNewlines() { + val message = "Hello\n\n" + + val sections = EmailSectionExtractor.extract(message) + + assertThat(sections.size).isEqualTo(1) + with(sections[0]) { + assertThat(quoteDepth).isEqualTo(0) + assertThat(toString()).isEqualTo(message) + } + } + + @Test + fun quoteFollowedByReply() { + val message = + """ + Alice wrote: + > Hi there + + Hi, what's up? + """.trimIndent() + + val sections = EmailSectionExtractor.extract(message) + + assertThat(sections.size).isEqualTo(3) + with(sections[0]) { + assertThat(quoteDepth).isEqualTo(0) + assertThat(toString()).isEqualTo("Alice wrote:\n") + } + with(sections[1]) { + assertThat(quoteDepth).isEqualTo(1) + assertThat(toString()).isEqualTo("Hi there\n") + } + with(sections[2]) { + assertThat(quoteDepth).isEqualTo(0) + assertThat(toString()).isEqualTo("\nHi, what's up?") + } + } + + @Test + fun replyFollowedByTwoQuoteLevels() { + val message = + """ + Three + + Bob wrote: + > Two + >${" "} + > Alice wrote: + >> One + """.trimIndent() + + val sections = EmailSectionExtractor.extract(message) + + assertThat(sections.size).isEqualTo(3) + with(sections[0]) { + assertThat(quoteDepth).isEqualTo(0) + assertThat(toString()).isEqualTo("Three\n\nBob wrote:\n") + } + with(sections[1]) { + assertThat(quoteDepth).isEqualTo(1) + assertThat(toString()).isEqualTo("Two\n\nAlice wrote:\n") + } + with(sections[2]) { + assertThat(quoteDepth).isEqualTo(2) + assertThat(toString()).isEqualTo("One") + } + } + + @Test + fun quoteEndingWithEmptyLineButNoNewline() { + val message = + """ + > Quoted text + > + """.trimIndent() + + val sections = EmailSectionExtractor.extract(message) + + assertThat(sections.size).isEqualTo(1) + with(sections[0]) { + assertThat(quoteDepth).isEqualTo(1) + // Note: "Quoted text\n\n" would be a better representation of the quoted text. The goal of this test is + // not to preserve the current behavior of only ending in one newline, but to make sure we don't add the + // last line twice. + assertThat(toString()).isEqualTo("Quoted text\n") + } + } + + @Test + fun chaosQuoting() { + val message = + """ + >>> One + > Three + Four + >> Two${"\n"} + """.trimIndent() + + val sections = EmailSectionExtractor.extract(message) + + assertThat(sections.size).isEqualTo(4) + with(sections[0]) { + assertThat(quoteDepth).isEqualTo(3) + assertThat(toString()).isEqualTo("One\n") + } + with(sections[1]) { + assertThat(quoteDepth).isEqualTo(1) + assertThat(toString()).isEqualTo("Three\n") + } + with(sections[2]) { + assertThat(quoteDepth).isEqualTo(0) + assertThat(toString()).isEqualTo("Four\n") + } + with(sections[3]) { + assertThat(quoteDepth).isEqualTo(2) + assertThat(toString()).isEqualTo("Two\n") + } + } + + @Test + fun quotedSectionStartingWithEmptyLine() { + val message = + """ + Quote header: + > + > Quoted text + """.trimIndent() + + val sections = EmailSectionExtractor.extract(message) + + assertThat(sections.size).isEqualTo(2) + with(sections[0]) { + assertThat(quoteDepth).isEqualTo(0) + assertThat(toString()).isEqualTo("Quote header:\n") + } + with(sections[1]) { + assertThat(quoteDepth).isEqualTo(1) + assertThat(toString()).isEqualTo("\nQuoted text") + } + } + + @Test + fun quotedBlankLinesShouldNotContributeToIndentValue() { + val message = "" + + ">\n" + + "> Quoted text\n" + + "> \n" + + "> \n" + + "> More quoted text" + + val sections = EmailSectionExtractor.extract(message) + + assertThat(sections.size).isEqualTo(1) + with(sections[0]) { + assertThat(quoteDepth).isEqualTo(1) + assertThat(toString()).isEqualTo("\nQuoted text\n \n\nMore quoted text") + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/html/EmailSectionTest.kt b/app/core/src/test/java/com/fsck/k9/message/html/EmailSectionTest.kt new file mode 100644 index 0000000..edd1872 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/html/EmailSectionTest.kt @@ -0,0 +1,93 @@ +package com.fsck.k9.message.html + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isSameAs +import org.junit.Test + +class EmailSectionTest { + @Test + fun charAt() { + assertThat("[a]".asEmailSection()[0]).isEqualTo('a') + assertThat(".[a]".asEmailSection()[0]).isEqualTo('a') + assertThat("[a].".asEmailSection()[0]).isEqualTo('a') + assertThat("[ a]".asEmailSection()[0]).isEqualTo('a') + assertThat("[abc]".asEmailSection()[0]).isEqualTo('a') + + assertThat("[a][b]".asEmailSection()[1]).isEqualTo('b') + assertThat("[a][bc]".asEmailSection()[1]).isEqualTo('b') + assertThat("[ab]".asEmailSection()[1]).isEqualTo('b') + assertThat("[ab][c]".asEmailSection()[1]).isEqualTo('b') + assertThat("[a][b][c]".asEmailSection()[1]).isEqualTo('b') + assertThat(".[a][b][c]".asEmailSection()[1]).isEqualTo('b') + assertThat(".[a].[b][c]".asEmailSection()[1]).isEqualTo('b') + assertThat(".[a].[b].[c]".asEmailSection()[1]).isEqualTo('b') + assertThat("[ a][ b][ c]".asEmailSection()[1]).isEqualTo('b') + assertThat("[a]..[bc]".asEmailSection()[1]).isEqualTo('b') + + assertThat("[abc]".asEmailSection()[2]).isEqualTo('c') + assertThat("[ab][c]".asEmailSection()[2]).isEqualTo('c') + assertThat("[a][bc]".asEmailSection()[2]).isEqualTo('c') + assertThat("[a][b][c]".asEmailSection()[2]).isEqualTo('c') + assertThat(".[a].[b].[c].".asEmailSection()[2]).isEqualTo('c') + assertThat("[ a][ b][ c]".asEmailSection()[2]).isEqualTo('c') + } + + @Test + fun length() { + assertThat("[]".asEmailSection().length).isEqualTo(0) + assertThat("...[]...".asEmailSection().length).isEqualTo(0) + assertThat("[ ]".asEmailSection().length).isEqualTo(0) + assertThat("[ ][ ]".asEmailSection().length).isEqualTo(1) + assertThat("[One]".asEmailSection().length).isEqualTo(3) + assertThat("[One][Two]".asEmailSection().length).isEqualTo(6) + } + + @Test + fun subSequence() { + val section = "[ One][ Two][ Three]".asEmailSection() + + assertThat(section.subSequence(0, 11)).isSameAs(section) + assertThat(section.subSequence(0, 3).asString()).isEqualTo("One") + assertThat(section.subSequence(0, 2).asString()).isEqualTo("On") + assertThat(section.subSequence(1, 3).asString()).isEqualTo("ne") + assertThat(section.subSequence(1, 2).asString()).isEqualTo("n") + assertThat(section.subSequence(0, 4).asString()).isEqualTo("OneT") + assertThat(section.subSequence(1, 4).asString()).isEqualTo("neT") + assertThat(section.subSequence(1, 6).asString()).isEqualTo("neTwo") + assertThat(section.subSequence(1, 7).asString()).isEqualTo("neTwoT") + assertThat(section.subSequence(1, 11).asString()).isEqualTo("neTwoThree") + assertThat(section.subSequence(3, 11).asString()).isEqualTo("TwoThree") + assertThat(section.subSequence(4, 11).asString()).isEqualTo("woThree") + assertThat(section.subSequence(4, 9).asString()).isEqualTo("woThr") + assertThat(section.subSequence(6, 9).asString()).isEqualTo("Thr") + assertThat(section.subSequence(7, 10).asString()).isEqualTo("hre") + assertThat(section.subSequence(6, 11).asString()).isEqualTo("Three") + } + + private fun CharSequence.asString() = StringBuilder(length).apply { + this@asString.forEach { append(it) } + }.toString() + + private fun String.asEmailSection(): EmailSection { + val builder = EmailSection.Builder(this, 0) + + var startIndex = -1 + var isStartOfLine = true + var spaces = 0 + this.forEachIndexed { index, c -> + when (c) { + '[' -> { + startIndex = index + 1 + isStartOfLine = true + spaces = 0 + } + ' ' -> if (isStartOfLine) spaces++ + ']' -> builder.addSegment(spaces, startIndex, index) + else -> isStartOfLine = false + } + } + + return builder.build() + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/html/GenericUriParserTest.kt b/app/core/src/test/java/com/fsck/k9/message/html/GenericUriParserTest.kt new file mode 100644 index 0000000..c804116 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/html/GenericUriParserTest.kt @@ -0,0 +1,72 @@ +package com.fsck.k9.message.html + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.assertNotNull +import org.junit.Test + +class GenericUriParserTest { + private val parser = GenericUriParser() + + @Test + fun `mailto URIs`() { + // Examples from RFC 6068 + assertUriValid("mailto:chris@example.com") + assertUriValid("mailto:infobot@example.com?subject=current-issue") + assertUriValid("mailto:infobot@example.com?body=send%20current-issue") + assertUriValid("mailto:infobot@example.com?body=send%20current-issue%0D%0Asend%20index") + assertUriValid("mailto:list@example.org?In-Reply-To=%3C3469A91.D10AF4C@example.com%3E") + assertUriValid("mailto:majordomo@example.com?body=subscribe%20bamboo-l") + assertUriValid("mailto:joe@example.com?cc=bob@example.com&body=hello") + assertUriValid("mailto:gorby%25kremvax@example.com") + assertUriValid("mailto:unlikely%3Faddress@example.com?blat=foop") + assertUriValid("mailto:%22not%40me%22@example.org") + assertUriValid("mailto:%22oh%5C%5Cno%22@example.org") + assertUriValid("mailto:%22%5C%5C%5C%22it's%5C%20ugly%5C%5C%5C%22%22@example.org") + assertUriValid("mailto:user@example.org?subject=caf%C3%A9") + assertUriValid("mailto:user@example.org?subject=%3D%3Futf-8%3FQ%3Fcaf%3DC3%3DA9%3F%3D") + assertUriValid("mailto:user@example.org?subject=%3D%3Fiso-8859-1%3FQ%3Fcaf%3DE9%3F%3D") + assertUriValid("mailto:user@example.org?subject=caf%C3%A9&body=caf%C3%A9") + assertUriValid("mailto:user@%E7%B4%8D%E8%B1%86.example.org?subject=Test&body=NATTO") + } + + @Test + fun `XMPP URIs`() { + // Examples from RFC 5122 + assertUriValid("xmpp:node@example.com") + assertUriValid("xmpp://guest@example.com") + assertUriValid("xmpp://guest@example.com/support@example.com?message") + assertUriValid("xmpp:support@example.com?message") + assertUriValid("xmpp:example-node@example.com/some-resource") + assertUriValid("xmpp:example.com") + assertUriValid("xmpp:example-node@example.com?message") + assertUriValid("xmpp:example-node@example.com?message;subject=Hello%20World") + assertUriValid("xmpp:nasty!%23\$%25()*+,-.;=%3F%5B%5C%5D%5E_%60%7B%7C%7D~node@example.com") + assertUriValid("xmpp:node@example.com/repulsive%20!%23%22\$%25&'()*+,-.%2F:;%3C=%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~resource") + assertUriValid("xmpp:ji%C5%99i@%C4%8Dechy.example/v%20Praze") + } + + @Test + fun `matrix URIs`() { + // Examples from MSC 2312 + assertUriValid("matrix:r/someroom:example.org") + assertUriValid("matrix:u/me:example.org") + assertUriValid("matrix:r/someroom:example.org/e/Arbitrary_Event_Id") + assertUriValid("matrix:u/her:example.org") + assertUriValid("matrix:u/her:example.org?action=chat") + assertUriValid("matrix:roomid/rid:example.org") + assertUriValid("matrix:r/us:example.org") + assertUriValid("matrix:roomid/rid:example.org?action=join&via=example2.org") + assertUriValid("matrix:r/us:example.org?action=join") + assertUriValid("matrix:r/us:example.org/e/lol823y4bcp3qo4") + assertUriValid("matrix:roomid/rid:example.org/event/lol823y4bcp3qo4?via=example2.org") + } + + private fun assertUriValid(input: String) { + val result = parser.parseUri(input, 0) + + assertNotNull(result) { uriMatch -> + assertThat(uriMatch.uri).isEqualTo(input) + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/html/HtmlConverterTest.kt b/app/core/src/test/java/com/fsck/k9/message/html/HtmlConverterTest.kt new file mode 100644 index 0000000..057d450 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/html/HtmlConverterTest.kt @@ -0,0 +1,612 @@ +package com.fsck.k9.message.html + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.crlf +import com.fsck.k9.mail.removeLineBreaks +import org.junit.Test + +class HtmlConverterTest { + @Test + fun `textToHtml() should convert quoted text using blockquote tags`() { + val message = + """ + Panama! + + Bob Barker wrote: + > a canal + > + > Dorothy Jo Gideon espoused: + > >A man, a plan... + > Too easy! + + Nice job :) + >> Guess! + """.trimIndent().crlf() + + val result = HtmlConverter.textToHtml(message) + + assertThat(result).isEqualTo( + """ + |
    +            |
    + |Panama!
    + |
    + |Bob Barker <bob@aol.com> wrote:
    + |
    + |
    + |
    + | a canal
    + |
    + | Dorothy Jo Gideon <dorothy@aol.com> espoused:
    + |
    + |
    + |
    + |A man, a plan...
    + |
    + |
    + |
    + |Too easy!
    + |
    + |
    + |
    + |
    + |Nice job :)
    + |
    + |
    + |
    + |
    + |Guess! + |
    + |
    + |
    + |
    + """.trimMargin().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should retain indentation inside quoted text`() { + val message = + """ + *facepalm* + + Bob Barker wrote: + > A wise man once said... + > + > LOL F1RST!!!!! + > + > :) + """.trimIndent().crlf() + + val result = HtmlConverter.textToHtml(message) + + assertThat(result).isEqualTo( + """ + |
    +            |
    + |*facepalm*
    + |
    + |Bob Barker <bob@aol.com> wrote:
    + |
    + |
    + |
    + | A wise man once said...
    + |
    + | LOL F1RST!!!!!
    + |
    + | :) + |
    + |
    + |
    + """.trimMargin().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() with various quotation depths`() { + val message = + """ + zero + > one + >> two + >>> three + >>>> four + >>>>> five + >>>>>> six + """.trimIndent().crlf() + + val result = HtmlConverter.textToHtml(message) + + assertThat(result).isEqualTo( + """ + |
    +            |
    + |zero
    + |
    + |
    + |
    + |one
    + |
    + |
    + |
    + |two
    + |
    + |
    + |
    + |three
    + |
    + |
    + |
    + |four
    + |
    + |
    + |
    + |five
    + |
    + |
    + |
    + |six + |
    + |
    + |
    + |
    + |
    + |
    + |
    + |
    + """.trimMargin().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should preserve spaces at the start of a line`() { + val message = + """ + |foo + | bar + | baz + | + """.trimMargin().crlf() + + val result = HtmlConverter.textToHtml(message) + + assertThat(result).isEqualTo( + """ + |
    +            |
    + |foo
    + | bar
    + | baz
    + |
    + |
    + """.trimMargin().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should preserve spaces at the start of a line followed by special characters`() { + val message = + """ + | + | & + | ${" "} + | < + | >${" "} + | + """.trimMargin().crlf() + + val result = HtmlConverter.textToHtml(message) + + assertThat(result).isEqualTo( + """ + |
    +            |
    + |
    + | &
    + |
    + | <
    + |
    + |
    + |
    + |
    + |
    + |
    + |
    + """.trimMargin().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should replace common horizontal divider ASCII patterns with HR tags`() { + val text = + """ + text + --------------------------- + some other text + =========================== + more text + -=-=-=-=-=-=-=-=-=-=-=-=-=- + scissors below + -- >8 -- + other direction + -- 8< -- + end + """.trimIndent() + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo( + """ +
    +            
    + text +
    + some other text +
    + more text +
    + scissors below +
    + other direction +
    + end +
    +
    + """.trimIndent().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should not convert dashes mixed with spaces to an HR tag`() { + val text = + """ + hello + --- --- --- --- --- + foo bar + """.trimIndent() + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo( + """ +
    +            
    + hello
    + --- --- --- --- ---
    + foo bar +
    +
    + """.trimIndent().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should merge consecutive horizontal dividers into a single HR tag`() { + val text = + """ + hello + ------------ + --------------- + foo bar + """.trimIndent() + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo( + """ +
    +            
    + hello +
    + foo bar +
    +
    + """.trimIndent().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should not replace dashed horizontal divider prefixed with text`() { + val text = "hello----\n\n" + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo( + """ +
    +            
    + hello----
    +
    +
    +
    + """.trimIndent().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should not replace double dash with an HR tag`() { + val text = "--\n" + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo("""
    --
    """) + } + + @Test + fun `textToHtml() should not replace double equal sign with an HR tag`() { + val text = "==\n" + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo("""
    ==
    """) + } + + @Test + fun `textToHtml() should not replace double underscore with an HR tag`() { + val text = "__\n" + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo("""
    __
    """) + } + + @Test + fun `textToHtml() should replace any combination of three consecutive divider characters with an HR tag`() { + val text = + """ + --= + -=- + === + ___ + + """.trimIndent() + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo("""

    """) + } + + @Test + fun `textToHtml() should replace dashes at the start of the input`() { + val text = "---------------------------\nfoo bar" + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo( + """ +
    +            
    +
    + foo bar +
    +
    + """.trimIndent().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should replace dashes at the end of the input`() { + val text = "hello\n__________________________________" + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo( + """ +
    +            
    + hello +
    +
    +
    + """.trimIndent().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should replace horizontal divider using ASCII scissors with an HR tag`() { + val text = + """ + hello + -- %< -------------- >8 -- + world + """.trimIndent() + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo( + """ +
    +            
    + hello +
    + world +
    +
    + """.trimIndent().removeLineBreaks() + ) + } + + @Test + fun `textToHtml() should wrap email signature in a DIV`() { + val text = + """ + text + --${" "} + signature with url: https://domain.example/ + """.trimIndent() + + val result = HtmlConverter.textToHtml(text) + + assertThat(result).isEqualTo( + """ +
    +            
    + text
    +
    + --
    + signature with url: https://domain.example/ +
    +
    +
    + """.trimIndent().removeLineBreaks() + ) + } + + @Test + fun `textToHtmlFragment() with single space at the start of a line`() { + val text = " foo" + + val result = HtmlConverter.textToHtmlFragment(text) + + assertThat(result).isEqualTo("
    \u00A0foo
    ") + } + + @Test + fun `textToHtmlFragment() with two spaces at the start of a line`() { + val text = " foo" + + val result = HtmlConverter.textToHtmlFragment(text) + + assertThat(result).isEqualTo("
    \u00A0 foo
    ") + } + + @Test + fun `textToHtmlFragment() with consecutive spaces at the start of a line`() { + val text = " some words here" + + val result = HtmlConverter.textToHtmlFragment(text) + + assertThat(result).isEqualTo("
    \u00A0\u00A0\u00A0 some words here
    ") + } + + @Test + fun `textToHtmlFragment() with consecutive spaces between words`() { + val text = "foo bar" + + val result = HtmlConverter.textToHtmlFragment(text) + + assertThat(result).isEqualTo("
    foo\u00A0 bar
    ") + } + + @Test + fun `textToHtmlFragment() with single space at the end of a line`() { + val text = "foo \n" + + val result = HtmlConverter.textToHtmlFragment(text) + + assertThat(result).isEqualTo("
    foo
    ") + } + + @Test + fun `textToHtmlFragment() with consecutive spaces at the end of a line`() { + val text = "some words here \n" + + val result = HtmlConverter.textToHtmlFragment(text) + + assertThat(result).isEqualTo("
    some words here\u00A0\u00A0
    ") + } + + @Test + fun `htmlToText() should convert BR tags to line breaks`() { + val input = + """ + One
    + Two
    +
    + Three + """.trimIndent().removeLineBreaks() + + val result = HtmlConverter.htmlToText(input) + + assertThat(result).isEqualTo( + """ + One + Two + + Three + """.trimIndent() + ) + } + + @Test + fun `htmlToText() should insert line breaks after block elements`() { + val input = + """ +

    One

    +

    + Two
    + Three +

    +
    Four
    + """.trimIndent().removeLineBreaks() + + val result = HtmlConverter.htmlToText(input) + + assertThat(result).isEqualTo( + """ + One + + Two + Three + + Four + """.trimIndent() + ) + } + + @Test + fun `htmlToText() should include link URIs`() { + val input = "Link text" + + val result = HtmlConverter.htmlToText(input) + + assertThat(result).isEqualTo("Link text ") + } + + @Test + fun `htmlToText() should not duplicate URI when link URI and text are the same`() { + val input = "Text https://domain.example/path/ more text" + + val result = HtmlConverter.htmlToText(input) + + assertThat(result).isEqualTo("Text https://domain.example/path/ more text") + } + + @Test + fun `htmlToText() should not duplicate URI when the link text is just the link URI with some formatting`() { + val input = "https://domain.example/path/" + + val result = HtmlConverter.htmlToText(input) + + assertThat(result).isEqualTo("https://domain.example/path/") + } + + @Test + fun `htmlToText() should strip line breaks`() { + val input = + """ + One + Two + Three + """.trimIndent() + + val result = HtmlConverter.htmlToText(input) + + assertThat(result).isEqualTo("One Two Three") + } + + @Test + fun `htmlToText() with long text line should not add line breaks to output`() { + val input = + """ + |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam sit amet finibus felis, + | viverra ullamcorper justo. Suspendisse potenti. Etiam erat sem, interdum a condimentum quis, + | fringilla quis orci. + """.trimMargin().removeLineBreaks() + + val result = HtmlConverter.htmlToText(input) + + assertThat(result).isEqualTo(input) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/html/HtmlHelper.kt b/app/core/src/test/java/com/fsck/k9/message/html/HtmlHelper.kt new file mode 100644 index 0000000..2a31f49 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/html/HtmlHelper.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.message.html + +import org.jsoup.Jsoup +import org.jsoup.safety.Safelist + +object HtmlHelper { + @JvmStatic + fun extractText(html: String): String { + return Jsoup.clean(html, Safelist.none()) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/html/HttpUriParserTest.java b/app/core/src/test/java/com/fsck/k9/message/html/HttpUriParserTest.java new file mode 100644 index 0000000..2405c48 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/html/HttpUriParserTest.java @@ -0,0 +1,312 @@ +package com.fsck.k9.message.html; + + +import org.junit.Assert; +import org.junit.Test; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + + +public class HttpUriParserTest { + private final HttpUriParser parser = new HttpUriParser(); + + + @Test + public void emptyUriIgnored() { + assertInvalidUri("http://"); + } + + @Test + public void emptyAuthorityIgnored() { + assertInvalidUri("http:///"); + } + + @Test + public void simpleDomain() { + assertValidUri("http://www.google.com"); + } + + @Test + public void simpleDomainWithHttps() { + assertValidUri("https://www.google.com"); + } + + @Test + public void simpleRtspUri() { + assertValidUri("rtsp://example.com/media.mp4"); + } + + @Test + public void invalidDomainIgnored() { + assertInvalidUri("http://-www.google.com"); + } + + @Test + public void domainWithTrailingSlash() { + assertValidUri("http://www.google.com/"); + } + + @Test + public void domainWithUserInfo() { + assertValidUri("http://test@google.com/"); + } + + @Test + public void domainWithFullUserInfo() { + assertValidUri("http://test:secret@google.com/"); + } + + @Test + public void domainWithoutWww() { + assertValidUri("http://google.com/"); + } + + @Test + public void query() { + assertValidUri("http://google.com/give/me/?q=mode&c=information"); + } + + @Test + public void fragment() { + assertValidUri("http://google.com/give/me#only-the-best"); + } + + @Test + public void queryAndFragment() { + assertValidUri("http://google.com/give/me/?q=mode&c=information#only-the-best"); + } + + @Test + public void ipv4Address() { + assertValidUri("http://127.0.0.1"); + } + + @Test + public void ipv4AddressWithTrailingSlash() { + assertValidUri("http://127.0.0.1/"); + } + + @Test + public void ipv4AddressWithEmptyPort() { + assertValidUri("http://127.0.0.1:"); + } + + @Test + public void ipv4AddressWithPort() { + assertValidUri("http://127.0.0.1:524/"); + } + + @Test + public void ipv6Address() { + assertValidUri("http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]"); + } + + @Test + public void ipv6AddressWithPort() { + assertValidUri("http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80"); + } + + @Test + public void ipv6AddressWithTrailingSlash() { + assertValidUri("http://[1080:0:0:0:8:800:200C:417A]/"); + } + + @Test + public void ipv6AddressWithEndCompression() { + assertValidUri("http://[3ffe:2a00:100:7031::1]"); + } + + @Test + public void ipv6AddressWithBeginCompression() { + assertValidUri("http://[1080::8:800:200C:417A]/"); + } + + @Test + public void ipv6AddressWithCompressionPort() { + assertValidUri("http://[::FFFF:129.144.52.38]:80/"); + } + + @Test + public void ipv6AddressWithPrependedCompression() { + assertValidUri("http://[::192.9.5.5]/"); + } + + @Test + public void ipv6AddressWithTrailingIp4AndPort() { + assertValidUri("http://[::192.9.5.5]:80/"); + } + + @Test + public void ipv6WithoutClosingSquareBracketIgnored() { + assertInvalidUri("http://[1080:0:0:0:8:80:200C:417A/"); + } + + @Test + public void ipv6InvalidClosingSquareBracketIgnored() { + assertInvalidUri("http://[1080:0:0:0:8:800:270C:417A/]"); + } + + @Test + public void domainWithTrailingSpace() { + String text = "http://google.com/ "; + + UriMatch uriMatch = parser.parseUri(text, 0); + + assertUriMatch("http://google.com/", uriMatch); + } + + @Test + public void domainWithTrailingNewline() { + String text = "http://google.com/\n"; + + UriMatch uriMatch = parser.parseUri(text, 0); + + assertUriMatch("http://google.com/", uriMatch); + } + + @Test + public void domainWithTrailingAngleBracket() { + String text = ""; + + UriMatch uriMatch = parser.parseUri(text, 1); + + assertUriMatch("http://google.com/", uriMatch, 1); + } + + @Test + public void uriInMiddleAfterInput() { + String prefix = "prefix "; + String uri = "http://google.com/"; + String text = prefix + uri; + + UriMatch uriMatch = parser.parseUri(text, prefix.length()); + + assertUriMatch("http://google.com/", uriMatch, prefix.length()); + } + + @Test + public void uriInMiddleOfInput() { + String prefix = "prefix "; + String uri = "http://google.com/"; + String postfix = " postfix"; + String text = prefix + uri + postfix; + + UriMatch uriMatch = parser.parseUri(text, prefix.length()); + + assertUriMatch("http://google.com/", uriMatch, prefix.length()); + } + + @Test + public void uriWrappedInParentheses() { + String input = "(https://domain.example/)"; + + UriMatch uriMatch = parser.parseUri(input, 1); + + assertUriMatch("https://domain.example/", uriMatch, 1); + } + + @Test + public void uriContainingParentheses() { + String input = "https://domain.example/(parentheses)"; + + UriMatch uriMatch = parser.parseUri(input, 0); + + assertUriMatch("https://domain.example/(parentheses)", uriMatch, 0); + } + + @Test + public void uriContainingParenthesesWrappedInParentheses() { + String input = "(https://domain.example/(parentheses))"; + + UriMatch uriMatch = parser.parseUri(input, 1); + + assertUriMatch("https://domain.example/(parentheses)", uriMatch, 1); + } + + @Test + public void uriEndingInDotAtEndOfText() { + String input = "URL: https://domain.example/path."; + + UriMatch uriMatch = parser.parseUri(input, 5); + + assertUriMatch("https://domain.example/path", uriMatch, 5); + } + + + @Test + public void uriEndingInDotWithAdditionalText() { + String input = "URL: https://domain.example/path. Some other text"; + + UriMatch uriMatch = parser.parseUri(input, 5); + + assertUriMatch("https://domain.example/path", uriMatch, 5); + } + + @Test + public void uriWrappedInAngleBracketsEndingInDot() { + String input = "URL: "; + + UriMatch uriMatch = parser.parseUri(input, 6); + + assertUriMatch("https://domain.example/path.", uriMatch, 6); + } + + @Test + public void uriWrappedInParenthesesEndingInDot() { + String input = "URL: (https://domain.example/path.)"; + + UriMatch uriMatch = parser.parseUri(input, 6); + + assertUriMatch("https://domain.example/path.", uriMatch, 6); + } + + @Test + public void uriWrappedInParenthesesFollowedByADot() { + String input = "URL: (https://domain.example/path)."; + + UriMatch uriMatch = parser.parseUri(input, 6); + + assertUriMatch("https://domain.example/path", uriMatch, 6); + } + + @Test + public void uriWrappedInParenthesesFollowedByADotAndSomeOtherText() { + String input = "URL: (https://domain.example/path). Some other text"; + + UriMatch uriMatch = parser.parseUri(input, 6); + + assertUriMatch("https://domain.example/path", uriMatch, 6); + } + + @Test + public void uriWrappedInParenthesesFollowedByAQuestionMarkAndSomeOtherText() { + String input = "URL: (https://domain.example/path)? Some other text"; + + UriMatch uriMatch = parser.parseUri(input, 6); + + assertUriMatch("https://domain.example/path", uriMatch, 6); + } + + + private void assertValidUri(String uri) { + UriMatch uriMatch = parser.parseUri(uri, 0); + assertUriMatch(uri, uriMatch); + } + + private void assertUriMatch(String uri, UriMatch uriMatch) { + assertUriMatch(uri, uriMatch, 0); + } + + private void assertUriMatch(String uri, UriMatch uriMatch, int offset) { + assertNotNull(uriMatch); + Assert.assertEquals(offset, uriMatch.getStartIndex()); + Assert.assertEquals(uri.length() + offset, uriMatch.getEndIndex()); + Assert.assertEquals(uri, uriMatch.getUri().toString()); + } + + private void assertInvalidUri(String uri) { + UriMatch uriMatch = parser.parseUri(uri, 0); + assertNull(uriMatch); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/html/UriMatcherTest.kt b/app/core/src/test/java/com/fsck/k9/message/html/UriMatcherTest.kt new file mode 100644 index 0000000..fd0b02e --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/html/UriMatcherTest.kt @@ -0,0 +1,106 @@ +package com.fsck.k9.message.html + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isNotEqualTo +import org.junit.Test + +class UriMatcherTest { + @Test + fun emptyText() { + assertNoMatch("") + } + + @Test + fun textWithoutUri() { + assertNoMatch("some text here") + } + + @Test + fun simpleUri() { + assertUrisFound("http://example.org", "http://example.org") + } + + @Test + fun uriPrecededBySpace() { + assertUrisFound(" http://example.org", "http://example.org") + } + + @Test + fun uriPrecededByTab() { + assertUrisFound("\thttp://example.org", "http://example.org") + } + + @Test + fun uriPrecededByOpeningParenthesis() { + assertUrisFound("(http://example.org", "http://example.org") + } + + @Test + fun uriPrecededBySomeText() { + assertUrisFound("Check out my fantastic URI: http://example.org", "http://example.org") + } + + @Test + fun uriWithTrailingText() { + assertUrisFound("http://example.org/ is the best", "http://example.org/") + } + + @Test + fun uriEmbeddedInText() { + assertUrisFound("prefix http://example.org/ suffix", "http://example.org/") + } + + @Test + fun uriWithUppercaseScheme() { + assertUrisFound("HTTP://example.org/", "HTTP://example.org/") + } + + @Test + fun uriNotPrecededByValidSeparator() { + assertNoMatch("myhttp://example.org") + } + + @Test + fun uriNotPrecededByValidSeparatorFollowedByValidUri() { + assertUrisFound("myhttp: http://example.org", "http://example.org") + } + + @Test + fun schemaMatchWithInvalidUriInMiddleOfTextFollowedByValidUri() { + assertUrisFound("prefix http:42 http://example.org", "http://example.org") + } + + @Test + fun multipleValidUrisInRow() { + assertUrisFound( + "prefix http://uri1.example.org some text http://uri2.example.org/path postfix", + "http://uri1.example.org", + "http://uri2.example.org/path", + ) + } + + private fun assertNoMatch(text: String) { + val uriMatches = UriMatcher.findUris(text) + assertThat(uriMatches).isEmpty() + } + + private fun assertUrisFound(text: String, vararg uris: String) { + val uriMatches = UriMatcher.findUris(text) + assertThat(uriMatches).hasSize(uris.size) + var i = 0 + val end = uris.size + while (i < end) { + val uri = uris[i] + val startIndex = text.indexOf(uri) + assertThat(startIndex).isNotEqualTo(-1) + val uriMatch = uriMatches[i] + assertThat(uriMatch.startIndex).isEqualTo(startIndex) + assertThat(uriMatch.endIndex).isEqualTo(startIndex + uri.length) + assertThat(uriMatch.uri).isEqualTo(uri) + i++ + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/quote/QuoteDateFormatterTest.kt b/app/core/src/test/java/com/fsck/k9/message/quote/QuoteDateFormatterTest.kt new file mode 100644 index 0000000..635f785 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/quote/QuoteDateFormatterTest.kt @@ -0,0 +1,73 @@ +package com.fsck.k9.message.quote + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.K9 +import java.time.ZonedDateTime +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import org.junit.After +import org.junit.Before +import org.junit.Test + +class QuoteDateFormatterTest { + private lateinit var originalLocale: Locale + private var originalTimeZone: TimeZone? = null + private val quoteDateFormatter = QuoteDateFormatter() + + @Before + fun setUp() { + originalLocale = Locale.getDefault() + originalTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("GMT+02:00")) + } + + @After + fun tearDown() { + Locale.setDefault(originalLocale) + TimeZone.setDefault(originalTimeZone) + } + + @Test + fun hideTimeZoneEnabled_UsLocale() { + K9.isHideTimeZone = true + Locale.setDefault(Locale.US) + + val formattedDate = quoteDateFormatter.format("2020-09-19T20:00:00+00:00".toDate()) + + assertThat(formattedDate).isEqualTo("September 19, 2020 at 8:00:00 PM UTC") + } + + @Test + fun hideTimeZoneEnabled_GermanyLocale() { + K9.isHideTimeZone = true + Locale.setDefault(Locale.GERMANY) + + val formattedDate = quoteDateFormatter.format("2020-09-19T20:00:00+00:00".toDate()) + + assertThat(formattedDate).isEqualTo("19. September 2020 um 20:00:00 UTC") + } + + @Test + fun hideTimeZoneDisabled_UsLocale() { + K9.isHideTimeZone = false + Locale.setDefault(Locale.US) + + val formattedDate = quoteDateFormatter.format("2020-09-19T20:00:00+00:00".toDate()) + + assertThat(formattedDate).isEqualTo("September 19, 2020 at 10:00:00 PM GMT+02:00") + } + + @Test + fun hideTimeZoneDisabled_GermanyLocale() { + K9.isHideTimeZone = false + Locale.setDefault(Locale.GERMANY) + + val formattedDate = quoteDateFormatter.format("2020-09-19T20:00:00+00:00".toDate()) + + assertThat(formattedDate).isEqualTo("19. September 2020 um 22:00:00 GMT+02:00") + } + + private fun String.toDate() = Date(ZonedDateTime.parse(this).toEpochSecond() * 1000L) +} diff --git a/app/core/src/test/java/com/fsck/k9/message/quote/TextQuoteCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/message/quote/TextQuoteCreatorTest.kt new file mode 100644 index 0000000..8dcb905 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/quote/TextQuoteCreatorTest.kt @@ -0,0 +1,114 @@ +package com.fsck.k9.message.quote + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.Account.QuoteStyle +import com.fsck.k9.RobolectricTest +import com.fsck.k9.TestCoreResourceProvider +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.Message.RecipientType +import com.fsck.k9.mail.crlf +import java.util.Date +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock + +class TextQuoteCreatorTest : RobolectricTest() { + val sentDate = Date(1540421219L) + val originalMessage = mock { + on { sentDate } doReturn sentDate + on { from } doReturn Address.parse("Alice ") + on { getRecipients(RecipientType.TO) } doReturn Address.parse("bob@recipient.example") + on { getRecipients(RecipientType.CC) } doReturn emptyArray
    () + on { subject } doReturn "Message subject" + } + val quoteDateFormatter = mock { + on { format(eq(sentDate)) } doReturn "January 18, 1970 7:53:41 PM UTC" + } + val textQuoteCreator = TextQuoteCreator(quoteDateFormatter, TestCoreResourceProvider()) + + @Test + fun prefixQuote() { + val messageBody = "Line 1\r\nLine 2\r\nLine 3" + val quoteStyle = QuoteStyle.PREFIX + val quotePrefix = "> " + + val quote = createQuote(messageBody, quoteStyle, quotePrefix) + + assertThat(quote).isEqualTo( + """ + On January 18, 1970 7:53:41 PM UTC, Alice wrote: + > Line 1 + > Line 2 + > Line 3 + """.trimIndent().crlf() + ) + } + + @Test + fun prefixQuote_withPrefixThatNeedsEncoding() { + val messageBody = "Line 1\r\nLine 2" + val quoteStyle = QuoteStyle.PREFIX + val quotePrefix = "$1\\t " + + val quote = createQuote(messageBody, quoteStyle, quotePrefix) + + assertThat(quote).isEqualTo( + """ + On January 18, 1970 7:53:41 PM UTC, Alice wrote: + $1\t Line 1 + $1\t Line 2 + """.trimIndent().crlf() + ) + } + + @Test + fun prefixQuote_withLongLines() { + val messageBody = + """ + [-------] [-------] [-------] [-------] [-------] [-------] [-------] [-------] [-------] [-------] + [-------------------------------------------------------------------------------------------------] + """.trimIndent().crlf() + val quoteStyle = QuoteStyle.PREFIX + val quotePrefix = "> " + + val quote = createQuote(messageBody, quoteStyle, quotePrefix) + + assertThat(quote).isEqualTo( + """ + On January 18, 1970 7:53:41 PM UTC, Alice wrote: + > [-------] [-------] [-------] [-------] [-------] [-------] [-------] [-------] [-------] [-------] + > [-------------------------------------------------------------------------------------------------] + """.trimIndent().crlf() + ) + } + + @Test + fun headerQuote() { + val messageBody = "Line 1\r\nLine 2\r\nLine 3" + val quoteStyle = QuoteStyle.HEADER + + val quote = createQuote(messageBody, quoteStyle) + + assertThat(quote).isEqualTo( + """ + + -------- Original Message -------- + From: Alice + Sent: January 18, 1970 7:53:41 PM UTC + To: bob@recipient.example + Subject: Message subject + + Line 1 + Line 2 + Line 3 + """.trimIndent().crlf() + ) + } + + private fun createQuote(messageBody: String, quoteStyle: QuoteStyle, quotePrefix: String = ""): String { + return textQuoteCreator.quoteOriginalTextMessage(originalMessage, messageBody, quoteStyle, quotePrefix) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.kt b/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.kt new file mode 100644 index 0000000..e50d1e8 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.kt @@ -0,0 +1,203 @@ +package com.fsck.k9.message.signature + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.message.html.HtmlHelper.extractText +import com.fsck.k9.message.signature.HtmlSignatureRemover.Companion.stripSignature +import com.fsck.k9.testing.removeNewlines +import org.junit.Test + +class HtmlSignatureRemoverTest { + @Test + fun `old K-9 Mail signature format`() { + val html = + """This is the body text
    --
    Sent from my Android device with K-9 Mail. Please excuse my brevity.""" + + val withoutSignature = stripSignature(html) + + assertThat(extractText(withoutSignature)).isEqualTo("This is the body text") + } + + @Test + fun `old Thunderbird signature format`() { + val html = + """ + + + + + +

    This is the body text
    +

    + --
    +
    Sent from my Android device with K-9 Mail. Please excuse my brevity.
    + + + """.trimIndent() + + val withoutSignature = stripSignature(html) + + assertThat(extractText(withoutSignature)).isEqualTo("This is the body text") + } + + @Test + fun `signature before blockquote tag`() { + val html = + """ + + + +
    + This is the body text
    + --
    +
    Sent from my Android device with K-9 Mail. Please excuse my brevity.
    +
    + + + """.trimIndent().removeNewlines() + + val withoutSignature = stripSignature(html) + + assertThat(withoutSignature).isEqualTo( + """
    This is the body text
    """ + ) + } + + @Test + fun `should not strip signature inside blockquote tag`() { + val html = + """ + + + +
    + This is some quoted text
    + --
    + Inner signature +
    +
    + This is the body text +
    + + + """.trimIndent().removeNewlines() + + val withoutSignature = stripSignature(html) + + assertThat(withoutSignature).isEqualTo(html) + } + + @Test + fun `signature between blockquote tags`() { + val html = + """ + + + +
    Some quote
    +
    This is the body text
    + --
    +
    Sent from my Android device with K-9 Mail. Please excuse my brevity.
    +
    --
    Signature inside signature +
    + + + """.trimIndent().removeNewlines() + + val withoutSignature = stripSignature(html) + + assertThat(withoutSignature).isEqualTo( + """ + + + +
    Some quote
    +
    This is the body text
    + + + """.trimIndent().removeNewlines() + ) + } + + @Test + fun `signature after last blockquote tag`() { + val html = + """ + + + + This is the body text
    +
    Some quote
    +
    + --
    + Sent from my Android device with K-9 Mail. Please excuse my brevity. + + + """.trimIndent().removeNewlines() + + val withoutSignature = stripSignature(html) + + assertThat(withoutSignature).isEqualTo( + """ + + + + This is the body text
    +
    Some quote
    + + + """.trimIndent().removeNewlines() + ) + } + + @Test + fun `K-9 Mail signature format`() { + val html = + """ + + + + This is the body text.
    +
    +
    + --
    + And this is the signature text. +
    + + + """.trimIndent().removeNewlines() + + val withoutSignature = stripSignature(html) + + assertThat(withoutSignature).isEqualTo( + """ + + + + + This is the body text.
    +
    + + + """.trimIndent().removeNewlines() + ) + } + + @Test + fun `signature delimiter with non-breaking space character entity`() { + val html = "Body text
    -- 
    Signature text" + + val withoutSignature = stripSignature(html) + + assertThat(extractText(withoutSignature)).isEqualTo("Body text") + } + + @Test + fun `signature delimiter with non-breaking space`() { + val html = "Body text
    --\u00A0
    Signature text" + + val withoutSignature = stripSignature(html) + + assertThat(extractText(withoutSignature)).isEqualTo("Body text") + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/signature/TextSignatureRemoverTest.java b/app/core/src/test/java/com/fsck/k9/message/signature/TextSignatureRemoverTest.java new file mode 100644 index 0000000..1eea796 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/signature/TextSignatureRemoverTest.java @@ -0,0 +1,21 @@ +package com.fsck.k9.message.signature; + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class TextSignatureRemoverTest { + @Test + public void shouldStripSignature() throws Exception { + String text = "This is the body text\r\n" + + "\r\n" + + "-- \r\n" + + "Sent from my Android device with K-9 Mail. Please excuse my brevity."; + + String withoutSignature = TextSignatureRemover.stripSignature(text); + + assertEquals("This is the body text\r\n\r\n", withoutSignature); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/AuthenticationErrorNotificationControllerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/AuthenticationErrorNotificationControllerTest.kt new file mode 100644 index 0000000..aed171f --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/AuthenticationErrorNotificationControllerTest.kt @@ -0,0 +1,121 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.test.core.app.ApplicationProvider +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.testing.MockHelper.mockBuilder +import org.junit.Test +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never + +private const val INCOMING = true +private const val OUTGOING = false +private const val ACCOUNT_NUMBER = 1 +private const val ACCOUNT_NAME = "TestAccount" + +class AuthenticationErrorNotificationControllerTest : RobolectricTest() { + private val resourceProvider = TestNotificationResourceProvider() + private val notification = mock() + private val lockScreenNotification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val lockScreenNotificationBuilder = createFakeNotificationBuilder(lockScreenNotification) + private val notificationHelper = createFakeNotificationHelper( + notificationManager, + builder, + lockScreenNotificationBuilder + ) + private val account = createFakeAccount() + private val controller = TestAuthenticationErrorNotificationController() + private val contentIntent = mock() + + @Test + fun showAuthenticationErrorNotification_withIncomingServer_shouldCreateNotification() { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, INCOMING) + + controller.showAuthenticationErrorNotification(account, INCOMING) + + verify(notificationManager).notify(notificationId, notification) + assertAuthenticationErrorNotificationContents() + } + + @Test + fun clearAuthenticationErrorNotification_withIncomingServer_shouldCancelNotification() { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, INCOMING) + + controller.clearAuthenticationErrorNotification(account, INCOMING) + + verify(notificationManager).cancel(notificationId) + } + + @Test + fun showAuthenticationErrorNotification_withOutgoingServer_shouldCreateNotification() { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, OUTGOING) + + controller.showAuthenticationErrorNotification(account, OUTGOING) + + verify(notificationManager).notify(notificationId, notification) + assertAuthenticationErrorNotificationContents() + } + + @Test + fun clearAuthenticationErrorNotification_withOutgoingServer_shouldCancelNotification() { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, OUTGOING) + + controller.clearAuthenticationErrorNotification(account, OUTGOING) + + verify(notificationManager).cancel(notificationId) + } + + private fun assertAuthenticationErrorNotificationContents() { + verify(builder).setSmallIcon(resourceProvider.iconWarning) + verify(builder).setTicker("Authentication failed") + verify(builder).setContentTitle("Authentication failed") + verify(builder).setContentText("Authentication failed for $ACCOUNT_NAME. Update your server settings.") + verify(builder).setContentIntent(contentIntent) + verify(builder).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Authentication failed") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + private fun createFakeNotificationBuilder(notification: Notification): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn notification + } + } + + private fun createFakeNotificationHelper( + notificationManager: NotificationManagerCompat, + notificationBuilder: NotificationCompat.Builder, + lockScreenNotificationBuilder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) }.doReturn(notificationBuilder, lockScreenNotificationBuilder) + } + } + + private fun createFakeAccount(): Account { + return mock { + on { accountNumber } doReturn ACCOUNT_NUMBER + on { displayName } doReturn ACCOUNT_NAME + } + } + + internal inner class TestAuthenticationErrorNotificationController : + AuthenticationErrorNotificationController(notificationHelper, mock(), resourceProvider) { + + override fun createContentIntent(account: Account, incoming: Boolean): PendingIntent { + return contentIntent + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/BaseNotificationDataCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/notification/BaseNotificationDataCreatorTest.kt new file mode 100644 index 0000000..43e56a8 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/BaseNotificationDataCreatorTest.kt @@ -0,0 +1,211 @@ +package com.fsck.k9.notification + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isSameAs +import com.fsck.k9.Account +import com.fsck.k9.Identity +import com.fsck.k9.K9 +import com.fsck.k9.K9.LockScreenNotificationVisibility +import com.fsck.k9.NotificationLight +import com.fsck.k9.NotificationVibration +import com.fsck.k9.VibratePattern +import org.junit.Test +import org.mockito.kotlin.mock + +class BaseNotificationDataCreatorTest { + private val account = createAccount() + private val notificationDataCreator = BaseNotificationDataCreator() + + @Test + fun `account instance`() { + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.account).isSameAs(account) + } + + @Test + fun `account name from name property`() { + account.name = "name" + account.email = "irrelevant@k9mail.example" + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.accountName).isEqualTo("name") + } + + @Test + fun `account name is blank`() { + account.name = "" + account.email = "test@k9mail.example" + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.accountName).isEqualTo("test@k9mail.example") + } + + @Test + fun `account name is null`() { + account.name = null + account.email = "test@k9mail.example" + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.accountName).isEqualTo("test@k9mail.example") + } + + @Test + fun `group key`() { + account.accountNumber = 42 + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.groupKey).isEqualTo("newMailNotifications-42") + } + + @Test + fun `notification color`() { + account.chipColor = 0xFF0000 + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.color).isEqualTo(0xFF0000) + } + + @Test + fun `new messages count`() { + val notificationData = createNotificationData(senders = listOf("irrelevant", "irrelevant")) + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.newMessagesCount).isEqualTo(2) + } + + @Test + fun `do not display notification on lock screen`() { + setLockScreenMode(LockScreenNotificationVisibility.NOTHING) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.lockScreenNotificationData).isEqualTo(LockScreenNotificationData.None) + } + + @Test + fun `display application name on lock screen`() { + setLockScreenMode(LockScreenNotificationVisibility.APP_NAME) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.lockScreenNotificationData).isEqualTo(LockScreenNotificationData.AppName) + } + + @Test + fun `display new message count on lock screen`() { + setLockScreenMode(LockScreenNotificationVisibility.MESSAGE_COUNT) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.lockScreenNotificationData).isEqualTo(LockScreenNotificationData.MessageCount) + } + + @Test + fun `display message sender names on lock screen`() { + setLockScreenMode(LockScreenNotificationVisibility.SENDERS) + val notificationData = createNotificationData(senders = listOf("Sender One", "Sender Two", "Sender Three")) + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.lockScreenNotificationData).isInstanceOf(LockScreenNotificationData.SenderNames::class.java) + val senderNamesData = result.lockScreenNotificationData as LockScreenNotificationData.SenderNames + assertThat(senderNamesData.senderNames).isEqualTo("Sender One, Sender Two, Sender Three") + } + + @Test + fun `display notification on lock screen`() { + setLockScreenMode(LockScreenNotificationVisibility.EVERYTHING) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.lockScreenNotificationData).isEqualTo(LockScreenNotificationData.Public) + } + + @Test + fun ringtone() { + account.updateNotificationSettings { it.copy(ringtone = "content://ringtone/1") } + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.appearance.ringtone).isEqualTo("content://ringtone/1") + } + + @Test + fun `vibration pattern`() { + account.updateNotificationSettings { + it.copy( + vibration = NotificationVibration( + isEnabled = true, + pattern = VibratePattern.Pattern3, + repeatCount = 2, + ), + ) + } + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.appearance.vibrationPattern).isNotNull() + .isEqualTo(NotificationVibration.getSystemPattern(VibratePattern.Pattern3, 2)) + } + + @Test + fun `led color`() { + account.updateNotificationSettings { it.copy(light = NotificationLight.Green) } + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.appearance.ledColor).isEqualTo(0xFF00FF00L.toInt()) + } + + private fun setLockScreenMode(mode: LockScreenNotificationVisibility) { + K9.lockScreenNotificationVisibility = mode + } + + private fun createNotificationData(senders: List = emptyList()): NotificationData { + val activeNotifications = senders.mapIndexed { index, sender -> + NotificationHolder( + notificationId = index, + timestamp = 0L, + content = NotificationContent( + messageReference = mock(), + sender = sender, + preview = "irrelevant", + summary = "irrelevant", + subject = "irrelevant", + ), + ) + } + return NotificationData(account, activeNotifications, inactiveNotifications = emptyList()) + } + + private fun createAccount(): Account { + return Account("00000000-0000-4000-0000-000000000000").apply { + name = "account name" + replaceIdentities(listOf(Identity())) + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/CertificateErrorNotificationControllerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/CertificateErrorNotificationControllerTest.kt new file mode 100644 index 0000000..3681ff4 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/CertificateErrorNotificationControllerTest.kt @@ -0,0 +1,124 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.test.core.app.ApplicationProvider +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.testing.MockHelper.mockBuilder +import org.junit.Test +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never + +private const val INCOMING = true +private const val OUTGOING = false +private const val ACCOUNT_NUMBER = 1 +private const val ACCOUNT_NAME = "TestAccount" + +class CertificateErrorNotificationControllerTest : RobolectricTest() { + private val resourceProvider: NotificationResourceProvider = TestNotificationResourceProvider() + private val notification = mock() + private val lockScreenNotification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val lockScreenNotificationBuilder = createFakeNotificationBuilder(lockScreenNotification) + private val notificationHelper = createFakeNotificationHelper( + notificationManager, + builder, + lockScreenNotificationBuilder + ) + private val account = createFakeAccount() + private val controller = TestCertificateErrorNotificationController() + private val contentIntent = mock() + + @Test + fun testShowCertificateErrorNotificationForIncomingServer() { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, INCOMING) + + controller.showCertificateErrorNotification(account, INCOMING) + + verify(notificationManager).notify(notificationId, notification) + assertCertificateErrorNotificationContents() + } + + @Test + fun testClearCertificateErrorNotificationsForIncomingServer() { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, INCOMING) + + controller.clearCertificateErrorNotifications(account, INCOMING) + + verify(notificationManager).cancel(notificationId) + } + + @Test + fun testShowCertificateErrorNotificationForOutgoingServer() { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, OUTGOING) + + controller.showCertificateErrorNotification(account, OUTGOING) + + verify(notificationManager).notify(notificationId, notification) + assertCertificateErrorNotificationContents() + } + + @Test + fun testClearCertificateErrorNotificationsForOutgoingServer() { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, OUTGOING) + + controller.clearCertificateErrorNotifications(account, OUTGOING) + + verify(notificationManager).cancel(notificationId) + } + + private fun assertCertificateErrorNotificationContents() { + verify(builder).setSmallIcon(resourceProvider.iconWarning) + verify(builder).setTicker("Certificate error for $ACCOUNT_NAME") + verify(builder).setContentTitle("Certificate error for $ACCOUNT_NAME") + verify(builder).setContentText("Check your server settings") + verify(builder).setContentIntent(contentIntent) + verify(builder).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Certificate error") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + private fun createFakeNotificationBuilder(notification: Notification): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn notification + } + } + + private fun createFakeNotificationHelper( + notificationManager: NotificationManagerCompat, + notificationBuilder: NotificationCompat.Builder, + lockScreenNotificationBuilder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) }.doReturn(notificationBuilder, lockScreenNotificationBuilder) + } + } + + private fun createFakeAccount(): Account { + return mock { + on { accountNumber } doReturn ACCOUNT_NUMBER + on { displayName } doReturn ACCOUNT_NAME + on { uuid } doReturn "test-uuid" + } + } + + internal inner class TestCertificateErrorNotificationController : CertificateErrorNotificationController( + notificationHelper, + mock(), + resourceProvider + ) { + override fun createContentIntent(account: Account, incoming: Boolean): PendingIntent { + return contentIntent + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/LockScreenNotificationCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/notification/LockScreenNotificationCreatorTest.kt new file mode 100644 index 0000000..f92ec2a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/LockScreenNotificationCreatorTest.kt @@ -0,0 +1,116 @@ +package com.fsck.k9.notification + +import androidx.core.app.NotificationCompat +import androidx.test.core.app.ApplicationProvider +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.testing.MockHelper.mockBuilder +import org.junit.Test +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class LockScreenNotificationCreatorTest : RobolectricTest() { + private val account = Account("00000000-0000-0000-0000-000000000000") + private val resourceProvider = TestNotificationResourceProvider() + private val builder = createFakeNotificationBuilder() + private val publicBuilder = createFakeNotificationBuilder() + private var notificationCreator = LockScreenNotificationCreator( + notificationHelper = createFakeNotificationHelper(publicBuilder), + resourceProvider = resourceProvider + ) + + @Test + fun `no lock screen notification`() { + val baseNotificationData = createBaseNotificationData(LockScreenNotificationData.None) + + notificationCreator.configureLockScreenNotification(builder, baseNotificationData) + + verify(builder).setVisibility(NotificationCompat.VISIBILITY_SECRET) + } + + @Test + fun `app name`() { + val baseNotificationData = createBaseNotificationData(LockScreenNotificationData.AppName) + + notificationCreator.configureLockScreenNotification(builder, baseNotificationData) + + verify(builder).setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + } + + @Test + fun `regular notification on lock screen`() { + val baseNotificationData = createBaseNotificationData(LockScreenNotificationData.Public) + + notificationCreator.configureLockScreenNotification(builder, baseNotificationData) + + verify(builder).setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + @Test + fun `list of sender names`() { + val baseNotificationData = createBaseNotificationData( + lockScreenNotificationData = LockScreenNotificationData.SenderNames("Alice, Bob"), + newMessagesCount = 2 + ) + + notificationCreator.configureLockScreenNotification(builder, baseNotificationData) + + verify(publicBuilder).setSmallIcon(resourceProvider.iconNewMail) + verify(publicBuilder).setNumber(2) + verify(publicBuilder).setContentTitle("2 new messages") + verify(publicBuilder).setContentText("Alice, Bob") + verify(builder).setPublicVersion(publicBuilder.build()) + } + + @Test + fun `new message count`() { + val baseNotificationData = createBaseNotificationData( + lockScreenNotificationData = LockScreenNotificationData.MessageCount, + accountName = "Account name", + newMessagesCount = 23 + ) + + notificationCreator.configureLockScreenNotification(builder, baseNotificationData) + + verify(publicBuilder).setSmallIcon(resourceProvider.iconNewMail) + verify(publicBuilder).setNumber(23) + verify(publicBuilder).setContentTitle("23 new messages") + verify(publicBuilder).setContentText("Account name") + verify(builder).setPublicVersion(publicBuilder.build()) + } + + private fun createFakeNotificationBuilder(): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn mock() + } + } + + private fun createFakeNotificationHelper(builder: NotificationCompat.Builder): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { createNotificationBuilder(any(), any()) } doReturn builder + } + } + + private fun createBaseNotificationData( + lockScreenNotificationData: LockScreenNotificationData, + accountName: String = "irrelevant", + newMessagesCount: Int = 0 + ): BaseNotificationData { + return BaseNotificationData( + account = account, + accountName = accountName, + groupKey = "irrelevant", + color = 0, + newMessagesCount = newMessagesCount, + lockScreenNotificationData = lockScreenNotificationData, + appearance = NotificationAppearance( + ringtone = null, + vibrationPattern = longArrayOf(), + ledColor = 0 + ) + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt new file mode 100644 index 0000000..bb01e82 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt @@ -0,0 +1,478 @@ +package com.fsck.k9.notification + +import app.k9mail.core.testing.TestClock +import assertk.assertThat +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.doesNotContain +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LocalMessage +import com.fsck.k9.mailstore.LocalStore +import com.fsck.k9.mailstore.LocalStoreProvider +import com.fsck.k9.mailstore.MessageStoreManager +import com.fsck.k9.mailstore.NotificationMessage +import kotlin.test.assertNotNull +import kotlinx.datetime.Instant +import org.junit.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stubbing + +private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000" +private const val ACCOUNT_NAME = "Personal" +private const val ACCOUNT_COLOR = 0xFF112233L.toInt() +private const val FOLDER_ID = 42L +private const val TIMESTAMP = 23L + +class NewMailNotificationManagerTest { + private val mockedNotificationMessages = mutableListOf() + private val account = createAccount() + private val notificationContentCreator = mock() + private val localStoreProvider = createLocalStoreProvider() + private val clock = TestClock(Instant.fromEpochMilliseconds(TIMESTAMP)) + private val manager = NewMailNotificationManager( + notificationContentCreator, + createNotificationRepository(), + BaseNotificationDataCreator(), + SingleMessageNotificationDataCreator(), + SummaryNotificationDataCreator(SingleMessageNotificationDataCreator()), + clock + ) + + @Test + fun `add first notification`() { + val message = addMessageToNotificationContentCreator( + sender = "sender", + subject = "subject", + preview = "preview", + summary = "summary", + messageUid = "msg-1" + ) + + val result = manager.addNewMailNotification(account, message, silent = false) + + assertNotNull(result) + assertThat(result.singleNotificationData.first().content).isEqualTo( + NotificationContent( + messageReference = createMessageReference("msg-1"), + sender = "sender", + subject = "subject", + preview = "preview", + summary = "summary" + ) + ) + assertThat(result.summaryNotificationData).isNotNull().isInstanceOf(SummarySingleNotificationData::class.java) + val summaryNotificationData = result.summaryNotificationData as SummarySingleNotificationData + assertThat(summaryNotificationData.singleNotificationData.isSilent).isFalse() + } + + @Test + fun `add second notification`() { + val messageOne = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Hi Bob", + preview = "How are you?", + summary = "Alice Hi Bob", + messageUid = "msg-1" + ) + val messageTwo = addMessageToNotificationContentCreator( + sender = "Zoe", + subject = "Meeting", + preview = "We need to talk", + summary = "Zoe Meeting", + messageUid = "msg-2" + ) + manager.addNewMailNotification(account, messageOne, silent = false) + val timestamp = TIMESTAMP + 1000 + clock.changeTimeTo(Instant.fromEpochMilliseconds(timestamp)) + + val result = manager.addNewMailNotification(account, messageTwo, silent = false) + + assertNotNull(result) + assertThat(result.singleNotificationData.first().content).isEqualTo( + NotificationContent( + messageReference = createMessageReference("msg-2"), + sender = "Zoe", + subject = "Meeting", + preview = "We need to talk", + summary = "Zoe Meeting" + ) + ) + assertThat(result.baseNotificationData.newMessagesCount).isEqualTo(2) + assertThat(result.summaryNotificationData).isNotNull().isInstanceOf(SummaryInboxNotificationData::class.java) + val summaryNotificationData = result.summaryNotificationData as SummaryInboxNotificationData + assertThat(summaryNotificationData.content).isEqualTo(listOf("Zoe Meeting", "Alice Hi Bob")) + assertThat(summaryNotificationData.messageReferences).isEqualTo( + listOf( + createMessageReference("msg-2"), + createMessageReference("msg-1") + ) + ) + assertThat(summaryNotificationData.additionalMessagesCount).isEqualTo(0) + assertThat(summaryNotificationData.isSilent).isFalse() + } + + @Test + fun `add one more notification when already displaying the maximum number of notifications`() { + addMaximumNumberOfNotifications() + val message = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Another one", + preview = "Are you tired of me yet?", + summary = "Alice Another one", + messageUid = "msg-x" + ) + + val result = manager.addNewMailNotification(account, message, silent = false) + + assertNotNull(result) + val notificationId = NotificationIds.getSingleMessageNotificationId(account, index = 0) + assertThat(result.cancelNotificationIds).isEqualTo(listOf(notificationId)) + assertThat(result.singleNotificationData.first().notificationId).isEqualTo(notificationId) + } + + @Test + fun `remove notification when none was added before should return null`() { + val result = manager.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(createMessageReference("any")) + } + + assertThat(result).isNull() + } + + @Test + fun `remove notification with untracked notification ID should return null`() { + val message = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Another one", + preview = "Are you tired of me yet?", + summary = "Alice Another one", + messageUid = "msg-x" + ) + manager.addNewMailNotification(account, message, silent = false) + + val result = manager.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(createMessageReference("untracked")) + } + + assertThat(result).isNull() + } + + @Test + fun `remove last remaining notification`() { + val message = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Hello", + preview = "How are you?", + summary = "Alice Hello", + messageUid = "msg-1" + ) + manager.addNewMailNotification(account, message, silent = false) + + val result = manager.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(createMessageReference("msg-1")) + } + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).containsExactlyInAnyOrder( + NotificationIds.getNewMailSummaryNotificationId(account), + NotificationIds.getSingleMessageNotificationId(account, 0) + ) + assertThat(data.singleNotificationData).isEmpty() + assertThat(data.summaryNotificationData).isNull() + } + } + + @Test + fun `remove one of three notifications`() { + val messageOne = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "One", + preview = "preview", + summary = "Alice One", + messageUid = "msg-1" + ) + manager.addNewMailNotification(account, messageOne, silent = false) + val messageTwo = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Two", + preview = "preview", + summary = "Alice Two", + messageUid = "msg-2" + ) + val dataTwo = manager.addNewMailNotification(account, messageTwo, silent = true) + assertNotNull(dataTwo) + val notificationIdTwo = dataTwo.singleNotificationData.first().notificationId + val messageThree = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Three", + preview = "preview", + summary = "Alice Three", + messageUid = "msg-3" + ) + manager.addNewMailNotification(account, messageThree, silent = true) + + val result = manager.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(createMessageReference("msg-2")) + } + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).isEqualTo(listOf(notificationIdTwo)) + assertThat(data.singleNotificationData).isEmpty() + assertThat(data.baseNotificationData.newMessagesCount).isEqualTo(2) + assertThat(data.summaryNotificationData).isNotNull().isInstanceOf(SummaryInboxNotificationData::class.java) + val summaryNotificationData = data.summaryNotificationData as SummaryInboxNotificationData + assertThat(summaryNotificationData.content).isEqualTo(listOf("Alice Three", "Alice One")) + assertThat(summaryNotificationData.messageReferences).isEqualTo( + listOf( + createMessageReference("msg-3"), + createMessageReference("msg-1") + ) + ) + } + } + + @Test + fun `remove notification when additional notifications are available`() { + val message = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Another one", + preview = "Are you tired of me yet?", + summary = "Alice Another one", + messageUid = "msg-restore" + ) + manager.addNewMailNotification(account, message, silent = false) + addMaximumNumberOfNotifications() + + val result = manager.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(createMessageReference("msg-1")) + } + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).hasSize(1) + assertThat(data.baseNotificationData.newMessagesCount) + .isEqualTo(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) + + val singleNotificationData = data.singleNotificationData.first() + assertThat(singleNotificationData.notificationId).isEqualTo(data.cancelNotificationIds.first()) + assertThat(singleNotificationData.isSilent).isTrue() + assertThat(singleNotificationData.content).isEqualTo( + NotificationContent( + messageReference = createMessageReference("msg-restore"), + sender = "Alice", + subject = "Another one", + preview = "Are you tired of me yet?", + summary = "Alice Another one" + ) + ) + } + } + + @Test + fun `restore notifications without persisted notifications`() { + val result = manager.restoreNewMailNotifications(account) + + assertThat(result).isNull() + } + + @Test + fun `restore notifications with single persisted notification`() { + addNotificationMessage( + notificationId = 10, + timestamp = 20L, + sender = "Sender", + subject = "Subject", + summary = "Summary", + preview = "Preview", + messageUid = "uid-1" + ) + + val result = manager.restoreNewMailNotifications(account) + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).isEmpty() + assertThat(data.baseNotificationData.newMessagesCount).isEqualTo(1) + assertThat(data.singleNotificationData).hasSize(1) + + val singleNotificationData = data.singleNotificationData.first() + assertThat(singleNotificationData.notificationId).isEqualTo(10) + assertThat(singleNotificationData.isSilent).isTrue() + assertThat(singleNotificationData.addLockScreenNotification).isTrue() + assertThat(singleNotificationData.content).isEqualTo( + NotificationContent( + messageReference = createMessageReference("uid-1"), + sender = "Sender", + subject = "Subject", + preview = "Preview", + summary = "Summary" + ) + ) + + assertThat(data.summaryNotificationData).isNotNull().isInstanceOf(SummarySingleNotificationData::class.java) + val summaryNotificationData = data.summaryNotificationData as SummarySingleNotificationData + assertThat(summaryNotificationData.singleNotificationData.isSilent).isTrue() + assertThat(summaryNotificationData.singleNotificationData.content).isEqualTo( + NotificationContent( + messageReference = createMessageReference("uid-1"), + sender = "Sender", + subject = "Subject", + preview = "Preview", + summary = "Summary" + ) + ) + } + } + + @Test + fun `restore notifications with one inactive persisted notification`() { + addMaximumNumberOfNotificationMessages() + addNotificationMessage( + notificationId = null, + timestamp = 1000L, + sender = "inactive", + subject = "inactive", + summary = "inactive", + preview = "inactive", + messageUid = "uid-inactive" + ) + + val result = manager.restoreNewMailNotifications(account) + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).isEmpty() + assertThat(data.baseNotificationData.newMessagesCount) + .isEqualTo(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + 1) + assertThat(data.singleNotificationData).hasSize(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) + assertThat(data.singleNotificationData.map { it.content.sender }).doesNotContain("inactive") + + assertThat(data.summaryNotificationData).isNotNull().isInstanceOf(SummaryInboxNotificationData::class.java) + val summaryNotificationData = data.summaryNotificationData as SummaryInboxNotificationData + assertThat(summaryNotificationData.isSilent).isTrue() + } + } + + private fun createAccount(): Account { + return Account(ACCOUNT_UUID).apply { + name = ACCOUNT_NAME + chipColor = ACCOUNT_COLOR + } + } + + private fun addMaximumNumberOfNotifications() { + repeat(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) { index -> + val message = addMessageToNotificationContentCreator( + sender = "sender", + subject = "subject", + preview = "preview", + summary = "summary", + messageUid = "msg-$index" + ) + manager.addNewMailNotification(account, message, silent = true) + } + } + + private fun addMessageToNotificationContentCreator( + sender: String, + subject: String, + preview: String, + summary: String, + messageUid: String + ): LocalMessage { + val message = mock() + + stubbing(notificationContentCreator) { + on { createFromMessage(account, message) } doReturn + NotificationContent( + messageReference = createMessageReference(messageUid), + sender, + subject, + preview, + summary + ) + } + + return message + } + + private fun addNotificationMessage( + notificationId: Int?, + timestamp: Long, + sender: String, + subject: String, + preview: String, + summary: String, + messageUid: String + ) { + val message = mock() + + val notificationMessage = NotificationMessage(message, notificationId, timestamp) + mockedNotificationMessages.add(notificationMessage) + + stubbing(notificationContentCreator) { + on { createFromMessage(account, message) } doReturn + NotificationContent( + messageReference = createMessageReference(messageUid), + sender, + subject, + preview, + summary + ) + } + } + + private fun addMaximumNumberOfNotificationMessages() { + repeat(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) { index -> + addNotificationMessage( + notificationId = index, + timestamp = index.toLong(), + sender = "irrelevant", + subject = "irrelevant", + preview = "irrelevant", + summary = "irrelevant", + messageUid = "uid-$index" + ) + } + } + + private fun createMessageReference(messageUid: String): MessageReference { + return MessageReference(ACCOUNT_UUID, FOLDER_ID, messageUid) + } + + private fun createLocalStoreProvider(): LocalStoreProvider { + val localStore = createLocalStore() + return mock { + on { getInstance(account) } doReturn localStore + } + } + + private fun createLocalStore(): LocalStore { + return mock { + on { notificationMessages } doAnswer { mockedNotificationMessages.toList() } + } + } + + private fun createNotificationRepository(): NotificationRepository { + val notificationStoreProvider = mock { + on { getNotificationStore(account) } doReturn mock() + } + val messageStoreManager = mock { + on { getMessageStore(account) } doReturn mock() + } + + return NotificationRepository( + notificationStoreProvider, + localStoreProvider, + messageStoreManager, + notificationContentCreator + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.kt new file mode 100644 index 0000000..e5670c8 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.kt @@ -0,0 +1,166 @@ +package com.fsck.k9.notification + +import app.k9mail.core.android.common.contact.ContactRepository +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Message.RecipientType +import com.fsck.k9.mailstore.LocalMessage +import com.fsck.k9.message.extractors.PreviewResult.PreviewType +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stubbing + +private const val ACCOUNT_UUID = "1-2-3" +private const val FOLDER_ID = 23L +private const val UID = "42" +private const val PREVIEW = "Message preview text" +private const val SUBJECT = "Message subject" +private const val SENDER_ADDRESS = "alice@example.com" +private const val SENDER_NAME = "Alice" +private const val RECIPIENT_ADDRESS = "bob@example.com" +private const val RECIPIENT_NAME = "Bob" + +class NotificationContentCreatorTest : RobolectricTest() { + private val contactRepository = createFakeContentRepository() + private val resourceProvider = TestNotificationResourceProvider() + private val contentCreator = createNotificationContentCreator() + private val messageReference = createMessageReference() + private val account = createFakeAccount() + private val message = createFakeLocalMessage(messageReference) + + @Test + fun createFromMessage_withRegularMessage() { + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.messageReference).isEqualTo(messageReference) + assertThat(content.sender).isEqualTo(SENDER_NAME) + assertThat(content.subject).isEqualTo(SUBJECT) + assertThat(content.preview.toString()).isEqualTo("$SUBJECT\n$PREVIEW") + assertThat(content.summary.toString()).isEqualTo("$SENDER_NAME $SUBJECT") + } + + @Test + fun createFromMessage_withoutSubject() { + stubbing(message) { + on { subject } doReturn null + } + + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.subject).isEqualTo("(No subject)") + assertThat(content.preview.toString()).isEqualTo(PREVIEW) + assertThat(content.summary.toString()).isEqualTo("$SENDER_NAME (No subject)") + } + + @Test + fun createFromMessage_withoutPreview() { + stubbing(message) { + on { previewType } doReturn PreviewType.NONE + on { preview } doReturn null + } + + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.subject).isEqualTo(SUBJECT) + assertThat(content.preview.toString()).isEqualTo(SUBJECT) + } + + @Test + fun createFromMessage_withErrorPreview() { + stubbing(message) { + on { previewType } doReturn PreviewType.ERROR + on { preview } doReturn null + } + + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.subject).isEqualTo(SUBJECT) + assertThat(content.preview.toString()).isEqualTo(SUBJECT) + } + + @Test + fun createFromMessage_withEncryptedMessage() { + stubbing(message) { + on { previewType } doReturn PreviewType.ENCRYPTED + on { preview } doReturn null + } + + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.subject).isEqualTo(SUBJECT) + assertThat(content.preview.toString()).isEqualTo("$SUBJECT\n*Encrypted*") + } + + @Test + fun createFromMessage_withoutSender() { + stubbing(message) { + on { from } doReturn null + } + + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.sender).isEqualTo("No sender") + assertThat(content.summary.toString()).isEqualTo(SUBJECT) + } + + @Test + fun createFromMessage_withMessageFromSelf() { + stubbing(account) { + on { isAnIdentity(any>()) } doReturn true + } + + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.sender).isEqualTo("To:Bob") + assertThat(content.summary.toString()).isEqualTo("To:Bob $SUBJECT") + } + + @Test + fun createFromMessage_withoutEmptyMessage() { + stubbing(message) { + on { from } doReturn null + on { subject } doReturn null + on { previewType } doReturn PreviewType.NONE + on { preview } doReturn null + } + + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.sender).isEqualTo("No sender") + assertThat(content.subject).isEqualTo("(No subject)") + assertThat(content.preview.toString()).isEqualTo("(No subject)") + assertThat(content.summary.toString()).isEqualTo("(No subject)") + } + + private fun createNotificationContentCreator(): NotificationContentCreator { + return NotificationContentCreator( + resourceProvider, + contactRepository, + ) + } + + private fun createFakeAccount(): Account = mock() + + private fun createFakeContentRepository(): ContactRepository = mock() + + private fun createMessageReference(): MessageReference { + return MessageReference(ACCOUNT_UUID, FOLDER_ID, UID) + } + + private fun createFakeLocalMessage(messageReference: MessageReference): LocalMessage { + return mock { + on { makeMessageReference() } doReturn messageReference + on { previewType } doReturn PreviewType.TEXT + on { preview } doReturn PREVIEW + on { subject } doReturn SUBJECT + on { from } doReturn arrayOf(Address(SENDER_ADDRESS, SENDER_NAME)) + on { getRecipients(RecipientType.TO) } doReturn arrayOf(Address(RECIPIENT_ADDRESS, RECIPIENT_NAME)) + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt new file mode 100644 index 0000000..0850862 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt @@ -0,0 +1,253 @@ +package com.fsck.k9.notification + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isSameAs +import assertk.assertions.isTrue +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.controller.MessageReference +import kotlin.test.assertNotNull +import org.junit.Test + +private const val ACCOUNT_UUID = "1-2-3" +private const val ACCOUNT_NUMBER = 23 +private const val FOLDER_ID = 42L +private const val TIMESTAMP = 0L + +class NotificationDataStoreTest : RobolectricTest() { + private val account = createAccount() + private val notificationDataStore = NotificationDataStore() + + @Test + fun testAddNotificationContent() { + val content = createNotificationContent("1") + + val result = notificationDataStore.addNotification(account, content, TIMESTAMP) + + assertNotNull(result) + assertThat(result.shouldCancelNotification).isFalse() + + val holder = result.notificationHolder + + assertThat(holder).isNotNull() + assertThat(holder.notificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 0)) + assertThat(holder.content).isEqualTo(content) + } + + @Test + fun testAddNotificationContentWithReplacingNotification() { + notificationDataStore.addNotification(account, createNotificationContent("1"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("2"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("3"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("4"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("5"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("6"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("7"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("8"), TIMESTAMP) + + val result = notificationDataStore.addNotification(account, createNotificationContent("9"), TIMESTAMP) + + assertNotNull(result) + assertThat(result.shouldCancelNotification).isTrue() + assertThat(result.cancelNotificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 0)) + } + + @Test + fun testRemoveNotificationForMessage() { + val content = createNotificationContent("1") + notificationDataStore.addNotification(account, content, TIMESTAMP) + + val result = notificationDataStore.removeNotifications(account) { listOf(content.messageReference) } + + assertNotNull(result) { removeResult -> + assertThat(removeResult.cancelNotificationIds) + .containsExactly(NotificationIds.getSingleMessageNotificationId(account, 0)) + assertThat(removeResult.notificationHolders).isEmpty() + } + } + + @Test + fun testRemoveNotificationForMessageWithRecreatingNotification() { + notificationDataStore.addNotification(account, createNotificationContent("1"), TIMESTAMP) + val content = createNotificationContent("2") + notificationDataStore.addNotification(account, content, TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("3"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("4"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("5"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("6"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("7"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("8"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("9"), TIMESTAMP) + val latestContent = createNotificationContent("10") + notificationDataStore.addNotification(account, latestContent, TIMESTAMP) + + val result = notificationDataStore.removeNotifications(account) { listOf(latestContent.messageReference) } + + assertNotNull(result) { removeResult -> + assertThat(removeResult.cancelNotificationIds) + .containsExactly(NotificationIds.getSingleMessageNotificationId(account, 1)) + assertThat(removeResult.notificationHolders).hasSize(1) + + val holder = removeResult.notificationHolders.first() + assertThat(holder.notificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 1)) + assertThat(holder.content).isEqualTo(content) + } + } + + @Test + fun `remove multiple notifications`() { + repeat(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + 1) { index -> + notificationDataStore.addNotification(account, createNotificationContent(index.toString()), TIMESTAMP) + } + + val result = notificationDataStore.removeNotifications(account) { it.dropLast(1) } + + assertNotNull(result) { removeResult -> + assertThat(removeResult.notificationData.newMessagesCount).isEqualTo(1) + assertThat(removeResult.cancelNotificationIds).hasSize(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) + } + } + + @Test + fun `remove all notifications`() { + repeat(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + 1) { index -> + notificationDataStore.addNotification(account, createNotificationContent(index.toString()), TIMESTAMP) + } + + val result = notificationDataStore.removeNotifications(account) { it } + + assertNotNull(result) { removeResult -> + assertThat(removeResult.notificationData.newMessagesCount).isEqualTo(0) + assertThat(removeResult.notificationHolders).hasSize(0) + assertThat(removeResult.notificationStoreOperations).hasSize(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + 1) + for (notificationStoreOperation in removeResult.notificationStoreOperations) { + assertThat(notificationStoreOperation).isInstanceOf(NotificationStoreOperation.Remove::class.java) + } + } + } + + @Test + fun testRemoveDoesNotLeakNotificationIds() { + for (i in 1..MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + 1) { + val content = createNotificationContent(i.toString()) + notificationDataStore.addNotification(account, content, TIMESTAMP) + notificationDataStore.removeNotifications(account) { listOf(content.messageReference) } + } + } + + @Test + fun testNewMessagesCount() { + val contentOne = createNotificationContent("1") + val resultOne = notificationDataStore.addNotification(account, contentOne, TIMESTAMP) + assertNotNull(resultOne) + assertThat(resultOne.notificationData.newMessagesCount).isEqualTo(1) + + val contentTwo = createNotificationContent("2") + val resultTwo = notificationDataStore.addNotification(account, contentTwo, TIMESTAMP) + assertNotNull(resultTwo) + assertThat(resultTwo.notificationData.newMessagesCount).isEqualTo(2) + } + + @Test + fun testIsSingleMessageNotification() { + val resultOne = notificationDataStore.addNotification(account, createNotificationContent("1"), TIMESTAMP) + assertNotNull(resultOne) + assertThat(resultOne.notificationData.isSingleMessageNotification).isTrue() + + val resultTwo = notificationDataStore.addNotification(account, createNotificationContent("2"), TIMESTAMP) + assertNotNull(resultTwo) + assertThat(resultTwo.notificationData.isSingleMessageNotification).isFalse() + } + + @Test + fun testGetHolderForLatestNotification() { + val content = createNotificationContent("1") + val addResult = notificationDataStore.addNotification(account, content, TIMESTAMP) + + assertNotNull(addResult) + assertThat(addResult.notificationData.activeNotifications.first()).isEqualTo(addResult.notificationHolder) + } + + @Test + fun `adding notification for message with active notification should update notification`() { + val content1 = createNotificationContent("1") + val content2 = createNotificationContent("1") + + val resultOne = notificationDataStore.addNotification(account, content1, TIMESTAMP) + val resultTwo = notificationDataStore.addNotification(account, content2, TIMESTAMP) + + assertNotNull(resultOne) + assertNotNull(resultTwo) + assertThat(resultTwo.notificationData.activeNotifications).hasSize(1) + assertThat(resultTwo.notificationData.activeNotifications.first().content).isSameAs(content2) + assertThat(resultTwo.notificationStoreOperations).isEmpty() + with(resultTwo.notificationHolder) { + assertThat(notificationId).isEqualTo(resultOne.notificationHolder.notificationId) + assertThat(timestamp).isEqualTo(resultOne.notificationHolder.timestamp) + assertThat(content).isSameAs(content2) + } + assertThat(resultTwo.shouldCancelNotification).isFalse() + } + + @Test + fun `adding notification for message with inactive notification should update notificationData`() { + notificationDataStore.addNotification(account, createNotificationContent("1"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("2"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("3"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("4"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("5"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("6"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("7"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("8"), TIMESTAMP) + val latestNotificationContent = createNotificationContent("9") + notificationDataStore.addNotification(account, latestNotificationContent, TIMESTAMP) + val content = createNotificationContent("1") + + val resultOne = notificationDataStore.addNotification(account, content, TIMESTAMP) + + assertThat(resultOne).isNull() + + val resultTwo = notificationDataStore.removeNotifications(account) { + listOf(latestNotificationContent.messageReference) + } + + assertNotNull(resultTwo) + val notificationHolder = resultTwo.notificationData.activeNotifications.first { notificationHolder -> + notificationHolder.content.messageReference == content.messageReference + } + assertThat(notificationHolder.content).isSameAs(content) + } + + private fun createAccount(): Account { + return Account("00000000-0000-4000-0000-000000000000").apply { + accountNumber = ACCOUNT_NUMBER + } + } + + private fun createMessageReference(uid: String): MessageReference { + return MessageReference(ACCOUNT_UUID, FOLDER_ID, uid) + } + + private fun createNotificationContent(uid: String): NotificationContent { + val messageReference = createMessageReference(uid) + return createNotificationContent(messageReference) + } + + private fun createNotificationContent(messageReference: MessageReference): NotificationContent { + return NotificationContent( + messageReference = messageReference, + sender = "irrelevant", + subject = "irrelevant", + preview = "irrelevant", + summary = "irrelevant" + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NotificationIdsTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NotificationIdsTest.kt new file mode 100644 index 0000000..24fec67 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/NotificationIdsTest.kt @@ -0,0 +1,125 @@ +package com.fsck.k9.notification + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.containsNoDuplicates +import assertk.assertions.doesNotContain +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import com.fsck.k9.Account +import org.junit.Test + +class NotificationIdsTest { + @Test + fun `all general notification IDs are unique`() { + val notificationIds = getGeneralNotificationIds() + + assertThat(notificationIds).containsNoDuplicates() + } + + @Test + fun `avoid notification ID 0`() { + val notificationIds = getGeneralNotificationIds() + + assertThat(notificationIds).doesNotContain(0) + } + + @Test + fun `all notification IDs of an account are unique`() { + val account = createAccount(0) + + val notificationIds = getAccountNotificationIds(account) + + assertThat(notificationIds).containsNoDuplicates() + } + + @Test + fun `notification IDs of adjacent accounts do not overlap`() { + val account1 = createAccount(0) + val account2 = createAccount(1) + + val notificationIds1 = getAccountNotificationIds(account1) + val notificationIds2 = getAccountNotificationIds(account2) + + assertThat(actual = notificationIds1 intersect notificationIds2, name = "Reused notification IDs").isEmpty() + } + + @Test + fun `no gaps between general and account notification IDs`() { + // We avoid gaps. So this test failing is an indication that getGeneralNotificationIds() and/or + // getAccountNotificationIds() need to be updated. + val account = createAccount(0) + + val generalNotificationIds = getGeneralNotificationIds() + val accountNotificationIds = getAccountNotificationIds(account) + + val maxGeneralNotificationId = requireNotNull(generalNotificationIds.maxOrNull()) + val minAccountNotificationId = requireNotNull(accountNotificationIds.minOrNull()) + assertThat(maxGeneralNotificationId + 1).isEqualTo(minAccountNotificationId) + } + + @Test + fun `no gaps in notification IDs of an account`() { + // We avoid gaps. So this test failing is an indication that getAccountNotificationIds() needs to be updated. + val account = createAccount(0) + + val notificationIds = getAccountNotificationIds(account) + + val minNotificationId = requireNotNull(notificationIds.minOrNull()) + val maxNotificationId = requireNotNull(notificationIds.maxOrNull()) + val notificationIdRange = (minNotificationId..maxNotificationId) + assertThat(actual = notificationIdRange - notificationIds, name = "Skipped notification IDs").isEmpty() + } + + @Test + fun `no gap between notification IDs of adjacent accounts`() { + // We avoid gaps. So this test failing is an indication that getAccountNotificationIds() needs to be updated. + val account1 = createAccount(1) + val account2 = createAccount(2) + + val notificationIds1 = getAccountNotificationIds(account1) + val notificationIds2 = getAccountNotificationIds(account2) + + val maxNotificationId1 = requireNotNull(notificationIds1.maxOrNull()) + val minNotificationId2 = requireNotNull(notificationIds2.minOrNull()) + assertThat(maxNotificationId1 + 1).isEqualTo(minNotificationId2) + } + + @Test + fun `all message notification IDs`() { + val account = createAccount(1) + + val notificationIds = NotificationIds.getAllMessageNotificationIds(account) + + val expected = getNewMessageNotificationIds(account) + NotificationIds.getNewMailSummaryNotificationId(account) + assertThat(notificationIds).containsExactly(*expected) + } + + private fun getGeneralNotificationIds(): List { + return listOf(NotificationIds.PUSH_NOTIFICATION_ID) + } + + private fun getAccountNotificationIds(account: Account): List { + return listOf( + NotificationIds.getSendFailedNotificationId(account), + NotificationIds.getCertificateErrorNotificationId(account, true), + NotificationIds.getCertificateErrorNotificationId(account, false), + NotificationIds.getAuthenticationErrorNotificationId(account, true), + NotificationIds.getAuthenticationErrorNotificationId(account, false), + NotificationIds.getFetchingMailNotificationId(account), + NotificationIds.getNewMailSummaryNotificationId(account), + ) + getNewMessageNotificationIds(account) + } + + private fun getNewMessageNotificationIds(account: Account): Array { + return (0 until MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS).map { index -> + NotificationIds.getSingleMessageNotificationId(account, index) + }.toTypedArray() + } + + private fun createAccount(accountNumber: Int): Account { + return Account("uuid").apply { + this.accountNumber = accountNumber + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/SendFailedNotificationControllerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/SendFailedNotificationControllerTest.kt new file mode 100644 index 0000000..e92089f --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SendFailedNotificationControllerTest.kt @@ -0,0 +1,94 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.test.core.app.ApplicationProvider +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.testing.MockHelper.mockBuilder +import org.junit.Test +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never + +private const val ACCOUNT_NUMBER = 1 +private const val ACCOUNT_NAME = "TestAccount" + +class SendFailedNotificationControllerTest : RobolectricTest() { + private val resourceProvider: NotificationResourceProvider = TestNotificationResourceProvider() + private val notification = mock() + private val lockScreenNotification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val lockScreenNotificationBuilder = createFakeNotificationBuilder(lockScreenNotification) + private val account = createFakeAccount() + private val contentIntent = mock() + private val notificationId = NotificationIds.getSendFailedNotificationId(account) + private val controller = SendFailedNotificationController( + notificationHelper = createFakeNotificationHelper(notificationManager, builder, lockScreenNotificationBuilder), + actionBuilder = createActionBuilder(contentIntent), + resourceProvider = resourceProvider + ) + + @Test + fun testShowSendFailedNotification() { + val exception = Exception() + + controller.showSendFailedNotification(account, exception) + + verify(notificationManager).notify(notificationId, notification) + verify(builder).setSmallIcon(resourceProvider.iconWarning) + verify(builder).setTicker("Failed to send some messages") + verify(builder).setContentTitle("Failed to send some messages") + verify(builder).setContentText("Exception") + verify(builder).setContentIntent(contentIntent) + verify(builder).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Failed to send some messages") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + @Test + fun testClearSendFailedNotification() { + controller.clearSendFailedNotification(account) + + verify(notificationManager).cancel(notificationId) + } + + private fun createFakeNotificationBuilder(notification: Notification): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn notification + } + } + + private fun createFakeNotificationHelper( + notificationManager: NotificationManagerCompat, + notificationBuilder: NotificationCompat.Builder, + lockScreenNotificationBuilder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) }.doReturn(notificationBuilder, lockScreenNotificationBuilder) + } + } + + private fun createFakeAccount(): Account { + return mock { + on { accountNumber } doReturn ACCOUNT_NUMBER + on { name } doReturn ACCOUNT_NAME + } + } + + private fun createActionBuilder(contentIntent: PendingIntent): NotificationActionCreator { + return mock { + on { createViewFolderListPendingIntent(any()) } doReturn contentIntent + on { createViewFolderPendingIntent(any(), anyLong()) } doReturn contentIntent + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt new file mode 100644 index 0000000..603e171 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt @@ -0,0 +1,287 @@ +package com.fsck.k9.notification + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.doesNotContain +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.K9.NotificationQuickDelete +import com.fsck.k9.controller.MessageReference +import org.junit.Test + +class SingleMessageNotificationDataCreatorTest { + private val account = createAccount() + private val notificationDataCreator = SingleMessageNotificationDataCreator() + + @Test + fun `base properties`() { + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 23, + content = content, + timestamp = 9000, + addLockScreenNotification = true + ) + + assertThat(result.notificationId).isEqualTo(23) + assertThat(result.isSilent).isTrue() + assertThat(result.timestamp).isEqualTo(9000) + assertThat(result.content).isEqualTo(content) + assertThat(result.addLockScreenNotification).isTrue() + } + + @Test + fun `summary notification base properties`() { + val content = createNotificationContent() + val notificationData = createNotificationData(content) + + val result = notificationDataCreator.createSummarySingleNotificationData( + timestamp = 9000, + silent = false, + data = notificationData + ) + + assertThat(result.singleNotificationData.notificationId).isEqualTo( + NotificationIds.getNewMailSummaryNotificationId(account) + ) + assertThat(result.singleNotificationData.isSilent).isFalse() + assertThat(result.singleNotificationData.timestamp).isEqualTo(9000) + assertThat(result.singleNotificationData.content).isEqualTo(content) + assertThat(result.singleNotificationData.addLockScreenNotification).isFalse() + } + + @Test + fun `default actions`() { + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).contains(NotificationAction.Reply) + assertThat(result.actions).contains(NotificationAction.MarkAsRead) + assertThat(result.wearActions).contains(WearNotificationAction.Reply) + assertThat(result.wearActions).contains(WearNotificationAction.MarkAsRead) + } + + @Test + fun `always show delete action without confirmation`() { + setDeleteAction(NotificationQuickDelete.ALWAYS) + setConfirmDeleteFromNotification(false) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).contains(NotificationAction.Delete) + assertThat(result.wearActions).contains(WearNotificationAction.Delete) + } + + @Test + fun `always show delete action with confirmation`() { + setDeleteAction(NotificationQuickDelete.ALWAYS) + setConfirmDeleteFromNotification(true) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).contains(NotificationAction.Delete) + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Delete) + } + + @Test + fun `show delete action for single notification without confirmation`() { + setDeleteAction(NotificationQuickDelete.FOR_SINGLE_MSG) + setConfirmDeleteFromNotification(false) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).contains(NotificationAction.Delete) + assertThat(result.wearActions).contains(WearNotificationAction.Delete) + } + + @Test + fun `show delete action for single notification with confirmation`() { + setDeleteAction(NotificationQuickDelete.FOR_SINGLE_MSG) + setConfirmDeleteFromNotification(true) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).contains(NotificationAction.Delete) + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Delete) + } + + @Test + fun `never show delete action`() { + setDeleteAction(NotificationQuickDelete.NEVER) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).doesNotContain(NotificationAction.Delete) + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Delete) + } + + @Test + fun `archive action with archive folder`() { + account.archiveFolderId = 1 + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.wearActions).contains(WearNotificationAction.Archive) + } + + @Test + fun `archive action without archive folder`() { + account.archiveFolderId = null + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Archive) + } + + @Test + fun `spam action with spam folder and without spam confirmation`() { + account.spamFolderId = 1 + setConfirmSpam(false) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.wearActions).contains(WearNotificationAction.Spam) + } + + @Test + fun `spam action with spam folder and with spam confirmation`() { + account.spamFolderId = 1 + setConfirmSpam(true) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Spam) + } + + @Test + fun `spam action without spam folder and without spam confirmation`() { + account.spamFolderId = null + setConfirmSpam(false) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Spam) + } + + private fun setDeleteAction(mode: NotificationQuickDelete) { + K9.notificationQuickDeleteBehaviour = mode + } + + private fun setConfirmDeleteFromNotification(confirm: Boolean) { + K9.isConfirmDeleteFromNotification = confirm + } + + private fun setConfirmSpam(confirm: Boolean) { + K9.isConfirmSpam = confirm + } + + private fun createAccount(): Account { + return Account("00000000-0000-0000-0000-000000000000").apply { + accountNumber = 42 + } + } + + private fun createNotificationContent() = NotificationContent( + messageReference = MessageReference("irrelevant", 1, "irrelevant"), + sender = "irrelevant", + subject = "irrelevant", + preview = "irrelevant", + summary = "irrelevant" + ) + + private fun createNotificationData(content: NotificationContent): NotificationData { + return NotificationData( + account, + activeNotifications = listOf( + NotificationHolder( + notificationId = 1, + timestamp = 0, + content = content + ) + ), + inactiveNotifications = emptyList() + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt new file mode 100644 index 0000000..f37276a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt @@ -0,0 +1,283 @@ +package com.fsck.k9.notification + +import app.k9mail.core.testing.TestClock +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.doesNotContain +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.controller.MessageReference +import kotlinx.datetime.Clock +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module + +private val TIMESTAMP = 0L + +class SummaryNotificationDataCreatorTest { + private val account = createAccount() + private val notificationDataCreator = SummaryNotificationDataCreator(SingleMessageNotificationDataCreator()) + + @Before + fun setUp() { + startKoin { + modules( + module { + single { TestClock() } + } + ) + } + } + + @After + fun tearDown() { + stopKoin() + setQuietTime(false) + } + + @Test + fun `single new message`() { + val notificationData = createNotificationData() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = false + ) + + assertThat(result).isInstanceOf(SummarySingleNotificationData::class.java) + } + + @Test + fun `single notification during quiet time`() { + setQuietTime(true) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = false + ) + + val summaryNotificationData = result as SummarySingleNotificationData + assertThat(summaryNotificationData.singleNotificationData.isSilent).isTrue() + } + + @Test + fun `single notification with quiet time disabled`() { + setQuietTime(false) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = false + ) + + val summaryNotificationData = result as SummarySingleNotificationData + assertThat(summaryNotificationData.singleNotificationData.isSilent).isFalse() + } + + @Test + fun `inbox-style notification during quiet time`() { + setQuietTime(true) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = false + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.isSilent).isTrue() + } + + @Test + fun `inbox-style notification with quiet time disabled`() { + setQuietTime(false) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = false + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.isSilent).isFalse() + } + + @Test + fun `inbox-style base properties`() { + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.notificationId).isEqualTo( + NotificationIds.getNewMailSummaryNotificationId(account) + ) + assertThat(summaryNotificationData.isSilent).isTrue() + assertThat(summaryNotificationData.timestamp).isEqualTo(TIMESTAMP) + } + + @Test + fun `default actions`() { + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.actions).contains(SummaryNotificationAction.MarkAsRead) + assertThat(summaryNotificationData.wearActions).contains(SummaryWearNotificationAction.MarkAsRead) + } + + @Test + fun `always show delete action without confirmation`() { + setDeleteAction(K9.NotificationQuickDelete.ALWAYS) + setConfirmDeleteFromNotification(false) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.actions).contains(SummaryNotificationAction.Delete) + assertThat(summaryNotificationData.wearActions).contains(SummaryWearNotificationAction.Delete) + } + + @Test + fun `always show delete action with confirmation`() { + setDeleteAction(K9.NotificationQuickDelete.ALWAYS) + setConfirmDeleteFromNotification(true) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.actions).contains(SummaryNotificationAction.Delete) + assertThat(summaryNotificationData.wearActions).doesNotContain(SummaryWearNotificationAction.Delete) + } + + @Test + fun `show delete action for single notification without confirmation`() { + setDeleteAction(K9.NotificationQuickDelete.FOR_SINGLE_MSG) + setConfirmDeleteFromNotification(false) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.actions).doesNotContain(SummaryNotificationAction.Delete) + assertThat(summaryNotificationData.wearActions).doesNotContain(SummaryWearNotificationAction.Delete) + } + + @Test + fun `never show delete action`() { + setDeleteAction(K9.NotificationQuickDelete.NEVER) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.actions).doesNotContain(SummaryNotificationAction.Delete) + assertThat(summaryNotificationData.wearActions).doesNotContain(SummaryWearNotificationAction.Delete) + } + + @Test + fun `archive action with archive folder`() { + account.archiveFolderId = 1 + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.wearActions).contains(SummaryWearNotificationAction.Archive) + } + + @Test + fun `archive action without archive folder`() { + account.archiveFolderId = null + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.wearActions).doesNotContain(SummaryWearNotificationAction.Archive) + } + + private fun setQuietTime(quietTime: Boolean) { + K9.isQuietTimeEnabled = quietTime + if (quietTime) { + K9.quietTimeStarts = "0:00" + K9.quietTimeEnds = "23:59" + } + } + + private fun setDeleteAction(mode: K9.NotificationQuickDelete) { + K9.notificationQuickDeleteBehaviour = mode + } + + private fun setConfirmDeleteFromNotification(confirm: Boolean) { + K9.isConfirmDeleteFromNotification = confirm + } + + private fun createAccount(): Account { + return Account("00000000-0000-0000-0000-000000000000").apply { + accountNumber = 42 + } + } + + private fun createNotificationContent() = NotificationContent( + messageReference = MessageReference("irrelevant", 1, "irrelevant"), + sender = "irrelevant", + subject = "irrelevant", + preview = "irrelevant", + summary = "irrelevant" + ) + + private fun createNotificationData( + contentList: List = listOf(createNotificationContent()) + ): NotificationData { + val activeNotifications = contentList.mapIndexed { index, content -> + NotificationHolder(notificationId = index, TIMESTAMP, content) + } + + return NotificationData(account, activeNotifications, inactiveNotifications = emptyList()) + } + + private fun createNotificationDataWithMultipleMessages(times: Int = 2): NotificationData { + val contentList = buildList { + repeat(times) { + add(createNotificationContent()) + } + } + return createNotificationData(contentList) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/SyncNotificationControllerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/SyncNotificationControllerTest.kt new file mode 100644 index 0000000..18b5aa1 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SyncNotificationControllerTest.kt @@ -0,0 +1,152 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.test.core.app.ApplicationProvider +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.mailstore.LocalFolder +import com.fsck.k9.notification.NotificationIds.getFetchingMailNotificationId +import com.fsck.k9.testing.MockHelper.mockBuilder +import org.junit.Test +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never + +private const val ACCOUNT_NUMBER = 1 +private const val ACCOUNT_NAME = "TestAccount" +private const val FOLDER_SERVER_ID = "INBOX" +private const val FOLDER_NAME = "Inbox" + +class SyncNotificationControllerTest : RobolectricTest() { + private val resourceProvider: NotificationResourceProvider = TestNotificationResourceProvider() + private val notification = mock() + private val lockScreenNotification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val lockScreenNotificationBuilder = createFakeNotificationBuilder(lockScreenNotification) + private val account = createFakeAccount() + private val contentIntent = mock() + private val controller = SyncNotificationController( + notificationHelper = createFakeNotificationHelper(notificationManager, builder, lockScreenNotificationBuilder), + actionBuilder = createActionBuilder(contentIntent), + resourceProvider = resourceProvider + ) + + @Test + fun testShowSendingNotification() { + val notificationId = getFetchingMailNotificationId(account) + + controller.showSendingNotification(account) + + verify(notificationManager).notify(notificationId, notification) + verify(builder).setSmallIcon(resourceProvider.iconSendingMail) + verify(builder).setTicker("Sending mail: $ACCOUNT_NAME") + verify(builder).setContentTitle("Sending mail") + verify(builder).setContentText(ACCOUNT_NAME) + verify(builder).setContentIntent(contentIntent) + verify(builder).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Sending mail") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + @Test + fun testClearSendingNotification() { + val notificationId = getFetchingMailNotificationId(account) + + controller.clearSendingNotification(account) + + verify(notificationManager).cancel(notificationId) + } + + @Test + fun testGetFetchingMailNotificationId() { + val localFolder = createFakeLocalFolder() + val notificationId = getFetchingMailNotificationId(account) + + controller.showFetchingMailNotification(account, localFolder) + + verify(notificationManager).notify(notificationId, notification) + verify(builder).setSmallIcon(resourceProvider.iconCheckingMail) + verify(builder).setTicker("Checking mail: $ACCOUNT_NAME:$FOLDER_NAME") + verify(builder).setContentTitle("Checking mail") + verify(builder).setContentText("$ACCOUNT_NAME:$FOLDER_NAME") + verify(builder).setContentIntent(contentIntent) + verify(builder).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Checking mail") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + @Test + fun testShowEmptyFetchingMailNotification() { + val notificationId = getFetchingMailNotificationId(account) + + controller.showEmptyFetchingMailNotification(account) + + verify(notificationManager).notify(notificationId, notification) + verify(builder).setSmallIcon(resourceProvider.iconCheckingMail) + verify(builder).setContentTitle("Checking mail") + verify(builder).setContentText(ACCOUNT_NAME) + verify(builder).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Checking mail") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + @Test + fun testClearSendFailedNotification() { + val notificationId = getFetchingMailNotificationId(account) + + controller.clearFetchingMailNotification(account) + + verify(notificationManager).cancel(notificationId) + } + + private fun createFakeNotificationBuilder(notification: Notification): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn notification + } + } + + private fun createFakeNotificationHelper( + notificationManager: NotificationManagerCompat, + notificationBuilder: NotificationCompat.Builder, + lockScreenNotificationBuilder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) }.doReturn(notificationBuilder, lockScreenNotificationBuilder) + } + } + + private fun createFakeAccount(): Account { + return mock { + on { accountNumber } doReturn ACCOUNT_NUMBER + on { name } doReturn ACCOUNT_NAME + on { displayName } doReturn ACCOUNT_NAME + on { outboxFolderId } doReturn 33L + } + } + + private fun createActionBuilder(contentIntent: PendingIntent): NotificationActionCreator { + return mock { + on { createViewFolderPendingIntent(eq(account), anyLong()) } doReturn contentIntent + } + } + + private fun createFakeLocalFolder(): LocalFolder { + return mock { + on { serverId } doReturn FOLDER_SERVER_ID + on { name } doReturn FOLDER_NAME + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt b/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt new file mode 100644 index 0000000..b347bc7 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt @@ -0,0 +1,88 @@ +package com.fsck.k9.notification + +class TestNotificationResourceProvider : NotificationResourceProvider { + override val iconWarning: Int = 1 + override val iconMarkAsRead: Int = 2 + override val iconDelete: Int = 3 + override val iconReply: Int = 4 + override val iconNewMail: Int = 5 + override val iconSendingMail: Int = 6 + override val iconCheckingMail: Int = 7 + override val wearIconMarkAsRead: Int = 8 + override val wearIconDelete: Int = 9 + override val wearIconArchive: Int = 10 + override val wearIconReplyAll: Int = 11 + override val wearIconMarkAsSpam: Int = 12 + + override val pushChannelName = "Synchronize (Push)" + override val pushChannelDescription = "Displayed while waiting for new messages" + override val messagesChannelName = "Messages" + override val messagesChannelDescription = "Notifications related to messages" + override val miscellaneousChannelName = "Miscellaneous" + override val miscellaneousChannelDescription = "Miscellaneous notifications like errors etc." + + override fun authenticationErrorTitle(): String = "Authentication failed" + + override fun authenticationErrorBody(accountName: String): String = + "Authentication failed for $accountName. Update your server settings." + + override fun notifyErrorTitle(): String = "Notification error" + + override fun notifyErrorText(): String { + return "An error has occurred while trying to create a system notification for a new message. " + + "The reason is most likely a missing notification sound.\n" + + "\n" + + "Tap to open notification settings." + } + + override fun certificateErrorTitle(): String = "Certificate error" + + override fun certificateErrorTitle(accountName: String): String = "Certificate error for $accountName" + + override fun certificateErrorBody(): String = "Check your server settings" + + override fun newMessagesTitle(newMessagesCount: Int): String = when (newMessagesCount) { + 1 -> "1 new message" + else -> "$newMessagesCount new messages" + } + + override fun additionalMessages(overflowMessagesCount: Int, accountName: String): String = + "+ $overflowMessagesCount more on $accountName" + + override fun previewEncrypted(): String = "*Encrypted*" + + override fun noSubject(): String = "(No subject)" + + override fun recipientDisplayName(recipientDisplayName: String): String = "To:$recipientDisplayName" + + override fun noSender(): String = "No sender" + + override fun sendFailedTitle(): String = "Failed to send some messages" + + override fun sendingMailTitle(): String = "Sending mail" + + override fun sendingMailBody(accountName: String): String = "Sending mail: $accountName" + + override fun checkingMailTicker(accountName: String, folderName: String): String = + "Checking mail: $accountName:$folderName" + + override fun checkingMailTitle(): String = "Checking mail" + + override fun checkingMailSeparator(): String = ":" + + override fun actionMarkAsRead(): String = "Mark Read" + + override fun actionMarkAllAsRead(): String = "Mark All Read" + + override fun actionDelete(): String = "Delete" + + override fun actionDeleteAll(): String = "Delete All" + + override fun actionReply(): String = "Reply" + + override fun actionArchive(): String = "Archive" + + override fun actionArchiveAll(): String = "Archive All" + + override fun actionMarkAsSpam(): String = "Spam" +} diff --git a/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt b/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt new file mode 100644 index 0000000..5819c1a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt @@ -0,0 +1,75 @@ +package com.fsck.k9.preferences + +import com.fsck.k9.K9RobolectricTest +import com.fsck.k9.Preferences +import com.fsck.k9.mailstore.FolderRepository +import java.io.ByteArrayOutputStream +import org.jdom2.Document +import org.jdom2.input.SAXBuilder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.koin.core.component.inject +import org.mockito.kotlin.mock +import org.robolectric.RuntimeEnvironment + +class SettingsExporterTest : K9RobolectricTest() { + private val contentResolver = RuntimeEnvironment.getApplication().contentResolver + private val preferences: Preferences by inject() + private val folderSettingsProvider: FolderSettingsProvider by inject() + private val folderRepository: FolderRepository by inject() + private val settingsExporter = SettingsExporter( + contentResolver, + preferences, + folderSettingsProvider, + folderRepository, + notificationSettingsUpdater = mock() + ) + + @Test + fun exportPreferences_producesXML() { + val document = exportPreferences(false, emptySet()) + + assertEquals("k9settings", document.rootElement.name) + } + + @Test + fun exportPreferences_setsVersionToLatest() { + val document = exportPreferences(false, emptySet()) + + assertEquals(Settings.VERSION.toString(), document.rootElement.getAttributeValue("version")) + } + + @Test + fun exportPreferences_setsFormatTo1() { + val document = exportPreferences(false, emptySet()) + + assertEquals("1", document.rootElement.getAttributeValue("format")) + } + + @Test + fun exportPreferences_exportsGlobalSettingsWhenRequested() { + val document = exportPreferences(true, emptySet()) + + assertNotNull(document.rootElement.getChild("global")) + } + + @Test + fun exportPreferences_ignoresGlobalSettingsWhenRequested() { + val document = exportPreferences(false, emptySet()) + + assertNull(document.rootElement.getChild("global")) + } + + private fun exportPreferences(globalSettings: Boolean, accounts: Set): Document { + return ByteArrayOutputStream().use { outputStream -> + settingsExporter.exportPreferences(outputStream, globalSettings, accounts) + parseXml(outputStream.toByteArray()) + } + } + + private fun parseXml(xml: ByteArray): Document { + return SAXBuilder().build(xml.inputStream()) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java b/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java new file mode 100644 index 0000000..50504e5 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java @@ -0,0 +1,229 @@ +package com.fsck.k9.preferences; + + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import android.content.Context; + +import com.fsck.k9.K9RobolectricTest; +import com.fsck.k9.Preferences; +import com.fsck.k9.mail.AuthType; +import kotlin.text.Charsets; +import okio.Buffer; +import org.junit.Before; +import org.junit.Test; +import org.robolectric.RuntimeEnvironment; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + + +public class SettingsImporterTest extends K9RobolectricTest { + private final Context context = RuntimeEnvironment.getApplication(); + + @Before + public void before() { + deletePreExistingAccounts(); + } + + private void deletePreExistingAccounts() { + Preferences preferences = Preferences.getPreferences(); + preferences.clearAccounts(); + } + + @Test(expected = SettingsImportExportException.class) + public void importSettings_throwsExceptionOnBlankFile() throws SettingsImportExportException { + InputStream inputStream = inputStreamOf(""); + List accountUuids = new ArrayList<>(); + + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); + } + + @Test(expected = SettingsImportExportException.class) + public void importSettings_throwsExceptionOnMissingFormat() throws SettingsImportExportException { + InputStream inputStream = inputStreamOf(""); + List accountUuids = new ArrayList<>(); + + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); + } + + @Test(expected = SettingsImportExportException.class) + public void importSettings_throwsExceptionOnInvalidFormat() throws SettingsImportExportException { + InputStream inputStream = inputStreamOf(""); + List accountUuids = new ArrayList<>(); + + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); + } + + @Test(expected = SettingsImportExportException.class) + public void importSettings_throwsExceptionOnNonPositiveFormat() throws SettingsImportExportException { + InputStream inputStream = inputStreamOf(""); + List accountUuids = new ArrayList<>(); + + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); + } + + @Test(expected = SettingsImportExportException.class) + public void importSettings_throwsExceptionOnMissingVersion() throws SettingsImportExportException { + InputStream inputStream = inputStreamOf(""); + List accountUuids = new ArrayList<>(); + + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); + } + + @Test(expected = SettingsImportExportException.class) + public void importSettings_throwsExceptionOnInvalidVersion() throws SettingsImportExportException { + InputStream inputStream = inputStreamOf(""); + List accountUuids = new ArrayList<>(); + + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); + } + + @Test(expected = SettingsImportExportException.class) + public void importSettings_throwsExceptionOnNonPositiveVersion() throws SettingsImportExportException { + InputStream inputStream = inputStreamOf(""); + List accountUuids = new ArrayList<>(); + + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); + } + + @Test + public void parseSettings_account() throws SettingsImportExportException { + String validUUID = UUID.randomUUID().toString(); + InputStream inputStream = inputStreamOf("" + + "Account"); + List accountUuids = new ArrayList<>(); + accountUuids.add("1"); + + SettingsImporter.Imported results = SettingsImporter.parseSettings(inputStream, true, accountUuids, true); + + assertEquals(1, results.accounts.size()); + assertEquals("Account", results.accounts.get(validUUID).name); + assertEquals(validUUID, results.accounts.get(validUUID).uuid); + } + + @Test + public void parseSettings_account_identities() throws SettingsImportExportException { + String validUUID = UUID.randomUUID().toString(); + InputStream inputStream = inputStreamOf("" + + "Account" + + "user@gmail.com" + + ""); + List accountUuids = new ArrayList<>(); + accountUuids.add("1"); + + SettingsImporter.Imported results = SettingsImporter.parseSettings(inputStream, true, accountUuids, true); + + assertEquals(1, results.accounts.size()); + assertEquals(validUUID, results.accounts.get(validUUID).uuid); + assertEquals(1, results.accounts.get(validUUID).identities.size()); + assertEquals("user@gmail.com", results.accounts.get(validUUID).identities.get(0).email); + } + + + @Test + public void parseSettings_account_cram_md5() throws SettingsImportExportException { + String validUUID = UUID.randomUUID().toString(); + InputStream inputStream = inputStreamOf("" + + "Account" + + "CRAM_MD5" + + ""); + List accountUuids = new ArrayList<>(); + accountUuids.add(validUUID); + + SettingsImporter.Imported results = SettingsImporter.parseSettings(inputStream, true, accountUuids, false); + + assertEquals("Account", results.accounts.get(validUUID).name); + assertEquals(validUUID, results.accounts.get(validUUID).uuid); + assertEquals(AuthType.CRAM_MD5, results.accounts.get(validUUID).incoming.authenticationType); + } + + @Test + public void importSettings_disablesAccountsNeedingPasswords() throws SettingsImportExportException { + String validUUID = UUID.randomUUID().toString(); + InputStream inputStream = inputStreamOf("" + + "Account" + + "" + + "SSL_TLS_REQUIRED" + + "user@gmail.com" + + "CRAM_MD5" + + "googlemail.com" + + "" + + "" + + "SSL_TLS_REQUIRED" + + "user@googlemail.com" + + "CRAM_MD5" + + "googlemail.com" + + "" + + "b" + + "user@gmail.com" + + ""); + List accountUuids = new ArrayList<>(); + accountUuids.add(validUUID); + + SettingsImporter.ImportResults results = SettingsImporter.importSettings( + context, inputStream, true, accountUuids, false); + + assertEquals(0, results.erroneousAccounts.size()); + assertEquals(1, results.importedAccounts.size()); + assertEquals("Account", results.importedAccounts.get(0).imported.name); + assertEquals(validUUID, results.importedAccounts.get(0).imported.uuid); + assertTrue(results.importedAccounts.get(0).incomingPasswordNeeded); + assertTrue(results.importedAccounts.get(0).outgoingPasswordNeeded); + } + + @Test + public void getImportStreamContents_account() throws SettingsImportExportException { + String validUUID = UUID.randomUUID().toString(); + InputStream inputStream = inputStreamOf("" + + "" + + "" + + "Account" + + "" + + "" + + "user@gmail.com" + + "" + + "" + + "" + + ""); + + SettingsImporter.ImportContents results = SettingsImporter.getImportStreamContents(inputStream); + + assertEquals(false, results.globalSettings); + assertEquals(1, results.accounts.size()); + assertEquals("Account", results.accounts.get(0).name); + assertEquals(validUUID, results.accounts.get(0).uuid); + } + + @Test + public void getImportStreamContents_alternativeName() throws SettingsImportExportException { + String validUUID = UUID.randomUUID().toString(); + InputStream inputStream = inputStreamOf("" + + "" + + "" + + "" + + "" + + "" + + "user@gmail.com" + + "" + + "" + + "" + + ""); + + SettingsImporter.ImportContents results = SettingsImporter.getImportStreamContents(inputStream); + + assertEquals(false, results.globalSettings); + assertEquals(1, results.accounts.size()); + assertEquals("user@gmail.com", results.accounts.get(0).name); + assertEquals(validUUID, results.accounts.get(0).uuid); + } + + private InputStream inputStreamOf(String data) { + return new Buffer() + .writeString(data, Charsets.UTF_8) + .inputStream(); + } +} diff --git a/app/core/src/test/java/com/fsck/k9/sasl/OAuthBearerTest.kt b/app/core/src/test/java/com/fsck/k9/sasl/OAuthBearerTest.kt new file mode 100644 index 0000000..fbb10d6 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/sasl/OAuthBearerTest.kt @@ -0,0 +1,32 @@ +package com.fsck.k9.sasl + +import assertk.assertThat +import assertk.assertions.isEqualTo +import okio.ByteString.Companion.decodeBase64 +import org.junit.Test + +class OAuthBearerTest { + @Test + fun `username that does not need encoding`() { + val username = "user@domain.example" + val token = "token" + + val result = buildOAuthBearerInitialClientResponse(username, token) + + assertThat(result).isEqualTo("bixhPXVzZXJAZG9tYWluLmV4YW1wbGUsAWF1dGg9QmVhcmVyIHRva2VuAQE=") + assertThat(result.decodeBase64()?.utf8()) + .isEqualTo("n,a=user@domain.example,\u0001auth=Bearer token\u0001\u0001") + } + + @Test + fun `username contains equal sign that needs to be encoded`() { + val username = "user=name@domain.example" + val token = "token" + + val result = buildOAuthBearerInitialClientResponse(username, token) + + assertThat(result).isEqualTo("bixhPXVzZXI9M0RuYW1lQGRvbWFpbi5leGFtcGxlLAFhdXRoPUJlYXJlciB0b2tlbgEB") + assertThat(result.decodeBase64()?.utf8()) + .isEqualTo("n,a=user=3Dname@domain.example,\u0001auth=Bearer token\u0001\u0001") + } +} diff --git a/app/core/src/test/java/com/fsck/k9/setup/ServerNameSuggesterTest.java b/app/core/src/test/java/com/fsck/k9/setup/ServerNameSuggesterTest.java new file mode 100644 index 0000000..7b05276 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/setup/ServerNameSuggesterTest.java @@ -0,0 +1,59 @@ +package com.fsck.k9.setup; + + +import com.fsck.k9.preferences.Protocols; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class ServerNameSuggesterTest { + private ServerNameSuggester serverNameSuggester; + + + @Before + public void setUp() throws Exception { + serverNameSuggester = new ServerNameSuggester(); + } + + @Test + public void suggestServerName_forImapServer() throws Exception { + String serverType = Protocols.IMAP; + String domainPart = "example.org"; + + String result = serverNameSuggester.suggestServerName(serverType, domainPart); + + assertEquals("imap.example.org", result); + } + + @Test + public void suggestServerName_forPop3Server() throws Exception { + String serverType = Protocols.POP3; + String domainPart = "example.org"; + + String result = serverNameSuggester.suggestServerName(serverType, domainPart); + + assertEquals("pop3.example.org", result); + } + + @Test + public void suggestServerName_forWebDavServer() throws Exception { + String serverType = Protocols.WEBDAV; + String domainPart = "example.org"; + + String result = serverNameSuggester.suggestServerName(serverType, domainPart); + + assertEquals("exchange.example.org", result); + } + + @Test + public void suggestServerName_forSmtpServer() throws Exception { + String serverType = Protocols.SMTP; + String domainPart = "example.org"; + + String result = serverNameSuggester.suggestServerName(serverType, domainPart); + + assertEquals("smtp.example.org", result); + } +} diff --git a/app/core/src/test/resources/autocrypt/no_autocrypt.eml b/app/core/src/test/resources/autocrypt/no_autocrypt.eml new file mode 100644 index 0000000..947af1c --- /dev/null +++ b/app/core/src/test/resources/autocrypt/no_autocrypt.eml @@ -0,0 +1,11 @@ +From: Alice +To: Bob +Subject: INBOME with invalid type attribute +Date: Sat, 17 Dec 2016 10:51:48 +0100 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This message contains no INBOME header + +An agent capable of INBOME level 0 should not try to parse it as a header and crash. diff --git a/app/core/src/test/resources/autocrypt/rsa2048-broken-base64.eml b/app/core/src/test/resources/autocrypt/rsa2048-broken-base64.eml new file mode 100644 index 0000000..b2a984d --- /dev/null +++ b/app/core/src/test/resources/autocrypt/rsa2048-broken-base64.eml @@ -0,0 +1,35 @@ +From: Alice +To: Bob +Subject: an INBOME RSA test +Autocrypt: addr=alice@testsuite.autocrypt.org; keydata= + m!@#$%hVF+ABCADu17FBUgA3mCemeKbNaBTyWe3VGxjbu7fUyHgdLK7i3tnd7IRtxQy/AEN2t6Vq + 0/xeZEAKYRInsHI/HjvmhqPeWFzipk71jRQ02WUY1pZytFjYNIrTdMk4eLYdC1N0go83PU33V4R8 + fc2fWHD8N5JPsDH2xOB6WNWkMPxgMbtGIa0QTx7TINhDif4/1/VcrX3wz1gZ6xYI+sujbC54iBZo + qbEfu4SFVvp53d+a1plxBzuZ/X6nqJqcysiS7ORMieBvU6W/mVeiAxwN4qcAI5s+rGmRnP8ltONK + /P1ScH6lmELgqm8Z/M0wdiYgywme/bdEQOg3s0S/8nCIFmwUchN7ABEBAAG0HWFsaWNlQHRlc3Rz + dWl0ZS5hdXRvY3J5cHQub3JniQFOBBMBCAA4FiEEfi47NkGai9tG9hBruvxTPNmTvX8FAlhVF+AC + GwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQuvxTPNmTvX/k4wf+JJZ0M0rZeAXbnxdR6HDU + ZYL734Z8x/HRpz3vzK4VQQJ4oIbUQPwydZmAlTlglQY48IWWOdJnYvn2pIhlTM/T8q9ZfmOyp6i1 + jxFCPT+2ma4DjNOqYFhfnULE/MYc6xeVaBcwGj7yvAW7YY7156/wDo6+9TCd/a9mzOFCGS0yQoRa + K3uDajA+G/SmbC8t/3X8+5sapvi9Ru0HNkIzaj1jhH+kW6628E7nkf9aN9LodXHfs1UtfuLqM8VG + Ysk9474x9QxbsrJ4YvXeFwM9zAs+Pvj4lnpH/0WOU8jJc3uarluGH58kTHM5/5+p0TeMpOHX7OEw + JndsBOV9gFc6FMx4hLkBDQRYVRfiAQgA3ad+Aat4UY8xvQQutLYb8e417XZN1zVmKypyReB0l0Zf + HA6Qc7uxnJQ7dzIEZAxdnjvTYJaCFrOCBXAyPHpShVMpKqQP+kBBY/WiC3BSUALR3xqp7k5/sjLD + +K4dAacXEc7nyXP5o5+oqBXEH8Ls5X440c9A3EdsvVlncvSW5ILLItlFHmQd6f1ynnjK+FQwYJRJ + ypDuqJpYkA1vn7+XxeQShpX105rM3C8tJUxRAP3QFimenn4Zm2BDhQpCneuBt239rkXOAXsR0PnJ + fV8eNAEsE8IIqnPoSlBme5DZAri69+joYmTeSKGuj4aoxzDlx1AQigwpMISLciTXLnJypwARAQAB + iQE2BBgBCAAgFiEEfi47NkGai9tG9hBruvxTPNmTvX8FAlhVF+ICGwwACgkQuvxTPNmTvX8dOwf9 + H72BGoYJkuuFrbQ6F/mH7gG9z3ytQHRD2Z0ja+3O7YnJBpotHFFjF7yHGj0FtmQR0Q7KnhkJ/3mv + fkvuaH3Gcjli/E7VASastuFDFkGANLmGZVGQQ2iTYFG1aejjtGb01vcaPrgE9WDueMB+Pn6/QbDc + 5SWCrVWrRFZKrwbAGw35GySoFYpxXyCNsk6q6Db56plllPZjrYj7axF0yN536D1ntEVFDOdKZq8x + Tb9P/4Tq9NKRLE4+aO6qCqEOz+V1OeOvYLw58BfnzXY8rXF93D/86YLyilv6p5WGaS/cRhIzr+Xq + +qBLD/vW+dh72e8MvcduX3tXV3Vkg0mkGekdOw== +Date: Sat, 17 Dec 2016 10:07:48 +0100 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This contains an INBOME header with invalid characters in the base64 key +data. + +This header should be ignored by all user agents. diff --git a/app/core/src/test/resources/autocrypt/rsa2048-explicit-type.eml b/app/core/src/test/resources/autocrypt/rsa2048-explicit-type.eml new file mode 100644 index 0000000..72a6b95 --- /dev/null +++ b/app/core/src/test/resources/autocrypt/rsa2048-explicit-type.eml @@ -0,0 +1,40 @@ +From: Alice +To: Bob +Subject: inbome rsa2048 with correct type +Autocrypt: addr=alice@testsuite.autocrypt.org; type=1; keydata= + mQENBFhVF+ABCADu17FBUgA3mCemeKbNaBTyWe3VGxjbu7fUyHgdLK7i3tnd7IRtxQy/AEN2t6Vq + 0/xeZEAKYRInsHI/HjvmhqPeWFzipk71jRQ02WUY1pZytFjYNIrTdMk4eLYdC1N0go83PU33V4R8 + fc2fWHD8N5JPsDH2xOB6WNWkMPxgMbtGIa0QTx7TINhDif4/1/VcrX3wz1gZ6xYI+sujbC54iBZo + qbEfu4SFVvp53d+a1plxBzuZ/X6nqJqcysiS7ORMieBvU6W/mVeiAxwN4qcAI5s+rGmRnP8ltONK + /P1ScH6lmELgqm8Z/M0wdiYgywme/bdEQOg3s0S/8nCIFmwUchN7ABEBAAG0HWFsaWNlQHRlc3Rz + dWl0ZS5hdXRvY3J5cHQub3JniQFOBBMBCAA4FiEEfi47NkGai9tG9hBruvxTPNmTvX8FAlhVF+AC + GwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQuvxTPNmTvX/k4wf+JJZ0M0rZeAXbnxdR6HDU + ZYL734Z8x/HRpz3vzK4VQQJ4oIbUQPwydZmAlTlglQY48IWWOdJnYvn2pIhlTM/T8q9ZfmOyp6i1 + jxFCPT+2ma4DjNOqYFhfnULE/MYc6xeVaBcwGj7yvAW7YY7156/wDo6+9TCd/a9mzOFCGS0yQoRa + K3uDajA+G/SmbC8t/3X8+5sapvi9Ru0HNkIzaj1jhH+kW6628E7nkf9aN9LodXHfs1UtfuLqM8VG + Ysk9474x9QxbsrJ4YvXeFwM9zAs+Pvj4lnpH/0WOU8jJc3uarluGH58kTHM5/5+p0TeMpOHX7OEw + JndsBOV9gFc6FMx4hLkBDQRYVRfiAQgA3ad+Aat4UY8xvQQutLYb8e417XZN1zVmKypyReB0l0Zf + HA6Qc7uxnJQ7dzIEZAxdnjvTYJaCFrOCBXAyPHpShVMpKqQP+kBBY/WiC3BSUALR3xqp7k5/sjLD + +K4dAacXEc7nyXP5o5+oqBXEH8Ls5X440c9A3EdsvVlncvSW5ILLItlFHmQd6f1ynnjK+FQwYJRJ + ypDuqJpYkA1vn7+XxeQShpX105rM3C8tJUxRAP3QFimenn4Zm2BDhQpCneuBt239rkXOAXsR0PnJ + fV8eNAEsE8IIqnPoSlBme5DZAri69+joYmTeSKGuj4aoxzDlx1AQigwpMISLciTXLnJypwARAQAB + iQE2BBgBCAAgFiEEfi47NkGai9tG9hBruvxTPNmTvX8FAlhVF+ICGwwACgkQuvxTPNmTvX8dOwf9 + H72BGoYJkuuFrbQ6F/mH7gG9z3ytQHRD2Z0ja+3O7YnJBpotHFFjF7yHGj0FtmQR0Q7KnhkJ/3mv + fkvuaH3Gcjli/E7VASastuFDFkGANLmGZVGQQ2iTYFG1aejjtGb01vcaPrgE9WDueMB+Pn6/QbDc + 5SWCrVWrRFZKrwbAGw35GySoFYpxXyCNsk6q6Db56plllPZjrYj7axF0yN536D1ntEVFDOdKZq8x + Tb9P/4Tq9NKRLE4+aO6qCqEOz+V1OeOvYLw58BfnzXY8rXF93D/86YLyilv6p5WGaS/cRhIzr+Xq + +qBLD/vW+dh72e8MvcduX3tXV3Vkg0mkGekdOw== +Date: Sat, 17 Dec 2016 10:49:02 +0100 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This message contains the standard INBOME header, but includes an +explicit "type=p" attribute. This is the default value of type, so it +should not be necessary to include this attribute. + +"p" signifies that the key value is a specialized subset of OpenPGP. + +This should be accepted by any agent capable of INBOME level 0. + + --dkg diff --git a/app/core/src/test/resources/autocrypt/rsa2048-simple-to-bot.eml b/app/core/src/test/resources/autocrypt/rsa2048-simple-to-bot.eml new file mode 100644 index 0000000..09241ab --- /dev/null +++ b/app/core/src/test/resources/autocrypt/rsa2048-simple-to-bot.eml @@ -0,0 +1,34 @@ +From: Alice +To: Autocrypt-Bot +Subject: an INBOME RSA test +INBOME: to=alice@testsuite.autocrypt.org; keydata= + mQENBFhVF+ABCADu17FBUgA3mCemeKbNaBTyWe3VGxjbu7fUyHgdLK7i3tnd7IRtxQy/AEN2t6Vq + 0/xeZEAKYRInsHI/HjvmhqPeWFzipk71jRQ02WUY1pZytFjYNIrTdMk4eLYdC1N0go83PU33V4R8 + fc2fWHD8N5JPsDH2xOB6WNWkMPxgMbtGIa0QTx7TINhDif4/1/VcrX3wz1gZ6xYI+sujbC54iBZo + qbEfu4SFVvp53d+a1plxBzuZ/X6nqJqcysiS7ORMieBvU6W/mVeiAxwN4qcAI5s+rGmRnP8ltONK + /P1ScH6lmELgqm8Z/M0wdiYgywme/bdEQOg3s0S/8nCIFmwUchN7ABEBAAG0HWFsaWNlQHRlc3Rz + dWl0ZS5hdXRvY3J5cHQub3JniQFOBBMBCAA4FiEEfi47NkGai9tG9hBruvxTPNmTvX8FAlhVF+AC + GwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQuvxTPNmTvX/k4wf+JJZ0M0rZeAXbnxdR6HDU + ZYL734Z8x/HRpz3vzK4VQQJ4oIbUQPwydZmAlTlglQY48IWWOdJnYvn2pIhlTM/T8q9ZfmOyp6i1 + jxFCPT+2ma4DjNOqYFhfnULE/MYc6xeVaBcwGj7yvAW7YY7156/wDo6+9TCd/a9mzOFCGS0yQoRa + K3uDajA+G/SmbC8t/3X8+5sapvi9Ru0HNkIzaj1jhH+kW6628E7nkf9aN9LodXHfs1UtfuLqM8VG + Ysk9474x9QxbsrJ4YvXeFwM9zAs+Pvj4lnpH/0WOU8jJc3uarluGH58kTHM5/5+p0TeMpOHX7OEw + JndsBOV9gFc6FMx4hLkBDQRYVRfiAQgA3ad+Aat4UY8xvQQutLYb8e417XZN1zVmKypyReB0l0Zf + HA6Qc7uxnJQ7dzIEZAxdnjvTYJaCFrOCBXAyPHpShVMpKqQP+kBBY/WiC3BSUALR3xqp7k5/sjLD + +K4dAacXEc7nyXP5o5+oqBXEH8Ls5X440c9A3EdsvVlncvSW5ILLItlFHmQd6f1ynnjK+FQwYJRJ + ypDuqJpYkA1vn7+XxeQShpX105rM3C8tJUxRAP3QFimenn4Zm2BDhQpCneuBt239rkXOAXsR0PnJ + fV8eNAEsE8IIqnPoSlBme5DZAri69+joYmTeSKGuj4aoxzDlx1AQigwpMISLciTXLnJypwARAQAB + iQE2BBgBCAAgFiEEfi47NkGai9tG9hBruvxTPNmTvX8FAlhVF+ICGwwACgkQuvxTPNmTvX8dOwf9 + H72BGoYJkuuFrbQ6F/mH7gG9z3ytQHRD2Z0ja+3O7YnJBpotHFFjF7yHGj0FtmQR0Q7KnhkJ/3mv + fkvuaH3Gcjli/E7VASastuFDFkGANLmGZVGQQ2iTYFG1aejjtGb01vcaPrgE9WDueMB+Pn6/QbDc + 5SWCrVWrRFZKrwbAGw35GySoFYpxXyCNsk6q6Db56plllPZjrYj7axF0yN536D1ntEVFDOdKZq8x + Tb9P/4Tq9NKRLE4+aO6qCqEOz+V1OeOvYLw58BfnzXY8rXF93D/86YLyilv6p5WGaS/cRhIzr+Xq + +qBLD/vW+dh72e8MvcduX3tXV3Vkg0mkGekdOw== +Date: Sat, 17 Dec 2016 10:07:48 +0100 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This contains a default, minimal INBOME header using an RSA 2048 key. + +This should be importable and valid by any agent supporting INBOME level 0. diff --git a/app/core/src/test/resources/autocrypt/rsa2048-simple.eml b/app/core/src/test/resources/autocrypt/rsa2048-simple.eml new file mode 100644 index 0000000..56417bc --- /dev/null +++ b/app/core/src/test/resources/autocrypt/rsa2048-simple.eml @@ -0,0 +1,34 @@ +From: Alice +To: Bob +Subject: an INBOME RSA test +Autocrypt: addr=alice@testsuite.autocrypt.org; keydata= + mQENBFhVF+ABCADu17FBUgA3mCemeKbNaBTyWe3VGxjbu7fUyHgdLK7i3tnd7IRtxQy/AEN2t6Vq + 0/xeZEAKYRInsHI/HjvmhqPeWFzipk71jRQ02WUY1pZytFjYNIrTdMk4eLYdC1N0go83PU33V4R8 + fc2fWHD8N5JPsDH2xOB6WNWkMPxgMbtGIa0QTx7TINhDif4/1/VcrX3wz1gZ6xYI+sujbC54iBZo + qbEfu4SFVvp53d+a1plxBzuZ/X6nqJqcysiS7ORMieBvU6W/mVeiAxwN4qcAI5s+rGmRnP8ltONK + /P1ScH6lmELgqm8Z/M0wdiYgywme/bdEQOg3s0S/8nCIFmwUchN7ABEBAAG0HWFsaWNlQHRlc3Rz + dWl0ZS5hdXRvY3J5cHQub3JniQFOBBMBCAA4FiEEfi47NkGai9tG9hBruvxTPNmTvX8FAlhVF+AC + GwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQuvxTPNmTvX/k4wf+JJZ0M0rZeAXbnxdR6HDU + ZYL734Z8x/HRpz3vzK4VQQJ4oIbUQPwydZmAlTlglQY48IWWOdJnYvn2pIhlTM/T8q9ZfmOyp6i1 + jxFCPT+2ma4DjNOqYFhfnULE/MYc6xeVaBcwGj7yvAW7YY7156/wDo6+9TCd/a9mzOFCGS0yQoRa + K3uDajA+G/SmbC8t/3X8+5sapvi9Ru0HNkIzaj1jhH+kW6628E7nkf9aN9LodXHfs1UtfuLqM8VG + Ysk9474x9QxbsrJ4YvXeFwM9zAs+Pvj4lnpH/0WOU8jJc3uarluGH58kTHM5/5+p0TeMpOHX7OEw + JndsBOV9gFc6FMx4hLkBDQRYVRfiAQgA3ad+Aat4UY8xvQQutLYb8e417XZN1zVmKypyReB0l0Zf + HA6Qc7uxnJQ7dzIEZAxdnjvTYJaCFrOCBXAyPHpShVMpKqQP+kBBY/WiC3BSUALR3xqp7k5/sjLD + +K4dAacXEc7nyXP5o5+oqBXEH8Ls5X440c9A3EdsvVlncvSW5ILLItlFHmQd6f1ynnjK+FQwYJRJ + ypDuqJpYkA1vn7+XxeQShpX105rM3C8tJUxRAP3QFimenn4Zm2BDhQpCneuBt239rkXOAXsR0PnJ + fV8eNAEsE8IIqnPoSlBme5DZAri69+joYmTeSKGuj4aoxzDlx1AQigwpMISLciTXLnJypwARAQAB + iQE2BBgBCAAgFiEEfi47NkGai9tG9hBruvxTPNmTvX8FAlhVF+ICGwwACgkQuvxTPNmTvX8dOwf9 + H72BGoYJkuuFrbQ6F/mH7gG9z3ytQHRD2Z0ja+3O7YnJBpotHFFjF7yHGj0FtmQR0Q7KnhkJ/3mv + fkvuaH3Gcjli/E7VASastuFDFkGANLmGZVGQQ2iTYFG1aejjtGb01vcaPrgE9WDueMB+Pn6/QbDc + 5SWCrVWrRFZKrwbAGw35GySoFYpxXyCNsk6q6Db56plllPZjrYj7axF0yN536D1ntEVFDOdKZq8x + Tb9P/4Tq9NKRLE4+aO6qCqEOz+V1OeOvYLw58BfnzXY8rXF93D/86YLyilv6p5WGaS/cRhIzr+Xq + +qBLD/vW+dh72e8MvcduX3tXV3Vkg0mkGekdOw== +Date: Sat, 17 Dec 2016 10:07:48 +0100 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This contains a default, minimal INBOME header using an RSA 2048 key. + +This should be importable and valid by any agent supporting INBOME level 0. diff --git a/app/core/src/test/resources/autocrypt/rsa2048-unknown-critical.eml b/app/core/src/test/resources/autocrypt/rsa2048-unknown-critical.eml new file mode 100644 index 0000000..05edf24 --- /dev/null +++ b/app/core/src/test/resources/autocrypt/rsa2048-unknown-critical.eml @@ -0,0 +1,35 @@ +From: Alice +To: Bob +Subject: another inbome with rsa2048 but with an unknown critical attribute +Autocrypt: addr=alice@testsuite.autocrypt.org; danger=do-not-use; keydata= + mQENBFhVGA8BCADK+qTRkAfax0LtJ6RiyxzuAFyIohBTwvtcOM2sd/tRmWq1eyNif5AGDnc1+b6X + zJ6l3BXiYM/8qXU/F04UA5BP05SgIqXjqT5I13blrydjKtUbZFchK7lJU7cyDbar+TH70DZURSQm + MusCj0+fdx6hx8y4LSOM68rjwVeq7JXAPU78QQsYgMrbtkf5mZWUquDdb7tEoxU+PcNifvtvuHF2 + ILv09a4Fi8thJG4i/3LxMFtmMLIiZWLfk5KpXAKrOy436e1LCm3vesALcihPNppb803dgBqpvvEE + 9W7sg5NUy3P8+fTEuvI8HYYd+lEvYe2ojm4HVTts4YFHmzaGVzHLABEBAAG0HVRoaXMgc2hvdWxk + IG5ldmVyIGJlIGltcG9ydGVkiQFOBBMBCAA4FiEE0uuMX0KSMgVBfC/25MZusLe5gWMFAlhVGA8C + GwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQ5MZusLe5gWN9Hwf+PpLCCV7TiGc1nqIxLMTs + O84PVLSQZB642/QhLoMYXQ5iqty5H2FqGuK+uWLCnM+yIMDkcJC3ayWfa06fs3JOipVKlMh8hHnU + 6/FHJB+3eZrc4lhh5B67Vi8Xg43pTP+I9ct/PlbHvD9kYw+DpcmCz0XILhaUP0R1oQ6M5KI49uLg + LAdNczcEtcw3A/hZ5ZTUe3o3gav0XDBXFCgGjkI+CaMjKb/HjgNM9YsrGxUxH1RFMYqTfrmCklHD + EboQc1Qtzi5rIwzVR3zSryve9KHH75TCfDApghwUBKSLNh374hjTFj5v5kPAxG3njX6EOqHS/UVX + Mn5aEVn0n6S1y+DJZLkBDQRYVRgRAQgAzQgD/CluB1wuBeI8qaqmIxG8epHCPstQ4kee6FuFWi3F + Lqtyk1R9tB4UL40gpEkpzB+qYms/zs9SeicuNcXoXA4bMcNGDFz3mZ1d9qG2izgC19e9p50oXiMY + cr8GM1Qcb77dmxlk829cBpr+X7NDKJy9VMGsqNYukgFDnNIzty0oMdCLSzpqi3UtXtCGYDqIiltU + aT8XdMAvddr6Scgpkz3wrqi/bVagc+q4IdKL0r8iL7o3EnTf/5Dc2XUaCFJLCa3Rk6oat5kTWjan + sp/K5k/VzSDcESji8n6xl0OzD2okhmX8iJZg1hhyI8hNmtW3boe51Hkkdlj+wC8Y2Fgh4QARAQAB + iQE2BBgBCAAgFiEE0uuMX0KSMgVBfC/25MZusLe5gWMFAlhVGBECGwwACgkQ5MZusLe5gWOn/Qf/ + aeV7CqZW/YN4/LhXjJG7i+iDJYv/9Lr12dvgjO/sOlmDPHkEzXPMLKalm0biMPN7E1woQzcKt7Qy + eF/CRcVKK1TM6wdClOj2jErnWyx85/uZfnG9QRD41rhInk891A8LGebPZ6DJeJR/uwzMniEgNnKN + AMuGy95ckwlM3AfwzsKPTUUFnBAmSwWfMLRxjZPNefeo1Ic8mMRAT3d5sfDUx/4wm8tyiNLuOSkm + Ej6ONYpESD2sJGMo3ZY96pkzir7ZH++4mH6PwZg1ZT2nO+0PtaB9DHRGfBrzH85d4aLFZD9txx3p + ewabrNpYI/cJu9hUTaTM7wZaG5kmfStwihKYUg== +Date: Sat, 17 Dec 2016 10:32:49 +0100 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This message contains an INBOME header with RSA 2048, but with an +unknown critical attribute. Agents that are compatible with INBOME +level 0 should ignore this header because of the unknown critical +attribute. diff --git a/app/core/src/test/resources/autocrypt/rsa2048-unknown-non-critical.eml b/app/core/src/test/resources/autocrypt/rsa2048-unknown-non-critical.eml new file mode 100644 index 0000000..f5e046b --- /dev/null +++ b/app/core/src/test/resources/autocrypt/rsa2048-unknown-non-critical.eml @@ -0,0 +1,36 @@ +From: Alice +To: Bob +Subject: inbome: rsa 2048 with unknown non-critical attribute +Autocrypt: addr=alice@testsuite.autocrypt.org; _monkey=ignore; keydata= + mQENBFhVF+ABCADu17FBUgA3mCemeKbNaBTyWe3VGxjbu7fUyHgdLK7i3tnd7IRtxQy/AEN2t6Vq + 0/xeZEAKYRInsHI/HjvmhqPeWFzipk71jRQ02WUY1pZytFjYNIrTdMk4eLYdC1N0go83PU33V4R8 + fc2fWHD8N5JPsDH2xOB6WNWkMPxgMbtGIa0QTx7TINhDif4/1/VcrX3wz1gZ6xYI+sujbC54iBZo + qbEfu4SFVvp53d+a1plxBzuZ/X6nqJqcysiS7ORMieBvU6W/mVeiAxwN4qcAI5s+rGmRnP8ltONK + /P1ScH6lmELgqm8Z/M0wdiYgywme/bdEQOg3s0S/8nCIFmwUchN7ABEBAAG0HWFsaWNlQHRlc3Rz + dWl0ZS5hdXRvY3J5cHQub3JniQFOBBMBCAA4FiEEfi47NkGai9tG9hBruvxTPNmTvX8FAlhVF+AC + GwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQuvxTPNmTvX/k4wf+JJZ0M0rZeAXbnxdR6HDU + ZYL734Z8x/HRpz3vzK4VQQJ4oIbUQPwydZmAlTlglQY48IWWOdJnYvn2pIhlTM/T8q9ZfmOyp6i1 + jxFCPT+2ma4DjNOqYFhfnULE/MYc6xeVaBcwGj7yvAW7YY7156/wDo6+9TCd/a9mzOFCGS0yQoRa + K3uDajA+G/SmbC8t/3X8+5sapvi9Ru0HNkIzaj1jhH+kW6628E7nkf9aN9LodXHfs1UtfuLqM8VG + Ysk9474x9QxbsrJ4YvXeFwM9zAs+Pvj4lnpH/0WOU8jJc3uarluGH58kTHM5/5+p0TeMpOHX7OEw + JndsBOV9gFc6FMx4hLkBDQRYVRfiAQgA3ad+Aat4UY8xvQQutLYb8e417XZN1zVmKypyReB0l0Zf + HA6Qc7uxnJQ7dzIEZAxdnjvTYJaCFrOCBXAyPHpShVMpKqQP+kBBY/WiC3BSUALR3xqp7k5/sjLD + +K4dAacXEc7nyXP5o5+oqBXEH8Ls5X440c9A3EdsvVlncvSW5ILLItlFHmQd6f1ynnjK+FQwYJRJ + ypDuqJpYkA1vn7+XxeQShpX105rM3C8tJUxRAP3QFimenn4Zm2BDhQpCneuBt239rkXOAXsR0PnJ + fV8eNAEsE8IIqnPoSlBme5DZAri69+joYmTeSKGuj4aoxzDlx1AQigwpMISLciTXLnJypwARAQAB + iQE2BBgBCAAgFiEEfi47NkGai9tG9hBruvxTPNmTvX8FAlhVF+ICGwwACgkQuvxTPNmTvX8dOwf9 + H72BGoYJkuuFrbQ6F/mH7gG9z3ytQHRD2Z0ja+3O7YnJBpotHFFjF7yHGj0FtmQR0Q7KnhkJ/3mv + fkvuaH3Gcjli/E7VASastuFDFkGANLmGZVGQQ2iTYFG1aejjtGb01vcaPrgE9WDueMB+Pn6/QbDc + 5SWCrVWrRFZKrwbAGw35GySoFYpxXyCNsk6q6Db56plllPZjrYj7axF0yN536D1ntEVFDOdKZq8x + Tb9P/4Tq9NKRLE4+aO6qCqEOz+V1OeOvYLw58BfnzXY8rXF93D/86YLyilv6p5WGaS/cRhIzr+Xq + +qBLD/vW+dh72e8MvcduX3tXV3Vkg0mkGekdOw== +Date: Sat, 17 Dec 2016 10:34:30 +0100 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This message contains an INBOME header with RSA 2048, but with an +unknown non-critical attribute. Agents that are compatible with +INBOME level 0 should accept this header while ignoring the unknown +attribute. + diff --git a/app/core/src/test/resources/autocrypt/unknown-type.eml b/app/core/src/test/resources/autocrypt/unknown-type.eml new file mode 100644 index 0000000..bf79de0 --- /dev/null +++ b/app/core/src/test/resources/autocrypt/unknown-type.eml @@ -0,0 +1,36 @@ +From: Alice +To: Bob +Subject: INBOME with invalid type attribute +Autocrypt: addr=alice@testsuite.autocrypt.org; type=x; keydata= + mQENBFhVGA8BCADK+qTRkAfax0LtJ6RiyxzuAFyIohBTwvtcOM2sd/tRmWq1eyNif5AGDnc1+b6X + zJ6l3BXiYM/8qXU/F04UA5BP05SgIqXjqT5I13blrydjKtUbZFchK7lJU7cyDbar+TH70DZURSQm + MusCj0+fdx6hx8y4LSOM68rjwVeq7JXAPU78QQsYgMrbtkf5mZWUquDdb7tEoxU+PcNifvtvuHF2 + ILv09a4Fi8thJG4i/3LxMFtmMLIiZWLfk5KpXAKrOy436e1LCm3vesALcihPNppb803dgBqpvvEE + 9W7sg5NUy3P8+fTEuvI8HYYd+lEvYe2ojm4HVTts4YFHmzaGVzHLABEBAAG0HVRoaXMgc2hvdWxk + IG5ldmVyIGJlIGltcG9ydGVkiQFOBBMBCAA4FiEE0uuMX0KSMgVBfC/25MZusLe5gWMFAlhVGA8C + GwMFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQ5MZusLe5gWN9Hwf+PpLCCV7TiGc1nqIxLMTs + O84PVLSQZB642/QhLoMYXQ5iqty5H2FqGuK+uWLCnM+yIMDkcJC3ayWfa06fs3JOipVKlMh8hHnU + 6/FHJB+3eZrc4lhh5B67Vi8Xg43pTP+I9ct/PlbHvD9kYw+DpcmCz0XILhaUP0R1oQ6M5KI49uLg + LAdNczcEtcw3A/hZ5ZTUe3o3gav0XDBXFCgGjkI+CaMjKb/HjgNM9YsrGxUxH1RFMYqTfrmCklHD + EboQc1Qtzi5rIwzVR3zSryve9KHH75TCfDApghwUBKSLNh374hjTFj5v5kPAxG3njX6EOqHS/UVX + Mn5aEVn0n6S1y+DJZLkBDQRYVRgRAQgAzQgD/CluB1wuBeI8qaqmIxG8epHCPstQ4kee6FuFWi3F + Lqtyk1R9tB4UL40gpEkpzB+qYms/zs9SeicuNcXoXA4bMcNGDFz3mZ1d9qG2izgC19e9p50oXiMY + cr8GM1Qcb77dmxlk829cBpr+X7NDKJy9VMGsqNYukgFDnNIzty0oMdCLSzpqi3UtXtCGYDqIiltU + aT8XdMAvddr6Scgpkz3wrqi/bVagc+q4IdKL0r8iL7o3EnTf/5Dc2XUaCFJLCa3Rk6oat5kTWjan + sp/K5k/VzSDcESji8n6xl0OzD2okhmX8iJZg1hhyI8hNmtW3boe51Hkkdlj+wC8Y2Fgh4QARAQAB + iQE2BBgBCAAgFiEE0uuMX0KSMgVBfC/25MZusLe5gWMFAlhVGBECGwwACgkQ5MZusLe5gWOn/Qf/ + aeV7CqZW/YN4/LhXjJG7i+iDJYv/9Lr12dvgjO/sOlmDPHkEzXPMLKalm0biMPN7E1woQzcKt7Qy + eF/CRcVKK1TM6wdClOj2jErnWyx85/uZfnG9QRD41rhInk891A8LGebPZ6DJeJR/uwzMniEgNnKN + AMuGy95ckwlM3AfwzsKPTUUFnBAmSwWfMLRxjZPNefeo1Ic8mMRAT3d5sfDUx/4wm8tyiNLuOSkm + Ej6ONYpESD2sJGMo3ZY96pkzir7ZH++4mH6PwZg1ZT2nO+0PtaB9DHRGfBrzH85d4aLFZD9txx3p + ewabrNpYI/cJu9hUTaTM7wZaG5kmfStwihKYUg== +Date: Sat, 17 Dec 2016 10:51:48 +0100 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This message contains an INBOME header that claims to be of type "x", +which is not a specified type. + +An agent capable of INBOME level 0 should reject this inbome header +because of this type. (it should not try to parse it as a header). diff --git a/app/crypto-openpgp/build.gradle.kts b/app/crypto-openpgp/build.gradle.kts new file mode 100644 index 0000000..615e1be --- /dev/null +++ b/app/crypto-openpgp/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +dependencies { + implementation(projects.app.core) +} + +android { + namespace = "com.fsck.k9.crypto.openpgp" +} diff --git a/app/crypto-openpgp/src/main/java/com/fsck/k9/crypto/openpgp/EncryptionDetector.java b/app/crypto-openpgp/src/main/java/com/fsck/k9/crypto/openpgp/EncryptionDetector.java new file mode 100644 index 0000000..3605b51 --- /dev/null +++ b/app/crypto-openpgp/src/main/java/com/fsck/k9/crypto/openpgp/EncryptionDetector.java @@ -0,0 +1,67 @@ +package com.fsck.k9.crypto.openpgp; + + +import androidx.annotation.NonNull; + +import com.fsck.k9.crypto.MessageCryptoStructureDetector; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import com.fsck.k9.message.extractors.TextPartFinder; + +import static com.fsck.k9.mail.internet.MimeUtility.isSameMimeType; + + +//FIXME: Make this only detect OpenPGP messages. Move support for S/MIME messages to separate module. +class EncryptionDetector { + private final TextPartFinder textPartFinder; + + + EncryptionDetector(TextPartFinder textPartFinder) { + this.textPartFinder = textPartFinder; + } + + public boolean isEncrypted(@NonNull Message message) { + return isPgpMimeOrSMimeEncrypted(message) || containsInlinePgpEncryptedText(message); + } + + private boolean isPgpMimeOrSMimeEncrypted(Message message) { + return containsPartWithMimeType(message, "multipart/encrypted", "application/pkcs7-mime"); + } + + private boolean containsInlinePgpEncryptedText(Message message) { + Part textPart = textPartFinder.findFirstTextPart(message); + return MessageCryptoStructureDetector.isPartPgpInlineEncrypted(textPart); + } + + private boolean containsPartWithMimeType(Part part, String... wantedMimeTypes) { + String mimeType = part.getMimeType(); + if (isMimeTypeAnyOf(mimeType, wantedMimeTypes)) { + return true; + } + + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart multipart = (Multipart) body; + for (BodyPart bodyPart : multipart.getBodyParts()) { + if (containsPartWithMimeType(bodyPart, wantedMimeTypes)) { + return true; + } + } + } + + return false; + } + + private boolean isMimeTypeAnyOf(String mimeType, String... wantedMimeTypes) { + for (String wantedMimeType : wantedMimeTypes) { + if (isSameMimeType(mimeType, wantedMimeType)) { + return true; + } + } + + return false; + } +} diff --git a/app/crypto-openpgp/src/main/java/com/fsck/k9/crypto/openpgp/OpenPgpEncryptionExtractor.kt b/app/crypto-openpgp/src/main/java/com/fsck/k9/crypto/openpgp/OpenPgpEncryptionExtractor.kt new file mode 100644 index 0000000..079114e --- /dev/null +++ b/app/crypto-openpgp/src/main/java/com/fsck/k9/crypto/openpgp/OpenPgpEncryptionExtractor.kt @@ -0,0 +1,30 @@ +package com.fsck.k9.crypto.openpgp + +import com.fsck.k9.crypto.EncryptionExtractor +import com.fsck.k9.crypto.EncryptionResult +import com.fsck.k9.mail.Message +import com.fsck.k9.message.extractors.TextPartFinder + +class OpenPgpEncryptionExtractor internal constructor( + private val encryptionDetector: EncryptionDetector +) : EncryptionExtractor { + + override fun extractEncryption(message: Message): EncryptionResult? { + return if (encryptionDetector.isEncrypted(message)) { + EncryptionResult(ENCRYPTION_TYPE, 0) + } else { + null + } + } + + companion object { + const val ENCRYPTION_TYPE = "openpgp" + + @JvmStatic + fun newInstance(): OpenPgpEncryptionExtractor { + val textPartFinder = TextPartFinder() + val encryptionDetector = EncryptionDetector(textPartFinder) + return OpenPgpEncryptionExtractor(encryptionDetector) + } + } +} diff --git a/app/crypto-openpgp/src/test/java/com/fsck/k9/crypto/openpgp/EncryptionDetectorTest.java b/app/crypto-openpgp/src/test/java/com/fsck/k9/crypto/openpgp/EncryptionDetectorTest.java new file mode 100644 index 0000000..992b2d1 --- /dev/null +++ b/app/crypto-openpgp/src/test/java/com/fsck/k9/crypto/openpgp/EncryptionDetectorTest.java @@ -0,0 +1,109 @@ +package com.fsck.k9.crypto.openpgp; + + +import com.fsck.k9.mail.Message; +import com.fsck.k9.message.extractors.TextPartFinder; +import org.junit.Before; +import org.junit.Test; + +import static com.fsck.k9.crypto.openpgp.MessageCreationHelper.createMessage; +import static com.fsck.k9.crypto.openpgp.MessageCreationHelper.createMultipartMessage; +import static com.fsck.k9.crypto.openpgp.MessageCreationHelper.createPart; +import static com.fsck.k9.crypto.openpgp.MessageCreationHelper.createTextMessage; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + + +public class EncryptionDetectorTest { + private static final String CRLF = "\r\n"; + + + private EncryptionDetector encryptionDetector; + + + @Before + public void setUp() { + encryptionDetector = new EncryptionDetector(new TextPartFinder()); + } + + @Test + public void isEncrypted_withTextPlain_shouldReturnFalse() { + Message message = createTextMessage("text/plain", "plain text"); + + boolean encrypted = encryptionDetector.isEncrypted(message); + + assertFalse(encrypted); + } + + @Test + public void isEncrypted_withMultipartEncrypted_shouldReturnTrue() throws Exception { + Message message = createMultipartMessage("multipart/encrypted", + createPart("application/octet-stream"), createPart("application/octet-stream")); + + boolean encrypted = encryptionDetector.isEncrypted(message); + + assertTrue(encrypted); + } + + @Test + public void isEncrypted_withSMimePart_shouldReturnTrue() { + Message message = createMessage("application/pkcs7-mime"); + + boolean encrypted = encryptionDetector.isEncrypted(message); + + assertTrue(encrypted); + } + + @Test + public void isEncrypted_withMultipartMixedContainingSMimePart_shouldReturnTrue() throws Exception { + Message message = createMultipartMessage("multipart/mixed", + createPart("application/pkcs7-mime"), createPart("text/plain")); + + boolean encrypted = encryptionDetector.isEncrypted(message); + + assertTrue(encrypted); + } + + @Test + public void isEncrypted_withInlinePgp_shouldReturnTrue() { + Message message = createTextMessage("text/plain", "" + + "-----BEGIN PGP MESSAGE-----" + CRLF + + "some encrypted stuff here" + CRLF + + "-----END PGP MESSAGE-----"); + + boolean encrypted = encryptionDetector.isEncrypted(message); + + assertTrue(encrypted); + } + + @Test + public void isEncrypted_withPlainTextAndPreambleWithInlinePgp_shouldReturnFalse() { + Message message = createTextMessage("text/plain", "" + + "preamble" + CRLF + + "-----BEGIN PGP MESSAGE-----" + CRLF + + "some encrypted stuff here" + CRLF + + "-----END PGP MESSAGE-----" + CRLF + + "epilogue"); + + boolean encrypted = encryptionDetector.isEncrypted(message); + + assertFalse(encrypted); + } + + @Test + public void isEncrypted_withQuotedInlinePgp_shouldReturnFalse() { + Message message = createTextMessage("text/plain", "" + + "good talk!" + CRLF + + CRLF + + "> -----BEGIN PGP MESSAGE-----" + CRLF + + "> some encrypted stuff here" + CRLF + + "> -----END PGP MESSAGE-----" + CRLF + + CRLF + + "-- " + CRLF + + "my signature"); + + boolean encrypted = encryptionDetector.isEncrypted(message); + + assertFalse(encrypted); + } +} diff --git a/app/crypto-openpgp/src/test/java/com/fsck/k9/crypto/openpgp/MessageCreationHelper.java b/app/crypto-openpgp/src/test/java/com/fsck/k9/crypto/openpgp/MessageCreationHelper.java new file mode 100644 index 0000000..c174718 --- /dev/null +++ b/app/crypto-openpgp/src/test/java/com/fsck/k9/crypto/openpgp/MessageCreationHelper.java @@ -0,0 +1,51 @@ +package com.fsck.k9.crypto.openpgp; + + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.MimeHeader; +import com.fsck.k9.mail.internet.MimeMessage; +import com.fsck.k9.mail.internet.MimeMultipart; +import com.fsck.k9.mail.internet.TextBody; +import com.fsck.k9.mailstore.BinaryMemoryBody; + + +public class MessageCreationHelper { + public static BodyPart createPart(String mimeType) throws MessagingException { + BinaryMemoryBody body = new BinaryMemoryBody(new byte[0], "utf-8"); + return new MimeBodyPart(body, mimeType); + } + + public static Message createTextMessage(String mimeType, String text) { + TextBody body = new TextBody(text); + return createMessage(mimeType, body); + } + + public static Message createMultipartMessage(String mimeType, BodyPart... parts) { + MimeMultipart body = createMultipartBody(mimeType, parts); + return createMessage(mimeType, body); + } + + public static Message createMessage(String mimeType) { + return createMessage(mimeType, null); + } + + private static Message createMessage(String mimeType, Body body) { + MimeMessage message = new MimeMessage(); + message.setBody(body); + message.setHeader(MimeHeader.HEADER_CONTENT_TYPE, mimeType); + + return message; + } + + private static MimeMultipart createMultipartBody(String mimeType, BodyPart[] parts) { + MimeMultipart multipart = new MimeMultipart(mimeType, "boundary"); + for (BodyPart part : parts) { + multipart.addBodyPart(part); + } + return multipart; + } +} diff --git a/app/html-cleaner/build.gradle.kts b/app/html-cleaner/build.gradle.kts new file mode 100644 index 0000000..9f1351d --- /dev/null +++ b/app/html-cleaner/build.gradle.kts @@ -0,0 +1,9 @@ +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(libs.jsoup) +} diff --git a/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt new file mode 100644 index 0000000..0f0e5cd --- /dev/null +++ b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt @@ -0,0 +1,78 @@ +package app.k9mail.html.cleaner + +import org.jsoup.nodes.Document +import org.jsoup.safety.Cleaner +import org.jsoup.safety.Safelist + +internal class BodyCleaner { + private val cleaner: Cleaner + private val allowedBodyAttributes = setOf( + "id", "class", "dir", "lang", "style", + "alink", "background", "bgcolor", "link", "text", "vlink" + ) + + init { + val allowList = Safelist.relaxed() + .addTags("font", "hr", "ins", "del", "center", "map", "area", "title", "tt", "kbd", "samp", "var") + .addAttributes("font", "color", "face", "size") + .addAttributes("a", "name") + .addAttributes("div", "align") + .addAttributes( + "table", + "align", + "background", + "bgcolor", + "border", + "cellpadding", + "cellspacing", + "width" + ) + .addAttributes("tr", "align", "background", "bgcolor", "valign") + .addAttributes( + "th", + "align", "background", "bgcolor", "colspan", "headers", "height", "nowrap", "rowspan", "scope", + "sorted", "valign", "width" + ) + .addAttributes( + "td", + "align", "background", "bgcolor", "colspan", "headers", "height", "nowrap", "rowspan", "scope", + "valign", "width" + ) + .addAttributes("map", "name") + .addAttributes("area", "shape", "coords", "href", "alt") + .addProtocols("area", "href", "http", "https") + .addAttributes("img", "usemap") + .addAttributes(":all", "class", "style", "id", "dir") + .addProtocols("img", "src", "http", "https", "cid", "data") + // Allow all URI schemes in links + .removeProtocols("a", "href", "ftp", "http", "https", "mailto") + + cleaner = Cleaner(allowList) + } + + fun clean(dirtyDocument: Document): Document { + val cleanedDocument = cleaner.clean(dirtyDocument) + copyDocumentType(dirtyDocument, cleanedDocument) + copyBodyAttributes(dirtyDocument, cleanedDocument) + return cleanedDocument + } + + private fun copyDocumentType(dirtyDocument: Document, cleanedDocument: Document) { + dirtyDocument.documentType()?.let { documentType -> + cleanedDocument.insertChildren(0, documentType) + } + } + + private fun copyBodyAttributes(dirtyDocument: Document, cleanedDocument: Document) { + val cleanedBody = cleanedDocument.body() + for (attribute in dirtyDocument.body().attributes()) { + if (attribute.key !in allowedBodyAttributes) continue + + if (attribute.hasDeclaredValue()) { + cleanedBody.attr(attribute.key, attribute.value) + } else { + cleanedBody.attr(attribute.key, true) + } + } + } +} diff --git a/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HeadCleaner.kt b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HeadCleaner.kt new file mode 100644 index 0000000..1485291 --- /dev/null +++ b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HeadCleaner.kt @@ -0,0 +1,75 @@ +package app.k9mail.html.cleaner + +import org.jsoup.nodes.DataNode +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.parser.Tag +import org.jsoup.select.NodeTraversor +import org.jsoup.select.NodeVisitor + +private val ALLOWED_TAGS = listOf("style", "meta", "base") + +internal class HeadCleaner { + fun clean(dirtyDocument: Document, cleanedDocument: Document) { + copySafeNodes(dirtyDocument.head(), cleanedDocument.head()) + } + + private fun copySafeNodes(source: Element, destination: Element) { + val cleaningVisitor = CleaningVisitor(source, destination) + NodeTraversor.traverse(cleaningVisitor, source) + } +} + +internal class CleaningVisitor( + private val root: Element, + private var destination: Element +) : NodeVisitor { + private var elementToSkip: Element? = null + + override fun head(source: Node, depth: Int) { + if (elementToSkip != null) return + + if (source is Element) { + if (isSafeTag(source)) { + val sourceTag = source.tagName() + val destinationAttributes = source.attributes().clone() + val destinationChild = Element(Tag.valueOf(sourceTag), source.baseUri(), destinationAttributes) + destination.appendChild(destinationChild) + destination = destinationChild + } else if (source !== root) { + elementToSkip = source + } + } else if (source is TextNode) { + val destinationText = TextNode(source.wholeText) + destination.appendChild(destinationText) + } else if (source is DataNode && isSafeTag(source.parent())) { + val destinationData = DataNode(source.wholeData) + destination.appendChild(destinationData) + } + } + + override fun tail(source: Node, depth: Int) { + if (source === elementToSkip) { + elementToSkip = null + } else if (source is Element && isSafeTag(source)) { + destination = destination.parent() ?: error("Missing parent") + } + } + + private fun isSafeTag(node: Node?): Boolean { + if (node == null || isMetaRefresh(node)) return false + + val tag = node.nodeName().lowercase() + return tag in ALLOWED_TAGS + } + + private fun isMetaRefresh(node: Node): Boolean { + val tag = node.nodeName().lowercase() + if (tag != "meta") return false + + val attributeValue = node.attributes().getIgnoreCase("http-equiv").trim().lowercase() + return attributeValue == "refresh" + } +} diff --git a/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlHeadProvider.kt b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlHeadProvider.kt new file mode 100644 index 0000000..cf234cc --- /dev/null +++ b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlHeadProvider.kt @@ -0,0 +1,5 @@ +package app.k9mail.html.cleaner + +interface HtmlHeadProvider { + val headHtml: String +} diff --git a/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlProcessor.kt b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlProcessor.kt new file mode 100644 index 0000000..2da379b --- /dev/null +++ b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlProcessor.kt @@ -0,0 +1,25 @@ +package app.k9mail.html.cleaner + +import org.jsoup.nodes.Document + +class HtmlProcessor(private val htmlHeadProvider: HtmlHeadProvider) { + private val htmlSanitizer = HtmlSanitizer() + + fun processForDisplay(html: String): String { + return htmlSanitizer.sanitize(html) + .addCustomHeadContents() + .toCompactString() + } + + private fun Document.addCustomHeadContents() = apply { + head().append(htmlHeadProvider.headHtml) + } + + private fun Document.toCompactString(): String { + outputSettings() + .prettyPrint(false) + .indentAmount(0) + + return html() + } +} diff --git a/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlSanitizer.kt b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlSanitizer.kt new file mode 100644 index 0000000..d11bb30 --- /dev/null +++ b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/HtmlSanitizer.kt @@ -0,0 +1,16 @@ +package app.k9mail.html.cleaner + +import org.jsoup.Jsoup +import org.jsoup.nodes.Document + +internal class HtmlSanitizer { + private val headCleaner = HeadCleaner() + private val bodyCleaner = BodyCleaner() + + fun sanitize(html: String): Document { + val dirtyDocument = Jsoup.parse(html) + val cleanedDocument = bodyCleaner.clean(dirtyDocument) + headCleaner.clean(dirtyDocument, cleanedDocument) + return cleanedDocument + } +} diff --git a/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt b/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt new file mode 100644 index 0000000..f783e39 --- /dev/null +++ b/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt @@ -0,0 +1,516 @@ +package app.k9mail.html.cleaner + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.jsoup.nodes.Document +import org.junit.Test + +class HtmlSanitizerTest { + private val htmlSanitizer = HtmlSanitizer() + + @Test + fun shouldRemoveMetaRefreshInHead() { + val html = + """ + + + Message + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("Message") + } + + @Test + fun shouldRemoveMetaRefreshBetweenHeadAndBody() { + val html = + """ + + + + Message + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("Message") + } + + @Test + fun shouldRemoveMetaRefreshInBody() { + val html = + """ + + + Message + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("Message") + } + + @Test + fun shouldRemoveMetaRefreshWithUpperCaseAttributeValue() { + val html = + """ + + + Message + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("Message") + } + + @Test + fun shouldRemoveMetaRefreshWithMixedCaseAttributeValue() { + val html = + """ + + + Message + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("Message") + } + + @Test + fun shouldRemoveMetaRefreshWithoutQuotesAroundAttributeValue() { + val html = + """ + + + Message + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("Message") + } + + @Test + fun shouldRemoveMetaRefreshWithSpacesInAttributeValue() { + val html = + """ + + + Message + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("Message") + } + + @Test + fun shouldRemoveMultipleMetaRefreshTags() { + val html = + """ + + + Message + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("Message") + } + + @Test + fun shouldRemoveMetaRefreshButKeepOtherMetaTags() { + val html = + """ + + + + + + Message + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo( + """ + + + Message + + """.trimIndent().trimLineBreaks() + ) + } + + @Test + fun shouldProduceValidHtmlFromHtmlWithXmlDeclaration() { + val html = + """ + + + + + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("") + } + + @Test + fun shouldNormalizeTables() { + val html = "
    " + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo( + "
    " + ) + } + + @Test + fun shouldHtmlEncodeXmlDirectives() { + val html = + """ + + + +
    Hmailserver service shutdown:Ok
    + + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo( + """ + + +
    Hmailserver service shutdown:Ok
    + + """.trimIndent().trimLineBreaks() + ) + } + + @Test + fun shouldKeepHrTags() { + val html = "one
    two
    three" + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("one
    two
    three") + } + + @Test + fun shouldKeepInsDelTags() { + val html = "InsertedDeleted" + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo(html) + } + + @Test + fun shouldKeepMapAreaTags() { + val html = + """ + + + + + Sun + Mercury + Venus + + + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo(html) + } + + @Test + fun shouldKeepImgUsemap() { + val html = + """ + + + + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo(html) + } + + @Test + fun shouldKeepAllowedElementsInHeadAndSkipTheRest() { + val html = + """ + + + remove this + + + + + """.trimIndent().trimLineBreaks() + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo("") + } + + @Test + fun shouldRemoveIFrames() { + val html = """