diff --git a/app-common/build.gradle.kts b/app-common/build.gradle.kts index 4bcba76fb7c..1155e5e7408 100644 --- a/app-common/build.gradle.kts +++ b/app-common/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(projects.feature.account.avatar.impl) implementation(projects.feature.account.setup) implementation(projects.feature.mail.account.api) + implementation(projects.feature.mail.message.composer) implementation(projects.feature.migration.provider) implementation(projects.feature.notification.api) implementation(projects.feature.notification.impl) diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt index f43927d5215..3d8411b57ff 100644 --- a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt @@ -3,6 +3,7 @@ package net.thunderbird.app.common.feature import app.k9mail.feature.launcher.FeatureLauncherExternalContract import app.k9mail.feature.launcher.di.featureLauncherModule import net.thunderbird.app.common.feature.mail.appCommonFeatureMailModule +import net.thunderbird.feature.mail.message.composer.inject.featureMessageComposerModule import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract import net.thunderbird.feature.notification.impl.inject.featureNotificationModule import org.koin.android.ext.koin.androidContext @@ -11,6 +12,7 @@ import org.koin.dsl.module internal val appCommonFeatureModule = module { includes(featureLauncherModule) includes(featureNotificationModule) + includes(featureMessageComposerModule) includes(appCommonFeatureMailModule) factory { diff --git a/feature/mail/message/composer/build.gradle.kts b/feature/mail/message/composer/build.gradle.kts new file mode 100644 index 00000000000..c80bbe6a3e1 --- /dev/null +++ b/feature/mail/message/composer/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) + alias(libs.plugins.dev.mokkery) +} + +android { + namespace = "net.thunderbird.feature.mail.message.composer" +} + +dependencies { + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.ui.theme.api) + implementation(projects.feature.notification.api) +} diff --git a/feature/mail/message/composer/src/main/kotlin/net/thunderbird/feature/mail/message/composer/dialog/SentFolderNotFoundConfirmationDialog.kt b/feature/mail/message/composer/src/main/kotlin/net/thunderbird/feature/mail/message/composer/dialog/SentFolderNotFoundConfirmationDialog.kt new file mode 100644 index 00000000000..86bf5c50766 --- /dev/null +++ b/feature/mail/message/composer/src/main/kotlin/net/thunderbird/feature/mail/message/composer/dialog/SentFolderNotFoundConfirmationDialog.kt @@ -0,0 +1,56 @@ +package net.thunderbird.feature.mail.message.composer.dialog + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.organism.BasicDialog +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.feature.mail.message.composer.R + +@Composable +fun SentFolderNotFoundConfirmationDialog( + showDialog: Boolean, + onAssignSentFolderClick: () -> Unit, + onSendAndDeleteClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (showDialog) { + BasicDialog( + headlineText = stringResource(R.string.sent_folder_not_found_dialog_title), + supportingText = stringResource(R.string.sent_folder_not_found_dialog_supporting_text), + content = { + ButtonText( + onClick = onAssignSentFolderClick, + text = stringResource(R.string.sent_folder_not_found_dialog_assign_folder_action), + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Folder, + contentDescription = null, + modifier = Modifier.padding(end = MainTheme.spacings.half), + ) + }, + ) + }, + buttons = { + ButtonText( + text = stringResource(R.string.sent_folder_not_found_dialog_cancel_action), + onClick = onDismiss, + ) + ButtonText( + text = stringResource(R.string.sent_folder_not_found_dialog_send_and_delete_action), + onClick = onSendAndDeleteClick, + color = MainTheme.colors.error, + ) + }, + onDismissRequest = onDismiss, + contentPadding = PaddingValues(horizontal = MainTheme.spacings.default), + modifier = modifier, + ) + } +} diff --git a/feature/mail/message/composer/src/main/kotlin/net/thunderbird/feature/mail/message/composer/dialog/SentFolderNotFoundConfirmationDialogFragment.kt b/feature/mail/message/composer/src/main/kotlin/net/thunderbird/feature/mail/message/composer/dialog/SentFolderNotFoundConfirmationDialogFragment.kt new file mode 100644 index 00000000000..01afeab7d2b --- /dev/null +++ b/feature/mail/message/composer/src/main/kotlin/net/thunderbird/feature/mail/message/composer/dialog/SentFolderNotFoundConfirmationDialogFragment.kt @@ -0,0 +1,73 @@ +package net.thunderbird.feature.mail.message.composer.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.Window +import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.setFragmentResult +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import net.thunderbird.feature.mail.message.composer.dialog.SentFolderNotFoundConfirmationDialogFragmentFactory.Companion.ACCOUNT_UUID_ARG +import net.thunderbird.feature.mail.message.composer.dialog.SentFolderNotFoundConfirmationDialogFragmentFactory.Companion.RESULT_CODE_ASSIGN_SENT_FOLDER_REQUEST_KEY +import net.thunderbird.feature.mail.message.composer.dialog.SentFolderNotFoundConfirmationDialogFragmentFactory.Companion.RESULT_CODE_SEND_AND_DELETE_REQUEST_KEY +import org.koin.android.ext.android.inject + +class SentFolderNotFoundConfirmationDialogFragment : DialogFragment() { + private val themeProvider: FeatureThemeProvider by inject() + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val accountUuid = requireNotNull(requireArguments().getString(ACCOUNT_UUID_ARG)) { + "The $ACCOUNT_UUID_ARG argument is missing from the arguments bundle." + } + dialog?.requestWindowFeature(Window.FEATURE_NO_TITLE) + return ComposeView(requireContext()).apply { + setContent { + themeProvider.WithTheme { + SentFolderNotFoundConfirmationDialog( + showDialog = true, + onAssignSentFolderClick = { + dismiss() + setFragmentResult( + requestKey = RESULT_CODE_ASSIGN_SENT_FOLDER_REQUEST_KEY, + result = bundleOf(ACCOUNT_UUID_ARG to accountUuid), + ) + }, + onSendAndDeleteClick = { + dismiss() + setFragmentResult( + requestKey = RESULT_CODE_SEND_AND_DELETE_REQUEST_KEY, + result = bundleOf(ACCOUNT_UUID_ARG to accountUuid), + ) + }, + onDismiss = ::dismiss, + ) + } + } + } + } + + companion object Factory : SentFolderNotFoundConfirmationDialogFragmentFactory { + private const val TAG = "SentFolderNotFoundConfirmationDialogFragment" + override fun show(accountUuid: String, fragmentManager: FragmentManager) { + SentFolderNotFoundConfirmationDialogFragment().apply { + arguments = bundleOf(ACCOUNT_UUID_ARG to accountUuid) + show(fragmentManager, TAG) + } + } + } +} + +interface SentFolderNotFoundConfirmationDialogFragmentFactory { + companion object { + const val RESULT_CODE_ASSIGN_SENT_FOLDER_REQUEST_KEY = + "SentFolderNotFoundConfirmationDialogFragmentFactory_assign_sent_folder" + const val RESULT_CODE_SEND_AND_DELETE_REQUEST_KEY = + "SentFolderNotFoundConfirmationDialogFragmentFactory_send_and_delete" + const val ACCOUNT_UUID_ARG = "SetupArchiveFolderDialogFragmentFactory_accountUuid" + } + + fun show(accountUuid: String, fragmentManager: FragmentManager) +} diff --git a/feature/mail/message/composer/src/main/kotlin/net/thunderbird/feature/mail/message/composer/inject/FeatureMessageComposerModule.kt b/feature/mail/message/composer/src/main/kotlin/net/thunderbird/feature/mail/message/composer/inject/FeatureMessageComposerModule.kt new file mode 100644 index 00000000000..38ed1056004 --- /dev/null +++ b/feature/mail/message/composer/src/main/kotlin/net/thunderbird/feature/mail/message/composer/inject/FeatureMessageComposerModule.kt @@ -0,0 +1,11 @@ +package net.thunderbird.feature.mail.message.composer.inject + +import net.thunderbird.feature.mail.message.composer.dialog.SentFolderNotFoundConfirmationDialogFragment +import net.thunderbird.feature.mail.message.composer.dialog.SentFolderNotFoundConfirmationDialogFragmentFactory +import org.koin.dsl.module + +val featureMessageComposerModule = module { + factory { + SentFolderNotFoundConfirmationDialogFragment.Factory + } +} diff --git a/feature/mail/message/composer/src/main/res/values/strings.xml b/feature/mail/message/composer/src/main/res/values/strings.xml new file mode 100644 index 00000000000..b2f33d1afd2 --- /dev/null +++ b/feature/mail/message/composer/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Sent folder not found + To save this email after sending, set a Sent folder in Account Settings. + Assign Sent Folder + Cancel + Send and Delete + diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/content/SentFolderNotFoundNotification.android.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/content/SentFolderNotFoundNotification.android.kt new file mode 100644 index 00000000000..0873fe675df --- /dev/null +++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/content/SentFolderNotFoundNotification.android.kt @@ -0,0 +1,9 @@ +package net.thunderbird.feature.notification.api.content + +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.icon.outlined.Warning +import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon +import net.thunderbird.feature.notification.api.ui.icon.NotificationIcons + +internal actual val NotificationIcons.SentFolderNotFound: NotificationIcon + get() = NotificationIcon(inAppNotificationIcon = Icons.Outlined.Warning) diff --git a/feature/notification/api/src/commonMain/composeResources/values/strings.xml b/feature/notification/api/src/commonMain/composeResources/values/strings.xml index d07dac24ffb..107ebf55ef6 100644 --- a/feature/notification/api/src/commonMain/composeResources/values/strings.xml +++ b/feature/notification/api/src/commonMain/composeResources/values/strings.xml @@ -56,9 +56,13 @@ Spam Retry Update Server Settings + Assign folder Check Error Notifications Some messages need your attention. Open notifications View support article + + Sent folder not available. + diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/outcome/NotificationCommandOutcome.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/outcome/NotificationCommandOutcome.kt index e75445de29d..cd05d5a126a 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/outcome/NotificationCommandOutcome.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/outcome/NotificationCommandOutcome.kt @@ -24,6 +24,9 @@ typealias NotificationCommandOutcome = Outcome { val notificationId: NotificationId + val rawNotificationId: Int + @Discouraged("This is a utility getter to enable usage in Java code. Use notificationId instead.") + get() = notificationId.value val command: NotificationCommand? /** diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/SentFolderNotFoundNotification.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/SentFolderNotFoundNotification.kt new file mode 100644 index 00000000000..d807144fa18 --- /dev/null +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/SentFolderNotFoundNotification.kt @@ -0,0 +1,53 @@ +package net.thunderbird.feature.notification.api.content + +import net.thunderbird.feature.notification.api.NotificationSeverity +import net.thunderbird.feature.notification.api.ui.action.NotificationAction +import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon +import net.thunderbird.feature.notification.api.ui.icon.NotificationIcons +import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle +import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyle +import net.thunderbird.feature.notification.resources.api.Res +import net.thunderbird.feature.notification.resources.api.sent_folder_not_found_title +import org.jetbrains.compose.resources.getString + +/** + * A notification that is displayed when the configured 'Sent' folder for an account is not configured. + * + * This typically happens when the folder was automatically detected or if it was manually changed to None by the user. + * + * The notification prompts the user to assign a new 'Sent' folder for the specified account. + * + * @property accountUuid The unique identifier of the account for which the 'Sent' folder is missing. + * @property title The main title text of the notification, loaded from resources. + */ +@ConsistentCopyVisibility +data class SentFolderNotFoundNotification internal constructor( + override val accountUuid: String, + override val title: String, +) : AppNotification(), InAppNotification { + override val contentText: String = title + override val severity: NotificationSeverity = NotificationSeverity.Warning + override val icon: NotificationIcon get() = NotificationIcons.SentFolderNotFound + override val actions: Set = setOf(NotificationAction.AssignSentFolder(accountUuid)) + override val inAppNotificationStyle: InAppNotificationStyle + // TODO(9572): Properly setup the notification priority. + get() = inAppNotificationStyle { bannerGlobal(priority = Int.MAX_VALUE) } +} + +/** + * Icon for the 'Sent Folder Not Found' notification. + */ +internal expect val NotificationIcons.SentFolderNotFound: NotificationIcon + +/** + * Factory function to create a [SentFolderNotFoundNotification]. + * + * @param accountUuid The unique identifier of the account for which the 'Sent' folder is missing. + * @return A new instance of [SentFolderNotFoundNotification] with the title loaded from string resources. + */ +suspend fun SentFolderNotFoundNotification( + accountUuid: String, +): SentFolderNotFoundNotification = SentFolderNotFoundNotification( + accountUuid = accountUuid, + title = getString(Res.string.sent_folder_not_found_title), +) diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/dismisser/compat/NotificationDismisserCompat.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/dismisser/compat/NotificationDismisserCompat.kt index 9e5960b25aa..39c01c3bfcd 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/dismisser/compat/NotificationDismisserCompat.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/dismisser/compat/NotificationDismisserCompat.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import net.thunderbird.feature.notification.api.NotificationId import net.thunderbird.feature.notification.api.command.outcome.NotificationCommandOutcome import net.thunderbird.feature.notification.api.content.Notification import net.thunderbird.feature.notification.api.dismisser.NotificationDismisser @@ -32,6 +33,12 @@ class NotificationDismisserCompat @JvmOverloads constructor( ) : DisposableHandle { private val scope = CoroutineScope(SupervisorJob() + mainImmediateDispatcher) + fun dismiss(notificationId: Int, onResultListener: OnResultListener) { + notificationDismisser.dismiss(NotificationId(notificationId)) + .onEach { outcome -> onResultListener.onResult(outcome) } + .launchIn(scope) + } + fun dismiss(notification: Notification, onResultListener: OnResultListener) { notificationDismisser.dismiss(notification) .onEach { outcome -> onResultListener.onResult(outcome) } diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt index be4a3dcf3d1..bb6e3f2d87f 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt @@ -14,6 +14,7 @@ import net.thunderbird.feature.notification.resources.api.Res import net.thunderbird.feature.notification.resources.api.banner_inline_notification_open_notifications import net.thunderbird.feature.notification.resources.api.banner_inline_notification_view_support_article import net.thunderbird.feature.notification.resources.api.notification_action_archive +import net.thunderbird.feature.notification.resources.api.notification_action_assign_sent_folder import net.thunderbird.feature.notification.resources.api.notification_action_delete import net.thunderbird.feature.notification.resources.api.notification_action_mark_as_read import net.thunderbird.feature.notification.resources.api.notification_action_reply @@ -122,6 +123,11 @@ sealed class NotificationAction { override val labelResource: StringResource = Res.string.banner_inline_notification_open_notifications } + data class AssignSentFolder(val accountUuid: String) : NotificationAction() { + override val icon: NotificationIcon? = null + override val labelResource: StringResource = Res.string.notification_action_assign_sent_folder + } + /** * Represents a custom notification action. * diff --git a/feature/notification/api/src/jvmMain/kotlin/net/thunderbird/feature/notification/api/content/SentFolderNotFoundNotification.jvm.kt b/feature/notification/api/src/jvmMain/kotlin/net/thunderbird/feature/notification/api/content/SentFolderNotFoundNotification.jvm.kt new file mode 100644 index 00000000000..046734456ac --- /dev/null +++ b/feature/notification/api/src/jvmMain/kotlin/net/thunderbird/feature/notification/api/content/SentFolderNotFoundNotification.jvm.kt @@ -0,0 +1,7 @@ +package net.thunderbird.feature.notification.api.content + +import net.thunderbird.feature.notification.api.ui.icon.ERROR_MESSAGE +import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon +import net.thunderbird.feature.notification.api.ui.icon.NotificationIcons + +internal actual val NotificationIcons.SentFolderNotFound: NotificationIcon get() = error(ERROR_MESSAGE) diff --git a/feature/notification/api/src/jvmMain/kotlin/net/thunderbird/feature/notification/api/ui/icon/NotificationIcons.jvm.kt b/feature/notification/api/src/jvmMain/kotlin/net/thunderbird/feature/notification/api/ui/icon/NotificationIcons.jvm.kt index c51755d2485..4bcce530fdc 100644 --- a/feature/notification/api/src/jvmMain/kotlin/net/thunderbird/feature/notification/api/ui/icon/NotificationIcons.jvm.kt +++ b/feature/notification/api/src/jvmMain/kotlin/net/thunderbird/feature/notification/api/ui/icon/NotificationIcons.jvm.kt @@ -1,6 +1,6 @@ package net.thunderbird.feature.notification.api.ui.icon -private const val ERROR_MESSAGE = "Can't send notifications from a jvm library. Use android library or app instead." +internal const val ERROR_MESSAGE = "Can't send notifications from a jvm library. Use android library or app instead." internal actual val NotificationIcons.AlarmPermissionMissing: NotificationIcon get() = error(ERROR_MESSAGE) internal actual val NotificationIcons.AuthenticationError: NotificationIcon get() = error(ERROR_MESSAGE) diff --git a/legacy/ui/legacy/build.gradle.kts b/legacy/ui/legacy/build.gradle.kts index fb9dafe5b4b..eafacfd1512 100644 --- a/legacy/ui/legacy/build.gradle.kts +++ b/legacy/ui/legacy/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(projects.feature.settings.import) implementation(projects.feature.telemetry.api) implementation(projects.feature.mail.message.list) + implementation(projects.feature.mail.message.composer) compileOnly(projects.mail.protocols.imap) diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java index 8c5d356c428..02d144d4fa9 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java @@ -2,11 +2,15 @@ import java.io.File; +import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.regex.Pattern; import android.annotation.SuppressLint; import android.app.Activity; @@ -55,6 +59,9 @@ import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.fsck.k9.activity.compose.MessageComposeInAppNotificationFragment; +import com.fsck.k9.ui.settings.account.AccountSettingsActivity; +import com.fsck.k9.ui.settings.account.AccountSettingsFragment; +import kotlin.Unit; import net.thunderbird.core.android.account.LegacyAccountDto; import app.k9mail.legacy.di.DI; import net.thunderbird.core.android.account.Identity; @@ -126,8 +133,17 @@ import net.thunderbird.core.android.contact.ContactIntentHelper; import net.thunderbird.core.featureflag.FeatureFlagProvider; import net.thunderbird.core.featureflag.compat.FeatureFlagProviderCompat; +import net.thunderbird.core.outcome.OutcomeKt; import net.thunderbird.core.preference.GeneralSettingsManager; import net.thunderbird.core.ui.theme.manager.ThemeManager; +import net.thunderbird.feature.mail.message.composer.dialog.SentFolderNotFoundConfirmationDialogFragmentFactory; +import net.thunderbird.feature.notification.api.command.outcome.CommandExecutionFailed; +import net.thunderbird.feature.notification.api.content.NotificationFactoryCoroutineCompat; +import net.thunderbird.feature.notification.api.content.SentFolderNotFoundNotification; +import net.thunderbird.feature.notification.api.dismisser.NotificationDismisser; +import net.thunderbird.feature.notification.api.dismisser.compat.NotificationDismisserCompat; +import net.thunderbird.feature.notification.api.sender.NotificationSender; +import net.thunderbird.feature.notification.api.sender.compat.NotificationSenderCompat; import net.thunderbird.feature.search.legacy.LocalMessageSearch; import org.openintents.openpgp.OpenPgpApiManager; import org.openintents.openpgp.util.OpenPgpIntentStarter; @@ -135,6 +151,10 @@ import static com.fsck.k9.activity.compose.AttachmentPresenter.REQUEST_CODE_ATTACHMENT_URI; import static app.k9mail.core.android.common.camera.CameraCaptureHandler.CAMERA_PERMISSION_REQUEST_CODE; import static app.k9mail.core.android.common.camera.CameraCaptureHandler.REQUEST_IMAGE_CAPTURE; +import static net.thunderbird.feature.mail.message.composer.dialog.SentFolderNotFoundConfirmationDialogFragmentFactory.ACCOUNT_UUID_ARG; +import static net.thunderbird.feature.mail.message.composer.dialog.SentFolderNotFoundConfirmationDialogFragmentFactory.RESULT_CODE_ASSIGN_SENT_FOLDER_REQUEST_KEY; +import static net.thunderbird.feature.mail.message.composer.dialog.SentFolderNotFoundConfirmationDialogFragmentFactory.RESULT_CODE_SEND_AND_DELETE_REQUEST_KEY; +import static net.thunderbird.feature.notification.api.content.SentFolderNotFoundNotificationKt.SentFolderNotFoundNotification; @SuppressWarnings("deprecation") // TODO get rid of activity dialogs and indeterminate progress bars @@ -172,6 +192,8 @@ public class MessageCompose extends K9Activity implements OnClickListener, private static final String STATE_KEY_READ_RECEIPT = "com.fsck.k9.activity.MessageCompose.messageReadReceipt"; private static final String STATE_KEY_CHANGES_MADE_SINCE_LAST_SAVE = "com.fsck.k9.activity.MessageCompose.changesMadeSinceLastSave"; private static final String STATE_ALREADY_NOTIFIED_USER_OF_EMPTY_SUBJECT = "alreadyNotifiedUserOfEmptySubject"; + private static final String STATE_ACTIVE_IN_APP_NOTIFICATIONS = + "com.fsck.k9.activity.MessageCompose.activeInAppNotifications"; private static final String FRAGMENT_WAITING_FOR_ATTACHMENT = "waitingForAttachment"; @@ -208,6 +230,15 @@ public class MessageCompose extends K9Activity implements OnClickListener, private final CameraCaptureHandler cameraCaptureHandler = DI.get(CameraCaptureHandler.class); private final FeatureFlagProvider featureFlagProvider = DI.get(FeatureFlagProvider.class); + private final NotificationSender notificationSender = DI.get(NotificationSender.class); + private final NotificationSenderCompat notificationSenderCompat = new NotificationSenderCompat(notificationSender); + private final NotificationDismisser notificationDismisser = DI.get(NotificationDismisser.class); + private final NotificationDismisserCompat notificationDismisserCompat = + new NotificationDismisserCompat(notificationDismisser); + private final SentFolderNotFoundConfirmationDialogFragmentFactory sentFolderNotFoundDialogFragmentFactory = + DI.get(SentFolderNotFoundConfirmationDialogFragmentFactory.class); + + private final Set activeInAppNotifications = new HashSet<>(); private QuotedMessagePresenter quotedMessagePresenter; private MessageLoaderHelper messageLoaderHelper; @@ -263,6 +294,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, private boolean navigateUp; private boolean sendMessageHasBeenTriggered = false; + private boolean ignoreSentFolderNotAssigned = false; @Override public void onCreate(Bundle savedInstanceState) { @@ -293,16 +325,7 @@ public void onCreate(Bundle savedInstanceState) { final Intent intent = getIntent(); - String messageReferenceString = intent.getStringExtra(EXTRA_MESSAGE_REFERENCE); - relatedMessageReference = MessageReference.parse(messageReferenceString); - - final String accountUuid = (relatedMessageReference != null) ? - relatedMessageReference.getAccountUuid() : - intent.getStringExtra(EXTRA_ACCOUNT); - - if (accountUuid != null) { - account = preferences.getAccount(accountUuid); - } + fetchAccount(intent); if (account == null) { account = preferences.getDefaultAccount(); @@ -538,12 +561,104 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { setProgressBarIndeterminateVisibility(true); currentMessageBuilder.reattachCallback(this); } + setupSentFolderNotFoundDialogResults(); + } + + private void setupSentFolderNotFoundDialogResults() { + getSupportFragmentManager().setFragmentResultListener( + RESULT_CODE_ASSIGN_SENT_FOLDER_REQUEST_KEY, + this, + (requestKey, result) -> { + if (RESULT_CODE_ASSIGN_SENT_FOLDER_REQUEST_KEY.equals(requestKey)) { + final String accountUuid = result.getString(ACCOUNT_UUID_ARG); + AccountSettingsActivity.start(this, + Objects.requireNonNull(accountUuid), + AccountSettingsFragment.PREFERENCE_FOLDERS); + } + } + ); + getSupportFragmentManager().setFragmentResultListener( + RESULT_CODE_SEND_AND_DELETE_REQUEST_KEY, + this, + (requestKey, result) -> { + if (RESULT_CODE_SEND_AND_DELETE_REQUEST_KEY.equals(requestKey)) { + ignoreSentFolderNotAssigned = true; + performSendAfterChecks(); + } + } + ); + } + + private void fetchAccount(Intent intent) { + String messageReferenceString = intent.getStringExtra(EXTRA_MESSAGE_REFERENCE); + relatedMessageReference = MessageReference.parse(messageReferenceString); + + final String accountUuid = (relatedMessageReference != null) ? + relatedMessageReference.getAccountUuid() : + intent.getStringExtra(EXTRA_ACCOUNT); + + if (accountUuid != null) { + account = preferences.getAccount(accountUuid); + } } @Override protected void onResume() { super.onResume(); messagingController.addListener(messagingListener); + + if (account == null) { + fetchAccount(getIntent()); + } + + dismissActiveInAppNotifications(); + triggerIfNeededSentFolderNotFoundInAppNotification(); + } + + private void triggerIfNeededSentFolderNotFoundInAppNotification() { + if (account != null && account.getSentFolderId() == null) { + final SentFolderNotFoundNotification notification = NotificationFactoryCoroutineCompat.create( + continuation -> SentFolderNotFoundNotification(account.getUuid(), continuation) + ); + notificationSenderCompat.send(notification, outcome -> { + OutcomeKt.handle( + outcome, + success -> { + activeInAppNotifications.add(success.getRawNotificationId()); + return Unit.INSTANCE; + }, + failure -> { + final Throwable throwable = failure instanceof CommandExecutionFailed + ? ((CommandExecutionFailed) failure).getThrowable() + : null; + Log.e(throwable, "Failed to send in-app notification. Failure = " + failure); + return Unit.INSTANCE; + }); + }); + } + } + + private void dismissActiveInAppNotifications() { + for (Integer notificationId : activeInAppNotifications) { + notificationDismisserCompat.dismiss( + notificationId, + outcome -> { + OutcomeKt.handle( + outcome, + success -> { + activeInAppNotifications.remove(success.getRawNotificationId()); + return Unit.INSTANCE; + }, + failure -> { + final Throwable throwable = failure instanceof CommandExecutionFailed + ? ((CommandExecutionFailed) failure).getThrowable() + : null; + Log.e(throwable, "Failed to dismiss in-app notification. Failure = " + failure); + return Unit.INSTANCE; + } + ); + }); + } } @Override @@ -571,7 +686,7 @@ public void onPause() { * Quoted text, */ @Override - protected void onSaveInstanceState(Bundle outState) { + protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, relatedMessageProcessed); @@ -585,6 +700,7 @@ protected void onSaveInstanceState(Bundle outState) { outState.putBoolean(STATE_KEY_READ_RECEIPT, requestReadReceipt); outState.putBoolean(STATE_KEY_CHANGES_MADE_SINCE_LAST_SAVE, changesMadeSinceLastSave); outState.putBoolean(STATE_ALREADY_NOTIFIED_USER_OF_EMPTY_SUBJECT, alreadyNotifiedUserOfEmptySubject); + outState.putIntegerArrayList(STATE_ACTIVE_IN_APP_NOTIFICATIONS, new ArrayList<>(activeInAppNotifications)); replyToPresenter.onSaveInstanceState(outState); recipientPresenter.onSaveInstanceState(outState); @@ -624,6 +740,11 @@ protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { referencedMessageIds = savedInstanceState.getString(STATE_REFERENCES); changesMadeSinceLastSave = savedInstanceState.getBoolean(STATE_KEY_CHANGES_MADE_SINCE_LAST_SAVE); alreadyNotifiedUserOfEmptySubject = savedInstanceState.getBoolean(STATE_ALREADY_NOTIFIED_USER_OF_EMPTY_SUBJECT); + final List activeInAppNotifications = savedInstanceState + .getIntegerArrayList(STATE_ACTIVE_IN_APP_NOTIFICATIONS); + if (activeInAppNotifications != null && !activeInAppNotifications.isEmpty()) { + this.activeInAppNotifications.addAll(activeInAppNotifications); + } updateFrom(); @@ -748,6 +869,11 @@ public void performSendAfterChecks() { return; } + if (!ignoreSentFolderNotAssigned && !account.hasSentFolder()) { + sentFolderNotFoundDialogFragmentFactory.show(account.getUuid(), getSupportFragmentManager()); + return; + } + currentMessageBuilder = createMessageBuilder(false); if (currentMessageBuilder != null) { sendMessageHasBeenTriggered = true; @@ -887,6 +1013,9 @@ private void onAccountChosen(LegacyAccountDto account, Identity identity) { this.account = account; } + dismissActiveInAppNotifications(); + triggerIfNeededSentFolderNotFoundInAppNotification(); + // Show CC/BCC text input field when switching to an account that always wants them // displayed. // Please note that we're not hiding the fields if the user switches back to an account @@ -1743,30 +1872,35 @@ private void initializeActionBar() { private void initializeInAppNotificationFragment() { if (FeatureFlagProviderCompat - .provide(featureFlagProvider, "display_in_app_notifications") - .isDisabledOrUnavailable()) { + .provide(featureFlagProvider, "display_in_app_notifications") + .isDisabledOrUnavailable()) { return; } - if (account == null) { - Log.w("Can't initialize in-app notifications. Account is currently null"); + final List accounts = preferences.getAccounts(); + if (accounts.isEmpty()) { + Log.w("Can't initialize in-app notifications. No accounts were found."); return; } final FragmentManager fragmentManager = getSupportFragmentManager(); final Fragment currentFragment = fragmentManager - .findFragmentByTag(MessageComposeInAppNotificationFragment.FRAGMENT_TAG); + .findFragmentByTag(MessageComposeInAppNotificationFragment.FRAGMENT_TAG); if (currentFragment != null) { return; } + final ArrayList uuids = new ArrayList<>(); + for (LegacyAccountDto legacyAccountDto : accounts) { + uuids.add(legacyAccountDto.getUuid()); + } final MessageComposeInAppNotificationFragment inAppNotificationFragment = - MessageComposeInAppNotificationFragment.newInstance(account.getUuid()); + MessageComposeInAppNotificationFragment.newInstance(uuids); fragmentManager - .beginTransaction() - .add(R.id.message_compose_in_app_notifications_container, inAppNotificationFragment, - MessageComposeInAppNotificationFragment.FRAGMENT_TAG) - .commit(); + .beginTransaction() + .add(R.id.message_compose_in_app_notifications_container, inAppNotificationFragment, + MessageComposeInAppNotificationFragment.FRAGMENT_TAG) + .commit(); } // TODO We miss callbacks for this listener if they happens while we are paused! diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/MessageComposeInAppNotificationFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/MessageComposeInAppNotificationFragment.kt index 9250c943891..aa60b302ce2 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/MessageComposeInAppNotificationFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/MessageComposeInAppNotificationFragment.kt @@ -6,14 +6,13 @@ import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import com.fsck.k9.ui.settings.account.AccountSettingsActivity +import com.fsck.k9.ui.settings.account.AccountSettingsFragment import com.google.android.material.snackbar.Snackbar import kotlinx.collections.immutable.persistentSetOf import net.thunderbird.core.logging.Logger import net.thunderbird.core.ui.theme.api.FeatureThemeProvider -import net.thunderbird.feature.account.AccountId -import net.thunderbird.feature.account.AccountIdFactory import net.thunderbird.feature.notification.api.ui.InAppNotificationHost import net.thunderbird.feature.notification.api.ui.action.NotificationAction import net.thunderbird.feature.notification.api.ui.host.DisplayInAppNotificationFlag @@ -27,13 +26,13 @@ class MessageComposeInAppNotificationFragment : Fragment() { private val themeProvider: FeatureThemeProvider by inject() private val logger: Logger by inject() private var parentView: View? = null - private var accountId: AccountId? = null + private var accountIds: Set = emptySet() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { arg -> - accountId = requireNotNull(arg.getString(ARG_ACCOUNT_ID)?.let { AccountIdFactory.of(it) }) { - "Argument $ARG_ACCOUNT_ID is required" + accountIds = requireNotNull(arg.getStringArray(ARG_ACCOUNT_IDS)?.toSet()) { + "Missing argument $ARG_ACCOUNT_IDS" } } } @@ -53,7 +52,7 @@ class MessageComposeInAppNotificationFragment : Fragment() { onSnackbarNotificationEvent = ::onSnackbarInAppNotificationEvent, eventFilter = { event -> val accountUuid = event.notification.accountUuid - accountUuid != null && accountUuid == accountId?.asRaw() + accountUuid != null && accountUuid in accountIds }, ) } @@ -88,19 +87,28 @@ class MessageComposeInAppNotificationFragment : Fragment() { private fun onNotificationActionClick(action: NotificationAction) { logger.verbose(TAG) { "onNotificationActionClick() called with: action = $action" } + when (action) { + is NotificationAction.AssignSentFolder -> + AccountSettingsActivity.start( + context = requireContext(), + accountUuid = action.accountUuid, + startScreenKey = AccountSettingsFragment.PREFERENCE_FOLDERS, + ) + + else -> Unit + } } companion object { - private const val ARG_ACCOUNT_ID = "MessageComposeInAppNotificationFragment_account_id" + private const val ARG_ACCOUNT_IDS = "MessageComposeInAppNotificationFragment_account_ids" const val FRAGMENT_TAG = "MessageComposeInAppNotificationFragment" - fun newInstance(accountId: AccountId): MessageComposeInAppNotificationFragment = + @JvmStatic + fun newInstance(accountUuids: List): MessageComposeInAppNotificationFragment = MessageComposeInAppNotificationFragment().apply { - arguments = bundleOf(ARG_ACCOUNT_ID to accountId.asRaw()) + arguments = Bundle().apply { + putStringArray(ARG_ACCOUNT_IDS, accountUuids.toTypedArray()) + } } - - @JvmStatic - fun newInstance(accountUuid: String): MessageComposeInAppNotificationFragment = - newInstance(AccountIdFactory.of(accountUuid)) } } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index a629199214e..6e929dd9939 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -103,6 +103,7 @@ import net.thunderbird.feature.account.avatar.AvatarMonogramCreator import net.thunderbird.feature.mail.folder.api.OutboxFolderManager import net.thunderbird.feature.mail.message.list.domain.DomainContract import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogFragmentFactory +import net.thunderbird.feature.notification.api.content.SentFolderNotFoundNotification import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent import net.thunderbird.feature.notification.api.ui.InAppNotificationHost import net.thunderbird.feature.notification.api.ui.action.NotificationAction @@ -2172,8 +2173,11 @@ class MessageListFragment : } override fun filterInAppNotificationEvents(event: InAppNotificationEvent): Boolean { - val accountUuid = event.notification.accountUuid - return accountUuid != null && accountUuid in accountUuids + val notification = event.notification + val accountUuid = notification.accountUuid + return notification !is SentFolderNotFoundNotification && + accountUuid != null && + accountUuid in accountUuids } override fun onNotificationActionClicked(action: NotificationAction) = onNotificationActionClick(action) diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsActivity.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsActivity.kt index 5c99465eead..193a8cb3e45 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsActivity.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsActivity.kt @@ -126,20 +126,18 @@ class AccountSettingsActivity : K9Activity(), OnPreferenceStartScreenCallback { private const val ARG_START_SCREEN_KEY = "startScreen" @JvmStatic - fun start(context: Context, accountUuid: String) { + @JvmOverloads + fun start(context: Context, accountUuid: String, startScreenKey: String? = null) { val intent = Intent(context, AccountSettingsActivity::class.java).apply { putExtra(ARG_ACCOUNT_UUID, accountUuid) + startScreenKey?.let { putExtra(ARG_START_SCREEN_KEY, it) } } context.startActivity(intent) } @JvmStatic fun startCryptoSettings(context: Context, accountUuid: String) { - val intent = Intent(context, AccountSettingsActivity::class.java).apply { - putExtra(ARG_ACCOUNT_UUID, accountUuid) - putExtra(ARG_START_SCREEN_KEY, AccountSettingsFragment.PREFERENCE_OPENPGP) - } - context.startActivity(intent) + start(context, accountUuid, AccountSettingsFragment.PREFERENCE_OPENPGP) } } } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt index acedd7c2abb..a4e760f859b 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt @@ -490,7 +490,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr private const val PREFERENCE_OPENPGP_ENABLE = "openpgp_provider" private const val PREFERENCE_OPENPGP_KEY = "openpgp_key" private const val PREFERENCE_AUTOCRYPT_TRANSFER = "autocrypt_transfer" - private const val PREFERENCE_FOLDERS = "folders" + internal const val PREFERENCE_FOLDERS = "folders" private const val PREFERENCE_AUTO_EXPAND_FOLDER = "account_setup_auto_expand_folder" private const val PREFERENCE_SUBSCRIBED_FOLDERS_ONLY = "subscribed_folders_only" private const val PREFERENCE_ARCHIVE_FOLDER = "archive_folder" diff --git a/settings.gradle.kts b/settings.gradle.kts index f8881e03514..5cd669ec688 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -97,6 +97,7 @@ include( include( ":feature:mail:account:api", ":feature:mail:folder:api", + ":feature:mail:message:composer", ":feature:mail:message:list", )