Skip to content

Commit

Permalink
feat: Add scheduled Drafts (#2143)
Browse files Browse the repository at this point in the history
  • Loading branch information
KevinBoulongne authored Feb 10, 2025
2 parents c8ed034 + 2fb4a2e commit 9beccd7
Show file tree
Hide file tree
Showing 94 changed files with 2,848 additions and 549 deletions.
144 changes: 96 additions & 48 deletions .idea/navEditor.xml

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion app/src/main/java/com/infomaniak/mail/MatomoMail.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* Infomaniak Mail - Android
* Copyright (C) 2022-2024 Infomaniak Network SA
* Copyright (C) 2022-2025 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
Expand Down Expand Up @@ -269,6 +269,10 @@ object MatomoMail : MatomoCore {
trackEvent("settingsAutoAdvance", name)
}

fun Fragment.trackScheduleSendEvent(name: String) {
trackEvent("scheduleSend", name)
}

// We need to invert this logical value to keep a coherent value for analytics because actions
// conditions are inverted (ex: if the condition is `message.isSpam`, then we want to unspam)
private fun Boolean.toMailActionValue() = (!this).toFloat()
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/infomaniak/mail/data/LocalSettings.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* Infomaniak Mail - Android
* Copyright (C) 2022-2024 Infomaniak Network SA
* Copyright (C) 2022-2025 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
Expand Down Expand Up @@ -69,6 +69,7 @@ class LocalSettings private constructor(context: Context) : SharedValues {
var autoAdvanceNaturalThread by sharedValue("autoAdvanceNaturalThreadKey", AutoAdvanceMode.FOLLOWING_THREAD)
var showWebViewOutdated by sharedValue("showWebViewOutdatedKey", true)
var accessTokenApiCallRecord by sharedValue<ApiCallRecord>("accessTokenApiCallRecordKey", null)
var lastSelectedScheduleEpoch by sharedValue<Long>("lastSelectedScheduleEpochKey", null)

fun removeSettings() = sharedPreferences.transaction { clear() }

Expand Down
50 changes: 32 additions & 18 deletions app/src/main/java/com/infomaniak/mail/data/api/ApiRepository.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* Infomaniak Mail - Android
* Copyright (C) 2022-2024 Infomaniak Network SA
* Copyright (C) 2022-2025 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
Expand Down Expand Up @@ -30,7 +30,6 @@ import com.infomaniak.lib.core.utils.FORMAT_FULL_DATE_WITH_HOUR
import com.infomaniak.lib.core.utils.format
import com.infomaniak.mail.data.LocalSettings.AiEngine
import com.infomaniak.mail.data.models.*
import com.infomaniak.mail.data.models.AttachmentDisposition
import com.infomaniak.mail.data.models.addressBook.AddressBooksResult
import com.infomaniak.mail.data.models.ai.AiMessage
import com.infomaniak.mail.data.models.ai.AiResult
Expand All @@ -43,6 +42,7 @@ import com.infomaniak.mail.data.models.correspondent.Contact
import com.infomaniak.mail.data.models.correspondent.Recipient
import com.infomaniak.mail.data.models.draft.Draft
import com.infomaniak.mail.data.models.draft.SaveDraftResult
import com.infomaniak.mail.data.models.draft.ScheduleDraftResult
import com.infomaniak.mail.data.models.draft.SendDraftResult
import com.infomaniak.mail.data.models.getMessages.ActivitiesResult
import com.infomaniak.mail.data.models.getMessages.GetMessagesByUidsResult
Expand All @@ -58,7 +58,6 @@ import com.infomaniak.mail.data.models.thread.ThreadResult
import com.infomaniak.mail.ui.newMessage.AiViewModel.Shortcut
import com.infomaniak.mail.utils.Utils
import io.realm.kotlin.ext.copyFromRealm
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
Expand Down Expand Up @@ -170,28 +169,35 @@ object ApiRepository : ApiRepositoryCore() {
}

fun saveDraft(mailboxUuid: String, draft: Draft, okHttpClient: OkHttpClient): ApiResponse<SaveDraftResult> {

val body = getDraftBody(draft)

fun postDraft(): ApiResponse<SaveDraftResult> = callApi(ApiRoutes.draft(mailboxUuid), POST, body, okHttpClient)

fun putDraft(uuid: String): ApiResponse<SaveDraftResult> =
callApi(ApiRoutes.draft(mailboxUuid, uuid), PUT, body, okHttpClient)

return draft.remoteUuid?.let(::putDraft) ?: run(::postDraft)
return uploadDraft(mailboxUuid, draft, okHttpClient)
}

fun sendDraft(mailboxUuid: String, draft: Draft, okHttpClient: OkHttpClient): ApiResponse<SendDraftResult> {
return uploadDraft(mailboxUuid, draft, okHttpClient)
}

val body = getDraftBody(draft)
fun scheduleDraft(mailboxUuid: String, draft: Draft, okHttpClient: OkHttpClient): ApiResponse<ScheduleDraftResult> {
return uploadDraft(mailboxUuid, draft, okHttpClient)
}

fun postDraft(): ApiResponse<SendDraftResult> = callApi(ApiRoutes.draft(mailboxUuid), POST, body, okHttpClient)
private inline fun <reified T> uploadDraft(mailboxUuid: String, draft: Draft, okHttpClient: OkHttpClient): ApiResponse<T> {
val body = getDraftBody(draft)
return draft.remoteUuid?.let { putDraft(mailboxUuid, body, okHttpClient, it) }
?: run { postDraft(mailboxUuid, body, okHttpClient) }
}

fun putDraft(uuid: String): ApiResponse<SendDraftResult> =
callApi(ApiRoutes.draft(mailboxUuid, uuid), PUT, body, okHttpClient)
private inline fun <reified T> putDraft(
mailboxUuid: String,
body: String,
okHttpClient: OkHttpClient,
uuid: String,
): ApiResponse<T> = callApi(ApiRoutes.draft(mailboxUuid, uuid), PUT, body, okHttpClient)

return draft.remoteUuid?.let(::putDraft) ?: run(::postDraft)
}
private inline fun <reified T> postDraft(
mailboxUuid: String,
body: String,
okHttpClient: OkHttpClient,
): ApiResponse<T> = callApi(ApiRoutes.draft(mailboxUuid), POST, body, okHttpClient)

private fun getDraftBody(draft: Draft): String {
val updatedDraft = if (draft.identityId == Draft.NO_IDENTITY.toString()) {
Expand Down Expand Up @@ -240,6 +246,14 @@ object ApiRepository : ApiRepositoryCore() {
return callApi(ApiRoutes.draft(mailboxUuid, remoteDraftUuid), DELETE)
}

fun unscheduleDraft(unscheduleDraftUrl: String): ApiResponse<Unit> {
return callApi(ApiRoutes.resource(unscheduleDraftUrl), DELETE)
}

fun rescheduleDraft(draftResource: String, scheduleDate: Date): ApiResponse<Unit> {
return callApi(ApiRoutes.rescheduleDraft(draftResource, scheduleDate), PUT)
}

fun getDraft(messageDraftResource: String): ApiResponse<Draft> = callApi(ApiRoutes.resource(messageDraftResource), GET)

fun addToFavorites(mailboxUuid: String, messagesUids: List<String>): List<ApiResponse<Unit>> {
Expand Down
15 changes: 12 additions & 3 deletions app/src/main/java/com/infomaniak/mail/data/api/ApiRoutes.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* Infomaniak Mail - Android
* Copyright (C) 2022-2024 Infomaniak Network SA
* Copyright (C) 2022-2025 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
Expand All @@ -18,8 +18,12 @@
package com.infomaniak.mail.data.api

import com.infomaniak.lib.core.BuildConfig.INFOMANIAK_API_V1
import com.infomaniak.lib.core.utils.FORMAT_SCHEDULE_MAIL
import com.infomaniak.lib.core.utils.format
import com.infomaniak.mail.BuildConfig.MAIL_API
import com.infomaniak.mail.utils.Utils
import java.net.URLEncoder
import java.util.Date

object ApiRoutes {

Expand Down Expand Up @@ -127,11 +131,11 @@ object ApiRoutes {
}

fun folders(mailboxUuid: String): String {
return "${mailMailbox(mailboxUuid)}/folder"
return "${mailMailbox(mailboxUuid)}/folder?with=ik-static"
}

private fun folder(mailboxUuid: String, folderId: String): String {
return "${folders(mailboxUuid)}/$folderId"
return "${mailMailbox(mailboxUuid)}/folder/$folderId"
}

fun flushFolder(mailboxUuid: String, folderId: String): String {
Expand Down Expand Up @@ -223,6 +227,11 @@ object ApiRoutes {
return "${draft(mailboxUuid)}/$remoteDraftUuid"
}

fun rescheduleDraft(draftResource: String, scheduleDate: Date): String {
val formatedDate = scheduleDate.format(FORMAT_SCHEDULE_MAIL)
return "${MAIL_API}${draftResource}/schedule?schedule_date=${URLEncoder.encode(formatedDate, "UTF-8")}"
}

fun createAttachment(mailboxUuid: String): String {
return "${draft(mailboxUuid)}/attachment"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ object RealmDatabase {

//region Configurations versions
const val USER_INFO_SCHEMA_VERSION = 2L
const val MAILBOX_INFO_SCHEMA_VERSION = 7L
const val MAILBOX_CONTENT_SCHEMA_VERSION = 19L
const val MAILBOX_INFO_SCHEMA_VERSION = 8L
const val MAILBOX_CONTENT_SCHEMA_VERSION = 20L
//endregion

//region Configurations names
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ val MAILBOX_INFO_MIGRATION = AutomaticSchemaMigration { migrationContext ->
val MAILBOX_CONTENT_MIGRATION = AutomaticSchemaMigration { migrationContext ->
SentryDebug.addMigrationBreadcrumb(migrationContext)
migrationContext.deleteRealmFromFirstMigration()
migrationContext.keepDefaultValuesAfterNineteenthMigration()
}

// Migrate to version #1
private fun MigrationContext.deleteRealmFromFirstMigration() {
if (oldRealm.schemaVersion() < 1L) newRealm.deleteAll()
}

//region Use default property values when adding a new column in a migration
/**
* Migrate from version #6
*
Expand Down Expand Up @@ -73,3 +75,26 @@ private fun MigrationContext.keepDefaultValuesAfterSixthMigration() {
}
}
}

// Migrate from version #19
private fun MigrationContext.keepDefaultValuesAfterNineteenthMigration() {

if (oldRealm.schemaVersion() <= 19L) {

enumerate(className = "Folder") { _: DynamicRealmObject, newObject: DynamicMutableRealmObject? ->
newObject?.apply {
// Add property with default value
set(propertyName = "isDisplayed", value = true)
}
}

enumerate(className = "Message") { oldObject: DynamicRealmObject, newObject: DynamicMutableRealmObject? ->
newObject?.apply {
// Rename property without losing its previous value
set(propertyName = "isScheduledMessage", value = oldObject.getValue<Boolean>(fieldName = "isScheduled"))
}
}

}
}
//endregion
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,31 @@ class FolderController @Inject constructor(

//region Get data
fun getMenuDrawerDefaultFoldersAsync(): Flow<ResultsChange<Folder>> {
return getFoldersQuery(mailboxContentRealm(), withoutType = FoldersType.CUSTOM, withoutChildren = true).asFlow()
return getFoldersQuery(
realm = mailboxContentRealm(),
withoutTypes = listOf(FoldersType.CUSTOM),
withoutChildren = true,
).asFlow()
}

fun getMenuDrawerCustomFoldersAsync(): Flow<ResultsChange<Folder>> {
return getFoldersQuery(mailboxContentRealm(), withoutType = FoldersType.DEFAULT, withoutChildren = true).asFlow()
return getFoldersQuery(
realm = mailboxContentRealm(),
withoutTypes = listOf(FoldersType.DEFAULT),
withoutChildren = true,
).asFlow()
}

fun getSearchFoldersAsync(): Flow<ResultsChange<Folder>> {
return getFoldersQuery(mailboxContentRealm(), withoutChildren = true).asFlow()
}

fun getMoveFolders(): RealmResults<Folder> {
return getFoldersQuery(mailboxContentRealm(), withoutType = FoldersType.DRAFT, withoutChildren = true).find()
return getFoldersQuery(
realm = mailboxContentRealm(),
withoutTypes = listOf(FoldersType.SCHEDULED_DRAFTS, FoldersType.DRAFT),
withoutChildren = true,
).find()
}

fun getFolder(id: String): Folder? {
Expand Down Expand Up @@ -108,20 +120,25 @@ class FolderController @Inject constructor(

remoteFolders.forEach { remoteFolder ->

getFolder(remoteFolder.id, realm = this)?.let { localFolder ->
val localFolder = getFolder(remoteFolder.id, realm = this)

if (remoteFolder.role == FolderRole.SCHEDULED_DRAFTS && localFolder == null) remoteFolder.isDisplayed = false

localFolder?.let {

val collapseStateNeedsReset = remoteFolder.isRoot && remoteFolder.children.isEmpty()
val isCollapsed = if (collapseStateNeedsReset) false else localFolder.isCollapsed
val isCollapsed = if (collapseStateNeedsReset) false else it.isCollapsed

remoteFolder.initLocalValues(
localFolder.lastUpdatedAt,
localFolder.cursor,
localFolder.unreadCountLocal,
localFolder.threads,
localFolder.oldMessagesUidsToFetch,
localFolder.newMessagesUidsToFetch,
localFolder.remainingOldMessagesToFetch,
localFolder.isHidden,
it.lastUpdatedAt,
it.cursor,
it.unreadCountLocal,
it.threads,
it.oldMessagesUidsToFetch,
it.newMessagesUidsToFetch,
it.remainingOldMessagesToFetch,
it.isDisplayed,
it.isHidden,
isCollapsed,
)
}
Expand All @@ -134,6 +151,7 @@ class FolderController @Inject constructor(
enum class FoldersType {
DEFAULT,
CUSTOM,
SCHEDULED_DRAFTS,
DRAFT,
}

Expand All @@ -145,17 +163,21 @@ class FolderController @Inject constructor(
//region Queries
private fun getFoldersQuery(
realm: TypedRealm,
withoutType: FoldersType? = null,
withoutTypes: List<FoldersType> = emptyList(),
withoutChildren: Boolean = false,
visibleFoldersOnly: Boolean = true,
): RealmQuery<Folder> {
val rootsQuery = if (withoutChildren) " AND $isRootFolder" else ""
val typeQuery = when (withoutType) {
FoldersType.DEFAULT -> " AND ${Folder.rolePropertyName} == nil"
FoldersType.CUSTOM -> " AND ${Folder.rolePropertyName} != nil"
FoldersType.DRAFT -> " AND ${Folder.rolePropertyName} != '${FolderRole.DRAFT.name}'"
null -> ""
val typeQuery = withoutTypes.joinToString(separator = "") {
when (it) {
FoldersType.DEFAULT -> " AND ${Folder.rolePropertyName} == nil"
FoldersType.CUSTOM -> " AND ${Folder.rolePropertyName} != nil"
FoldersType.SCHEDULED_DRAFTS -> " AND ${Folder.rolePropertyName} != '${FolderRole.SCHEDULED_DRAFTS.name}'"
FoldersType.DRAFT -> " AND ${Folder.rolePropertyName} != '${FolderRole.DRAFT.name}'"
}
}
return realm.query<Folder>("$isNotSearch${rootsQuery}${typeQuery}").sortFolders()
val visibilityQuery = if (visibleFoldersOnly) " AND ${Folder::isDisplayed.name} == true" else ""
return realm.query<Folder>("${isNotSearch}${rootsQuery}${typeQuery}${visibilityQuery}").sortFolders()
}

private fun getFoldersQuery(exceptionsFoldersIds: List<String>, realm: TypedRealm): RealmQuery<Folder> {
Expand All @@ -170,7 +192,7 @@ class FolderController @Inject constructor(
//region Get data
private fun getFolders(exceptionsFoldersIds: List<String> = emptyList(), realm: TypedRealm): RealmResults<Folder> {
val realmQuery = if (exceptionsFoldersIds.isEmpty()) {
getFoldersQuery(realm)
getFoldersQuery(realm, visibleFoldersOnly = false)
} else {
getFoldersQuery(exceptionsFoldersIds, realm)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,13 @@ class MessageController @Inject constructor(private val mailboxContentRealm: Rea

fun getLastMessageToExecuteAction(thread: Thread): Message = with(thread) {

val isNotScheduledDraft = "${Message::isScheduledDraft.name} == false"
val isNotFromMe = "SUBQUERY(${Message::from.name}, \$recipient, " +
"\$recipient.${Recipient::email.name} != '${AccountUtils.currentMailboxEmail}').@count > 0"

return messages.query("$isNotDraft AND $isNotFromMe").find().lastOrNull()
?: messages.query(isNotDraft).find().lastOrNull()
return messages.query("$isNotDraft AND $isNotScheduledDraft AND $isNotFromMe").find().lastOrNull()
?: messages.query("$isNotDraft AND $isNotScheduledDraft").find().lastOrNull()
?: messages.query(isNotScheduledDraft).find().lastOrNull()
?: messages.last()
}

Expand All @@ -83,11 +85,11 @@ class MessageController @Inject constructor(private val mailboxContentRealm: Rea

fun getMovableMessages(thread: Thread): List<Message> {
val byFolderId = "${Message::folderId.name} == '${thread.folderId}'"
return getMessagesAndDuplicates(thread, "$byFolderId AND $isNotScheduled")
return getMessagesAndDuplicates(thread, "$byFolderId AND $isNotScheduledMessage")
}

fun getUnscheduledMessages(thread: Thread): List<Message> {
return getMessagesAndDuplicates(thread, isNotScheduled)
return getMessagesAndDuplicates(thread, isNotScheduledMessage)
}

private fun getMessagesAndDuplicates(thread: Thread, query: String): List<Message> {
Expand Down Expand Up @@ -150,7 +152,7 @@ class MessageController @Inject constructor(private val mailboxContentRealm: Rea

companion object {
private val isNotDraft = "${Message::isDraft.name} == false"
private val isNotScheduled = "${Message::isScheduled.name} == false"
private val isNotScheduledMessage = "${Message::isScheduledMessage.name} == false"

//region Queries
private fun getMessagesQuery(messageUid: String, realm: TypedRealm): RealmQuery<Message> {
Expand Down
Loading

0 comments on commit 9beccd7

Please sign in to comment.