WIP: attachments

This commit is contained in:
Philipp Heckel 2022-01-04 00:54:18 +01:00
parent d84a7266b8
commit f2bb3a022b
6 changed files with 122 additions and 21 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 5, "version": 5,
"identityHash": "306578182c2ad0f9803956beda094d28", "identityHash": "425a0bc96c8aae9d01985b0f4d7579dc",
"entities": [ "entities": [
{ {
"tableName": "Subscription", "tableName": "Subscription",
@ -80,7 +80,7 @@
}, },
{ {
"tableName": "Notification", "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, `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, `attachmentName` TEXT, `attachmentType` TEXT, `attachmentExpires` INTEGER, `attachmentUrl` TEXT, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`, `subscriptionId`))",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -131,6 +131,30 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{
"fieldPath": "attachmentName",
"columnName": "attachmentName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachmentType",
"columnName": "attachmentType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachmentExpires",
"columnName": "attachmentExpires",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachmentUrl",
"columnName": "attachmentUrl",
"affinity": "TEXT",
"notNull": false
},
{ {
"fieldPath": "deleted", "fieldPath": "deleted",
"columnName": "deleted", "columnName": "deleted",
@ -152,7 +176,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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, '306578182c2ad0f9803956beda094d28')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '425a0bc96c8aae9d01985b0f4d7579dc')"
] ]
} }
} }

View file

@ -51,6 +51,11 @@ data class Notification(
@ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID @ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max @ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
@ColumnInfo(name = "tags") val tags: String, @ColumnInfo(name = "tags") val tags: String,
@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 = "attachmentUrl") val attachmentUrl: String?,
@ColumnInfo(name = "deleted") val deleted: Boolean, @ColumnInfo(name = "deleted") val deleted: Boolean,
) )

View file

@ -120,6 +120,11 @@ class ApiService {
message = message.message, message = message.message,
priority = toPriority(message.priority), priority = toPriority(message.priority),
tags = joinTags(message.tags), tags = joinTags(message.tags),
attachmentName = message.attachment?.name,
attachmentType = message.attachment?.type,
attachmentSize = message.attachment?.size,
attachmentExpires = message.attachment?.expires?.toLong(),
attachmentUrl = message.attachment?.url,
notificationId = Random.nextInt(), notificationId = Random.nextInt(),
deleted = false deleted = false
) )
@ -149,6 +154,11 @@ class ApiService {
message = message.message, message = message.message,
priority = toPriority(message.priority), priority = toPriority(message.priority),
tags = joinTags(message.tags), tags = joinTags(message.tags),
attachmentName = message.attachment?.name,
attachmentType = message.attachment?.type,
attachmentSize = message.attachment?.size,
attachmentExpires = message.attachment?.expires,
attachmentUrl = message.attachment?.url,
notificationId = 0, notificationId = 0,
deleted = false deleted = false
) )
@ -165,12 +175,22 @@ class ApiService {
val priority: Int?, val priority: Int?,
val tags: List<String>?, val tags: List<String>?,
val title: String?, val title: String?,
val message: String val message: String,
val attachment: Attachment?,
)
@Keep
private data class Attachment(
val name: String,
val type: String,
val size: Long,
val expires: Long,
val url: String,
) )
companion object { companion object {
val USER_AGENT = "ntfy/${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}; Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT})"
private const val TAG = "NtfyApiService" private const val TAG = "NtfyApiService"
private val USER_AGENT = "ntfy/${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}; Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT})"
// These constants have corresponding values in the server codebase! // These constants have corresponding values in the server codebase!
const val CONTROL_TOPIC = "~control" const val CONTROL_TOPIC = "~control"

View file

@ -26,7 +26,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
val broadcast = shouldBroadcast(subscription) val broadcast = shouldBroadcast(subscription)
val distribute = shouldDistribute(subscription) val distribute = shouldDistribute(subscription)
if (notify) { if (notify) {
notifier.send(subscription, notification) notifier.display(subscription, notification)
} }
if (broadcast) { if (broadcast) {
broadcaster.send(subscription, notification, muted) broadcaster.send(subscription, notification, muted)

View file

@ -6,10 +6,11 @@ import android.app.PendingIntent
import android.app.TaskStackBuilder import android.app.TaskStackBuilder
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.RingtoneManager import android.media.RingtoneManager
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.heckel.ntfy.R import io.heckel.ntfy.R
@ -19,11 +20,43 @@ import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.formatMessage import io.heckel.ntfy.util.formatMessage
import io.heckel.ntfy.util.formatTitle import io.heckel.ntfy.util.formatTitle
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
class NotificationService(val context: Context) { class NotificationService(val context: Context) {
fun send(subscription: Subscription, notification: Notification) { private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
fun display(subscription: Subscription, notification: Notification) {
Log.d(TAG, "Displaying notification $notification") Log.d(TAG, "Displaying notification $notification")
val imageAttachment = notification.attachmentUrl != null && (notification.attachmentType?.startsWith("image/") ?: false)
if (imageAttachment) {
downloadImageAndDisplay(subscription, notification)
} else {
displayInternal(subscription, notification)
}
}
fun cancel(notification: Notification) {
if (notification.notificationId != 0) {
Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}")
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notification.notificationId)
}
}
fun createNotificationChannels() {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
(1..5).forEach { priority -> maybeCreateNotificationChannel(notificationManager, priority) }
}
private fun displayInternal(subscription: Subscription, notification: Notification, bitmap: Bitmap? = null) {
// Create an Intent for the activity you want to start // Create an Intent for the activity you want to start
val intent = Intent(context, DetailActivity::class.java) val intent = Intent(context, DetailActivity::class.java)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
@ -40,32 +73,41 @@ class NotificationService(val context: Context) {
val message = formatMessage(notification) val message = formatMessage(notification)
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val channelId = toChannelId(notification.priority) val channelId = toChannelId(notification.priority)
val notificationBuilder = NotificationCompat.Builder(context, channelId) var notificationBuilder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setColor(ContextCompat.getColor(context, R.color.primaryColor)) .setColor(ContextCompat.getColor(context, R.color.primaryColor))
.setContentTitle(title) .setContentTitle(title)
.setContentText(message) .setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setSound(defaultSoundUri) .setSound(defaultSoundUri)
.setContentIntent(pendingIntent) // Click target for notification .setContentIntent(pendingIntent) // Click target for notification
.setAutoCancel(true) // Cancel when notification is clicked .setAutoCancel(true) // Cancel when notification is clicked
notificationBuilder = if (bitmap != null) {
notificationBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(bitmap))
} else {
notificationBuilder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
maybeCreateNotificationChannel(notificationManager, notification.priority) maybeCreateNotificationChannel(notificationManager, notification.priority)
notificationManager.notify(notification.notificationId, notificationBuilder.build()) notificationManager.notify(notification.notificationId, notificationBuilder.build())
} }
fun cancel(notification: Notification) { private fun downloadImageAndDisplay(subscription: Subscription, notification: Notification) {
if (notification.notificationId != 0) { val url = notification.attachmentUrl ?: return
Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}") Log.d(TAG, "Downloading image $url")
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notification.notificationId)
}
}
fun createNotificationChannels() { val request = Request.Builder()
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager .url(url)
(1..5).forEach { priority -> maybeCreateNotificationChannel(notificationManager, priority) } .addHeader("User-Agent", ApiService.USER_AGENT)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
displayInternal(subscription, notification)
return
}
val bitmap = BitmapFactory.decodeStream(response.body!!.byteStream())
displayInternal(subscription, notification, bitmap)
}
} }
private fun maybeCreateNotificationChannel(notificationManager: NotificationManager, priority: Int) { private fun maybeCreateNotificationChannel(notificationManager: NotificationManager, priority: Int) {

View file

@ -56,6 +56,11 @@ class FirebaseService : FirebaseMessagingService() {
val message = data["message"] val message = data["message"]
val priority = data["priority"]?.toIntOrNull() val priority = data["priority"]?.toIntOrNull()
val tags = data["tags"] val tags = data["tags"]
val attachmentName = data["attachment_name"]
val attachmentType = data["attachment_type"]
val attachmentSize = data["attachment_size"]?.toLongOrNull()
val attachmentExpires = data["attachment_expires"]?.toLongOrNull()
val attachmentUrl = data["attachment_url"]
if (id == null || topic == null || message == null || timestamp == null) { if (id == null || topic == null || message == null || timestamp == null) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}") Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}")
return return
@ -73,9 +78,14 @@ class FirebaseService : FirebaseMessagingService() {
timestamp = timestamp, timestamp = timestamp,
title = title ?: "", title = title ?: "",
message = message, message = message,
notificationId = Random.nextInt(),
priority = toPriority(priority), priority = toPriority(priority),
tags = tags ?: "", tags = tags ?: "",
attachmentName = attachmentName,
attachmentType = attachmentType,
attachmentSize = attachmentSize,
attachmentExpires = attachmentExpires,
attachmentUrl = attachmentUrl,
notificationId = Random.nextInt(),
deleted = false deleted = false
) )
if (repository.addNotification(notification)) { if (repository.addNotification(notification)) {