Show connection status

This commit is contained in:
Philipp Heckel 2021-11-14 21:42:41 -05:00
parent f69d1f5ee1
commit cea43b3529
5 changed files with 67 additions and 15 deletions

View file

@ -1,8 +1,6 @@
package io.heckel.ntfy.data package io.heckel.ntfy.data
import android.content.Context import android.content.Context
import androidx.annotation.NonNull
import androidx.lifecycle.LiveData
import androidx.room.* import androidx.room.*
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
@ -15,9 +13,14 @@ data class Subscription(
@ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "instant") val instant: Boolean, @ColumnInfo(name = "instant") val instant: Boolean,
@Ignore val notifications: Int, @Ignore val notifications: Int,
@Ignore val lastActive: Long = 0 // Unix timestamp @Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
) { ) {
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean) : this(id, baseUrl, topic, instant, 0, 0) constructor(id: Long, baseUrl: String, topic: String, instant: Boolean) : this(id, baseUrl, topic, instant, 0, 0, ConnectionState.NOT_APPLICABLE)
}
enum class ConnectionState {
NOT_APPLICABLE, RECONNECTING, CONNECTED
} }
data class SubscriptionWithMetadata( data class SubscriptionWithMetadata(

View file

@ -2,12 +2,13 @@ package io.heckel.ntfy.data
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData import androidx.lifecycle.*
import androidx.lifecycle.asLiveData import java.util.concurrent.ConcurrentHashMap
import androidx.lifecycle.map
import kotlinx.coroutines.flow.map
class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) { class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
private val connectionStates = ConcurrentHashMap<Long, ConnectionState>()
private val connectionStatesLiveData = MutableLiveData(connectionStates)
init { init {
Log.d(TAG, "Created $this") Log.d(TAG, "Created $this")
} }
@ -16,7 +17,9 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
return subscriptionDao return subscriptionDao
.listFlow() .listFlow()
.asLiveData() .asLiveData()
.map { list -> toSubscriptionList(list) } .combineWith(connectionStatesLiveData) { subscriptionsWithMetadata, _ ->
toSubscriptionList(subscriptionsWithMetadata.orEmpty())
}
} }
fun getSubscriptionIdsWithInstantStatusLiveData(): LiveData<Set<Pair<Long, Boolean>>> { fun getSubscriptionIdsWithInstantStatusLiveData(): LiveData<Set<Pair<Long, Boolean>>> {
@ -98,13 +101,15 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
private fun toSubscriptionList(list: List<SubscriptionWithMetadata>): List<Subscription> { private fun toSubscriptionList(list: List<SubscriptionWithMetadata>): List<Subscription> {
return list.map { s -> return list.map { s ->
val connectionState = connectionStates.getOrElse(s.id) { ConnectionState.NOT_APPLICABLE }
Subscription( Subscription(
id = s.id, id = s.id,
baseUrl = s.baseUrl, baseUrl = s.baseUrl,
topic = s.topic, topic = s.topic,
instant = s.instant, instant = s.instant,
lastActive = s.lastActive, lastActive = s.lastActive,
notifications = s.notifications notifications = s.notifications,
state = connectionState
) )
} }
} }
@ -119,12 +124,29 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
topic = s.topic, topic = s.topic,
instant = s.instant, instant = s.instant,
lastActive = s.lastActive, lastActive = s.lastActive,
notifications = s.notifications notifications = s.notifications,
state = getState(s.id)
) )
} }
fun updateStateIfChanged(subscriptionId: Long, newState: ConnectionState) {
val state = connectionStates.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
if (state !== newState) {
if (newState == ConnectionState.NOT_APPLICABLE) {
connectionStates.remove(subscriptionId)
} else {
connectionStates[subscriptionId] = newState
}
connectionStatesLiveData.postValue(connectionStates)
}
}
private fun getState(subscriptionId: Long): ConnectionState {
return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
}
companion object { companion object {
private val TAG = "NtfyRepository" private const val TAG = "NtfyRepository"
private var instance: Repository? = null private var instance: Repository? = null
fun getInstance(subscriptionDao: SubscriptionDao, notificationDao: NotificationDao): Repository { fun getInstance(subscriptionDao: SubscriptionDao, notificationDao: NotificationDao): Repository {
@ -136,3 +158,18 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
} }
} }
} }
/* https://stackoverflow.com/a/57079290/1440785 */
fun <T, K, R> LiveData<T>.combineWith(
liveData: LiveData<K>,
block: (T?, K?) -> R
): LiveData<R> {
val result = MediatorLiveData<R>()
result.addSource(this) {
result.value = block(this.value, liveData.value)
}
result.addSource(liveData) {
result.value = block(this.value, liveData.value)
}
return result
}

View file

@ -12,6 +12,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicUrl import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.ui.MainActivity
@ -156,7 +157,10 @@ class SubscriberService : Service() {
onNotificationReceived(scope, subscription, n) onNotificationReceived(scope, subscription, n)
} }
val failed = AtomicBoolean(false) val failed = AtomicBoolean(false)
val fail = { e: Exception -> failed.set(true) } val fail = { e: Exception ->
failed.set(true)
repository.updateStateIfChanged(subscription.id, ConnectionState.RECONNECTING)
}
// Call /json subscribe endpoint and loop until the call fails, is canceled, // Call /json subscribe endpoint and loop until the call fails, is canceled,
// or the job or service are cancelled/stopped // or the job or service are cancelled/stopped
@ -164,11 +168,13 @@ class SubscriberService : Service() {
val call = api.subscribe(subscription.id, subscription.baseUrl, subscription.topic, since, notify, fail) val call = api.subscribe(subscription.id, subscription.baseUrl, subscription.topic, since, notify, fail)
calls[subscription.id] = call calls[subscription.id] = call
while (!failed.get() && !call.isCanceled() && isActive && isServiceStarted) { while (!failed.get() && !call.isCanceled() && isActive && isServiceStarted) {
repository.updateStateIfChanged(subscription.id, ConnectionState.CONNECTED)
Log.d(TAG, "[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=$isServiceStarted") Log.d(TAG, "[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=$isServiceStarted")
delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "[$url] Connection failed: ${e.message}", e) Log.e(TAG, "[$url] Connection failed: ${e.message}", e)
repository.updateStateIfChanged(subscription.id, ConnectionState.RECONNECTING)
} }
// If we're not cancelled yet, wait little before retrying (incremental back-off) // If we're not cancelled yet, wait little before retrying (incremental back-off)
@ -179,6 +185,7 @@ class SubscriberService : Service() {
} }
} }
Log.d(TAG, "[$url] Connection job SHUT DOWN") Log.d(TAG, "[$url] Connection job SHUT DOWN")
repository.updateStateIfChanged(subscription.id, ConnectionState.NOT_APPLICABLE)
} }
private fun onNotificationReceived(scope: CoroutineScope, subscription: Subscription, n: io.heckel.ntfy.data.Notification) { private fun onNotificationReceived(scope: CoroutineScope, subscription: Subscription, n: io.heckel.ntfy.data.Notification) {

View file

@ -9,6 +9,7 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl import io.heckel.ntfy.data.topicShortUrl
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -52,11 +53,14 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
fun bind(subscription: Subscription) { fun bind(subscription: Subscription) {
this.subscription = subscription this.subscription = subscription
val statusMessage = if (subscription.notifications == 1) { var statusMessage = if (subscription.notifications == 1) {
context.getString(R.string.main_item_status_text_one, subscription.notifications) context.getString(R.string.main_item_status_text_one, subscription.notifications)
} else { } else {
context.getString(R.string.main_item_status_text_not_one, subscription.notifications) context.getString(R.string.main_item_status_text_not_one, subscription.notifications)
} }
if (subscription.instant && subscription.state == ConnectionState.RECONNECTING) {
statusMessage += ", " + context.getString(R.string.main_item_status_reconnecting)
}
val dateText = if (subscription.lastActive == 0L) { val dateText = if (subscription.lastActive == 0L) {
"" ""
} else if (System.currentTimeMillis()/1000 - subscription.lastActive < 24 * 60 * 60) { } else if (System.currentTimeMillis()/1000 - subscription.lastActive < 24 * 60 * 60) {

View file

@ -16,7 +16,7 @@
<!-- Main activity: Action bar --> <!-- Main activity: Action bar -->
<string name="main_action_bar_title">Subscribed topics</string> <string name="main_action_bar_title">Subscribed topics</string>
<string name="main_menu_source_title">Report bugs</string> <string name="main_menu_source_title">View source &amp; report bugs</string>
<string name="main_menu_source_url">https://heckel.io/ntfy-android</string> <string name="main_menu_source_url">https://heckel.io/ntfy-android</string>
<string name="main_menu_website_title">Visit ntfy.sh</string> <string name="main_menu_website_title">Visit ntfy.sh</string>
@ -29,6 +29,7 @@
<!-- Main activity: List and such --> <!-- Main activity: List and such -->
<string name="main_item_status_text_one">%1$d notification</string> <string name="main_item_status_text_one">%1$d notification</string>
<string name="main_item_status_text_not_one">%1$d notifications</string> <string name="main_item_status_text_not_one">%1$d notifications</string>
<string name="main_item_status_reconnecting">reconnecting …</string>
<string name="main_add_button_description">Add subscription</string> <string name="main_add_button_description">Add subscription</string>
<string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string> <string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string>
<string name="main_how_to_intro">Click the button below to create or subscribe to a topic. After that, you can send messages via PUT or POST and you\'ll receive notifications on your phone.</string> <string name="main_how_to_intro">Click the button below to create or subscribe to a topic. After that, you can send messages via PUT or POST and you\'ll receive notifications on your phone.</string>