diff --git a/README.md b/README.md index c5056a6..b412f97 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ experience with power-user features tucked under an **Advanced Settings** group. > Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and > rendered in a hardened WebView (JavaScript off, remote images blocked by default), > with mark-read, star, and delete; and **composing** — a compose screen with device- -> contacts autocomplete that sends over SMTP, plus reply. Outlook Graph send, push -> notifications, and multi-account polish are upcoming. +> contacts autocomplete that sends over SMTP, plus reply; and **on-device new-mail +> notifications** (no push service) with persisted settings. Outlook Graph send, IMAP +> IDLE push, attachments, and multi-account polish are upcoming. ## Features (target MVP) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d56e61d..585a71a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -105,6 +105,7 @@ dependencies { ksp(libs.androidx.hilt.compiler) implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 37685d8..afe04fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + + /** Inserts only new messages, leaving existing rows (and their cached bodies) intact. */ @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertNew(messages: List) diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt new file mode 100644 index 0000000..885bb28 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "libremail_settings") + +/** User preferences. Only [dynamicColor] and [newMailNotifications] are wired to behaviour so far. */ +data class AppSettings( + val dynamicColor: Boolean = true, + val newMailNotifications: Boolean = true, + val pushIdle: Boolean = true, + val allowStartTls: Boolean = false, + val loadRemoteImages: Boolean = false, +) + +@Singleton +class SettingsRepository @Inject constructor( + @ApplicationContext private val context: Context, +) { + val settings: Flow = context.settingsDataStore.data.map { prefs -> + AppSettings( + dynamicColor = prefs[DYNAMIC_COLOR] ?: true, + newMailNotifications = prefs[NEW_MAIL_NOTIFICATIONS] ?: true, + pushIdle = prefs[PUSH_IDLE] ?: true, + allowStartTls = prefs[ALLOW_STARTTLS] ?: false, + loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false, + ) + } + + val dynamicColor: Flow = settings.map { it.dynamicColor } + + suspend fun isNewMailNotificationsEnabled(): Boolean = settings.first().newMailNotifications + + suspend fun setDynamicColor(value: Boolean) = put(DYNAMIC_COLOR, value) + suspend fun setNewMailNotifications(value: Boolean) = put(NEW_MAIL_NOTIFICATIONS, value) + suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value) + suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value) + suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value) + + private suspend fun put(key: Preferences.Key, value: Boolean) { + context.settingsDataStore.edit { it[key] = value } + } + + private companion object { + val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") + val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications") + val PUSH_IDLE = booleanPreferencesKey("push_idle") + val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls") + val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images") + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt index 158bde5..490fc16 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -5,18 +5,23 @@ import javax.inject.Inject import javax.inject.Singleton import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity +import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Account import org.libremail.mail.ImapClient +import org.libremail.notifications.MailNotifier -/** Fetches each account's recent INBOX headers and writes them into Room (the source of truth). */ +/** Fetches each account's recent INBOX headers into Room and notifies about newly-arrived mail. */ @Singleton class MailSyncer @Inject constructor( private val accountDao: AccountDao, private val messageDao: MessageDao, private val imapClient: ImapClient, private val connectionFactory: MailConnectionFactory, + private val settingsRepository: SettingsRepository, + private val notifier: MailNotifier, ) { /** Syncs every account. Succeeds if at least one account synced (or there are none). */ suspend fun syncAll(): Result { @@ -26,24 +31,40 @@ class MailSyncer @Inject constructor( var total = 0 var firstError: Throwable? = null var anySuccess = false + val newMessages = mutableListOf() for (entity in accounts) { syncAccount(entity.toDomain()).fold( - onSuccess = { total += it; anySuccess = true }, + onSuccess = { result -> + total += result.fetched + newMessages += result.newMessages + anySuccess = true + }, onFailure = { error -> if (firstError == null) firstError = error }, ) } + + if (newMessages.isNotEmpty() && settingsRepository.isNewMailNotificationsEnabled()) { + notifier.notifyNewMail(newMessages.sortedByDescending { it.timestampMillis }) + } return if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError!!) } - private suspend fun syncAccount(account: Account): Result = runCatching { + private suspend fun syncAccount(account: Account): Result = runCatching { val params = connectionFactory.imapParamsFor(account) val fetched = imapClient.fetchRecentInbox(params, INBOX_LIMIT) val entities = fetched.map { it.toEntity(account.id) } + + val existingIds = messageDao.getIdsForAccount(account.id).toHashSet() + // Don't notify on the very first sync of an account (would announce the whole inbox). + val newMessages = if (existingIds.isEmpty()) { + emptyList() + } else { + entities.filter { it.id !in existingIds && !it.isRead } + } + if (entities.isEmpty()) { messageDao.deleteByAccount(account.id) } else { - // Insert new headers (keeps any already-cached body), refresh header/flag columns, - // then drop messages that no longer exist on the server. messageDao.insertNew(entities) entities.forEach { messageDao.updateHeader( @@ -58,9 +79,11 @@ class MailSyncer @Inject constructor( } messageDao.deleteNotIn(account.id, entities.map { it.id }) } - fetched.size + AccountSyncResult(fetched = fetched.size, newMessages = newMessages) } + private data class AccountSyncResult(val fetched: Int, val newMessages: List) + private companion object { const val INBOX_LIMIT = 50 } diff --git a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt new file mode 100644 index 0000000..beadb85 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.notifications + +import android.Manifest +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import org.libremail.MainActivity +import org.libremail.R +import org.libremail.data.local.entity.MessageEntity + +/** Posts on-device new-mail notifications (no push service involved). */ +@Singleton +class MailNotifier @Inject constructor( + @ApplicationContext private val context: Context, +) { + // Permission is checked via hasPermission() below; lint can't trace the indirect guard. + @SuppressLint("MissingPermission") + fun notifyNewMail(messages: List) { + if (messages.isEmpty() || !hasPermission()) return + ensureChannel() + + val title = if (messages.size == 1) { + messages.first().sender + } else { + context.getString(R.string.notif_new_mail_count, messages.size) + } + val text = if (messages.size == 1) { + messages.first().subject + } else { + messages.joinToString(", ") { it.sender } + } + + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + } + + private fun hasPermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + + private fun ensureChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notif_channel_new_mail), + NotificationManager.IMPORTANCE_DEFAULT, + ) + NotificationManagerCompat.from(context).createNotificationChannel(channel) + } + + private companion object { + const val CHANNEL_ID = "new_mail" + const val NOTIFICATION_ID = 1001 + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt index dfba482..e91e3ba 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -43,8 +43,10 @@ fun SettingsScreen( onSelectTab: (TopDest) -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val advancedExpanded by viewModel.advancedExpanded.collectAsStateWithLifecycle() + Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.title_settings)) }) }, bottomBar = { LibreMailBottomBar(current = TopDest.SETTINGS, onSelect = onSelectTab) }, @@ -71,31 +73,40 @@ fun SettingsScreen( ClickRow(title = stringResource(R.string.settings_add_account), onClick = onAddAccount) HorizontalDivider() + SectionHeader(stringResource(R.string.settings_notifications)) + SwitchRow( + title = stringResource(R.string.settings_new_mail), + checked = settings.newMailNotifications, + onCheckedChange = viewModel::setNewMailNotifications, + subtitle = stringResource(R.string.settings_new_mail_summary), + ) + HorizontalDivider() + SectionHeader(stringResource(R.string.settings_appearance)) SwitchRow( title = stringResource(R.string.settings_dynamic_color), - checked = state.dynamicColor, + checked = settings.dynamicColor, onCheckedChange = viewModel::setDynamicColor, subtitle = stringResource(R.string.settings_dynamic_color_summary), ) HorizontalDivider() - AdvancedHeader(expanded = state.advancedExpanded, onToggle = viewModel::toggleAdvanced) - AnimatedVisibility(visible = state.advancedExpanded) { + AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced) + AnimatedVisibility(visible = advancedExpanded) { Column { SwitchRow( title = stringResource(R.string.settings_adv_idle), - checked = state.pushIdle, + checked = settings.pushIdle, onCheckedChange = viewModel::setPushIdle, ) SwitchRow( title = stringResource(R.string.settings_adv_starttls), - checked = state.allowStartTls, + checked = settings.allowStartTls, onCheckedChange = viewModel::setAllowStartTls, ) SwitchRow( title = stringResource(R.string.settings_adv_remote_images), - checked = state.loadRemoteImages, + checked = settings.loadRemoteImages, onCheckedChange = viewModel::setLoadRemoteImages, ) } diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt index 1ea25e0..bd6cb39 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt @@ -12,35 +12,37 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Account import org.libremail.domain.repository.AccountRepository -data class SettingsUiState( - val dynamicColor: Boolean = true, - val advancedExpanded: Boolean = false, - val pushIdle: Boolean = true, - val allowStartTls: Boolean = false, - val loadRemoteImages: Boolean = false, -) - @HiltViewModel class SettingsViewModel @Inject constructor( private val accountRepository: AccountRepository, + private val settingsRepository: SettingsRepository, ) : ViewModel() { - private val _state = MutableStateFlow(SettingsUiState()) - val state: StateFlow = _state.asStateFlow() - val accounts: StateFlow> = accountRepository.observeAccounts() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - fun removeAccount(id: String) { - viewModelScope.launch { accountRepository.deleteAccount(id) } - } + val settings: StateFlow = settingsRepository.settings + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()) - fun setDynamicColor(value: Boolean) = _state.update { it.copy(dynamicColor = value) } - fun toggleAdvanced() = _state.update { it.copy(advancedExpanded = !it.advancedExpanded) } - fun setPushIdle(value: Boolean) = _state.update { it.copy(pushIdle = value) } - fun setAllowStartTls(value: Boolean) = _state.update { it.copy(allowStartTls = value) } - fun setLoadRemoteImages(value: Boolean) = _state.update { it.copy(loadRemoteImages = value) } + private val _advancedExpanded = MutableStateFlow(false) + val advancedExpanded: StateFlow = _advancedExpanded.asStateFlow() + + fun toggleAdvanced() = _advancedExpanded.update { !it } + + fun removeAccount(id: String) = viewModelScope.launch { accountRepository.deleteAccount(id) }.let {} + + fun setDynamicColor(value: Boolean) = update { settingsRepository.setDynamicColor(value) } + fun setNewMailNotifications(value: Boolean) = update { settingsRepository.setNewMailNotifications(value) } + fun setPushIdle(value: Boolean) = update { settingsRepository.setPushIdle(value) } + fun setAllowStartTls(value: Boolean) = update { settingsRepository.setAllowStartTls(value) } + fun setLoadRemoteImages(value: Boolean) = update { settingsRepository.setLoadRemoteImages(value) } + + private inline fun update(crossinline action: suspend () -> Unit) { + viewModelScope.launch { action() } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd851a5..e2008b4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,8 +59,15 @@ SMTP security Test & add account + + New mail + %1$d new messages + Accounts + Notifications + New-mail notifications + Show a notification when new mail arrives No accounts yet Remove Appearance