Add "Force refresh" button in menu

This commit is contained in:
Philipp Heckel 2021-11-07 13:13:32 -05:00
parent a51c856d4c
commit e730b1657e
10 changed files with 135 additions and 20 deletions

View file

@ -1,8 +1,10 @@
package io.heckel.ntfy.app package io.heckel.ntfy.app
import android.app.Application import android.app.Application
import com.google.firebase.messaging.FirebaseMessagingService
import io.heckel.ntfy.data.Database import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.msg.ApiService
class Application : Application() { class Application : Application() {
private val database by lazy { Database.getInstance(this) } private val database by lazy { Database.getInstance(this) }

View file

@ -67,6 +67,9 @@ interface NotificationDao {
@Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId ORDER BY timestamp DESC") @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId ORDER BY timestamp DESC")
fun list(subscriptionId: Long): Flow<List<Notification>> fun list(subscriptionId: Long): Flow<List<Notification>>
@Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId")
fun listIds(subscriptionId: Long): List<String>
@Insert @Insert
fun add(notification: Notification) fun add(notification: Notification)

View file

@ -3,6 +3,7 @@ package io.heckel.ntfy.data
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
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>> { fun getAllSubscriptions(): LiveData<List<Subscription>> {
@ -37,6 +38,9 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
return notificationDao.list(subscriptionId).asLiveData() return notificationDao.list(subscriptionId).asLiveData()
} }
fun getAllNotificationIds(subscriptionId: Long): List<String> {
return notificationDao.listIds(subscriptionId)
}
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.data package io.heckel.ntfy.data
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}" fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
fun topicUrlJsonPoll(baseUrl: String, topic: String) = "${topicUrl(baseUrl, topic)}/json?poll=1&since=12h"
fun topicShortUrl(baseUrl: String, topic: String) = fun topicShortUrl(baseUrl: String, topic: String) =
topicUrl(baseUrl, topic) topicUrl(baseUrl, topic)
.replace("http://", "") .replace("http://", "")

View file

@ -0,0 +1,53 @@
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.*
class ApiService(context: Context) {
private val queue = Volley.newRequestQueue(context)
private val parser = NotificationParser()
fun publish(baseUrl: String, topic: String, message: String, successFn: Response.Listener<String>, failureFn: (VolleyError) -> Unit) {
val url = topicUrl(baseUrl, topic)
val stringRequest = object : StringRequest(Method.PUT, url, successFn, failureFn) {
override fun getBody(): ByteArray {
return message.toByteArray()
}
}
queue.add(stringRequest)
}
fun poll(subscriptionId: Long, baseUrl: String, topic: String, successFn: (List<Notification>) -> Unit, failureFn: (Exception) -> Unit) {
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)
}
}
val stringRequest = StringRequest(Request.Method.GET, url, parseSuccessFn, failureFn)
queue.add(stringRequest)
}
companion object {
private const val TAG = "NtfyApiService"
}
}

View file

@ -13,6 +13,7 @@ import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.* import io.heckel.ntfy.data.*
import io.heckel.ntfy.ui.DetailActivity import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.ui.MainActivity
@ -23,8 +24,7 @@ import java.util.*
import kotlin.random.Random import kotlin.random.Random
class MessagingService : FirebaseMessagingService() { class MessagingService : FirebaseMessagingService() {
private val database by lazy { Database.getInstance(this) } private val repository by lazy { (application as Application).repository }
private val repository by lazy { Repository.getInstance(database.subscriptionDao(), database.notificationDao()) }
private val job = SupervisorJob() private val job = SupervisorJob()
override fun onMessageReceived(remoteMessage: RemoteMessage) { override fun onMessageReceived(remoteMessage: RemoteMessage) {

View file

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

View file

@ -14,7 +14,9 @@ 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.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.android.volley.VolleyError
import com.android.volley.toolbox.StringRequest import com.android.volley.toolbox.StringRequest
import com.android.volley.toolbox.Volley import com.android.volley.toolbox.Volley
import io.heckel.ntfy.R import io.heckel.ntfy.R
@ -22,6 +24,10 @@ import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.topicShortUrl import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.data.topicUrl import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.msg.ApiService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.* import java.util.*
@ -29,6 +35,8 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
private val viewModel by viewModels<DetailViewModel> { private val viewModel by viewModels<DetailViewModel> {
DetailViewModelFactory((application as Application).repository) DetailViewModelFactory((application as Application).repository)
} }
private val repository by lazy { (application as Application).repository }
private lateinit var api: ApiService // Context-dependent
// 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()
@ -45,6 +53,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
setContentView(R.layout.detail_activity) setContentView(R.layout.detail_activity)
supportActionBar?.setDisplayHomeAsUpEnabled(true) // Show 'Back' button supportActionBar?.setDisplayHomeAsUpEnabled(true) // Show 'Back' button
// Dependencies that depend on Context
api = ApiService(this)
// 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)
subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return
@ -100,6 +111,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
onTestClick() onTestClick()
true true
} }
R.id.detail_menu_refresh -> {
onRefreshClick()
true
}
R.id.detail_menu_unsubscribe -> { R.id.detail_menu_unsubscribe -> {
onDeleteClick() onDeleteClick()
true true
@ -108,26 +123,39 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
} }
} }
private fun onTestClick() { private fun onRefreshClick() {
val url = topicUrl(subscriptionBaseUrl, subscriptionTopic) val activity = this
Log.d(TAG, "Sending test notification to $url") val successFn = { notifications: List<Notification> ->
lifecycleScope.launch(Dispatchers.IO) {
val localNotificationIds = repository.getAllNotificationIds(subscriptionId)
val newNotifications = notifications.filterNot { localNotificationIds.contains(it.id) }
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() }
}
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)
}
val queue = Volley.newRequestQueue(this) // This should be a Singleton :-O private fun onTestClick() {
val stringRequest = object : StringRequest( val message = getString(R.string.detail_test_message, Date().toString())
Method.PUT, val successFn = { _: String -> }
url, val failureFn = { error: VolleyError ->
{ _ -> /* Do nothing */ },
{ error ->
Toast Toast
.makeText(this, getString(R.string.detail_test_message_error, error.message), Toast.LENGTH_LONG) .makeText(this, getString(R.string.detail_test_message_error, error.message), Toast.LENGTH_LONG)
.show() .show()
} }
) { api.publish(subscriptionBaseUrl, subscriptionTopic, message, successFn, failureFn)
override fun getBody(): ByteArray {
return getString(R.string.detail_test_message, Date().toString()).toByteArray()
}
}
queue.add(stringRequest)
} }
private fun onDeleteClick() { private fun onDeleteClick() {

View file

@ -1,4 +1,5 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" > <menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/> <item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/>
<item android:id="@+id/detail_menu_refresh" android:title="@string/detail_menu_refresh"/>
<item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/> <item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/>
</menu> </menu>

View file

@ -47,8 +47,12 @@
<string name="detail_delete_dialog_cancel">Cancel</string> <string name="detail_delete_dialog_cancel">Cancel</string>
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string> <string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string>
<string name="detail_test_message_error">Could not send test message: %1$s</string> <string name="detail_test_message_error">Could not send test message: %1$s</string>
<string name="detail_refresh_message_result">%1$d notification(s) added</string>
<string name="detail_refresh_message_no_results">No new notifications found</string>
<string name="detail_refresh_message_error">Could not refresh topic: %1$s</string>
<!-- Detail activity: Action bar --> <!-- Detail activity: Action bar -->
<string name="detail_menu_refresh">Force refresh</string>
<string name="detail_menu_test">Send test notification</string> <string name="detail_menu_test">Send test notification</string>
<string name="detail_menu_unsubscribe">Unsubscribe</string> <string name="detail_menu_unsubscribe">Unsubscribe</string>