Periodic worker to refresh notifications; replace Volley with OkHttp

This commit is contained in:
Philipp Heckel 2021-11-11 19:41:29 -05:00
parent 9cdc73592c
commit 72d7a2f93d
17 changed files with 386 additions and 243 deletions

View file

@ -48,13 +48,16 @@ dependencies {
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion" implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
implementation 'com.google.code.gson:gson:2.8.8' implementation 'com.google.code.gson:gson:2.8.8'
// WorkManager
implementation "androidx.work:work-runtime-ktx:2.6.0"
// Room (SQLite) // Room (SQLite)
def roomVersion = "2.3.0" def roomVersion = "2.3.0"
implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-ktx:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
// Volley (HTTP library) // OkHttp (HTTP library)
implementation 'com.android.volley:volley:1.2.1' implementation "com.squareup.okhttp3:okhttp:4.9.2"
// Firebase, sigh ... // Firebase, sigh ...
implementation 'com.google.firebase:firebase-messaging:22.0.0' implementation 'com.google.firebase:firebase-messaging:22.0.0'

View file

@ -35,7 +35,7 @@
<!-- Firebase messaging --> <!-- Firebase messaging -->
<service <service
android:name=".msg.MessagingService" android:name=".msg.FirebaseService"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/> <action android:name="com.google.firebase.MESSAGING_EVENT"/>

View file

@ -47,11 +47,17 @@ abstract class Database : RoomDatabase() {
@Dao @Dao
interface SubscriptionDao { interface SubscriptionDao {
@Query("SELECT * FROM subscription ORDER BY lastActive DESC") @Query("SELECT * FROM subscription ORDER BY lastActive DESC")
fun list(): Flow<List<Subscription>> fun listFlow(): Flow<List<Subscription>>
@Query("SELECT * FROM subscription ORDER BY lastActive DESC")
fun list(): List<Subscription>
@Query("SELECT * FROM subscription WHERE baseUrl = :baseUrl AND topic = :topic") @Query("SELECT * FROM subscription WHERE baseUrl = :baseUrl AND topic = :topic")
fun get(baseUrl: String, topic: String): Subscription? fun get(baseUrl: String, topic: String): Subscription?
@Query("SELECT * FROM subscription WHERE id = :subscriptionId")
fun get(subscriptionId: Long): Subscription?
@Insert @Insert
fun add(subscription: Subscription) fun add(subscription: Subscription)
@ -73,6 +79,9 @@ interface NotificationDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun add(notification: Notification) fun add(notification: Notification)
@Query("SELECT * FROM notification WHERE id = :notificationId")
fun get(notificationId: String): Notification?
@Query("DELETE FROM notification WHERE id = :notificationId") @Query("DELETE FROM notification WHERE id = :notificationId")
fun remove(notificationId: String) fun remove(notificationId: String)

View file

@ -1,13 +1,22 @@
package io.heckel.ntfy.data package io.heckel.ntfy.data
import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import kotlinx.coroutines.flow.Flow import java.util.*
class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) { class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
fun getAllSubscriptions(): LiveData<List<Subscription>> { init {
return subscriptionDao.list().asLiveData() Log.d(TAG, "Created $this")
}
fun getSubscriptionsLiveData(): LiveData<List<Subscription>> {
return subscriptionDao.listFlow().asLiveData()
}
fun getSubscriptions(): List<Subscription> {
return subscriptionDao.list()
} }
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@ -34,23 +43,35 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
subscriptionDao.remove(subscriptionId) subscriptionDao.remove(subscriptionId)
} }
fun getAllNotifications(subscriptionId: Long): LiveData<List<Notification>> { fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> {
return notificationDao.list(subscriptionId).asLiveData() return notificationDao.list(subscriptionId).asLiveData()
} }
fun getAllNotificationIds(subscriptionId: Long): List<String> { fun onlyNewNotifications(subscriptionId: Long, notifications: List<Notification>): List<Notification> {
return notificationDao.listIds(subscriptionId) val existingIds = notificationDao.listIds(subscriptionId)
return notifications.filterNot { existingIds.contains(it.id) }
} }
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun addNotification(notification: Notification) { suspend fun addNotification(subscriptionId: Long, notification: Notification) {
val maybeExistingNotification = notificationDao.get(notification.id)
if (maybeExistingNotification != null) {
return
}
val subscription = subscriptionDao.get(subscriptionId) ?: return
val newSubscription = subscription.copy(notifications = subscription.notifications + 1, lastActive = Date().time/1000)
subscriptionDao.update(newSubscription)
notificationDao.add(notification) notificationDao.add(notification)
} }
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun removeNotification(notificationId: String) { suspend fun removeNotification(subscriptionId: Long, notificationId: String) {
val subscription = subscriptionDao.get(subscriptionId) ?: return
val newSubscription = subscription.copy(notifications = subscription.notifications - 1, lastActive = Date().time/1000)
subscriptionDao.update(newSubscription)
notificationDao.remove(notificationId) notificationDao.remove(notificationId)
} }
@ -61,6 +82,7 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
} }
companion object { companion object {
private 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 {

View file

@ -1,52 +1,67 @@
package io.heckel.ntfy.msg package io.heckel.ntfy.msg
import android.content.Context
import android.util.Log import android.util.Log
import com.android.volley.Request
import com.android.volley.Response
import com.android.volley.VolleyError
import com.android.volley.toolbox.StringRequest
import com.android.volley.toolbox.Volley
import com.google.gson.Gson import com.google.gson.Gson
import io.heckel.ntfy.R import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.data.* import io.heckel.ntfy.data.topicUrlJsonPoll
import io.heckel.ntfy.ui.DetailActivity import okhttp3.OkHttpClient
import kotlinx.coroutines.Job import okhttp3.Request
import kotlinx.coroutines.SupervisorJob import okhttp3.RequestBody.Companion.toRequestBody
import java.util.* import java.util.concurrent.TimeUnit
class ApiService(context: Context) { class ApiService {
private val queue = Volley.newRequestQueue(context) private val gson = Gson()
private val parser = NotificationParser() private val client = OkHttpClient.Builder()
.callTimeout(10, TimeUnit.SECONDS) // Total timeout for entire request
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
fun publish(baseUrl: String, topic: String, message: String, successFn: Response.Listener<String>, failureFn: (VolleyError) -> Unit) { fun publish(baseUrl: String, topic: String, message: String) {
val url = topicUrl(baseUrl, topic) val url = topicUrl(baseUrl, topic)
val stringRequest = object : StringRequest(Method.PUT, url, successFn, failureFn) { Log.d(TAG, "Publishing to $url")
override fun getBody(): ByteArray {
return message.toByteArray() val request = Request.Builder().url(url).put(message.toRequestBody()).build();
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw Exception("Unexpected response ${response.code} when publishing to $url")
} }
Log.d(TAG, "Successfully published to $url")
} }
queue.add(stringRequest)
} }
fun poll(subscriptionId: Long, baseUrl: String, topic: String, successFn: (List<Notification>) -> Unit, failureFn: (Exception) -> Unit) { fun poll(subscriptionId: Long, baseUrl: String, topic: String): List<Notification> {
val url = topicUrlJsonPoll(baseUrl, topic) val url = topicUrlJsonPoll(baseUrl, topic)
val parseSuccessFn = { response: String -> Log.d(TAG, "Polling topic $url")
try {
val notifications = response.trim().lines().map { line -> val request = Request.Builder().url(url).build();
parser.fromString(subscriptionId, line) client.newCall(request).execute().use { response ->
} if (!response.isSuccessful) {
Log.d(TAG, "Notifications: $notifications") throw Exception("Unexpected response ${response.code} when polling topic $url")
successFn(notifications)
} catch (e: Exception) {
failureFn(e)
} }
val body = response.body?.string()?.trim()
if (body == null || body.isEmpty()) return emptyList()
val notifications = body.lines().map { line ->
fromString(subscriptionId, line)
}
Log.d(TAG, "Notifications: $notifications")
return notifications
} }
val stringRequest = StringRequest(Request.Method.GET, url, parseSuccessFn, failureFn)
queue.add(stringRequest)
} }
private fun fromString(subscriptionId: Long, s: String): Notification {
val n = gson.fromJson(s, NotificationData::class.java) // Indirection to prevent accidental field renames, etc.
return Notification(n.id, subscriptionId, n.time, n.message)
}
private data class NotificationData(
val id: String,
val time: Long,
val message: String
)
companion object { companion object {
private const val TAG = "NtfyApiService" private const val TAG = "NtfyApiService"
} }

View file

@ -0,0 +1,65 @@
package io.heckel.ntfy.msg
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.*
class FirebaseService : FirebaseMessagingService() {
private val repository by lazy { (application as Application).repository }
private val job = SupervisorJob()
private val notifier = NotificationService(this)
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// We only process data messages
if (remoteMessage.data.isEmpty()) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}")
return
}
// Check if valid data, and send notification
val data = remoteMessage.data
val id = data["id"]
val timestamp = data["time"]?.toLongOrNull()
val topic = data["topic"]
val message = data["message"]
if (id == null || topic == null || message == null || timestamp == null) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}")
return
}
CoroutineScope(job).launch {
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!
// Add notification
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message)
repository.addNotification(subscription.id, notification)
// Send notification
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
notifier.send(subscription, message)
}
}
override fun onNewToken(token: String) {
// Called if the FCM registration token is updated
// We don't actually use or care about the token, since we're using topics
Log.d(TAG, "Registration token was updated: $token")
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
companion object {
private const val TAG = "NtfyFirebase"
}
}

View file

@ -1,112 +0,0 @@
package io.heckel.ntfy.msg
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.*
import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.*
import kotlin.random.Random
class MessagingService : FirebaseMessagingService() {
private val repository by lazy { (application as Application).repository }
private val job = SupervisorJob()
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// We only process data messages
if (remoteMessage.data.isEmpty()) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}")
return
}
// Check if valid data, and send notification
val data = remoteMessage.data
val id = data["id"]
val timestamp = data["time"]?.toLongOrNull()
val topic = data["topic"]
val message = data["message"]
if (id == null || topic == null || message == null || timestamp == null) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}")
return
}
CoroutineScope(job).launch {
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!
// Update message counter
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
val newSubscription = subscription.copy(notifications = subscription.notifications + 1, lastActive = Date().time/1000)
repository.updateSubscription(newSubscription)
// Add notification
val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message)
repository.addNotification(notification)
// Send notification
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
sendNotification(subscription, message)
}
}
override fun onNewToken(token: String) {
// Called if the FCM registration token is updated
// We don't actually use or care about the token, since we're using topics
Log.d(TAG, "Registration token was updated: $token")
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
private fun sendNotification(subscription: Subscription, message: String) {
val title = topicShortUrl(subscription.baseUrl, subscription.topic)
// Create an Intent for the activity you want to start
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
val pendingIntent: PendingIntent? = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(intent) // Add the intent, which inflates the back stack
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) // Get the PendingIntent containing the entire back stack
}
val channelId = getString(R.string.notification_channel_id)
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_notification_icon)
.setContentTitle(title)
.setContentText(message)
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent) // Click target for notification
.setAutoCancel(true) // Cancel when notification is clicked
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelName = getString(R.string.notification_channel_name)
val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(Random.nextInt(), notificationBuilder.build())
}
companion object {
private const val TAG = "NtfyFirebase"
}
}

View file

@ -1,19 +0,0 @@
package io.heckel.ntfy.msg
import com.google.gson.Gson
import io.heckel.ntfy.data.Notification
class NotificationParser {
private val gson = Gson()
fun fromString(subscriptionId: Long, s: String): Notification {
val n = gson.fromJson(s, NotificationData::class.java) // Indirection to prevent accidental field renames, etc.
return Notification(n.id, subscriptionId, n.time, n.message)
}
private data class NotificationData(
val id: String,
val time: Long,
val message: String
)
}

View file

@ -0,0 +1,63 @@
package io.heckel.ntfy.msg
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.*
import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.*
import kotlin.random.Random
class NotificationService(val context: Context) {
fun send(subscription: Subscription, message: String) {
val title = topicShortUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "Sending notification $title: $message")
// Create an Intent for the activity you want to start
val intent = Intent(context, DetailActivity::class.java)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
val pendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent) // Add the intent, which inflates the back stack
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) // Get the PendingIntent containing the entire back stack
}
val channelId = context.getString(R.string.notification_channel_id)
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notificationBuilder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification_icon)
.setContentTitle(title)
.setContentText(message)
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent) // Click target for notification
.setAutoCancel(true) // Cancel when notification is clicked
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelName = context.getString(R.string.notification_channel_name)
val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(Random.nextInt(), notificationBuilder.build())
}
companion object {
private const val TAG = "NtfyNotificationService"
}
}

View file

@ -19,7 +19,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.android.volley.VolleyError
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.Notification import io.heckel.ntfy.data.Notification
@ -34,7 +33,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
DetailViewModelFactory((application as Application).repository) DetailViewModelFactory((application as Application).repository)
} }
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private lateinit var api: ApiService // Context-dependent private val api = ApiService()
// Which subscription are we looking at // Which subscription are we looking at
private var subscriptionId: Long = 0L // Set in onCreate() private var subscriptionId: Long = 0L // Set in onCreate()
@ -49,10 +48,11 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.detail_activity) setContentView(R.layout.detail_activity)
supportActionBar?.setDisplayHomeAsUpEnabled(true) // Show 'Back' button
// Dependencies that depend on Context Log.d(MainActivity.TAG, "Create $this")
api = ApiService(this)
// Show 'Back' button
supportActionBar?.setDisplayHomeAsUpEnabled(true)
// Get extras required for the return to the main activity // Get extras required for the return to the main activity
subscriptionId = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, 0) subscriptionId = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, 0)
@ -124,40 +124,38 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
private fun onTestClick() { private fun onTestClick() {
Log.d(TAG, "Sending test notification to ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") Log.d(TAG, "Sending test notification to ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
val message = getString(R.string.detail_test_message, Date().toString()) lifecycleScope.launch(Dispatchers.IO) {
val successFn = { _: String -> } try {
val failureFn = { error: VolleyError -> val message = getString(R.string.detail_test_message, Date().toString())
Toast api.publish(subscriptionBaseUrl, subscriptionTopic, message)
.makeText(this, getString(R.string.detail_test_message_error, error.message), Toast.LENGTH_LONG) } catch (e: Exception) {
.show() Toast
.makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG)
.show()
}
} }
api.publish(subscriptionBaseUrl, subscriptionTopic, message, successFn, failureFn)
} }
private fun onRefreshClick() { private fun onRefreshClick() {
Log.d(TAG, "Fetching cached notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") Log.d(TAG, "Fetching cached notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
val activity = this lifecycleScope.launch(Dispatchers.IO) {
val successFn = { notifications: List<Notification> -> try {
lifecycleScope.launch(Dispatchers.IO) { val notifications = api.poll(subscriptionId, subscriptionBaseUrl, subscriptionTopic)
val localNotificationIds = repository.getAllNotificationIds(subscriptionId) val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications)
val newNotifications = notifications.filterNot { localNotificationIds.contains(it.id) }
val toastMessage = if (newNotifications.isEmpty()) { val toastMessage = if (newNotifications.isEmpty()) {
getString(R.string.detail_refresh_message_no_results) getString(R.string.detail_refresh_message_no_results)
} else { } else {
getString(R.string.detail_refresh_message_result, newNotifications.size) getString(R.string.detail_refresh_message_result, newNotifications.size)
} }
newNotifications.forEach { repository.addNotification(it) } // The meat! newNotifications.forEach { notification -> repository.addNotification(subscriptionId, notification) }
runOnUiThread { Toast.makeText(activity, toastMessage, Toast.LENGTH_LONG).show() } runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() }
} catch (e: Exception) {
Toast
.makeText(this@DetailActivity, getString(R.string.detail_refresh_message_error, e.message), Toast.LENGTH_LONG)
.show()
} }
Unit
} }
val failureFn = { error: Exception ->
Toast
.makeText(this, getString(R.string.detail_refresh_message_error, error.message), Toast.LENGTH_LONG)
.show()
}
api.poll(subscriptionId, subscriptionBaseUrl, subscriptionTopic, successFn, failureFn)
} }
private fun onDeleteClick() { private fun onDeleteClick() {
@ -174,8 +172,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
setResult(RESULT_OK, result) setResult(RESULT_OK, result)
finish() finish()
// Delete notifications // The deletion will be done in MainActivity.onResult
viewModel.removeAll(subscriptionId)
} }
.setNegativeButton(R.string.detail_delete_dialog_cancel) { _, _ -> /* Do nothing */ } .setNegativeButton(R.string.detail_delete_dialog_cancel) { _, _ -> /* Do nothing */ }
.create() .create()
@ -246,7 +243,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
builder builder
.setMessage(R.string.detail_action_mode_delete_dialog_message) .setMessage(R.string.detail_action_mode_delete_dialog_message)
.setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ -> .setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ ->
adapter.selected.map { viewModel.remove(it) } adapter.selected.map { notificationId -> viewModel.remove(subscriptionId, notificationId) }
finishActionMode() finishActionMode()
} }
.setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ -> .setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ ->

View file

@ -6,25 +6,16 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class DetailViewModel(private val repository: Repository) : ViewModel() { class DetailViewModel(private val repository: Repository) : ViewModel() {
fun list(subscriptionId: Long): LiveData<List<Notification>> { fun list(subscriptionId: Long): LiveData<List<Notification>> {
return repository.getAllNotifications(subscriptionId) return repository.getNotificationsLiveData(subscriptionId)
} }
fun add(notification: Notification) = viewModelScope.launch(Dispatchers.IO) { fun remove(subscriptionId: Long, notificationId: String) = viewModelScope.launch(Dispatchers.IO) {
repository.addNotification(notification) repository.removeNotification(subscriptionId, notificationId)
}
fun remove(notificationId: String) = viewModelScope.launch(Dispatchers.IO) {
repository.removeNotification(notificationId)
}
fun removeAll(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
repository.removeAllNotifications(subscriptionId)
} }
} }

View file

@ -3,29 +3,34 @@ package io.heckel.ntfy.ui
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.* import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.asFlow
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.work.*
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
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.Notification
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 io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.random.Random import kotlin.random.Random
class MainActivity : AppCompatActivity(), ActionMode.Callback { class MainActivity : AppCompatActivity(), ActionMode.Callback {
@ -33,18 +38,24 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
SubscriptionsViewModelFactory((application as Application).repository) SubscriptionsViewModelFactory((application as Application).repository)
} }
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private val api = ApiService()
private lateinit var mainList: RecyclerView private lateinit var mainList: RecyclerView
private lateinit var adapter: MainAdapter private lateinit var adapter: MainAdapter
private lateinit var fab: View private lateinit var fab: View
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private lateinit var api: ApiService // Context-dependent private var workManager: WorkManager? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity) setContentView(R.layout.main_activity)
Log.d(TAG, "Create $this")
// Dependencies that depend on Context // Dependencies that depend on Context
api = ApiService(this) workManager = WorkManager.getInstance(this)
notifier = NotificationService(this)
// Action bar // Action bar
title = getString(R.string.main_action_bar_title) title = getString(R.string.main_action_bar_title)
@ -76,6 +87,28 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
} }
} }
} }
// Kick off periodic polling
val sharedPref = getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
val workPolicy = if (sharedPref.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0) == PollWorker.VERSION) {
Log.d(TAG, "Poll worker version matches: choosing KEEP as existing work policy")
ExistingPeriodicWorkPolicy.KEEP
} else {
Log.d(TAG, "Poll worker version DOES NOT MATCH: choosing REPLACE as existing work policy")
sharedPref.edit()
.putInt(SHARED_PREFS_POLL_WORKER_VERSION, PollWorker.VERSION)
.apply()
ExistingPeriodicWorkPolicy.REPLACE
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val work = PeriodicWorkRequestBuilder<PollWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.addTag(PollWorker.TAG)
.addTag(PollWorker.WORK_NAME_PERIODIC)
.build()
workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC, workPolicy, work)
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -132,13 +165,14 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
} }
// Fetch cached messages // Fetch cached messages
val successFn = { notifications: List<Notification> -> lifecycleScope.launch(Dispatchers.IO) {
lifecycleScope.launch(Dispatchers.IO) { try {
notifications.forEach { repository.addNotification(it) } val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
notifications.forEach { notification -> repository.addNotification(subscription.id, notification) }
} catch (e: Exception) {
Log.e(TAG, "Unable to fetch notifications: ${e.stackTrace}")
} }
Unit
} }
api.poll(subscription.id, subscription.baseUrl, subscription.topic, successFn, { _ -> })
// Switch to detail view after adding it // Switch to detail view after adding it
onSubscriptionItemClick(subscription) onSubscriptionItemClick(subscription)
@ -158,19 +192,23 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
} }
} }
private fun refreshAllSubscriptions() { private fun refreshAllSubscriptions() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val successFn = { notifications: List<Notification> -> try {
lifecycleScope.launch(Dispatchers.IO) { Log.d(TAG, "Polling for new notifications")
notifications.forEach { repository.getSubscriptions().forEach { subscription ->
repository.addNotification(it) val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification ->
repository.addNotification(subscription.id, notification)
notifier?.send(subscription, notification.message)
} }
} }
Unit Log.d(TAG, "Finished polling for new notifications")
} } catch (e: Exception) {
repository.getAllSubscriptions().asFlow().collect { subscriptions -> Log.e(TAG, "Polling failed: ${e.message}", e)
subscriptions.forEach { subscription -> runOnUiThread {
api.poll(subscription.id, subscription.baseUrl, subscription.topic, successFn, { _ -> }) Toast.makeText(this@MainActivity, getString(R.string.poll_worker_exception, e.message), Toast.LENGTH_LONG).show()
} }
} }
} }
@ -316,5 +354,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic" const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic"
const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1 const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1
const val ANIMATION_DURATION = 80L const val ANIMATION_DURATION = 80L
const val SHARED_PREFS_ID = "MainPreferences"
const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
} }
} }

View file

@ -11,6 +11,8 @@ import androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.R import io.heckel.ntfy.R
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.util.*
class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) : class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
@ -45,6 +47,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
private val context: Context = itemView.context private val context: Context = itemView.context
private val nameView: TextView = itemView.findViewById(R.id.main_item_text) private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
private val statusView: TextView = itemView.findViewById(R.id.main_item_status) private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
fun bind(subscription: Subscription) { fun bind(subscription: Subscription) {
this.subscription = subscription this.subscription = subscription
@ -53,8 +56,14 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
} 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)
} }
val dateText = if (System.currentTimeMillis()/1000 - subscription.lastActive < 24 * 60 * 60) {
SimpleDateFormat("HH:mm").format(Date(subscription.lastActive*1000))
} else {
SimpleDateFormat("MM/dd").format(Date(subscription.lastActive*1000))
}
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic) nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
statusView.text = statusMessage statusView.text = statusMessage
dateView.text = dateText
itemView.setOnClickListener { onClick(subscription) } itemView.setOnClickListener { onClick(subscription) }
itemView.setOnLongClickListener { onLongClick(subscription); true } itemView.setOnLongClickListener { onLongClick(subscription); true }
if (selected.contains(subscription.id)) { if (selected.contains(subscription.id)) {

View file

@ -11,7 +11,7 @@ import kotlin.collections.List
class SubscriptionsViewModel(private val repository: Repository) : ViewModel() { class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
fun list(): LiveData<List<Subscription>> { fun list(): LiveData<List<Subscription>> {
return repository.getAllSubscriptions() return repository.getSubscriptionsLiveData()
} }
fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) { fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) {
@ -19,6 +19,7 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
} }
fun remove(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) { fun remove(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
repository.removeAllNotifications(subscriptionId)
repository.removeSubscription(subscriptionId) repository.removeSubscription(subscriptionId)
} }

View file

@ -0,0 +1,52 @@
package io.heckel.ntfy.work
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
// Every time the worker is changed, the periodic work has to be REPLACEd.
// This is facilitated in the MainActivity using the VERSION below.
override suspend fun doWork(): Result {
return withContext(Dispatchers.IO) {
Log.d(TAG, "Polling for new notifications")
val database = Database.getInstance(applicationContext)
val repository = Repository.getInstance(database.subscriptionDao(), database.notificationDao())
val notifier = NotificationService(applicationContext)
val api = ApiService()
try {
repository.getSubscriptions().forEach{ subscription ->
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification ->
repository.addNotification(subscription.id, notification)
notifier.send(subscription, notification.message)
}
}
Log.d(TAG, "Finished polling for new notifications")
return@withContext Result.success()
} catch (e: Exception) {
Log.e(TAG, "Failed checking messages: ${e.message}", e)
return@withContext Result.failure()
}
}
}
companion object {
const val VERSION = BuildConfig.VERSION_CODE
const val TAG = "NtfyPollWorker"
const val WORK_NAME_PERIODIC = "NtfyPollWorkerPeriodic"
}
}

View file

@ -7,11 +7,11 @@
<ImageView <ImageView
android:layout_width="35dp" android:layout_width="35dp"
android:layout_height="match_parent" app:srcCompat="@drawable/ic_sms_gray_24dp" android:layout_height="match_parent" app:srcCompat="@drawable/ic_sms_gray_24dp"
android:id="@+id/topic_image" android:layout_marginStart="20dp"/> android:id="@+id/topic_image" android:layout_marginStart="20dp" android:layout_weight="1"/>
<LinearLayout <LinearLayout
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent" android:layout_weight="20">
<TextView <TextView
android:text="ntfy.sh/example" android:text="ntfy.sh/example"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -27,6 +27,13 @@
android:textAppearance="@style/TextAppearance.AppCompat.Small" android:layout_marginStart="12dp" android:textAppearance="@style/TextAppearance.AppCompat.Small" android:layout_marginStart="12dp"
android:layout_marginBottom="10dp"/> android:layout_marginBottom="10dp"/>
</LinearLayout> </LinearLayout>
<TextView
android:text="yesterday"
android:layout_width="75dp"
android:layout_height="match_parent"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:id="@+id/main_item_date" android:layout_marginEnd="15dp" android:layout_weight="1"
android:layout_marginTop="10dp" android:textAlignment="textEnd"/>
</LinearLayout> </LinearLayout>

View file

@ -21,8 +21,8 @@
<string name="main_action_mode_delete_dialog_cancel">Cancel</string> <string name="main_action_mode_delete_dialog_cancel">Cancel</string>
<!-- Main activity: List and such --> <!-- Main activity: List and such -->
<string name="main_item_status_text_one">%1$d notification received</string> <string name="main_item_status_text_one">%1$d notification</string>
<string name="main_item_status_text_not_one">%1$d notifications received</string> <string name="main_item_status_text_not_one">%1$d notifications</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>