Combine ConnectionState and error details

This commit is contained in:
Philipp Heckel 2026-01-11 17:17:48 -05:00
parent 9f530ca623
commit 00a16f454b
11 changed files with 102 additions and 145 deletions

View file

@ -28,7 +28,7 @@ data class Subscription(
@Ignore val totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
@Ignore val connectionDetails: ConnectionDetails = ConnectionDetails()
) {
constructor(
id: Long,
@ -64,7 +64,7 @@ data class Subscription(
totalCount = 0,
newCount = 0,
lastActive = 0,
state = ConnectionState.NOT_APPLICABLE
connectionDetails = ConnectionDetails()
)
}
@ -73,19 +73,22 @@ enum class ConnectionState {
}
/**
* Represents a connection error for a specific baseUrl.
* Represents connection details for a specific baseUrl, including state and error info.
* This is not persisted to the database, but kept in memory.
*/
data class ConnectionError(
val baseUrl: String,
val message: String,
val throwable: Throwable?,
val timestamp: Long = System.currentTimeMillis(),
data class ConnectionDetails(
val state: ConnectionState = ConnectionState.NOT_APPLICABLE,
val error: String? = null,
val throwable: Throwable? = null,
val nextRetryTime: Long = 0L
) {
fun getStackTraceString(): String {
return throwable?.stackTraceToString() ?: ""
}
fun hasError(): Boolean {
return error != null
}
}
data class SubscriptionWithMetadata(

View file

@ -25,11 +25,8 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
private val clientCertificateDao = database.clientCertificateDao()
private val customHeaderDao = database.customHeaderDao()
private val connectionStates = ConcurrentHashMap<Long, ConnectionState>()
private val connectionStatesLiveData = MutableLiveData(connectionStates)
private val connectionErrors = ConcurrentHashMap<String, ConnectionError>()
private val connectionErrorsLiveData = MutableLiveData<Map<String, ConnectionError>>(emptyMap())
private val connectionDetails = ConcurrentHashMap<String, ConnectionDetails>()
private val connectionDetailsLiveData = MutableLiveData<Map<String, ConnectionDetails>>(connectionDetails)
// TODO Move these into an ApplicationState singleton
val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ...
@ -43,7 +40,7 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
return subscriptionDao
.listFlow()
.asLiveData()
.combineWith(connectionStatesLiveData) { subscriptionsWithMetadata, _ ->
.combineWith(connectionDetailsLiveData) { subscriptionsWithMetadata, _ ->
toSubscriptionList(subscriptionsWithMetadata.orEmpty())
}
}
@ -500,7 +497,6 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
private fun toSubscriptionList(list: List<SubscriptionWithMetadata>): List<Subscription> {
return list.map { s ->
val connectionState = connectionStates.getOrElse(s.id) { ConnectionState.NOT_APPLICABLE }
Subscription(
id = s.id,
baseUrl = s.baseUrl,
@ -519,7 +515,7 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
state = connectionState
connectionDetails = connectionDetails[s.baseUrl] ?: ConnectionDetails()
)
}
}
@ -546,60 +542,34 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
state = getState(s.id)
connectionDetails = connectionDetails[s.baseUrl] ?: ConnectionDetails()
)
}
fun updateState(subscriptionIds: Collection<Long>, newState: ConnectionState) {
var changed = false
subscriptionIds.forEach { subscriptionId ->
val state = connectionStates.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
if (state !== newState) {
changed = true
if (newState == ConnectionState.NOT_APPLICABLE) {
connectionStates.remove(subscriptionId)
} else {
connectionStates[subscriptionId] = newState
}
fun updateConnectionDetails(baseUrl: String, state: ConnectionState, error: String? = null, throwable: Throwable? = null, nextRetryTime: Long = 0L) {
val details = ConnectionDetails(state, error, throwable, nextRetryTime)
val current = connectionDetails[baseUrl]
if (current != details) {
if (state == ConnectionState.NOT_APPLICABLE && error == null) {
connectionDetails.remove(baseUrl)
} else {
connectionDetails[baseUrl] = details
}
}
if (changed) {
connectionStatesLiveData.postValue(connectionStates)
connectionDetailsLiveData.postValue(connectionDetails.toMap())
Log.d(TAG, "Connection details updated for $baseUrl: state=$state, error=$error, nextRetry=$nextRetryTime")
}
}
private fun getState(subscriptionId: Long): ConnectionState {
return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
fun getConnectionDetailsLiveData(): LiveData<Map<String, ConnectionDetails>> {
return connectionDetailsLiveData
}
fun getConnectionErrorsLiveData(): LiveData<Map<String, ConnectionError>> {
return connectionErrorsLiveData
fun getConnectionDetails(): Map<String, ConnectionDetails> {
return connectionDetails.toMap()
}
fun getConnectionErrors(): Map<String, ConnectionError> {
return connectionErrors.toMap()
}
fun updateConnectionError(baseUrl: String, message: String, throwable: Throwable?, nextRetryTime: Long = 0L) {
val error = ConnectionError(baseUrl, message, throwable, System.currentTimeMillis(), nextRetryTime)
connectionErrors[baseUrl] = error
connectionErrorsLiveData.postValue(connectionErrors.toMap())
Log.d(TAG, "Connection error updated for $baseUrl: $message (next retry at $nextRetryTime)")
}
fun clearConnectionError(baseUrl: String) {
if (connectionErrors.remove(baseUrl) != null) {
connectionErrorsLiveData.postValue(connectionErrors.toMap())
Log.d(TAG, "Connection error cleared for $baseUrl")
}
}
fun clearAllConnectionErrors() {
if (connectionErrors.isNotEmpty()) {
connectionErrors.clear()
connectionErrorsLiveData.postValue(emptyMap())
Log.d(TAG, "All connection errors cleared")
}
fun getConnectionDetailsForBaseUrl(baseUrl: String): ConnectionDetails? {
return connectionDetails[baseUrl]
}
companion object {

View file

@ -15,9 +15,8 @@ class JsonConnection(
private val api: ApiService,
private val user: User?,
private val sinceId: String?,
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
private val connectionDetailsListener: (Collection<Long>, ConnectionState, Throwable?, Long) -> Unit,
private val notificationListener: (Subscription, Notification) -> Unit,
private val errorListener: (String, Throwable, Long) -> Unit,
private val serviceActive: () -> Boolean
) : Connection {
private val baseUrl = connectionId.baseUrl
@ -51,9 +50,6 @@ class JsonConnection(
val fail = { e: Exception ->
failed.set(true)
lastError = e
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
}
}
// Call /json subscribe endpoint and loop until the call fails, is canceled,
@ -61,23 +57,20 @@ class JsonConnection(
try {
call = api.subscribe(baseUrl, topicsStr, since, user, notify, fail)
while (!failed.get() && !call.isCanceled() && isActive && serviceActive()) {
stateChangeListener(subscriptionIds, ConnectionState.CONNECTED)
connectionDetailsListener(subscriptionIds, ConnectionState.CONNECTED, null, 0L)
Log.d(TAG,"[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=${serviceActive()}")
delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled
}
} catch (e: Exception) {
Log.e(TAG, "[$url] Connection failed: ${e.message}", e)
lastError = e
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
}
}
// If we're not cancelled yet, wait little before retrying (incremental back-off)
if (isActive && serviceActive()) {
if (isActive && serviceActive() && lastError != null) {
retryMillis = nextRetryMillis(retryMillis, startTime)
val nextRetryTime = System.currentTimeMillis() + retryMillis
lastError?.let { errorListener(baseUrl, it, nextRetryTime) }
connectionDetailsListener(subscriptionIds, ConnectionState.CONNECTING, lastError, nextRetryTime)
Log.d(TAG, "[$url] Connection failed, retrying connection in ${retryMillis / 1000}s ...")
delay(retryMillis)
}

View file

@ -262,9 +262,9 @@ class SubscriberService : Service() {
val connection = if (connectionId.connectionProtocol == Repository.CONNECTION_PROTOCOL_WS) {
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
val httpClient = HttpUtil.wsClient(this, connectionId.baseUrl)
WsConnection(connectionId, repository, httpClient, user, customHeaders, since, ::onStateChanged, ::onNotificationReceived, ::onConnectionError, alarmManager)
WsConnection(connectionId, repository, httpClient, user, customHeaders, since, ::onConnectionDetailsChanged, ::onNotificationReceived, alarmManager)
} else {
JsonConnection(connectionId, scope, repository, api, user, since, ::onStateChanged, ::onNotificationReceived, ::onConnectionError, serviceActive)
JsonConnection(connectionId, scope, repository, api, user, since, ::onConnectionDetailsChanged, ::onNotificationReceived, serviceActive)
}
connections[connectionId] = connection
connection.start()
@ -305,22 +305,12 @@ class SubscriberService : Service() {
}
}
private fun onStateChanged(subscriptionIds: Collection<Long>, state: ConnectionState) {
repository.updateState(subscriptionIds, state)
// Clear connection error when successfully connected
if (state == ConnectionState.CONNECTED) {
subscriptionIds.firstOrNull()?.let { subscriptionId ->
val subscription = repository.getSubscription(subscriptionId)
if (subscription != null) {
repository.clearConnectionError(subscription.baseUrl)
}
}
}
}
private fun onConnectionError(baseUrl: String, throwable: Throwable, nextRetryTime: Long) {
val message = throwable.message ?: "Unknown error"
repository.updateConnectionError(baseUrl, message, throwable, nextRetryTime)
private fun onConnectionDetailsChanged(subscriptionIds: Collection<Long>, state: ConnectionState, throwable: Throwable?, nextRetryTime: Long) {
val subscriptionId = subscriptionIds.firstOrNull() ?: return
val subscription = repository.getSubscription(subscriptionId) ?: return
val baseUrl = subscription.baseUrl
val errorMessage = throwable?.message
repository.updateConnectionDetails(baseUrl, state, errorMessage, throwable, nextRetryTime)
}
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) {

View file

@ -40,9 +40,8 @@ class WsConnection(
private val user: User?,
private val customHeaders: List<CustomHeader>,
private val sinceId: String?,
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
private val connectionDetailsListener: (Collection<Long>, ConnectionState, Throwable?, Long) -> Unit,
private val notificationListener: (Subscription, Notification) -> Unit,
private val errorListener: (String, Throwable, Long) -> Unit,
private val alarmManager: AlarmManager
) : Connection {
private val parser = NotificationParser()
@ -143,7 +142,7 @@ class WsConnection(
if (errorCount > 0) {
errorCount = 0
}
stateChangeListener(subscriptionIds, ConnectionState.CONNECTED)
connectionDetailsListener(subscriptionIds, ConnectionState.CONNECTED, null, 0L)
}
}
@ -183,12 +182,11 @@ class WsConnection(
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection marked as closed. Not retrying.")
return@synchronize
}
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
state = State.Disconnected
errorCount++
val retrySeconds = RETRY_SECONDS.getOrNull(errorCount) ?: RETRY_SECONDS.last()
val nextRetryTime = System.currentTimeMillis() + (retrySeconds * 1000L)
errorListener(baseUrl, t, nextRetryTime)
connectionDetailsListener(subscriptionIds, ConnectionState.CONNECTING, t, nextRetryTime)
scheduleReconnect(retrySeconds)
}
}

View file

@ -17,14 +17,14 @@ import com.google.android.material.chip.Chip
import com.google.android.material.color.MaterialColors
import com.google.android.material.textfield.TextInputLayout
import io.heckel.ntfy.R
import io.heckel.ntfy.db.ConnectionError
import io.heckel.ntfy.db.ConnectionDetails
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.copyToClipboard
class ConnectionErrorFragment : DialogFragment() {
private lateinit var repository: Repository
private var connectionErrors: Map<String, ConnectionError> = emptyMap()
private var connectionDetails: Map<String, ConnectionDetails> = emptyMap()
private var selectedBaseUrl: String? = null
private var filterBaseUrl: String? = null
@ -57,12 +57,12 @@ class ConnectionErrorFragment : DialogFragment() {
// Dependencies
repository = Repository.getInstance(requireContext())
// Get connection errors, optionally filtered by baseUrl
val allErrors = repository.getConnectionErrors()
connectionErrors = if (filterBaseUrl != null) {
allErrors.filterKeys { it == filterBaseUrl }
// Get connection details with errors, optionally filtered by baseUrl
val allDetails = repository.getConnectionDetails()
connectionDetails = if (filterBaseUrl != null) {
allDetails.filterKeys { it == filterBaseUrl }.filterValues { it.hasError() }
} else {
allErrors
allDetails.filterValues { it.hasError() }
}
// Build root view
@ -97,7 +97,7 @@ class ConnectionErrorFragment : DialogFragment() {
stackTraceTextView = view.findViewById(R.id.connection_error_dialog_stack_trace)
// Setup server dropdown if multiple errors
val baseUrls = connectionErrors.keys.toList()
val baseUrls = connectionDetails.keys.toList()
if (baseUrls.size > 1) {
serverLayout.visibility = View.VISIBLE
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, baseUrls)
@ -126,12 +126,12 @@ class ConnectionErrorFragment : DialogFragment() {
dismiss()
}
// Observe connection errors to update countdown when it changes
repository.getConnectionErrorsLiveData().observe(this) { errors ->
connectionErrors = if (filterBaseUrl != null) {
errors.filterKeys { it == filterBaseUrl }
// Observe connection details to update countdown when it changes
repository.getConnectionDetailsLiveData().observe(this) { details ->
connectionDetails = if (filterBaseUrl != null) {
details.filterKeys { it == filterBaseUrl }.filterValues { it.hasError() }
} else {
errors
details.filterValues { it.hasError() }
}
updateErrorDisplay()
}
@ -162,10 +162,10 @@ class ConnectionErrorFragment : DialogFragment() {
}
private fun updateErrorDisplay() {
val error = selectedBaseUrl?.let { connectionErrors[it] }
if (error != null) {
errorTextView.text = error.message
stackTraceTextView.text = error.getStackTraceString().ifEmpty {
val details = selectedBaseUrl?.let { connectionDetails[it] }
if (details != null && details.hasError()) {
errorTextView.text = details.error
stackTraceTextView.text = details.getStackTraceString().ifEmpty {
getString(R.string.connection_error_dialog_no_stack_trace)
}
} else {
@ -177,9 +177,9 @@ class ConnectionErrorFragment : DialogFragment() {
}
private fun updateCountdown() {
val error = selectedBaseUrl?.let { connectionErrors[it] }
if (error != null && error.nextRetryTime > 0) {
val remainingMillis = error.nextRetryTime - System.currentTimeMillis()
val details = selectedBaseUrl?.let { connectionDetails[it] }
if (details != null && details.nextRetryTime > 0) {
val remainingMillis = details.nextRetryTime - System.currentTimeMillis()
if (remainingMillis > 0) {
val remainingSeconds = (remainingMillis / 1000).toInt()
countdownTextView.text = getString(R.string.connection_error_dialog_retry_countdown, remainingSeconds)
@ -198,13 +198,14 @@ class ConnectionErrorFragment : DialogFragment() {
}
private fun copyErrorToClipboard() {
val error = selectedBaseUrl?.let { connectionErrors[it] } ?: return
val baseUrl = selectedBaseUrl ?: return
val details = connectionDetails[baseUrl] ?: return
val text = buildString {
appendLine("Server: ${error.baseUrl}")
appendLine("Error: ${error.message}")
appendLine("Server: $baseUrl")
appendLine("Error: ${details.error}")
appendLine()
appendLine("Stack trace:")
append(error.getStackTraceString().ifEmpty { "No stack trace available" })
append(details.getStackTraceString().ifEmpty { "No stack trace available" })
}
copyToClipboard(requireContext(), "connection error", text)
}

View file

@ -361,9 +361,9 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
SubscriberServiceManager.refresh(this)
}
// Observe connection errors and update menu item visibility
repository.getConnectionErrorsLiveData().observe(this) { errors ->
showHideConnectionErrorMenuItem(errors)
// Observe connection details and update menu item visibility
repository.getConnectionDetailsLiveData().observe(this) { details ->
showHideConnectionErrorMenuItem(details)
}
// Mark this subscription as "open" so we don't receive notifications for it
@ -513,7 +513,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
showHideInstantMenuItems(subscriptionInstant)
showHideMutedUntilMenuItems(subscriptionMutedUntil)
showHideCopyMenuItems(subscription.baseUrl)
showHideConnectionErrorMenuItem(repository.getConnectionErrors())
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
updateTitle(subscriptionDisplayName)
}
}
@ -556,7 +556,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
showHideInstantMenuItems(subscriptionInstant)
showHideMutedUntilMenuItems(subscriptionMutedUntil)
showHideCopyMenuItems(subscriptionBaseUrl)
showHideConnectionErrorMenuItem(repository.getConnectionErrors())
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
// Regularly check if "notification muted" time has passed
// NOTE: This is done here, because then we know that we've initialized the menu items.
@ -817,14 +817,15 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
}
}
private fun showHideConnectionErrorMenuItem(errors: Map<String, io.heckel.ntfy.db.ConnectionError>) {
private fun showHideConnectionErrorMenuItem(details: Map<String, io.heckel.ntfy.db.ConnectionDetails>) {
if (!this::menu.isInitialized) {
return
}
runOnUiThread {
val connectionErrorItem = menu.findItem(R.id.detail_menu_connection_error)
// Only show if there's an error for this subscription's base URL
connectionErrorItem?.isVisible = errors.containsKey(subscriptionBaseUrl)
val hasError = details[subscriptionBaseUrl]?.hasError() == true
connectionErrorItem?.isVisible = hasError
}
}

View file

@ -253,9 +253,9 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
SubscriberServiceManager.refresh(this)
}
// Observe connection errors and update menu item visibility
repository.getConnectionErrorsLiveData().observe(this) { errors ->
showHideConnectionErrorMenuItem(errors)
// Observe connection details and update menu item visibility
repository.getConnectionDetailsLiveData().observe(this) { details ->
showHideConnectionErrorMenuItem(details)
}
// Battery banner
@ -375,7 +375,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
override fun onResume() {
super.onResume()
showHideNotificationMenuItems()
showHideConnectionErrorMenuItem(repository.getConnectionErrors())
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
redrawList()
}
@ -493,7 +493,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
}
showHideNotificationMenuItems()
showHideConnectionErrorMenuItem(repository.getConnectionErrors())
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
checkSubscriptionsMuted() // This is done here, because then we know that we've initialized the menu
return true
}
@ -557,13 +557,14 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
}
}
private fun showHideConnectionErrorMenuItem(errors: Map<String, io.heckel.ntfy.db.ConnectionError>) {
private fun showHideConnectionErrorMenuItem(details: Map<String, io.heckel.ntfy.db.ConnectionDetails>) {
if (!this::menu.isInitialized) {
return
}
runOnUiThread {
val connectionErrorItem = menu.findItem(R.id.main_menu_connection_error)
connectionErrorItem?.isVisible = errors.isNotEmpty()
val hasErrors = details.values.any { it.hasError() }
connectionErrorItem?.isVisible = hasErrors
}
}

View file

@ -92,7 +92,7 @@ class MainAdapter(
} else {
context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
}
if (subscription.instant && subscription.state == ConnectionState.CONNECTING) {
if (subscription.instant && subscription.connectionDetails.state == ConnectionState.CONNECTING) {
statusMessage += ", " + context.getString(R.string.main_item_status_reconnecting)
}
val date = Date(subscription.lastActive * 1000)
@ -120,7 +120,7 @@ class MainAdapter(
statusView.text = statusMessage
dateView.text = dateText
dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
val showConnectionError = subscription.instant && subscription.state == ConnectionState.CONNECTING
val showConnectionError = subscription.instant && subscription.connectionDetails.hasError()
connectionErrorImageView.visibility = if (showConnectionError) View.VISIBLE else View.GONE
notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE
notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE

View file

@ -1,6 +1,12 @@
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/detail_menu_connection_error"
android:icon="@drawable/ic_warning_white_24dp"
android:title="@string/main_menu_connection_error"
android:visible="false"
app:showAsAction="ifRoom" />
<item
android:id="@+id/detail_menu_notifications_enabled"
android:icon="@drawable/ic_notifications_white_24dp"
@ -26,12 +32,6 @@
android:icon="@drawable/ic_bolt_white_24dp"
android:title="@string/detail_menu_disable_instant"
app:showAsAction="ifRoom" />
<item
android:id="@+id/detail_menu_connection_error"
android:icon="@drawable/ic_warning_white_24dp"
android:title="@string/main_menu_connection_error"
android:visible="false"
app:showAsAction="ifRoom" />
<item
android:id="@+id/detail_menu_settings"
android:title="@string/detail_menu_settings" />

View file

@ -1,6 +1,12 @@
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/main_menu_connection_error"
android:icon="@drawable/ic_warning_white_24dp"
android:title="@string/main_menu_connection_error"
android:visible="false"
app:showAsAction="ifRoom" />
<item
android:id="@+id/main_menu_notifications_enabled"
android:icon="@drawable/ic_notifications_white_24dp"
@ -16,12 +22,6 @@
android:icon="@drawable/ic_notifications_off_white_outline_24dp"
android:title="@string/detail_menu_notifications_disabled_forever"
app:showAsAction="ifRoom" />
<item
android:id="@+id/main_menu_connection_error"
android:icon="@drawable/ic_warning_white_24dp"
android:title="@string/main_menu_connection_error"
android:visible="false"
app:showAsAction="ifRoom" />
<item
android:id="@+id/main_menu_settings"
android:title="@string/main_menu_settings_title" />