notification icons
This commit is contained in:
parent
60f90667d9
commit
ecefdd3df6
16 changed files with 457 additions and 35 deletions
|
@ -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')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
193
app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt
Normal file
193
app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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?) {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue