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 'com.google.code.gson:gson:2.8.8'
// WorkManager
implementation "androidx.work:work-runtime-ktx:2.6.0"
// Room (SQLite)
def roomVersion = "2.3.0"
implementation "androidx.room:room-ktx:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
// Volley (HTTP library)
implementation 'com.android.volley:volley:1.2.1'
// OkHttp (HTTP library)
implementation "com.squareup.okhttp3:okhttp:4.9.2"
// Firebase, sigh ...
implementation 'com.google.firebase:firebase-messaging:22.0.0'

View file

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

View file

@ -47,11 +47,17 @@ abstract class Database : RoomDatabase() {
@Dao
interface SubscriptionDao {
@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")
fun get(baseUrl: String, topic: String): Subscription?
@Query("SELECT * FROM subscription WHERE id = :subscriptionId")
fun get(subscriptionId: Long): Subscription?
@Insert
fun add(subscription: Subscription)
@ -73,6 +79,9 @@ interface NotificationDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun add(notification: Notification)
@Query("SELECT * FROM notification WHERE id = :notificationId")
fun get(notificationId: String): Notification?
@Query("DELETE FROM notification WHERE id = :notificationId")
fun remove(notificationId: String)

View file

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

View file

@ -1,52 +1,67 @@
package io.heckel.ntfy.msg
import android.content.Context
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 io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.*
import io.heckel.ntfy.ui.DetailActivity
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import java.util.*
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.data.topicUrlJsonPoll
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
class ApiService(context: Context) {
private val queue = Volley.newRequestQueue(context)
private val parser = NotificationParser()
class ApiService {
private val gson = Gson()
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 stringRequest = object : StringRequest(Method.PUT, url, successFn, failureFn) {
override fun getBody(): ByteArray {
return message.toByteArray()
Log.d(TAG, "Publishing to $url")
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 parseSuccessFn = { response: String ->
try {
val notifications = response.trim().lines().map { line ->
parser.fromString(subscriptionId, line)
}
Log.d(TAG, "Notifications: $notifications")
successFn(notifications)
} catch (e: Exception) {
failureFn(e)
Log.d(TAG, "Polling topic $url")
val request = Request.Builder().url(url).build();
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw Exception("Unexpected response ${response.code} when polling topic $url")
}
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 {
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.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.android.volley.VolleyError
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification
@ -34,7 +33,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
DetailViewModelFactory((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
private var subscriptionId: Long = 0L // Set in onCreate()
@ -49,10 +48,11 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.detail_activity)
supportActionBar?.setDisplayHomeAsUpEnabled(true) // Show 'Back' button
// Dependencies that depend on Context
api = ApiService(this)
Log.d(MainActivity.TAG, "Create $this")
// Show 'Back' button
supportActionBar?.setDisplayHomeAsUpEnabled(true)
// Get extras required for the return to the main activity
subscriptionId = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, 0)
@ -124,40 +124,38 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
private fun onTestClick() {
Log.d(TAG, "Sending test notification to ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
val message = getString(R.string.detail_test_message, Date().toString())
val successFn = { _: String -> }
val failureFn = { error: VolleyError ->
Toast
.makeText(this, getString(R.string.detail_test_message_error, error.message), Toast.LENGTH_LONG)
.show()
lifecycleScope.launch(Dispatchers.IO) {
try {
val message = getString(R.string.detail_test_message, Date().toString())
api.publish(subscriptionBaseUrl, subscriptionTopic, message)
} catch (e: Exception) {
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() {
Log.d(TAG, "Fetching cached notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
val activity = this
val successFn = { notifications: List<Notification> ->
lifecycleScope.launch(Dispatchers.IO) {
val localNotificationIds = repository.getAllNotificationIds(subscriptionId)
val newNotifications = notifications.filterNot { localNotificationIds.contains(it.id) }
lifecycleScope.launch(Dispatchers.IO) {
try {
val notifications = api.poll(subscriptionId, subscriptionBaseUrl, subscriptionTopic)
val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications)
val toastMessage = if (newNotifications.isEmpty()) {
getString(R.string.detail_refresh_message_no_results)
} else {
getString(R.string.detail_refresh_message_result, newNotifications.size)
}
newNotifications.forEach { repository.addNotification(it) } // The meat!
runOnUiThread { Toast.makeText(activity, toastMessage, Toast.LENGTH_LONG).show() }
newNotifications.forEach { notification -> repository.addNotification(subscriptionId, notification) }
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() {
@ -174,8 +172,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
setResult(RESULT_OK, result)
finish()
// Delete notifications
viewModel.removeAll(subscriptionId)
// The deletion will be done in MainActivity.onResult
}
.setNegativeButton(R.string.detail_delete_dialog_cancel) { _, _ -> /* Do nothing */ }
.create()
@ -246,7 +243,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
builder
.setMessage(R.string.detail_action_mode_delete_dialog_message)
.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()
}
.setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ ->

View file

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

View file

@ -3,29 +3,34 @@ package io.heckel.ntfy.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
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 androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.asFlow
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import androidx.work.*
import com.google.firebase.messaging.FirebaseMessaging
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl
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.flow.collect
import kotlinx.coroutines.launch
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.random.Random
class MainActivity : AppCompatActivity(), ActionMode.Callback {
@ -33,18 +38,24 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
SubscriptionsViewModelFactory((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 adapter: MainAdapter
private lateinit var fab: View
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?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
Log.d(TAG, "Create $this")
// Dependencies that depend on Context
api = ApiService(this)
workManager = WorkManager.getInstance(this)
notifier = NotificationService(this)
// Action bar
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 {
@ -132,13 +165,14 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
}
// Fetch cached messages
val successFn = { notifications: List<Notification> ->
lifecycleScope.launch(Dispatchers.IO) {
notifications.forEach { repository.addNotification(it) }
lifecycleScope.launch(Dispatchers.IO) {
try {
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
onSubscriptionItemClick(subscription)
@ -158,19 +192,23 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
}
}
private fun refreshAllSubscriptions() {
private fun refreshAllSubscriptions() {
lifecycleScope.launch(Dispatchers.IO) {
val successFn = { notifications: List<Notification> ->
lifecycleScope.launch(Dispatchers.IO) {
notifications.forEach {
repository.addNotification(it)
try {
Log.d(TAG, "Polling for new notifications")
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)
}
}
Unit
}
repository.getAllSubscriptions().asFlow().collect { subscriptions ->
subscriptions.forEach { subscription ->
api.poll(subscription.id, subscription.baseUrl, subscription.topic, successFn, { _ -> })
Log.d(TAG, "Finished polling for new notifications")
} catch (e: Exception) {
Log.e(TAG, "Polling failed: ${e.message}", e)
runOnUiThread {
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 REQUEST_CODE_DELETE_SUBSCRIPTION = 1
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.data.Subscription
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) :
@ -45,6 +47,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
private val context: Context = itemView.context
private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
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) {
this.subscription = subscription
@ -53,8 +56,14 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
} else {
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)
statusView.text = statusMessage
dateView.text = dateText
itemView.setOnClickListener { onClick(subscription) }
itemView.setOnLongClickListener { onLongClick(subscription); true }
if (selected.contains(subscription.id)) {

View file

@ -11,7 +11,7 @@ import kotlin.collections.List
class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
fun list(): LiveData<List<Subscription>> {
return repository.getAllSubscriptions()
return repository.getSubscriptionsLiveData()
}
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) {
repository.removeAllNotifications(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
android:layout_width="35dp"
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
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent" android:layout_weight="20">
<TextView
android:text="ntfy.sh/example"
android:layout_width="match_parent"
@ -27,6 +27,13 @@
android:textAppearance="@style/TextAppearance.AppCompat.Small" android:layout_marginStart="12dp"
android:layout_marginBottom="10dp"/>
</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>

View file

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