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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,15 @@
|
||||
<string name="manual_smtp_security">SMTP security</string>
|
||||
<string name="manual_test_and_add">Test & 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>
|
||||
|
||||
Reference in New Issue
Block a user