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