Remove preview; progress in notification

This commit is contained in:
Philipp Heckel 2022-01-08 21:32:43 -05:00
parent d440d0a633
commit dced0a2e06
10 changed files with 74 additions and 90 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 6, "version": 6,
"identityHash": "1ab02dd84a7f2655b4fc651574b24240", "identityHash": "fc725df9153ee7088ae8024428b7f2cf",
"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, `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`))", "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_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -167,12 +167,6 @@
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": false "notNull": false
}, },
{
"fieldPath": "attachment.previewUrl",
"columnName": "attachment_previewUrl",
"affinity": "TEXT",
"notNull": false
},
{ {
"fieldPath": "attachment.url", "fieldPath": "attachment.url",
"columnName": "attachment_url", "columnName": "attachment_url",
@ -185,12 +179,6 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": false "notNull": false
}, },
{
"fieldPath": "attachment.previewFile",
"columnName": "attachment_previewFile",
"affinity": "TEXT",
"notNull": false
},
{ {
"fieldPath": "attachment.progress", "fieldPath": "attachment.progress",
"columnName": "attachment_progress", "columnName": "attachment_progress",
@ -212,7 +200,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, '1ab02dd84a7f2655b4fc651574b24240')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fc725df9153ee7088ae8024428b7f2cf')"
] ]
} }
} }

View file

@ -108,5 +108,6 @@
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_icon" android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification"/> android:resource="@drawable/ic_notification"/>
</application> </application>
</manifest> </manifest>

View file

@ -63,14 +63,12 @@ data class Attachment(
@ColumnInfo(name = "type") val type: String?, // MIME type @ColumnInfo(name = "type") val type: String?, // MIME type
@ColumnInfo(name = "size") val size: Long?, // Size in bytes @ColumnInfo(name = "size") val size: Long?, // Size in bytes
@ColumnInfo(name = "expires") val expires: Long?, // Unix timestamp @ColumnInfo(name = "expires") val expires: Long?, // Unix timestamp
@ColumnInfo(name = "previewUrl") val previewUrl: String?,
@ColumnInfo(name = "url") val url: String, @ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "contentUri") val contentUri: String?, @ColumnInfo(name = "contentUri") val contentUri: String?,
@ColumnInfo(name = "previewFile") val previewFile: String?,
@ColumnInfo(name = "progress") val progress: Int, @ColumnInfo(name = "progress") val progress: Int,
) { ) {
constructor(name: String?, type: String?, size: Long?, expires: Long?, previewUrl: String?, url: String) : constructor(name: String?, type: String?, size: Long?, expires: Long?, url: String) :
this(name, type, size, expires, previewUrl, url, null, null, 0) this(name, type, size, expires, url, null, 0)
} }
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 6) @androidx.room.Database(entities = [Subscription::class, Notification::class], version = 6)

View file

@ -7,11 +7,7 @@ import com.google.gson.Gson
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.data.Attachment import io.heckel.ntfy.data.Attachment
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.*
import io.heckel.ntfy.util.topicUrlJson
import io.heckel.ntfy.util.topicUrlJsonPoll
import io.heckel.ntfy.util.toPriority
import io.heckel.ntfy.util.joinTags
import okhttp3.* import okhttp3.*
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
@ -119,7 +115,6 @@ class ApiService {
type = message.attachment.type, type = message.attachment.type,
size = message.attachment.size, size = message.attachment.size,
expires = message.attachment.expires, expires = message.attachment.expires,
previewUrl = message.attachment.preview_url,
url = message.attachment.url, url = message.attachment.url,
) )
} else null } else null
@ -160,7 +155,6 @@ class ApiService {
type = message.attachment.type, type = message.attachment.type,
size = message.attachment.size, size = message.attachment.size,
expires = message.attachment.expires, expires = message.attachment.expires,
previewUrl = message.attachment.preview_url,
url = message.attachment.url, url = message.attachment.url,
) )
} else null } else null
@ -174,7 +168,7 @@ class ApiService {
tags = joinTags(message.tags), tags = joinTags(message.tags),
click = message.click ?: "", click = message.click ?: "",
attachment = attachment, attachment = attachment,
notificationId = 0, // zero! notificationId = 0, // zero: when we poll, we do not want a notificationId!
deleted = false deleted = false
) )
} }
@ -192,16 +186,15 @@ class ApiService {
val click: String?, val click: String?,
val title: String?, val title: String?,
val message: String, val message: String,
val attachment: Attachment?, val attachment: MessageAttachment?,
) )
@Keep @Keep
private data class Attachment( private data class MessageAttachment(
val name: String, val name: String,
val type: String?, val type: String?,
val size: Long?, val size: Long?,
val expires: Long?, val expires: Long?,
val preview_url: String?,
val url: String, val url: String,
) )

View file

@ -2,7 +2,6 @@ package io.heckel.ntfy.msg
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
@ -13,10 +12,9 @@ import io.heckel.ntfy.data.Attachment
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.msg.NotificationService.Companion.PROGRESS_DONE
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class AttachmentDownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { class AttachmentDownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
@ -36,35 +34,10 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
val notification = repository.getNotification(notificationId) ?: return Result.failure() val notification = repository.getNotification(notificationId) ?: return Result.failure()
val subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure() val subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
val attachment = notification.attachment ?: return Result.failure() val attachment = notification.attachment ?: return Result.failure()
if (attachment.previewUrl != null) {
downloadPreview(subscription, notification, attachment)
}
downloadAttachment(repository, subscription, notification, attachment) downloadAttachment(repository, subscription, notification, attachment)
return Result.success() return Result.success()
} }
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()
.url(url)
.addHeader("User-Agent", ApiService.USER_AGENT)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
throw Exception("Preview download failed: ${response.code}")
}
val previewFile = File(applicationContext.cacheDir.absolutePath, "preview-" + notification.id)
Log.d(TAG, "Downloading preview to cache file: $previewFile")
FileOutputStream(previewFile).use { fileOut ->
response.body!!.byteStream().copyTo(fileOut)
}
Log.d(TAG, "Preview downloaded; updating notification")
notifier.update(subscription, notification)
}
}
private fun downloadAttachment(repository: Repository, subscription: Subscription, notification: Notification, attachment: Attachment) { private fun downloadAttachment(repository: Repository, subscription: Subscription, notification: Notification, attachment: Attachment) {
Log.d(TAG, "Downloading attachment from ${attachment.url}") Log.d(TAG, "Downloading attachment from ${attachment.url}")
@ -111,7 +84,7 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
val newAttachment = attachment.copy(contentUri = uri.toString()) val newAttachment = attachment.copy(contentUri = uri.toString())
val newNotification = notification.copy(attachment = newAttachment) val newNotification = notification.copy(attachment = newAttachment)
repository.updateNotification(newNotification) repository.updateNotification(newNotification)
notifier.update(subscription, newNotification) notifier.update(subscription, newNotification, progress = PROGRESS_DONE)
} }
} }

View file

@ -42,7 +42,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
} }
} }
if (download) { if (download) {
// Download attachment (+ preview if available) in the background via WorkManager // Download attachment in the background via WorkManager
// The indirection via WorkManager is required since this code may be executed // 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. // in a doze state and Internet may not be available. It's also best practice apparently.
scheduleAttachmentDownload(notification) scheduleAttachmentDownload(notification)

View file

@ -1,12 +1,7 @@
package io.heckel.ntfy.msg package io.heckel.ntfy.msg
import android.app.NotificationChannel import android.app.*
import android.app.NotificationManager import android.content.*
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.RingtoneManager import android.media.RingtoneManager
import android.net.Uri import android.net.Uri
@ -14,18 +9,15 @@ import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.workDataOf
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.ui.DetailActivity import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.formatBytes
import io.heckel.ntfy.util.formatDateShort
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 java.io.File
class NotificationService(val context: Context) { class NotificationService(val context: Context) {
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@ -54,7 +46,7 @@ class NotificationService(val context: Context) {
private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false, progress: Int = PROGRESS_NONE) { private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false, progress: Int = PROGRESS_NONE) {
val title = formatTitle(subscription, notification) val title = formatTitle(subscription, notification)
val message = formatMessage(notification) val message = maybeWithAttachmentInfo(formatMessage(notification), notification, progress)
val channelId = toChannelId(notification.priority) val channelId = toChannelId(notification.priority)
val builder = NotificationCompat.Builder(context, channelId) val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
@ -68,12 +60,25 @@ class NotificationService(val context: Context) {
maybeSetSound(builder, update) maybeSetSound(builder, update)
maybeSetProgress(builder, progress) maybeSetProgress(builder, progress)
maybeAddOpenAction(builder, notification) maybeAddOpenAction(builder, notification)
maybeAddCopyUrlAction(builder, notification) maybeAddBrowseAction(builder, notification)
maybeCreateNotificationChannel(notification.priority) maybeCreateNotificationChannel(notification.priority)
notificationManager.notify(notification.notificationId, builder.build()) notificationManager.notify(notification.notificationId, builder.build())
} }
private fun maybeWithAttachmentInfo(message: String, notification: Notification, progress: Int): String {
if (progress < 0 || notification.attachment == null) return message
val att = notification.attachment
val infos = mutableListOf<String>()
if (att.name != null) infos.add(att.name)
if (att.size != null) infos.add(formatBytes(att.size))
//if (att.expires != null && att.expires != 0L) infos.add(formatDateShort(att.expires))
if (progress >= 0) infos.add("${progress}%")
if (infos.size == 0) return message
if (progress < 100) return "Downloading ${infos.joinToString(", ")}\n${message}"
return "${message}\nFile: ${infos.joinToString(", ")}"
}
private fun maybeSetSound(builder: NotificationCompat.Builder, update: Boolean) { private fun maybeSetSound(builder: NotificationCompat.Builder, update: Boolean) {
if (!update) { if (!update) {
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
@ -84,10 +89,13 @@ class NotificationService(val context: Context) {
} }
private fun setStyle(builder: NotificationCompat.Builder, notification: Notification, message: String) { private fun setStyle(builder: NotificationCompat.Builder, notification: Notification, message: String) {
val previewFile = File(context.applicationContext.cacheDir.absolutePath, "preview-" + notification.id) val contentUri = notification.attachment?.contentUri
if (previewFile.exists()) { val isImage = listOf("image/jpeg", "image/png").contains(notification.attachment?.type)
if (contentUri != null && isImage) {
try { try {
val bitmap = BitmapFactory.decodeFile(previewFile.absolutePath) val resolver = context.applicationContext.contentResolver
val bitmapStream = resolver.openInputStream(Uri.parse(contentUri))
val bitmap = BitmapFactory.decodeStream(bitmapStream)
builder builder
.setLargeIcon(bitmap) .setLargeIcon(bitmap)
.setStyle(NotificationCompat.BigPictureStyle() .setStyle(NotificationCompat.BigPictureStyle()
@ -116,26 +124,27 @@ class NotificationService(val context: Context) {
} }
private fun maybeSetProgress(builder: NotificationCompat.Builder, progress: Int) { private fun maybeSetProgress(builder: NotificationCompat.Builder, progress: Int) {
if (progress >= 0) { if (progress in 0..99) {
builder.setProgress(100, progress, false) builder.setProgress(100, progress, false)
} else { } else {
builder.setProgress(0, 0, false) // Remove progress bar builder.setProgress(0, 0, false) // Remove progress bar
} }
} }
private fun maybeAddOpenAction(notificationBuilder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddOpenAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri != null) { if (notification.attachment?.contentUri != null) {
val contentUri = Uri.parse(notification.attachment.contentUri) val contentUri = Uri.parse(notification.attachment.contentUri)
val openIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, contentUri), 0) val intent = Intent(Intent.ACTION_VIEW, contentUri)
notificationBuilder.addAction(NotificationCompat.Action.Builder(0, "Open", openIntent).build()) val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build())
} }
} }
private fun maybeAddCopyUrlAction(builder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.url != null) { if (notification.attachment?.contentUri != null) {
// XXXXXXXXx val intent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
val copyUrlIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachment.url)), 0) val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
builder.addAction(NotificationCompat.Action.Builder(0, "Copy URL", copyUrlIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build())
} }
} }
@ -202,8 +211,10 @@ class NotificationService(val context: Context) {
companion object { companion object {
const val PROGRESS_NONE = -1 const val PROGRESS_NONE = -1
const val PROGRESS_INDETERMINATE = -2 const val PROGRESS_INDETERMINATE = -2
const val PROGRESS_DONE = 100
private const val TAG = "NtfyNotifService" private const val TAG = "NtfyNotifService"
private const val CHANNEL_ID_MIN = "ntfy-min" private const val CHANNEL_ID_MIN = "ntfy-min"
private const val CHANNEL_ID_LOW = "ntfy-low" private const val CHANNEL_ID_LOW = "ntfy-low"
private const val CHANNEL_ID_DEFAULT = "ntfy" private const val CHANNEL_ID_DEFAULT = "ntfy"

View file

@ -7,6 +7,7 @@ import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import java.security.SecureRandom import java.security.SecureRandom
import java.text.DateFormat import java.text.DateFormat
import java.text.StringCharacterIterator
import java.util.* import java.util.*
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}" fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
@ -19,8 +20,8 @@ fun topicShortUrl(baseUrl: String, topic: String) =
.replace("https://", "") .replace("https://", "")
fun formatDateShort(timestampSecs: Long): String { fun formatDateShort(timestampSecs: Long): String {
val mutedUntilDate = Date(timestampSecs*1000) val date = Date(timestampSecs*1000)
return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(mutedUntilDate) return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)
} }
fun toPriority(priority: Int?): Int { fun toPriority(priority: Int?): Int {
@ -126,3 +127,20 @@ fun randomString(len: Int): String {
inline fun <T1: Any, T2: Any, R: Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? { inline fun <T1: Any, T2: Any, R: Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? {
return if (p1 != null && p2 != null) block(p1, p2) else null return if (p1 != null && p2 != null) block(p1, p2) else null
} }
fun formatBytes(bytes: Long): String {
val absB = if (bytes == Long.MIN_VALUE) Long.MAX_VALUE else Math.abs(bytes)
if (absB < 1024) {
return "$bytes B"
}
var value = absB
val ci = StringCharacterIterator("KMGTPE")
var i = 40
while (i >= 0 && absB > 0xfffccccccccccccL shr i) {
value = value shr 10
ci.next()
i -= 10
}
value *= java.lang.Long.signum(bytes).toLong()
return java.lang.String.format("%.1f %ciB", value / 1024.0, ci.current())
}

View file

@ -144,6 +144,10 @@
<string name="notification_dialog_tomorrow">Until tomorrow</string> <string name="notification_dialog_tomorrow">Until tomorrow</string>
<string name="notification_dialog_forever">Forever</string> <string name="notification_dialog_forever">Forever</string>
<!-- Notification popup -->
<string name="notification_popup_action_open">Open</string>
<string name="notification_popup_action_browse">Browse</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="settings_notifications_header">Notifications</string> <string name="settings_notifications_header">Notifications</string>

View file

@ -62,7 +62,6 @@ class FirebaseService : FirebaseMessagingService() {
val attachmentType = data["attachment_type"] val attachmentType = data["attachment_type"]
val attachmentSize = data["attachment_size"]?.toLongOrNull() val attachmentSize = data["attachment_size"]?.toLongOrNull()
val attachmentExpires = data["attachment_expires"]?.toLongOrNull() val attachmentExpires = data["attachment_expires"]?.toLongOrNull()
val attachmentPreviewUrl = data["attachment_preview_url"]
val attachmentUrl = data["attachment_url"] val attachmentUrl = data["attachment_url"]
val truncated = (data["truncated"] ?: "") == "1" val truncated = (data["truncated"] ?: "") == "1"
if (id == null || topic == null || message == null || timestamp == null) { if (id == null || topic == null || message == null || timestamp == null) {
@ -88,7 +87,6 @@ class FirebaseService : FirebaseMessagingService() {
type = attachmentType, type = attachmentType,
size = attachmentSize, size = attachmentSize,
expires = attachmentExpires, expires = attachmentExpires,
previewUrl = attachmentPreviewUrl,
url = attachmentUrl, url = attachmentUrl,
) )
} else null } else null