notification icons

This commit is contained in:
Hunter Kehoe 2022-07-16 14:32:09 -06:00
parent 60f90667d9
commit ecefdd3df6
16 changed files with 457 additions and 35 deletions

View file

@ -2,11 +2,11 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 11, "version": 11,
"identityHash": "31f8e6a2032d1d404fad4307abf23e1b", "identityHash": "5a061926458ed65c80431be0a69a2450",
"entities": [ "entities": [
{ {
"tableName": "Subscription", "tableName": "Subscription",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, PRIMARY KEY(`id`))",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -50,6 +50,12 @@
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": false
},
{ {
"fieldPath": "icon", "fieldPath": "icon",
"columnName": "icon", "columnName": "icon",
@ -67,6 +73,12 @@
"columnName": "upConnectorToken", "columnName": "upConnectorToken",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": false "notNull": false
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
} }
], ],
"primaryKey": { "primaryKey": {
@ -100,7 +112,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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `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`))", "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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_type` TEXT, `icon_size` INTEGER, `icon_contentUri` TEXT, `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",
@ -175,6 +187,30 @@
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
{
"fieldPath": "icon.url",
"columnName": "icon_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "icon.type",
"columnName": "icon_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "icon.size",
"columnName": "icon_size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "icon.contentUri",
"columnName": "icon_contentUri",
"affinity": "TEXT",
"notNull": false
},
{ {
"fieldPath": "attachment.name", "fieldPath": "attachment.name",
"columnName": "attachment_name", "columnName": "attachment_name",
@ -314,7 +350,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, '31f8e6a2032d1d404fad4307abf23e1b')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5a061926458ed65c80431be0a69a2450')"
] ]
} }
} }

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 12, "version": 12,
"identityHash": "9363ad5196e88862acceb1bb9ee91124", "identityHash": "5a061926458ed65c80431be0a69a2450",
"entities": [ "entities": [
{ {
"tableName": "Subscription", "tableName": "Subscription",
@ -112,7 +112,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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `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`))", "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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_type` TEXT, `icon_size` INTEGER, `icon_contentUri` TEXT, `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",
@ -187,6 +187,30 @@
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
{
"fieldPath": "icon.url",
"columnName": "icon_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "icon.type",
"columnName": "icon_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "icon.size",
"columnName": "icon_size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "icon.contentUri",
"columnName": "icon_contentUri",
"affinity": "TEXT",
"notNull": false
},
{ {
"fieldPath": "attachment.name", "fieldPath": "attachment.name",
"columnName": "attachment_name", "columnName": "attachment_name",
@ -326,7 +350,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, '9363ad5196e88862acceb1bb9ee91124')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5a061926458ed65c80431be0a69a2450')"
] ]
} }
} }

View file

@ -6,6 +6,7 @@ import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonReader
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.Icon
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrl
@ -148,6 +149,16 @@ class Backuper(val context: Context) {
} else { } else {
null null
} }
val icon = if (n.icon != null) {
io.heckel.ntfy.db.Icon(
url = n.icon.url,
type = n.icon.type,
size = n.icon.size,
contentUri = n.icon.contentUri,
)
} else {
null
}
repository.addNotification(io.heckel.ntfy.db.Notification( repository.addNotification(io.heckel.ntfy.db.Notification(
id = n.id, id = n.id,
subscriptionId = n.subscriptionId, subscriptionId = n.subscriptionId,
@ -159,6 +170,7 @@ class Backuper(val context: Context) {
priority = n.priority, priority = n.priority,
tags = n.tags, tags = n.tags,
click = n.click, click = n.click,
icon = icon,
actions = actions, actions = actions,
attachment = attachment, attachment = attachment,
deleted = n.deleted deleted = n.deleted
@ -266,6 +278,16 @@ class Backuper(val context: Context) {
} else { } else {
null null
} }
val icon = if (n.icon != null) {
Icon(
url = n.icon.url,
type = n.icon.type,
size = n.icon.size,
contentUri = n.icon.contentUri,
)
} else {
null
}
Notification( Notification(
id = n.id, id = n.id,
subscriptionId = n.subscriptionId, subscriptionId = n.subscriptionId,
@ -276,6 +298,7 @@ class Backuper(val context: Context) {
priority = n.priority, priority = n.priority,
tags = n.tags, tags = n.tags,
click = n.click, click = n.click,
icon = icon,
actions = actions, actions = actions,
attachment = attachment, attachment = attachment,
deleted = n.deleted deleted = n.deleted
@ -347,6 +370,7 @@ data class Notification(
val priority: Int, // 1=min, 3=default, 5=max val priority: Int, // 1=min, 3=default, 5=max
val tags: String, val tags: String,
val click: String, // URL/intent to open on notification click val click: String, // URL/intent to open on notification click
val icon: Icon?,
val actions: List<Action>?, val actions: List<Action>?,
val attachment: Attachment?, val attachment: Attachment?,
val deleted: Boolean val deleted: Boolean
@ -377,6 +401,14 @@ data class Attachment(
val progress: Int, // Progress during download, -1 if not downloaded val progress: Int, // Progress during download, -1 if not downloaded
) )
data class Icon(
val url: String, // URL (mandatory, see ntfy server)
val type: String?, // MIME type
val size: Long?, // Size in bytes
val contentUri: String?, // After it's downloaded, the content:// location
val progress: Int, // Progress during download, -1 if not downloaded
)
data class User( data class User(
val baseUrl: String, val baseUrl: String,
val username: String, val username: String,

View file

@ -66,6 +66,7 @@ data class Notification(
@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 = "click") val click: String, // URL/intent to open on notification click @ColumnInfo(name = "click") val click: String, // URL/intent to open on notification click
@Embedded(prefix = "icon_") val icon: Icon?,
@ColumnInfo(name = "actions") val actions: List<Action>?, @ColumnInfo(name = "actions") val actions: List<Action>?,
@Embedded(prefix = "attachment_") val attachment: Attachment?, @Embedded(prefix = "attachment_") val attachment: Attachment?,
@ColumnInfo(name = "deleted") val deleted: Boolean, @ColumnInfo(name = "deleted") val deleted: Boolean,
@ -91,6 +92,17 @@ const val ATTACHMENT_PROGRESS_FAILED = -3
const val ATTACHMENT_PROGRESS_DELETED = -4 const val ATTACHMENT_PROGRESS_DELETED = -4
const val ATTACHMENT_PROGRESS_DONE = 100 const val ATTACHMENT_PROGRESS_DONE = 100
@Entity
data class Icon(
@ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server)
@ColumnInfo(name = "type") val type: String?, // MIME type
@ColumnInfo(name = "size") val size: Long?, // Size in bytes
@ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location
) {
constructor(url:String, type: String?, size: Long?) :
this(url, type, size, null)
}
@Entity @Entity
data class Action( data class Action(
@ColumnInfo(name = "id") val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager @ColumnInfo(name = "id") val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager
@ -269,6 +281,10 @@ abstract class Database : RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId TEXT") db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId TEXT")
db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT") db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT")
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_url TEXT") // Room limitation: Has to be nullable for @Embedded
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_type TEXT")
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_size INT")
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_contentUri TEXT")
} }
} }
} }
@ -368,6 +384,9 @@ interface NotificationDao {
@Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''") @Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''")
fun listDeletedWithAttachments(): List<Notification> fun listDeletedWithAttachments(): List<Notification>
@Query("SELECT * FROM notification WHERE deleted = 1 AND icon_contentUri <> ''")
fun listDeletedWithIcons(): List<Notification>
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun add(notification: Notification) fun add(notification: Notification)

View file

@ -92,6 +92,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
return notificationDao.listDeletedWithAttachments() return notificationDao.listDeletedWithAttachments()
} }
fun getDeletedNotificationsWithIcons(): List<Notification> {
return notificationDao.listDeletedWithIcons()
}
fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> { fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> {
return notificationDao.listFlow(subscriptionId).asLiveData() return notificationDao.listFlow(subscriptionId).asLiveData()
} }

View file

@ -21,7 +21,7 @@ import okhttp3.Response
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class DownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { class DownloadAttachmentWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request .callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
@ -80,9 +80,13 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
this.uri = uri // Required for cleanup in onStopped() this.uri = uri // Required for cleanup in onStopped()
Log.d(TAG, "Starting download to content URI: $uri") Log.d(TAG, "Starting download to content URI: $uri")
val contentLength = response.headers["Content-Length"]?.toLongOrNull()
var bytesCopied: Long = 0 var bytesCopied: Long = 0
val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
val downloadLimit = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) {
repository.getAutoDownloadMaxSize()
} else {
null
}
outFile.use { fileOut -> outFile.use { fileOut ->
val fileIn = response.body!!.byteStream() val fileIn = response.body!!.byteStream()
val buffer = ByteArray(BUFFER_SIZE) val buffer = ByteArray(BUFFER_SIZE)
@ -102,8 +106,8 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
save(attachment.copy(progress = progress)) save(attachment.copy(progress = progress))
lastProgress = System.currentTimeMillis() lastProgress = System.currentTimeMillis()
} }
if (contentLength != null && bytesCopied > contentLength) { if (downloadLimit != null && bytesCopied > downloadLimit) {
throw Exception("Attachment is longer than response headers said.") throw Exception("Attachment is longer than max download size.")
} }
fileOut.write(buffer, 0, bytes) fileOut.write(buffer, 0, bytes)
bytesCopied += bytes bytesCopied += bytes
@ -182,7 +186,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
Repository.AUTO_DOWNLOAD_NEVER -> return true Repository.AUTO_DOWNLOAD_NEVER -> return true
Repository.AUTO_DOWNLOAD_ALWAYS -> return false Repository.AUTO_DOWNLOAD_ALWAYS -> return false
else -> { else -> {
val size = attachment.size ?: return true // Abort if size unknown val size = attachment.size ?: return false // Don't abort if size unknown
return size > maxAutoDownloadSize return size > maxAutoDownloadSize
} }
} }

View file

@ -0,0 +1,193 @@
package io.heckel.ntfy.msg
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.ensureSafeNewFile
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.util.concurrent.TimeUnit
class DownloadIconWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
private val notifier = NotificationService(context)
private lateinit var repository: Repository
private lateinit var subscription: Subscription
private lateinit var notification: Notification
private lateinit var icon: Icon
private var uri: Uri? = null
override fun doWork(): Result {
if (context.applicationContext !is Application) return Result.failure()
val notificationId = inputData.getString(INPUT_DATA_ID) ?: return Result.failure()
val app = context.applicationContext as Application
repository = app.repository
notification = repository.getNotification(notificationId) ?: return Result.failure()
subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
icon = notification.icon ?: return Result.failure()
try {
downloadIcon()
} catch (e: Exception) {
failed(e)
}
return Result.success()
}
override fun onStopped() {
Log.d(TAG, "Icon download was canceled")
maybeDeleteFile()
}
private fun downloadIcon() {
Log.d(TAG, "Downloading icon from ${icon.url}")
try {
val request = Request.Builder()
.url(icon.url)
.addHeader("User-Agent", ApiService.USER_AGENT)
.build()
client.newCall(request).execute().use { response ->
Log.d(TAG, "Download: headers received: $response")
if (!response.isSuccessful || response.body == null) {
throw Exception("Unexpected response: ${response.code}")
}
save(updateIconFromResponse(response))
if (shouldAbortDownload()) {
Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting")
return
}
val resolver = applicationContext.contentResolver
val uri = createUri(notification)
this.uri = uri // Required for cleanup in onStopped()
Log.d(TAG, "Starting download to content URI: $uri")
val contentLength = response.headers["Content-Length"]?.toLongOrNull()
var bytesCopied: Long = 0
val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
val downloadLimit = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) {
repository.getAutoDownloadMaxSize()
} else {
null
}
outFile.use { fileOut ->
val fileIn = response.body!!.byteStream()
val buffer = ByteArray(BUFFER_SIZE)
var bytes = fileIn.read(buffer)
while (bytes >= 0) {
if (downloadLimit != null && bytesCopied > downloadLimit) {
throw Exception("Icon is longer than max download size.")
}
fileOut.write(buffer, 0, bytes)
bytesCopied += bytes
bytes = fileIn.read(buffer)
}
}
Log.d(TAG, "Icon download: successful response, proceeding with download")
save(icon.copy(
size = bytesCopied,
contentUri = uri.toString()
))
}
} catch (e: Exception) {
failed(e)
// Toast in a Worker: https://stackoverflow.com/a/56428145/1440785
val handler = Handler(Looper.getMainLooper())
handler.postDelayed({
Toast
.makeText(context, context.getString(R.string.detail_item_icon_download_failed, e.message), Toast.LENGTH_LONG)
.show()
}, 200)
}
}
private fun updateIconFromResponse(response: Response): Icon {
val size = if (response.headers["Content-Length"]?.toLongOrNull() != null) {
Log.d(TAG, "We got the long! icon here")
response.headers["Content-Length"]?.toLong()
} else {
icon.size // May be null!
}
val mimeType = if (response.headers["Content-Type"] != null) {
response.headers["Content-Type"]
} else {
val ext = MimeTypeMap.getFileExtensionFromUrl(icon.url)
if (ext != null) {
val typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
typeFromExt ?: icon.type // May be null!
} else {
icon.type // May be null!
}
}
Log.d(TAG, "New icon size: $size, type: $mimeType")
return icon.copy(
size = size,
type = mimeType
)
}
private fun failed(e: Exception) {
Log.w(TAG, "Icon download failed", e)
maybeDeleteFile()
}
private fun maybeDeleteFile() {
val uriCopy = uri
if (uriCopy != null) {
Log.d(TAG, "Deleting leftover icon $uriCopy")
val resolver = applicationContext.contentResolver
resolver.delete(uriCopy, null, null)
}
}
private fun save(newIcon: Icon) {
Log.d(TAG, "Updating icon: $newIcon")
icon = newIcon
notification = notification.copy(icon = newIcon)
notifier.update(subscription, notification)
repository.updateNotification(notification)
}
private fun shouldAbortDownload(): Boolean {
val maxAutoDownloadSize = MAX_ICON_DOWNLOAD_SIZE
val size = icon.size ?: return false // Don't abort if size unknown
return size > maxAutoDownloadSize
}
private fun createUri(notification: Notification): Uri {
val iconDir = File(context.cacheDir, ICON_CACHE_DIR)
if (!iconDir.exists() && !iconDir.mkdirs()) {
throw Exception("Cannot create cache directory for icons: $iconDir")
}
val file = ensureSafeNewFile(iconDir, notification.id)
return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file)
}
companion object {
const val INPUT_DATA_ID = "id"
const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml
const val MAX_ICON_DOWNLOAD_SIZE = 300000
private const val TAG = "NtfyIconDownload"
private const val ICON_CACHE_DIR = "icons"
private const val BUFFER_SIZE = 8 * 1024
}
}

View file

@ -15,25 +15,74 @@ import io.heckel.ntfy.util.Log
*/ */
object DownloadManager { object DownloadManager {
private const val TAG = "NtfyDownloadManager" private const val TAG = "NtfyDownloadManager"
private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_" private const val DOWNLOAD_WORK_ATTACHMENT_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_"
private const val DOWNLOAD_WORK_ICON_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_ICON_"
private const val DOWNLOAD_WORK_BOTH_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_BOTH_"
fun enqueue(context: Context, notificationId: String, userAction: Boolean) { fun enqueue(context: Context, notificationId: String, userAction: Boolean, type: DownloadType) {
when (type) {
DownloadType.ATTACHMENT -> enqueueAttachment(context, notificationId, userAction)
DownloadType.ICON -> enqueueIcon(context, notificationId)
DownloadType.BOTH -> enqueueAttachmentAndIcon(context, notificationId, userAction)
else -> Log.w(DownloadManager.TAG, "This should never happen. No download type given")
}
}
private fun enqueueAttachment(context: Context, notificationId: String, userAction: Boolean) {
val workManager = WorkManager.getInstance(context) val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + notificationId val workName = DOWNLOAD_WORK_ATTACHMENT_NAME_PREFIX + notificationId
Log.d(TAG,"Enqueuing work to download attachment for notification $notificationId, work: $workName") Log.d(TAG,"Enqueuing work to download attachment for notification $notificationId, work: $workName")
val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java) val workRequest = OneTimeWorkRequest.Builder(DownloadAttachmentWorker::class.java)
.setInputData(workDataOf( .setInputData(workDataOf(
DownloadWorker.INPUT_DATA_ID to notificationId, DownloadAttachmentWorker.INPUT_DATA_ID to notificationId,
DownloadWorker.INPUT_DATA_USER_ACTION to userAction DownloadAttachmentWorker.INPUT_DATA_USER_ACTION to userAction
)) ))
.build() .build()
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest) workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
} }
private fun enqueueIcon(context: Context, notificationId: String) {
val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_ICON_NAME_PREFIX + notificationId
Log.d(TAG,"Enqueuing work to download icon for notification $notificationId, work: $workName")
val workRequest = OneTimeWorkRequest.Builder(DownloadIconWorker::class.java)
.setInputData(workDataOf(
DownloadAttachmentWorker.INPUT_DATA_ID to notificationId
))
.build()
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
}
private fun enqueueAttachmentAndIcon(context: Context, notificationId: String, userAction: Boolean) {
val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_BOTH_NAME_PREFIX + notificationId
val attachmentWorkRequest = OneTimeWorkRequest.Builder(DownloadAttachmentWorker::class.java)
.setInputData(workDataOf(
DownloadAttachmentWorker.INPUT_DATA_ID to notificationId,
DownloadAttachmentWorker.INPUT_DATA_USER_ACTION to userAction
))
.build()
val iconWorkRequest = OneTimeWorkRequest.Builder(DownloadIconWorker::class.java)
.setInputData(workDataOf(
DownloadAttachmentWorker.INPUT_DATA_ID to notificationId
))
.build()
Log.d(TAG,"Enqueuing work to download both attachment and icon for notification $notificationId, work: $workName")
workManager.beginUniqueWork(workName, ExistingWorkPolicy.KEEP, attachmentWorkRequest)
.then(iconWorkRequest)
.enqueue()
}
fun cancel(context: Context, id: String) { fun cancel(context: Context, id: String) {
val workManager = WorkManager.getInstance(context) val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + id val workName = DOWNLOAD_WORK_ATTACHMENT_NAME_PREFIX + id
Log.d(TAG, "Cancelling download for notification $id, work: $workName") Log.d(TAG, "Cancelling attachment download for notification $id, work: $workName")
workManager.cancelUniqueWork(workName) workManager.cancelUniqueWork(workName)
} }
} }
enum class DownloadType {
ATTACHMENT,
ICON,
BOTH
}

View file

@ -13,6 +13,7 @@ data class Message(
val priority: Int?, val priority: Int?,
val tags: List<String>?, val tags: List<String>?,
val click: String?, val click: String?,
val icon: MessageIcon?,
val actions: List<MessageAction>?, val actions: List<MessageAction>?,
val title: String?, val title: String?,
val message: String, val message: String,
@ -43,4 +44,11 @@ data class MessageAction(
val extras: Map<String,String>?, // used in "broadcast" action val extras: Map<String,String>?, // used in "broadcast" action
) )
@Keep
data class MessageIcon(
val url: String,
val type: String?,
val size: Long?,
)
const val MESSAGE_ENCODING_BASE64 = "base64" const val MESSAGE_ENCODING_BASE64 = "base64"

View file

@ -29,7 +29,8 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
val notify = shouldNotify(subscription, notification, muted) val notify = shouldNotify(subscription, notification, muted)
val broadcast = shouldBroadcast(subscription) val broadcast = shouldBroadcast(subscription)
val distribute = shouldDistribute(subscription) val distribute = shouldDistribute(subscription)
val download = shouldDownload(notification) val downloadAttachment = shouldDownloadAttachment(notification)
val downloadIcon = shouldDownloadIcon(notification)
if (notify) { if (notify) {
notifier.display(subscription, notification) notifier.display(subscription, notification)
} }
@ -41,12 +42,16 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
distributor.sendMessage(appId, connectorToken, decodeBytesMessage(notification)) distributor.sendMessage(appId, connectorToken, decodeBytesMessage(notification))
} }
} }
if (download) { if (downloadAttachment && downloadIcon) {
DownloadManager.enqueue(context, notification.id, userAction = false) DownloadManager.enqueue(context, notification.id, userAction = false, type = DownloadType.BOTH)
} else if (downloadAttachment) {
DownloadManager.enqueue(context, notification.id, userAction = false, type = DownloadType.ATTACHMENT)
} else if (downloadIcon) {
DownloadManager.enqueue(context, notification.id, userAction = false, type = DownloadType.ICON)
} }
} }
private fun shouldDownload(notification: Notification): Boolean { private fun shouldDownloadAttachment(notification: Notification): Boolean {
if (notification.attachment == null) { if (notification.attachment == null) {
return false return false
} }
@ -67,6 +72,17 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
} }
} }
} }
private fun shouldDownloadIcon(notification: Notification): Boolean {
if (notification.icon == null) {
return false
}
val icon = notification.icon
val maxIconDownloadSize = DownloadIconWorker.MAX_ICON_DOWNLOAD_SIZE
if (icon.size == null) {
return true // DownloadWorker will bail out if attachment is too large!
}
return icon.size <= maxIconDownloadSize
}
private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {
if (subscription.upAppId != null) { if (subscription.upAppId != null) {

View file

@ -4,6 +4,7 @@ import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import io.heckel.ntfy.db.Action import io.heckel.ntfy.db.Action
import io.heckel.ntfy.db.Attachment import io.heckel.ntfy.db.Attachment
import io.heckel.ntfy.db.Icon
import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.util.joinTags import io.heckel.ntfy.util.joinTags
import io.heckel.ntfy.util.toPriority import io.heckel.ntfy.util.toPriority
@ -31,6 +32,13 @@ class NotificationParser {
url = message.attachment.url, url = message.attachment.url,
) )
} else null } else null
val icon = if (message.icon?.url != null) {
Icon(
url = message.icon.url,
type = message.icon.type,
size = message.icon.size,
)
} else null
val actions = if (message.actions != null) { val actions = if (message.actions != null) {
message.actions.map { a -> message.actions.map { a ->
Action( Action(
@ -59,6 +67,7 @@ class NotificationParser {
priority = toPriority(message.priority), priority = toPriority(message.priority),
tags = joinTags(message.tags), tags = joinTags(message.tags),
click = message.click ?: "", click = message.click ?: "",
icon = icon,
actions = actions, actions = actions,
attachment = attachment, attachment = attachment,
notificationId = notificationId, notificationId = notificationId,

View file

@ -96,6 +96,8 @@ class NotificationService(val context: Context) {
val contentUri = notification.attachment?.contentUri val contentUri = notification.attachment?.contentUri
val isSupportedImage = supportedImage(notification.attachment?.type) val isSupportedImage = supportedImage(notification.attachment?.type)
val subscriptionIcon = if (subscription.icon != null) subscription.icon.readBitmapFromUriOrNull(context) else null val subscriptionIcon = if (subscription.icon != null) subscription.icon.readBitmapFromUriOrNull(context) else null
val notificationIcon = if (notification.icon != null && supportedImage(notification.icon.type)) notification.icon.contentUri?.readBitmapFromUriOrNull(context) else null
val largeIcon = notificationIcon ?: subscriptionIcon
if (contentUri != null && isSupportedImage) { if (contentUri != null && isSupportedImage) {
try { try {
val attachmentBitmap = contentUri.readBitmapFromUri(context) val attachmentBitmap = contentUri.readBitmapFromUri(context)
@ -104,7 +106,7 @@ class NotificationService(val context: Context) {
.setLargeIcon(attachmentBitmap) .setLargeIcon(attachmentBitmap)
.setStyle(NotificationCompat.BigPictureStyle() .setStyle(NotificationCompat.BigPictureStyle()
.bigPicture(attachmentBitmap) .bigPicture(attachmentBitmap)
.bigLargeIcon(subscriptionIcon)) // May be null .bigLargeIcon(largeIcon)) // May be null
} catch (_: Exception) { } catch (_: Exception) {
val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification) val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification)
builder builder
@ -116,7 +118,7 @@ class NotificationService(val context: Context) {
builder builder
.setContentText(message) .setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message)) .setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setLargeIcon(subscriptionIcon) // May be null .setLargeIcon(largeIcon) // May be null
} }
} }
@ -275,7 +277,7 @@ class NotificationService(val context: Context) {
/** /**
* Receives the broadcast from * Receives the broadcast from
* - the "http" and "broadcast" action button (the "view" actio is handled differently) * - the "http" and "broadcast" action button (the "view" action is handled differently)
* - the "download"/"cancel" action button * - the "download"/"cancel" action button
* *
* Then queues a Worker via WorkManager to execute the action in the background * Then queues a Worker via WorkManager to execute the action in the background
@ -285,7 +287,7 @@ class NotificationService(val context: Context) {
val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return
val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return
when (type) { when (type) {
BROADCAST_TYPE_DOWNLOAD_START -> DownloadManager.enqueue(context, notificationId, userAction = true) BROADCAST_TYPE_DOWNLOAD_START -> DownloadManager.enqueue(context, notificationId, userAction = true, DownloadType.ATTACHMENT)
BROADCAST_TYPE_DOWNLOAD_CANCEL -> DownloadManager.cancel(context, notificationId) BROADCAST_TYPE_DOWNLOAD_CANCEL -> DownloadManager.cancel(context, notificationId)
BROADCAST_TYPE_USER_ACTION -> { BROADCAST_TYPE_USER_ACTION -> {
val actionId = intent.getStringExtra(BROADCAST_EXTRA_ACTION_ID) ?: return val actionId = intent.getStringExtra(BROADCAST_EXTRA_ACTION_ID) ?: return

View file

@ -16,7 +16,6 @@ import android.widget.*
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import androidx.constraintlayout.helper.widget.Flow import androidx.constraintlayout.helper.widget.Flow
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintProperties.WRAP_CONTENT
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@ -29,7 +28,8 @@ import com.stfalcon.imageviewer.StfalconImageViewer
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.db.* import io.heckel.ntfy.db.*
import io.heckel.ntfy.msg.DownloadManager import io.heckel.ntfy.msg.DownloadManager
import io.heckel.ntfy.msg.DownloadWorker import io.heckel.ntfy.msg.DownloadAttachmentWorker
import io.heckel.ntfy.msg.DownloadType
import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
@ -389,7 +389,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
val inFile = resolver.openInputStream(inUri) ?: throw Exception("Cannot open input stream") val inFile = resolver.openInputStream(inUri) ?: throw Exception("Cannot open input stream")
val outUri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { val outUri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), attachment.name) val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), attachment.name)
FileProvider.getUriForFile(context, DownloadWorker.FILE_PROVIDER_AUTHORITY, file) FileProvider.getUriForFile(context, DownloadAttachmentWorker.FILE_PROVIDER_AUTHORITY, file)
} else { } else {
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
resolver.insert(contentUri, values) ?: throw Exception("Cannot insert content") resolver.insert(contentUri, values) ?: throw Exception("Cannot insert content")
@ -443,7 +443,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD) ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD)
return true return true
} }
DownloadManager.enqueue(context, notification.id, userAction = true) DownloadManager.enqueue(context, notification.id, userAction = true, DownloadType.ATTACHMENT)
return true return true
} }

View file

@ -4,7 +4,6 @@ import android.content.ContentResolver
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
@ -21,7 +20,7 @@ import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.msg.DownloadWorker import io.heckel.ntfy.msg.DownloadAttachmentWorker
import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -396,7 +395,7 @@ class DetailSettingsActivity : AppCompatActivity() {
return null return null
} }
val file = File(dir, subscription.id.toString()) val file = File(dir, subscription.id.toString())
return FileProvider.getUriForFile(requireContext(), DownloadWorker.FILE_PROVIDER_AUTHORITY, file) return FileProvider.getUriForFile(requireContext(), DownloadAttachmentWorker.FILE_PROVIDER_AUTHORITY, file)
} }
private fun deleteIcon(uri: String?) { private fun deleteIcon(uri: String?) {

View file

@ -27,6 +27,7 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
deleteExpiredIcons() // Before notifications, so we will also catch manually deleted notifications
deleteExpiredAttachments() // Before notifications, so we will also catch manually deleted notifications deleteExpiredAttachments() // Before notifications, so we will also catch manually deleted notifications
deleteExpiredNotifications() deleteExpiredNotifications()
return@withContext Result.success() return@withContext Result.success()
@ -59,6 +60,31 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
} }
} }
private fun deleteExpiredIcons() {
Log.d(TAG, "Deleting icons for deleted notifications")
val resolver = applicationContext.contentResolver
val repository = Repository.getInstance(applicationContext)
val notifications = repository.getDeletedNotificationsWithIcons()
notifications.forEach { notification ->
try {
val icon = notification.icon ?: return
val contentUri = Uri.parse(icon.contentUri ?: return)
Log.d(TAG, "Deleting icon for notification ${notification.id}: ${icon.contentUri} (${icon.url})")
val deleted = resolver.delete(contentUri, null, null) > 0
if (!deleted) {
Log.w(TAG, "Unable to delete icon for notification ${notification.id}")
}
val newIcon = icon.copy(
contentUri = null,
)
val newNotification = notification.copy(icon = newIcon)
repository.updateNotification(newNotification)
} catch (e: Exception) {
Log.w(TAG, "Failed to delete icon for notification: ${e.message}", e)
}
}
}
private suspend fun deleteExpiredNotifications() { private suspend fun deleteExpiredNotifications() {
Log.d(TAG, "Deleting expired notifications") Log.d(TAG, "Deleting expired notifications")
val repository = Repository.getInstance(applicationContext) val repository = Repository.getInstance(applicationContext)

View file

@ -165,6 +165,7 @@
<string name="detail_item_download_info_download_failed">download failed</string> <string name="detail_item_download_info_download_failed">download failed</string>
<string name="detail_item_download_info_download_failed_expired">download failed, link expired</string> <string name="detail_item_download_info_download_failed_expired">download failed, link expired</string>
<string name="detail_item_download_info_download_failed_expires_x">download failed, link expires %1$s</string> <string name="detail_item_download_info_download_failed_expires_x">download failed, link expires %1$s</string>
<string name="detail_item_icon_download_failed">Could not download icon: %1$s</string>
<!-- Detail activity: Action bar --> <!-- Detail activity: Action bar -->
<string name="detail_menu_notifications_enabled">Notifications on</string> <string name="detail_menu_notifications_enabled">Notifications on</string>