Periodic worker to refresh notifications; replace Volley with OkHttp
This commit is contained in:
parent
9cdc73592c
commit
72d7a2f93d
17 changed files with 386 additions and 243 deletions
|
@ -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'
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
65
app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt
Normal file
65
app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
63
app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt
Normal file
63
app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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) { _, _ ->
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
52
app/src/main/java/io/heckel/ntfy/work/PollWorker.kt
Normal file
52
app/src/main/java/io/heckel/ntfy/work/PollWorker.kt
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue