Merge branch '126-notification-icons' into main

This commit is contained in:
Philipp Heckel 2022-09-11 15:32:02 -04:00
commit 813af97581
19 changed files with 462 additions and 67 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 12, "version": 12,
"identityHash": "9363ad5196e88862acceb1bb9ee91124", "identityHash": "d230005f4d9824ba9aa34c61003bdcbb",
"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_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,18 @@
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
{
"fieldPath": "icon.url",
"columnName": "icon_url",
"affinity": "TEXT",
"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 +338,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, 'd230005f4d9824ba9aa34c61003bdcbb')"
] ]
} }
} }

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,14 @@ class Backuper(val context: Context) {
} else { } else {
null null
} }
val icon = if (n.icon != null) {
io.heckel.ntfy.db.Icon(
url = n.icon.url,
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 +168,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 +276,14 @@ class Backuper(val context: Context) {
} else { } else {
null null
} }
val icon = if (n.icon != null) {
Icon(
url = n.icon.url,
contentUri = n.icon.contentUri,
)
} else {
null
}
Notification( Notification(
id = n.id, id = n.id,
subscriptionId = n.subscriptionId, subscriptionId = n.subscriptionId,
@ -276,6 +294,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 +366,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 +397,11 @@ 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 contentUri: String?, // After it's downloaded, the content:// location
)
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,15 @@ 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 = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location
) {
constructor(url:String) :
this(url, 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 +279,8 @@ 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_contentUri TEXT")
} }
} }
} }
@ -368,6 +380,12 @@ 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 DISTINCT icon_contentUri FROM notification WHERE deleted != 1 AND icon_contentUri <> ''")
fun listActiveIconUris(): List<String>
@Query("UPDATE notification SET icon_contentUri = null WHERE icon_contentUri = :uri")
fun clearIconUri(uri: String)
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun add(notification: Notification) fun add(notification: Notification)

View file

@ -92,6 +92,14 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
return notificationDao.listDeletedWithAttachments() return notificationDao.listDeletedWithAttachments()
} }
fun getActiveIconUris(): Set<String> {
return notificationDao.listActiveIconUris().toSet()
}
fun clearIconUri(uri: String) {
notificationDao.clearIconUri(uri)
}
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,9 @@ 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 = getDownloadLimit(userAction)
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 +102,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,12 +182,20 @@ 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
} }
} }
} }
private fun getDownloadLimit(userAction: Boolean): Long? {
return if (userAction || repository.getAutoDownloadMaxSize() == Repository.AUTO_DOWNLOAD_ALWAYS) {
null
} else {
repository.getAutoDownloadMaxSize()
}
}
private fun createUri(notification: Notification): Uri { private fun createUri(notification: Notification): Uri {
val attachmentDir = File(context.cacheDir, ATTACHMENT_CACHE_DIR) val attachmentDir = File(context.cacheDir, ATTACHMENT_CACHE_DIR)
if (!attachmentDir.exists() && !attachmentDir.mkdirs()) { if (!attachmentDir.exists() && !attachmentDir.mkdirs()) {

View file

@ -0,0 +1,168 @@
package io.heckel.ntfy.msg
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.sha256
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.util.Date
import java.util.concurrent.TimeUnit
class DownloadIconWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder()
.callTimeout(1, 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 {
val iconFile = createIconFile(icon)
val yesterdayTimestamp = Date().time - MAX_CACHE_MILLIS
if (!iconFile.exists() || iconFile.lastModified() < yesterdayTimestamp) {
downloadIcon(iconFile)
} else {
Log.d(TAG, "Loading icon from cache: $iconFile")
val iconUri = createIconUri(iconFile)
this.uri = iconUri // Required for cleanup in onStopped()
save(icon.copy(contentUri = iconUri.toString()))
}
} catch (e: Exception) {
failed(e)
}
return Result.success()
}
override fun onStopped() {
Log.d(TAG, "Icon download was canceled")
maybeDeleteFile()
}
private fun downloadIcon(iconFile: File) {
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, "Headers received: $response, Content-Length: ${response.headers["Content-Length"]}")
if (!response.isSuccessful || response.body == null) {
throw Exception("Unexpected response: ${response.code}")
} else if (shouldAbortDownload(response)) {
Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting")
return
}
val resolver = applicationContext.contentResolver
val uri = createIconUri(iconFile)
this.uri = uri // Required for cleanup in onStopped()
Log.d(TAG, "Starting download to content URI: $uri")
var bytesCopied: Long = 0
val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
val downloadLimit = getDownloadLimit()
outFile.use { fileOut ->
val fileIn = response.body!!.byteStream()
val buffer = ByteArray(BUFFER_SIZE)
var bytes = fileIn.read(buffer)
while (bytes >= 0) {
if (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(contentUri = uri.toString()))
}
} catch (e: Exception) {
failed(e)
}
}
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(response: Response): Boolean {
val maxAutoDownloadSize = getDownloadLimit()
val size = response.headers["Content-Length"]?.toLongOrNull() ?: return false // Don't abort here if size unknown
return size > maxAutoDownloadSize
}
private fun getDownloadLimit(): Long {
return if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) {
Math.min(repository.getAutoDownloadMaxSize(), MAX_ICON_DOWNLOAD_BYTES)
} else {
DEFAULT_MAX_ICON_DOWNLOAD_BYTES
}
}
private fun createIconFile(icon: Icon): File {
val iconDir = File(context.cacheDir, ICON_CACHE_DIR)
if (!iconDir.exists() && !iconDir.mkdirs()) {
throw Exception("Cannot create cache directory for icons: $iconDir")
}
val hash = icon.url.sha256()
return File(iconDir, hash)
}
private fun createIconUri(iconFile: File): Uri {
return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, iconFile)
}
companion object {
const val INPUT_DATA_ID = "id"
const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml
const val DEFAULT_MAX_ICON_DOWNLOAD_BYTES = 307_200L // 300 KB
const val MAX_ICON_DOWNLOAD_BYTES = 5_242_880L // 5 MB
const val MAX_CACHE_MILLIS = 1000*60*60*24 // 24 hours
const val ICON_CACHE_DIR = "icons"
private const val TAG = "NtfyIconDownload"
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: String?,
val actions: List<MessageAction>?, val actions: List<MessageAction>?,
val title: String?, val title: String?,
val message: String, val message: String,

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
} }
@ -55,8 +60,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
Log.d(TAG, "Attachment already expired at ${attachment.expires}, not downloading") Log.d(TAG, "Attachment already expired at ${attachment.expires}, not downloading")
return false return false
} }
val maxAutoDownloadSize = repository.getAutoDownloadMaxSize() when (val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()) {
when (maxAutoDownloadSize) {
Repository.AUTO_DOWNLOAD_ALWAYS -> return true Repository.AUTO_DOWNLOAD_ALWAYS -> return true
Repository.AUTO_DOWNLOAD_NEVER -> return false Repository.AUTO_DOWNLOAD_NEVER -> return false
else -> { else -> {
@ -67,6 +71,9 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
} }
} }
} }
private fun shouldDownloadIcon(notification: Notification): Boolean {
return notification.icon != null
}
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
@ -49,6 +50,7 @@ class NotificationParser {
) )
} }
} else null } else null
val icon: Icon? = if (message.icon != null) Icon(url = message.icon) else null
val notification = Notification( val notification = Notification(
id = message.id, id = message.id,
subscriptionId = subscriptionId, subscriptionId = subscriptionId,
@ -59,6 +61,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) 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.*
@ -80,6 +80,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
private val dateView: TextView = itemView.findViewById(R.id.detail_item_date_text) private val dateView: TextView = itemView.findViewById(R.id.detail_item_date_text)
private val titleView: TextView = itemView.findViewById(R.id.detail_item_title_text) private val titleView: TextView = itemView.findViewById(R.id.detail_item_title_text)
private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_text) private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_text)
private val iconView: ImageView = itemView.findViewById(R.id.detail_item_icon)
private val newDotImageView: View = itemView.findViewById(R.id.detail_item_new_dot) private val newDotImageView: View = itemView.findViewById(R.id.detail_item_new_dot)
private val tagsView: TextView = itemView.findViewById(R.id.detail_item_tags_text) private val tagsView: TextView = itemView.findViewById(R.id.detail_item_tags_text)
private val menuButton: ImageButton = itemView.findViewById(R.id.detail_item_menu_button) private val menuButton: ImageButton = itemView.findViewById(R.id.detail_item_menu_button)
@ -130,11 +131,13 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context)) cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context))
} }
val attachment = notification.attachment val attachment = notification.attachment
val exists = if (attachment?.contentUri != null) fileExists(context, attachment.contentUri) else false val attachmentFileStat = maybeFileStat(context, attachment?.contentUri)
val iconFileStat = maybeFileStat(context, notification.icon?.contentUri)
renderPriority(context, notification) renderPriority(context, notification)
resetCardButtons() resetCardButtons()
maybeRenderMenu(context, notification, exists) maybeRenderMenu(context, notification, attachmentFileStat)
maybeRenderAttachment(context, notification, exists) maybeRenderAttachment(context, notification, attachmentFileStat)
maybeRenderIcon(context, notification, iconFileStat)
maybeRenderActions(context, notification) maybeRenderActions(context, notification)
} }
@ -162,20 +165,35 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
} }
} }
private fun maybeRenderAttachment(context: Context, notification: Notification, exists: Boolean) { private fun maybeRenderAttachment(context: Context, notification: Notification, attachmentFileStat: FileInfo?) {
if (notification.attachment == null) { if (notification.attachment == null) {
attachmentImageView.visibility = View.GONE attachmentImageView.visibility = View.GONE
attachmentBoxView.visibility = View.GONE attachmentBoxView.visibility = View.GONE
return return
} }
val attachment = notification.attachment val attachment = notification.attachment
val image = attachment.contentUri != null && exists && supportedImage(attachment.type) val image = attachment.contentUri != null && supportedImage(attachment.type) && previewableImage(attachmentFileStat)
maybeRenderAttachmentImage(context, attachment, image) maybeRenderAttachmentImage(context, attachment, image)
maybeRenderAttachmentBox(context, notification, attachment, exists, image) maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, image)
} }
private fun maybeRenderMenu(context: Context, notification: Notification, exists: Boolean) { private fun maybeRenderIcon(context: Context, notification: Notification, iconStat: FileInfo?) {
val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, exists) // Heavy lifting not during on-click if (notification.icon == null || !previewableImage(iconStat)) {
iconView.visibility = View.GONE
return
}
try {
val icon = notification.icon
val bitmap = icon.contentUri?.readBitmapFromUri(context) ?: throw Exception("uri empty")
iconView.setImageBitmap(bitmap)
iconView.visibility = View.VISIBLE
} catch (_: Exception) {
iconView.visibility = View.GONE
}
}
private fun maybeRenderMenu(context: Context, notification: Notification, attachmentFileStat: FileInfo?) {
val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, attachmentFileStat) // Heavy lifting not during on-click
if (menuButtonPopupMenu != null) { if (menuButtonPopupMenu != null) {
menuButton.setOnClickListener { menuButtonPopupMenu.show() } menuButton.setOnClickListener { menuButtonPopupMenu.show() }
menuButton.visibility = View.VISIBLE menuButton.visibility = View.VISIBLE
@ -220,14 +238,14 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
return button return button
} }
private fun maybeRenderAttachmentBox(context: Context, notification: Notification, attachment: Attachment, exists: Boolean, image: Boolean) { private fun maybeRenderAttachmentBox(context: Context, notification: Notification, attachment: Attachment, attachmentFileStat: FileInfo?, image: Boolean) {
if (image) { if (image) {
attachmentBoxView.visibility = View.GONE attachmentBoxView.visibility = View.GONE
return return
} }
attachmentInfoView.text = formatAttachmentDetails(context, attachment, exists) attachmentInfoView.text = formatAttachmentDetails(context, attachment, attachmentFileStat)
attachmentIconView.setImageResource(mimeTypeToIconResource(attachment.type)) attachmentIconView.setImageResource(mimeTypeToIconResource(attachment.type))
val attachmentBoxPopupMenu = maybeCreateMenuPopup(context, attachmentBoxView, notification, exists) // Heavy lifting not during on-click val attachmentBoxPopupMenu = maybeCreateMenuPopup(context, attachmentBoxView, notification, attachmentFileStat) // Heavy lifting not during on-click
if (attachmentBoxPopupMenu != null) { if (attachmentBoxPopupMenu != null) {
attachmentBoxView.setOnClickListener { attachmentBoxPopupMenu.show() } attachmentBoxView.setOnClickListener { attachmentBoxPopupMenu.show() }
} else { } else {
@ -240,11 +258,12 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
attachmentBoxView.visibility = View.VISIBLE attachmentBoxView.visibility = View.VISIBLE
} }
private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification, exists: Boolean): PopupMenu? { private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification, attachmentFileStat: FileInfo?): PopupMenu? {
val popup = PopupMenu(context, anchor) val popup = PopupMenu(context, anchor)
popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu)
val attachment = notification.attachment // May be null val attachment = notification.attachment // May be null
val hasAttachment = attachment != null val hasAttachment = attachment != null
val attachmentExists = attachmentFileStat != null
val hasClickLink = notification.click != "" val hasClickLink = notification.click != ""
val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download)
val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel) val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel)
@ -266,10 +285,10 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
if (hasClickLink) { if (hasClickLink) {
copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) } copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) }
} }
openItem.isVisible = hasAttachment && exists openItem.isVisible = hasAttachment && attachmentExists
downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress downloadItem.isVisible = hasAttachment && !attachmentExists && !expired && !inProgress
deleteItem.isVisible = hasAttachment && exists deleteItem.isVisible = hasAttachment && attachmentExists
saveFileItem.isVisible = hasAttachment && exists saveFileItem.isVisible = hasAttachment && attachmentExists
copyUrlItem.isVisible = hasAttachment && !expired copyUrlItem.isVisible = hasAttachment && !expired
cancelItem.isVisible = hasAttachment && inProgress cancelItem.isVisible = hasAttachment && inProgress
copyContentsItem.isVisible = notification.click != "" copyContentsItem.isVisible = notification.click != ""
@ -282,8 +301,9 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
return popup return popup
} }
private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String { private fun formatAttachmentDetails(context: Context, attachment: Attachment, attachmentFileStat: FileInfo?): String {
val name = attachment.name val name = attachment.name
val exists = attachmentFileStat != null
val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE
val downloading = !exists && attachment.progress in 0..99 val downloading = !exists && attachment.progress in 0..99
val deleted = !exists && (attachment.progress == ATTACHMENT_PROGRESS_DONE || attachment.progress == ATTACHMENT_PROGRESS_DELETED) val deleted = !exists && (attachment.progress == ATTACHMENT_PROGRESS_DONE || attachment.progress == ATTACHMENT_PROGRESS_DELETED)
@ -389,7 +409,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 +463,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
} }
@ -499,6 +519,10 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
} }
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }
private fun previewableImage(fileStat: FileInfo?): Boolean {
return if (fileStat != null) fileStat.size <= IMAGE_PREVIEW_MAX_BYTES else false
}
} }
object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() { object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() {
@ -514,5 +538,6 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
companion object { companion object {
const val TAG = "NtfyDetailAdapter" const val TAG = "NtfyDetailAdapter"
const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876 const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876
const val IMAGE_PREVIEW_MAX_BYTES = 5 * 1024 * 1024 // Too large images crash the app with "Canvas: trying to draw too large(233280000bytes) bitmap."
} }
} }

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

@ -34,6 +34,8 @@ import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.firebase.FirebaseMessenger import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.DownloadManager
import io.heckel.ntfy.msg.DownloadType
import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.service.SubscriberService import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.service.SubscriberServiceManager
@ -456,7 +458,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
try { try {
val user = repository.getUser(subscription.baseUrl) // May be null val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user) val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
notifications.forEach { notification -> repository.addNotification(notification) } notifications.forEach { notification ->
repository.addNotification(notification)
if (notification.icon != null) {
DownloadManager.enqueue(this@MainActivity, notification.id, userAction = false, DownloadType.ICON)
}
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e) Log.e(TAG, "Unable to fetch notifications: ${e.message}", e)
} }

View file

@ -37,7 +37,10 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody import okhttp3.RequestBody
import okio.BufferedSink import okio.BufferedSink
import okio.source import okio.source
import java.io.* import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import java.text.DateFormat import java.text.DateFormat
import java.text.StringCharacterIterator import java.text.StringCharacterIterator
@ -259,6 +262,14 @@ fun fileStat(context: Context, contentUri: Uri?): FileInfo {
} }
} }
fun maybeFileStat(context: Context, contentUri: String?): FileInfo? {
return try {
fileStat(context, Uri.parse(contentUri)) // Throws if the file does not exist
} catch (_: Exception) {
null
}
}
data class FileInfo( data class FileInfo(
val filename: String, val filename: String,
val size: Long, val size: Long,
@ -469,3 +480,9 @@ fun copyToClipboard(context: Context, notification: Notification) {
.makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG) .makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG)
.show() .show()
} }
fun String.sha256(): String {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(this.toByteArray())
return digest.fold("") { str, it -> str + "%02x".format(it) }
}

View file

@ -2,16 +2,21 @@ package io.heckel.ntfy.work
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.content.FileProvider
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DELETED import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DELETED
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.msg.DownloadIconWorker
import io.heckel.ntfy.ui.DetailAdapter import io.heckel.ntfy.ui.DetailAdapter
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.fileStat
import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.util.topicShortUrl
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.util.*
/** /**
* Deletes notifications marked for deletion and attachments for deleted notifications. * Deletes notifications marked for deletion and attachments for deleted notifications.
@ -27,6 +32,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 +65,30 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
} }
} }
private fun deleteExpiredIcons() {
Log.d(TAG, "Deleting icons for deleted notifications")
val repository = Repository.getInstance(applicationContext)
val activeIconUris = repository.getActiveIconUris()
val activeIconFilenames = activeIconUris.map{ fileStat(applicationContext, Uri.parse(it)).filename }.toSet()
val iconDir = File(applicationContext.cacheDir, DownloadIconWorker.ICON_CACHE_DIR)
val allIconFilenames = iconDir.listFiles()?.map{ file -> file.name }.orEmpty()
val filenamesToDelete = allIconFilenames.minus(activeIconFilenames)
filenamesToDelete.forEach { filename ->
try {
val file = File(iconDir, filename)
val deleted = file.delete()
if (!deleted) {
Log.w(TAG, "Unable to delete icon: $filename")
}
val uri = FileProvider.getUriForFile(applicationContext,
DownloadIconWorker.FILE_PROVIDER_AUTHORITY, file).toString()
repository.clearIconUri(uri)
} catch (e: Exception) {
Log.w(TAG, "Failed to delete icon: ${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)
@ -84,7 +114,6 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
val deleteOlderThanTimestamp = (System.currentTimeMillis()/1000) - HARD_DELETE_AFTER_SECONDS val deleteOlderThanTimestamp = (System.currentTimeMillis()/1000) - HARD_DELETE_AFTER_SECONDS
Log.d(TAG, "[$logId] Hard deleting notifications older than $markDeletedOlderThanTimestamp") Log.d(TAG, "[$logId] Hard deleting notifications older than $markDeletedOlderThanTimestamp")
repository.removeNotificationsIfOlderThan(subscription.id, deleteOlderThanTimestamp) repository.removeNotificationsIfOlderThan(subscription.id, deleteOlderThanTimestamp)
} }
} }

View file

@ -25,7 +25,7 @@
android:orientation="horizontal" android:orientation="horizontal"
android:background="?android:attr/selectableItemBackground" android:background="?android:attr/selectableItemBackground"
android:focusable="true" android:focusable="true"
android:paddingBottom="6dp" android:paddingTop="6dp"> android:paddingBottom="6dp" android:paddingTop="6dp" android:paddingEnd="6dp">
<TextView <TextView
android:text="Sun, October 31, 2021, 10:43:12" android:text="Sun, October 31, 2021, 10:43:12"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -47,13 +47,12 @@
app:layout_constraintStart_toEndOf="@id/detail_item_priority_image" app:layout_constraintStart_toEndOf="@id/detail_item_priority_image"
android:layout_marginStart="5dp"/> android:layout_marginStart="5dp"/>
<ImageButton <ImageButton
android:layout_width="46dp" android:layout_width="28dp"
android:layout_height="26dp" app:srcCompat="@drawable/ic_more_horiz_gray_24dp" android:layout_height="26dp" app:srcCompat="@drawable/ic_more_horiz_gray_24dp"
android:id="@+id/detail_item_menu_button" android:id="@+id/detail_item_menu_button"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="7dp"
android:background="?android:attr/selectableItemBackground" android:paddingTop="-5dp" android:background="?android:attr/selectableItemBackground" android:paddingTop="-5dp"
/> app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="3dp"/>
<TextView <TextView
android:text="This is a very very very long message. It could be as long as 1024 charaters, which is a lot more than you'd think. No, really so far this message is barely 180 characters long. I can't believe how long 1024 bytes are. This is outrageous. Oh you know what, I think I won't type the whole thing. This seems a little too long for a sample text. Well, anyway, it was nice chatting. So far this message is about 400 bytes long. So maybe just double what you see and that's that." android:text="This is a very very very long message. It could be as long as 1024 charaters, which is a lot more than you'd think. No, really so far this message is barely 180 characters long. I can't believe how long 1024 bytes are. This is outrageous. Oh you know what, I think I won't type the whole thing. This seems a little too long for a sample text. Well, anyway, it was nice chatting. So far this message is about 400 bytes long. So maybe just double what you see and that's that."
android:layout_width="0dp" android:layout_width="0dp"
@ -64,8 +63,7 @@
android:autoLink="web" android:autoLink="web"
app:layout_constraintTop_toBottomOf="@id/detail_item_title_text" app:layout_constraintTop_toBottomOf="@id/detail_item_title_text"
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="12dp" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="12dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="12dp" app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_image" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="6dp"/>
app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_image"/>
<TextView <TextView
android:text="This is an optional title. It can also be a little longer but not too long." android:text="This is an optional title. It can also be a little longer but not too long."
android:layout_width="0dp" android:layout_width="0dp"
@ -74,10 +72,9 @@
android:textColor="?android:attr/textColorPrimary" android:textColor="?android:attr/textColorPrimary"
android:textAppearance="@style/TextAppearance.AppCompat.Medium" android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:autoLink="web" android:autoLink="web"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="12dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="12dp" android:textStyle="bold" android:layout_marginStart="12dp" android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/detail_item_date_text"/> app:layout_constraintTop_toBottomOf="@+id/detail_item_date_text" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="6dp" tools:layout_constraintEnd_toStartOf="@id/detail_item_icon"/>
<ImageView <ImageView
android:layout_width="16dp" android:layout_width="16dp"
android:layout_height="16dp" app:srcCompat="@drawable/ic_priority_5_24dp" android:layout_height="16dp" app:srcCompat="@drawable/ic_priority_5_24dp"
@ -90,9 +87,9 @@
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp"
android:id="@+id/detail_item_attachment_image" app:layout_constraintStart_toStartOf="parent" android:id="@+id/detail_item_attachment_image" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/detail_item_message_text" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/detail_item_message_text"
android:layout_marginStart="12dp" android:layout_marginEnd="12dp" android:layout_marginStart="12dp" android:layout_marginEnd="6dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:adjustViewBounds="true" android:maxHeight="150dp" android:layout_marginTop="5dp" android:adjustViewBounds="true" android:maxHeight="150dp" android:layout_marginTop="7dp"
app:shapeAppearanceOverlay="@style/roundedCornersImageView" android:visibility="visible" app:shapeAppearanceOverlay="@style/roundedCornersImageView" android:visibility="visible"
android:layout_marginBottom="3dp" app:layout_constraintBottom_toTopOf="@id/detail_item_tags_text"/> android:layout_marginBottom="3dp" app:layout_constraintBottom_toTopOf="@id/detail_item_tags_text"/>
<TextView <TextView
@ -102,7 +99,7 @@
android:id="@+id/detail_item_tags_text" android:id="@+id/detail_item_tags_text"
android:textAppearance="@style/TextAppearance.AppCompat.Small" android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="12dp" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="12dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="12dp" app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="6dp"
app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_image" app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_image"
app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_file_box" app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_file_box"
app:layout_constraintHorizontal_bias="0.0" android:layout_marginTop="2dp" app:layout_constraintHorizontal_bias="0.0" android:layout_marginTop="2dp"
@ -111,7 +108,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" app:layout_constraintTop_toBottomOf="@id/detail_item_tags_text" android:layout_height="wrap_content" app:layout_constraintTop_toBottomOf="@id/detail_item_tags_text"
android:id="@+id/detail_item_attachment_file_box" app:layout_constraintStart_toStartOf="parent" android:id="@+id/detail_item_attachment_file_box" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginStart="12dp" android:layout_marginEnd="12dp" app:layout_constraintEnd_toEndOf="parent" android:layout_marginStart="12dp" android:layout_marginEnd="6dp"
android:visibility="visible" android:layout_marginTop="2dp" android:visibility="visible" android:layout_marginTop="2dp"
android:background="?android:attr/selectableItemBackground" android:background="?android:attr/selectableItemBackground"
android:clickable="true" android:focusable="true" android:padding="4dp" android:paddingStart="0dp"> android:clickable="true" android:focusable="true" android:padding="4dp" android:paddingStart="0dp">
@ -193,5 +190,21 @@
android:id="@+id/detail_item_padding_bottom" android:id="@+id/detail_item_padding_bottom"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/detail_item_actions_wrapper" app:layout_constraintBottom_toBottomOf="parent"/> app:layout_constraintTop_toBottomOf="@id/detail_item_actions_wrapper" app:layout_constraintBottom_toBottomOf="parent"/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="0dp"
app:srcCompat="@drawable/ic_notification"
android:id="@+id/detail_item_icon"
android:visibility="visible"
android:maxHeight="40dp"
android:maxWidth="40dp"
android:adjustViewBounds="true"
android:scaleType="fitStart"
android:padding="0dp"
app:layout_constraintTop_toTopOf="@+id/detail_item_date_text"
app:layout_constraintBottom_toBottomOf="@+id/detail_item_message_text"
app:layout_constraintEnd_toStartOf="@id/detail_item_menu_button"
android:layout_marginEnd="6dp"/>
<androidx.constraintlayout.widget.Guideline android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/guideline2" app:layout_constraintGuide_begin="27dp" android:orientation="horizontal"/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>

View file

@ -1,17 +1,16 @@
package io.heckel.ntfy.firebase package io.heckel.ntfy.firebase
import android.content.Intent import android.content.Intent
import android.util.Base64
import androidx.work.* import androidx.work.*
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
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.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64
import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.msg.NotificationParser import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.service.SubscriberService import io.heckel.ntfy.service.SubscriberService
@ -90,6 +89,7 @@ class FirebaseService : FirebaseMessagingService() {
val priority = data["priority"]?.toIntOrNull() val priority = data["priority"]?.toIntOrNull()
val tags = data["tags"] val tags = data["tags"]
val click = data["click"] val click = data["click"]
val iconUrl = data["icon"]
val actions = data["actions"] // JSON array as string, sigh ... val actions = data["actions"] // JSON array as string, sigh ...
val encoding = data["encoding"] val encoding = data["encoding"]
val attachmentName = data["attachment_name"] ?: "attachment.bin" val attachmentName = data["attachment_name"] ?: "attachment.bin"
@ -124,6 +124,7 @@ class FirebaseService : FirebaseMessagingService() {
url = attachmentUrl, url = attachmentUrl,
) )
} else null } else null
val icon: Icon? = iconUrl?.let { Icon(url = it) }
val notification = Notification( val notification = Notification(
id = id, id = id,
subscriptionId = subscription.id, subscriptionId = subscription.id,
@ -134,6 +135,7 @@ class FirebaseService : FirebaseMessagingService() {
priority = toPriority(priority), priority = toPriority(priority),
tags = tags ?: "", tags = tags ?: "",
click = click ?: "", click = click ?: "",
icon = icon,
actions = parser.parseActions(actions), actions = parser.parseActions(actions),
attachment = attachment, attachment = attachment,
notificationId = Random.nextInt(), notificationId = Random.nextInt(),

View file

@ -4,11 +4,13 @@ Features:
* Polling is now done with since=<id> API, which makes deduping easier (#165) * Polling is now done with since=<id> API, which makes deduping easier (#165)
* Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket) * Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket)
* Move action buttons in notification cards (#236, thanks to @wunter8) * Move action buttons in notification cards (#236, thanks to @wunter8)
* Icons can be set for each individual notification (#126, thanks to @wunter8)
Bugs: Bugs:
* Long-click selecting of notifications doesn't scoll to the top anymore (#235, thanks to @wunter8) * Long-click selecting of notifications doesn't scoll to the top anymore (#235, thanks to @wunter8)
* Add attachment and click URL extras to MESSAGE_RECEIVED broadcast (#329, thanks to @wunter8) * Add attachment and click URL extras to MESSAGE_RECEIVED broadcast (#329, thanks to @wunter8)
* Accessibility: Clear/choose service URL button in base URL dropdown now has a label (#292, thanks to @mhameed for reporting) * Accessibility: Clear/choose service URL button in base URL dropdown now has a label (#292, thanks to @mhameed for reporting)
* Do not crash app if preview image too large (no ticket)
Additional translations: Additional translations:
* Italian (thanks to @Genio2003) * Italian (thanks to @Genio2003)