Add new-mail notifications and persisted settings

Increment 6 — notifications and settings.

- Local new-mail notifications (no push service): MailNotifier posts a notification
  when background sync finds newly-arrived unread mail, and tapping it opens the app.
  Adds a POST_NOTIFICATIONS request on launch.
- MailSyncer detects genuinely new messages (diff against cached ids, skipped on an
  account's first sync) and notifies when the setting is enabled.
- SettingsRepository (Preferences DataStore) persists settings; the Settings screen
  gains a Notifications section, and "Use wallpaper colors" now actually drives the
  Material You theme (MainActivity collects it reactively).
- assemble/test/lint green; verified on the Android 17 emulator — delivered a new
  message and the on-device notification appeared in the shade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 20:21:49 -05:00
co-authored by Claude Opus 4.8
parent 22ddc08a24
commit 40b0d9b3ad
11 changed files with 261 additions and 35 deletions
+1
View File
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".LibreMailApplication"
@@ -1,23 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.libremail.data.settings.SettingsRepository
import org.libremail.ui.LibreMailApp
import org.libremail.ui.theme.LibreMailTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
LibreMailTheme {
val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
LibreMailTheme(dynamicColor = dynamicColor) {
NotificationPermissionEffect()
LibreMailApp()
}
}
}
}
/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */
@Composable
private fun NotificationPermissionEffect() {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -16,6 +16,9 @@ interface MessageDao {
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
suspend fun getById(id: String): MessageEntity?
@Query("SELECT id FROM messages WHERE accountId = :accountId")
suspend fun getIdsForAccount(accountId: String): List<String>
/** Inserts only new messages, leaving existing rows (and their cached bodies) intact. */
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNew(messages: List<MessageEntity>)
@@ -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<Preferences> 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<AppSettings> = 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<Boolean> = 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<Boolean>, 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")
}
}
@@ -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<Int> {
@@ -26,24 +31,40 @@ class MailSyncer @Inject constructor(
var total = 0
var firstError: Throwable? = null
var anySuccess = false
val newMessages = mutableListOf<MessageEntity>()
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<Int> = runCatching {
private suspend fun syncAccount(account: Account): Result<AccountSyncResult> = 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<MessageEntity>)
private companion object {
const val INBOX_LIMIT = 50
}
@@ -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<MessageEntity>) {
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
}
}
@@ -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,
)
}
@@ -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<SettingsUiState> = _state.asStateFlow()
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun removeAccount(id: String) {
viewModelScope.launch { accountRepository.deleteAccount(id) }
}
val settings: StateFlow<AppSettings> = 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<Boolean> = _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() }
}
}
+7
View File
@@ -59,8 +59,15 @@
<string name="manual_smtp_security">SMTP security</string>
<string name="manual_test_and_add">Test &amp; add account</string>
<!-- Notifications -->
<string name="notif_channel_new_mail">New mail</string>
<string name="notif_new_mail_count">%1$d new messages</string>
<!-- Settings -->
<string name="settings_accounts">Accounts</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_new_mail">New-mail notifications</string>
<string name="settings_new_mail_summary">Show a notification when new mail arrives</string>
<string name="settings_no_accounts">No accounts yet</string>
<string name="account_remove">Remove</string>
<string name="settings_appearance">Appearance</string>