Schedule attachment download in dispatcher, not in NotificationService

This commit is contained in:
Philipp Heckel 2022-01-08 15:49:07 -05:00
parent be750b603b
commit d440d0a633
7 changed files with 161 additions and 112 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "6fd36c6995d3ae734f4ba7c8beaf9a95",
"identityHash": "1ab02dd84a7f2655b4fc651574b24240",
"entities": [
{
"tableName": "Subscription",
@ -80,7 +80,7 @@
},
{
"tableName": "Notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `attachmentName` TEXT, `attachmentType` TEXT, `attachmentSize` INTEGER, `attachmentExpires` INTEGER, `attachmentPreviewUrl` TEXT, `attachmentUrl` TEXT, `attachmentContentUri` TEXT, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`, `subscriptionId`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_previewUrl` TEXT, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_previewFile` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
"fields": [
{
"fieldPath": "id",
@ -137,53 +137,65 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "attachmentName",
"columnName": "attachmentName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachmentType",
"columnName": "attachmentType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachmentSize",
"columnName": "attachmentSize",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachmentExpires",
"columnName": "attachmentExpires",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachmentPreviewUrl",
"columnName": "attachmentPreviewUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachmentUrl",
"columnName": "attachmentUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachmentContentUri",
"columnName": "attachmentContentUri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachment.name",
"columnName": "attachment_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachment.type",
"columnName": "attachment_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachment.size",
"columnName": "attachment_size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachment.expires",
"columnName": "attachment_expires",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachment.previewUrl",
"columnName": "attachment_previewUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachment.url",
"columnName": "attachment_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachment.contentUri",
"columnName": "attachment_contentUri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachment.previewFile",
"columnName": "attachment_previewFile",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachment.progress",
"columnName": "attachment_progress",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
@ -200,7 +212,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6fd36c6995d3ae734f4ba7c8beaf9a95')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1ab02dd84a7f2655b4fc651574b24240')"
]
}
}

View file

@ -15,6 +15,7 @@ data class Subscription(
@ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule
@ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name
@ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token
// TODO autoDownloadAttachments, minPriority
@Ignore val totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp
@ -52,16 +53,26 @@ data class Notification(
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
@ColumnInfo(name = "tags") val tags: String,
@ColumnInfo(name = "click") val click: String, // URL/intent to open on notification click
@ColumnInfo(name = "attachmentName") val attachmentName: String?, // Filename
@ColumnInfo(name = "attachmentType") val attachmentType: String?, // MIME type
@ColumnInfo(name = "attachmentSize") val attachmentSize: Long?, // Size in bytes
@ColumnInfo(name = "attachmentExpires") val attachmentExpires: Long?, // Unix timestamp
@ColumnInfo(name = "attachmentPreviewUrl") val attachmentPreviewUrl: String?,
@ColumnInfo(name = "attachmentUrl") val attachmentUrl: String?,
@ColumnInfo(name = "attachmentContentUri") val attachmentContentUri: String?,
@Embedded(prefix = "attachment_") val attachment: Attachment?,
@ColumnInfo(name = "deleted") val deleted: Boolean,
)
@Entity
data class Attachment(
@ColumnInfo(name = "name") val name: String?, // Filename
@ColumnInfo(name = "type") val type: String?, // MIME type
@ColumnInfo(name = "size") val size: Long?, // Size in bytes
@ColumnInfo(name = "expires") val expires: Long?, // Unix timestamp
@ColumnInfo(name = "previewUrl") val previewUrl: String?,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "contentUri") val contentUri: String?,
@ColumnInfo(name = "previewFile") val previewFile: String?,
@ColumnInfo(name = "progress") val progress: Int,
) {
constructor(name: String?, type: String?, size: Long?, expires: Long?, previewUrl: String?, url: String) :
this(name, type, size, expires, previewUrl, url, null, null, 0)
}
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 6)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao

View file

@ -5,6 +5,7 @@ import android.util.Log
import androidx.annotation.Keep
import com.google.gson.Gson
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.data.Attachment
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.util.topicUrl
import io.heckel.ntfy.util.topicUrlJson
@ -112,6 +113,16 @@ class ApiService {
val message = gson.fromJson(line, Message::class.java)
if (message.event == EVENT_MESSAGE) {
val topic = message.topic
val attachment = if (message.attachment?.url != null) {
Attachment(
name = message.attachment.name,
type = message.attachment.type,
size = message.attachment.size,
expires = message.attachment.expires,
previewUrl = message.attachment.preview_url,
url = message.attachment.url,
)
} else null
val notification = Notification(
id = message.id,
subscriptionId = 0, // TO BE SET downstream
@ -121,13 +132,7 @@ class ApiService {
priority = toPriority(message.priority),
tags = joinTags(message.tags),
click = message.click ?: "",
attachmentName = message.attachment?.name,
attachmentType = message.attachment?.type,
attachmentSize = message.attachment?.size,
attachmentExpires = message.attachment?.expires,
attachmentPreviewUrl = message.attachment?.preview_url,
attachmentUrl = message.attachment?.url,
attachmentContentUri = null,
attachment = attachment,
notificationId = Random.nextInt(),
deleted = false
)
@ -149,6 +154,16 @@ class ApiService {
private fun fromString(subscriptionId: Long, s: String): Notification {
val message = gson.fromJson(s, Message::class.java)
val attachment = if (message.attachment?.url != null) {
Attachment(
name = message.attachment.name,
type = message.attachment.type,
size = message.attachment.size,
expires = message.attachment.expires,
previewUrl = message.attachment.preview_url,
url = message.attachment.url,
)
} else null
return Notification(
id = message.id,
subscriptionId = subscriptionId,
@ -158,14 +173,8 @@ class ApiService {
priority = toPriority(message.priority),
tags = joinTags(message.tags),
click = message.click ?: "",
attachmentName = message.attachment?.name,
attachmentType = message.attachment?.type,
attachmentSize = message.attachment?.size,
attachmentExpires = message.attachment?.expires,
attachmentPreviewUrl = message.attachment?.preview_url,
attachmentUrl = message.attachment?.url,
attachmentContentUri = null,
notificationId = 0,
attachment = attachment,
notificationId = 0, // zero!
deleted = false
)
}

View file

@ -9,6 +9,7 @@ import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Attachment
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
@ -34,15 +35,16 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
val repository = app.repository
val notification = repository.getNotification(notificationId) ?: return Result.failure()
val subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
if (notification.attachmentPreviewUrl != null) {
downloadPreview(repository, subscription, notification)
val attachment = notification.attachment ?: return Result.failure()
if (attachment.previewUrl != null) {
downloadPreview(subscription, notification, attachment)
}
downloadAttachment(repository, subscription, notification)
downloadAttachment(repository, subscription, notification, attachment)
return Result.success()
}
private fun downloadPreview(repository: Repository, subscription: Subscription, notification: Notification) {
val url = notification.attachmentPreviewUrl ?: return
private fun downloadPreview(subscription: Subscription, notification: Notification, attachment: Attachment) {
val url = attachment.previewUrl ?: return
Log.d(TAG, "Downloading preview from $url")
val request = Request.Builder()
@ -63,21 +65,20 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
}
}
private fun downloadAttachment(repository: Repository, subscription: Subscription, notification: Notification) {
val url = notification.attachmentUrl ?: return
Log.d(TAG, "Downloading attachment from $url")
private fun downloadAttachment(repository: Repository, subscription: Subscription, notification: Notification, attachment: Attachment) {
Log.d(TAG, "Downloading attachment from ${attachment.url}")
val request = Request.Builder()
.url(url)
.url(attachment.url)
.addHeader("User-Agent", ApiService.USER_AGENT)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
throw Exception("Attachment download failed: ${response.code}")
}
val name = notification.attachmentName ?: "attachment.bin"
val mimeType = notification.attachmentType ?: "application/octet-stream"
val size = notification.attachmentSize ?: 0
val name = attachment.name ?: "attachment.bin"
val mimeType = attachment.type ?: "application/octet-stream"
val size = attachment.size ?: 0
val resolver = applicationContext.contentResolver
val details = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
@ -107,7 +108,8 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
}
}
Log.d(TAG, "Attachment download: successful response, proceeding with download")
val newNotification = notification.copy(attachmentContentUri = uri.toString())
val newAttachment = attachment.copy(contentUri = uri.toString())
val newNotification = notification.copy(attachment = newAttachment)
repository.updateNotification(newNotification)
notifier.update(subscription, newNotification)
}

View file

@ -1,6 +1,10 @@
package io.heckel.ntfy.msg
import android.content.Context
import android.util.Log
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.workDataOf
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
@ -25,6 +29,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
val notify = shouldNotify(subscription, notification, muted)
val broadcast = shouldBroadcast(subscription)
val distribute = shouldDistribute(subscription)
val download = shouldDownload(subscription, notification)
if (notify) {
notifier.display(subscription, notification)
}
@ -36,6 +41,16 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
distributor.sendMessage(appId, connectorToken, notification.message)
}
}
if (download) {
// Download attachment (+ preview if available) in the background via WorkManager
// The indirection via WorkManager is required since this code may be executed
// in a doze state and Internet may not be available. It's also best practice apparently.
scheduleAttachmentDownload(notification)
}
}
private fun shouldDownload(subscription: Subscription, notification: Notification): Boolean {
return notification.attachment != null
}
private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {
@ -67,4 +82,19 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
}
return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000)
}
private fun scheduleAttachmentDownload(notification: Notification) {
Log.d(TAG, "Enqueuing work to download attachment (+ preview if available)")
val workManager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java)
.setInputData(workDataOf(
"id" to notification.id,
))
.build()
workManager.enqueue(workRequest)
}
companion object {
private const val TAG = "NtfyNotifDispatch"
}
}

View file

@ -32,16 +32,7 @@ class NotificationService(val context: Context) {
fun display(subscription: Subscription, notification: Notification) {
Log.d(TAG, "Displaying notification $notification")
// Display notification immediately
displayInternal(subscription, notification)
// Download attachment (+ preview if available) in the background via WorkManager
// The indirection via WorkManager is required since this code may be executed
// in a doze state and Internet may not be available. It's also best practice apparently.
if (notification.attachmentUrl != null) {
scheduleAttachmentDownload(subscription, notification)
}
}
fun update(subscription: Subscription, notification: Notification, progress: Int = PROGRESS_NONE) {
@ -133,32 +124,21 @@ class NotificationService(val context: Context) {
}
private fun maybeAddOpenAction(notificationBuilder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachmentContentUri != null) {
val contentUri = Uri.parse(notification.attachmentContentUri)
if (notification.attachment?.contentUri != null) {
val contentUri = Uri.parse(notification.attachment.contentUri)
val openIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, contentUri), 0)
notificationBuilder.addAction(NotificationCompat.Action.Builder(0, "Open", openIntent).build())
}
}
private fun maybeAddCopyUrlAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachmentUrl != null) {
if (notification.attachment?.url != null) {
// XXXXXXXXx
val copyUrlIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachmentUrl)), 0)
val copyUrlIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachment.url)), 0)
builder.addAction(NotificationCompat.Action.Builder(0, "Copy URL", copyUrlIntent).build())
}
}
private fun scheduleAttachmentDownload(subscription: Subscription, notification: Notification) {
Log.d(TAG, "Enqueuing work to download attachment (+ preview if available)")
val workManager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java)
.setInputData(workDataOf(
"id" to notification.id,
))
.build()
workManager.enqueue(workRequest)
}
private fun detailActivityIntent(subscription: Subscription): PendingIntent? {
val intent = Intent(context, DetailActivity::class.java)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
@ -223,7 +203,7 @@ class NotificationService(val context: Context) {
const val PROGRESS_NONE = -1
const val PROGRESS_INDETERMINATE = -2
private const val TAG = "NtfyNotificationService"
private const val TAG = "NtfyNotifService"
private const val CHANNEL_ID_MIN = "ntfy-min"
private const val CHANNEL_ID_LOW = "ntfy-low"
private const val CHANNEL_ID_DEFAULT = "ntfy"

View file

@ -6,6 +6,7 @@ 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.Attachment
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.msg.*
import io.heckel.ntfy.service.SubscriberService
@ -81,6 +82,16 @@ class FirebaseService : FirebaseMessagingService() {
}
// Add notification
val attachment = if (attachmentUrl != null) {
Attachment(
name = attachmentName,
type = attachmentType,
size = attachmentSize,
expires = attachmentExpires,
previewUrl = attachmentPreviewUrl,
url = attachmentUrl,
)
} else null
val notification = Notification(
id = id,
subscriptionId = subscription.id,
@ -90,13 +101,7 @@ class FirebaseService : FirebaseMessagingService() {
priority = toPriority(priority),
tags = tags ?: "",
click = click ?: "",
attachmentName = attachmentName,
attachmentType = attachmentType,
attachmentSize = attachmentSize,
attachmentExpires = attachmentExpires,
attachmentPreviewUrl = attachmentPreviewUrl,
attachmentUrl = attachmentUrl,
attachmentContentUri = null,
attachment = attachment,
notificationId = Random.nextInt(),
deleted = false
)