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 "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'
|
||||||
|
|
|
@ -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"/>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
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.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) { _, _ ->
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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
|
<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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue