diff --git a/app/src/main/java/io/heckel/ntfy/app/Application.kt b/app/src/main/java/io/heckel/ntfy/app/Application.kt index d54ec31..911c345 100644 --- a/app/src/main/java/io/heckel/ntfy/app/Application.kt +++ b/app/src/main/java/io/heckel/ntfy/app/Application.kt @@ -1,8 +1,10 @@ package io.heckel.ntfy.app import android.app.Application +import com.google.firebase.messaging.FirebaseMessagingService import io.heckel.ntfy.data.Database import io.heckel.ntfy.data.Repository +import io.heckel.ntfy.msg.ApiService class Application : Application() { private val database by lazy { Database.getInstance(this) } diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index ec36405..ae43662 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -67,6 +67,9 @@ interface NotificationDao { @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId ORDER BY timestamp DESC") fun list(subscriptionId: Long): Flow> + @Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") + fun listIds(subscriptionId: Long): List + @Insert fun add(notification: Notification) diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index e595e8c..2998cac 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -3,6 +3,7 @@ package io.heckel.ntfy.data import androidx.annotation.WorkerThread import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData +import kotlinx.coroutines.flow.Flow class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) { fun getAllSubscriptions(): LiveData> { @@ -37,6 +38,9 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif return notificationDao.list(subscriptionId).asLiveData() } + fun getAllNotificationIds(subscriptionId: Long): List { + return notificationDao.listIds(subscriptionId) + } @Suppress("RedundantSuspendModifier") @WorkerThread diff --git a/app/src/main/java/io/heckel/ntfy/data/Util.kt b/app/src/main/java/io/heckel/ntfy/data/Util.kt index fca51f7..d08ddb8 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Util.kt @@ -1,6 +1,7 @@ package io.heckel.ntfy.data 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) = topicUrl(baseUrl, topic) .replace("http://", "") diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt new file mode 100644 index 0000000..080a4e6 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -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, 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) -> 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" + } +} diff --git a/app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt b/app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt index 491ed07..6cb10f0 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt @@ -13,6 +13,7 @@ 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 @@ -23,8 +24,7 @@ import java.util.* import kotlin.random.Random class MessagingService : FirebaseMessagingService() { - private val database by lazy { Database.getInstance(this) } - private val repository by lazy { Repository.getInstance(database.subscriptionDao(), database.notificationDao()) } + private val repository by lazy { (application as Application).repository } private val job = SupervisorJob() override fun onMessageReceived(remoteMessage: RemoteMessage) { diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt new file mode 100644 index 0000000..f0ea51f --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -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 + ) +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index efe061a..01e82ad 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -14,7 +14,9 @@ import android.widget.Toast import androidx.activity.viewModels 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 com.android.volley.toolbox.StringRequest import com.android.volley.toolbox.Volley 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.topicShortUrl 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.* @@ -29,6 +35,8 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { private val viewModel by viewModels { 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 private var subscriptionId: Long = 0L // Set in onCreate() @@ -45,6 +53,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { setContentView(R.layout.detail_activity) supportActionBar?.setDisplayHomeAsUpEnabled(true) // Show 'Back' button + // Dependencies that depend on Context + api = ApiService(this) + // Get extras required for the return to the main activity subscriptionId = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, 0) subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return @@ -100,6 +111,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { onTestClick() true } + R.id.detail_menu_refresh -> { + onRefreshClick() + true + } R.id.detail_menu_unsubscribe -> { onDeleteClick() true @@ -108,26 +123,39 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { } } - private fun onTestClick() { - val url = topicUrl(subscriptionBaseUrl, subscriptionTopic) - Log.d(TAG, "Sending test notification to $url") - - val queue = Volley.newRequestQueue(this) // This should be a Singleton :-O - val stringRequest = object : StringRequest( - Method.PUT, - url, - { _ -> /* Do nothing */ }, - { error -> - Toast - .makeText(this, getString(R.string.detail_test_message_error, error.message), Toast.LENGTH_LONG) - .show() - } - ) { - override fun getBody(): ByteArray { - return getString(R.string.detail_test_message, Date().toString()).toByteArray() + private fun onRefreshClick() { + val activity = this + val successFn = { notifications: List -> + 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 } - queue.add(stringRequest) + 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 onTestClick() { + 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() + } + api.publish(subscriptionBaseUrl, subscriptionTopic, message, successFn, failureFn) } private fun onDeleteClick() { diff --git a/app/src/main/res/menu/detail_action_bar_menu.xml b/app/src/main/res/menu/detail_action_bar_menu.xml index 7b6e5de..852ef14 100644 --- a/app/src/main/res/menu/detail_action_bar_menu.xml +++ b/app/src/main/res/menu/detail_action_bar_menu.xml @@ -1,4 +1,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ebfad3f..7819b7b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,8 +47,12 @@ Cancel This is a test notification from the Ntfy Android app. It was sent at %1$s. Could not send test message: %1$s + %1$d notification(s) added + No new notifications found + Could not refresh topic: %1$s + Force refresh Send test notification Unsubscribe