diff --git a/app/build.gradle b/app/build.gradle index b081c89..8b3784d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a14c41..7a2652f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,7 +35,7 @@ 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 a7d6cae..24c932f 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -47,11 +47,17 @@ abstract class Database : RoomDatabase() { @Dao interface SubscriptionDao { @Query("SELECT * FROM subscription ORDER BY lastActive DESC") - fun list(): Flow> + fun listFlow(): Flow> + + @Query("SELECT * FROM subscription ORDER BY lastActive DESC") + fun list(): List @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) 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 2998cac..7b5d74a 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -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> { - return subscriptionDao.list().asLiveData() + init { + Log.d(TAG, "Created $this") + } + + fun getSubscriptionsLiveData(): LiveData> { + return subscriptionDao.listFlow().asLiveData() + } + + fun getSubscriptions(): List { + 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> { + fun getNotificationsLiveData(subscriptionId: Long): LiveData> { return notificationDao.list(subscriptionId).asLiveData() } - fun getAllNotificationIds(subscriptionId: Long): List { - return notificationDao.listIds(subscriptionId) + fun onlyNewNotifications(subscriptionId: Long, notifications: List): List { + 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 { diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index 080a4e6..54531c3 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -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, 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) -> Unit, failureFn: (Exception) -> Unit) { + fun poll(subscriptionId: Long, baseUrl: String, topic: String): List { 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" } diff --git a/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt b/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt new file mode 100644 index 0000000..559955d --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt @@ -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" + } +} diff --git a/app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt b/app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt deleted file mode 100644 index 6cb10f0..0000000 --- a/app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt +++ /dev/null @@ -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" - } -} diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt deleted file mode 100644 index f0ea51f..0000000 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ /dev/null @@ -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 - ) -} diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt new file mode 100644 index 0000000..e4058bc --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -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" + } +} 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 48427a4..f62f3a2 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -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 -> - 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) { _, _ -> diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt index c460583..ac5e11c 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt @@ -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> { - 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) } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index bd3a404..79a73ec 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -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(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 -> - 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 -> - 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" } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt index 8068657..7e040e1 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt @@ -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)) { diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt index 67cb9ab..9825f02 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt @@ -11,7 +11,7 @@ import kotlin.collections.List class SubscriptionsViewModel(private val repository: Repository) : ViewModel() { fun list(): LiveData> { - 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) } diff --git a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt new file mode 100644 index 0000000..40d0505 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -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" + } +} diff --git a/app/src/main/res/layout/main_fragment_item.xml b/app/src/main/res/layout/main_fragment_item.xml index f5bc1a1..199da81 100644 --- a/app/src/main/res/layout/main_fragment_item.xml +++ b/app/src/main/res/layout/main_fragment_item.xml @@ -7,11 +7,11 @@ + android:id="@+id/topic_image" android:layout_marginStart="20dp" android:layout_weight="1"/> + android:layout_height="match_parent" android:layout_weight="20"> + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d2e8aa4..1b1eb94 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,8 +21,8 @@ Cancel - %1$d notification received - %1$d notifications received + %1$d notification + %1$d notifications Add subscription It looks like you don\'t have any subscriptions yet. 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.