Merge branch '126-notification-icons' into main
This commit is contained in:
commit
813af97581
19 changed files with 462 additions and 67 deletions
|
@ -2,7 +2,7 @@
|
|||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 12,
|
||||
"identityHash": "9363ad5196e88862acceb1bb9ee91124",
|
||||
"identityHash": "d230005f4d9824ba9aa34c61003bdcbb",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
|
@ -112,7 +112,7 @@
|
|||
},
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
|
@ -187,6 +187,18 @@
|
|||
"affinity": "INTEGER",
|
||||
"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",
|
||||
"columnName": "attachment_name",
|
||||
|
@ -326,7 +338,7 @@
|
|||
"views": [],
|
||||
"setupQueries": [
|
||||
"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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ import com.google.gson.Gson
|
|||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.stream.JsonReader
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.Icon
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.topicUrl
|
||||
|
@ -148,6 +149,14 @@ class Backuper(val context: Context) {
|
|||
} else {
|
||||
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(
|
||||
id = n.id,
|
||||
subscriptionId = n.subscriptionId,
|
||||
|
@ -159,6 +168,7 @@ class Backuper(val context: Context) {
|
|||
priority = n.priority,
|
||||
tags = n.tags,
|
||||
click = n.click,
|
||||
icon = icon,
|
||||
actions = actions,
|
||||
attachment = attachment,
|
||||
deleted = n.deleted
|
||||
|
@ -266,6 +276,14 @@ class Backuper(val context: Context) {
|
|||
} else {
|
||||
null
|
||||
}
|
||||
val icon = if (n.icon != null) {
|
||||
Icon(
|
||||
url = n.icon.url,
|
||||
contentUri = n.icon.contentUri,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Notification(
|
||||
id = n.id,
|
||||
subscriptionId = n.subscriptionId,
|
||||
|
@ -276,6 +294,7 @@ class Backuper(val context: Context) {
|
|||
priority = n.priority,
|
||||
tags = n.tags,
|
||||
click = n.click,
|
||||
icon = icon,
|
||||
actions = actions,
|
||||
attachment = attachment,
|
||||
deleted = n.deleted
|
||||
|
@ -347,6 +366,7 @@ data class Notification(
|
|||
val priority: Int, // 1=min, 3=default, 5=max
|
||||
val tags: String,
|
||||
val click: String, // URL/intent to open on notification click
|
||||
val icon: Icon?,
|
||||
val actions: List<Action>?,
|
||||
val attachment: Attachment?,
|
||||
val deleted: Boolean
|
||||
|
@ -377,6 +397,11 @@ data class Attachment(
|
|||
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(
|
||||
val baseUrl: 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 = "tags") val tags: String,
|
||||
@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>?,
|
||||
@Embedded(prefix = "attachment_") val attachment: Attachment?,
|
||||
@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_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
|
||||
data class Action(
|
||||
@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) {
|
||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId 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 <> ''")
|
||||
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)
|
||||
fun add(notification: Notification)
|
||||
|
||||
|
|
|
@ -92,6 +92,14 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
|
|||
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>> {
|
||||
return notificationDao.listFlow(subscriptionId).asLiveData()
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ import okhttp3.Response
|
|||
import java.io.File
|
||||
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()
|
||||
.callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request
|
||||
.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()
|
||||
|
||||
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 = getDownloadLimit(userAction)
|
||||
outFile.use { fileOut ->
|
||||
val fileIn = response.body!!.byteStream()
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
|
@ -102,8 +102,8 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
|
|||
save(attachment.copy(progress = progress))
|
||||
lastProgress = System.currentTimeMillis()
|
||||
}
|
||||
if (contentLength != null && bytesCopied > contentLength) {
|
||||
throw Exception("Attachment is longer than response headers said.")
|
||||
if (downloadLimit != null && bytesCopied > downloadLimit) {
|
||||
throw Exception("Attachment is longer than max download size.")
|
||||
}
|
||||
fileOut.write(buffer, 0, 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_ALWAYS -> return false
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
val attachmentDir = File(context.cacheDir, ATTACHMENT_CACHE_DIR)
|
||||
if (!attachmentDir.exists() && !attachmentDir.mkdirs()) {
|
168
app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt
Normal file
168
app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt
Normal 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
|
||||
}
|
||||
}
|
|
@ -15,25 +15,74 @@ import io.heckel.ntfy.util.Log
|
|||
*/
|
||||
object DownloadManager {
|
||||
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 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")
|
||||
val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java)
|
||||
val workRequest = OneTimeWorkRequest.Builder(DownloadAttachmentWorker::class.java)
|
||||
.setInputData(workDataOf(
|
||||
DownloadWorker.INPUT_DATA_ID to notificationId,
|
||||
DownloadWorker.INPUT_DATA_USER_ACTION to userAction
|
||||
DownloadAttachmentWorker.INPUT_DATA_ID to notificationId,
|
||||
DownloadAttachmentWorker.INPUT_DATA_USER_ACTION to userAction
|
||||
))
|
||||
.build()
|
||||
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) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val workName = DOWNLOAD_WORK_NAME_PREFIX + id
|
||||
Log.d(TAG, "Cancelling download for notification $id, work: $workName")
|
||||
val workName = DOWNLOAD_WORK_ATTACHMENT_NAME_PREFIX + id
|
||||
Log.d(TAG, "Cancelling attachment download for notification $id, work: $workName")
|
||||
workManager.cancelUniqueWork(workName)
|
||||
}
|
||||
}
|
||||
|
||||
enum class DownloadType {
|
||||
ATTACHMENT,
|
||||
ICON,
|
||||
BOTH
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ data class Message(
|
|||
val priority: Int?,
|
||||
val tags: List<String>?,
|
||||
val click: String?,
|
||||
val icon: String?,
|
||||
val actions: List<MessageAction>?,
|
||||
val title: String?,
|
||||
val message: String,
|
||||
|
|
|
@ -29,7 +29,8 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
|
|||
val notify = shouldNotify(subscription, notification, muted)
|
||||
val broadcast = shouldBroadcast(subscription)
|
||||
val distribute = shouldDistribute(subscription)
|
||||
val download = shouldDownload(notification)
|
||||
val downloadAttachment = shouldDownloadAttachment(notification)
|
||||
val downloadIcon = shouldDownloadIcon(notification)
|
||||
if (notify) {
|
||||
notifier.display(subscription, notification)
|
||||
}
|
||||
|
@ -41,12 +42,16 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
|
|||
distributor.sendMessage(appId, connectorToken, decodeBytesMessage(notification))
|
||||
}
|
||||
}
|
||||
if (download) {
|
||||
DownloadManager.enqueue(context, notification.id, userAction = false)
|
||||
if (downloadAttachment && downloadIcon) {
|
||||
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) {
|
||||
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")
|
||||
return false
|
||||
}
|
||||
val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()
|
||||
when (maxAutoDownloadSize) {
|
||||
when (val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()) {
|
||||
Repository.AUTO_DOWNLOAD_ALWAYS -> return true
|
||||
Repository.AUTO_DOWNLOAD_NEVER -> return false
|
||||
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 {
|
||||
if (subscription.upAppId != null) {
|
||||
|
|
|
@ -4,6 +4,7 @@ import com.google.gson.Gson
|
|||
import com.google.gson.reflect.TypeToken
|
||||
import io.heckel.ntfy.db.Action
|
||||
import io.heckel.ntfy.db.Attachment
|
||||
import io.heckel.ntfy.db.Icon
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.util.joinTags
|
||||
import io.heckel.ntfy.util.toPriority
|
||||
|
@ -49,6 +50,7 @@ class NotificationParser {
|
|||
)
|
||||
}
|
||||
} else null
|
||||
val icon: Icon? = if (message.icon != null) Icon(url = message.icon) else null
|
||||
val notification = Notification(
|
||||
id = message.id,
|
||||
subscriptionId = subscriptionId,
|
||||
|
@ -59,6 +61,7 @@ class NotificationParser {
|
|||
priority = toPriority(message.priority),
|
||||
tags = joinTags(message.tags),
|
||||
click = message.click ?: "",
|
||||
icon = icon,
|
||||
actions = actions,
|
||||
attachment = attachment,
|
||||
notificationId = notificationId,
|
||||
|
|
|
@ -96,6 +96,8 @@ class NotificationService(val context: Context) {
|
|||
val contentUri = notification.attachment?.contentUri
|
||||
val isSupportedImage = supportedImage(notification.attachment?.type)
|
||||
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) {
|
||||
try {
|
||||
val attachmentBitmap = contentUri.readBitmapFromUri(context)
|
||||
|
@ -104,7 +106,7 @@ class NotificationService(val context: Context) {
|
|||
.setLargeIcon(attachmentBitmap)
|
||||
.setStyle(NotificationCompat.BigPictureStyle()
|
||||
.bigPicture(attachmentBitmap)
|
||||
.bigLargeIcon(subscriptionIcon)) // May be null
|
||||
.bigLargeIcon(largeIcon)) // May be null
|
||||
} catch (_: Exception) {
|
||||
val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification)
|
||||
builder
|
||||
|
@ -116,7 +118,7 @@ class NotificationService(val context: Context) {
|
|||
builder
|
||||
.setContentText(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
|
||||
* - 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
|
||||
*
|
||||
* 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 notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return
|
||||
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_USER_ACTION -> {
|
||||
val actionId = intent.getStringExtra(BROADCAST_EXTRA_ACTION_ID) ?: return
|
||||
|
|
|
@ -16,7 +16,6 @@ import android.widget.*
|
|||
import androidx.cardview.widget.CardView
|
||||
import androidx.constraintlayout.helper.widget.Flow
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.ConstraintProperties.WRAP_CONTENT
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
|
@ -29,7 +28,8 @@ import com.stfalcon.imageviewer.StfalconImageViewer
|
|||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.*
|
||||
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.Companion.ACTION_VIEW
|
||||
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 titleView: TextView = itemView.findViewById(R.id.detail_item_title_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 tagsView: TextView = itemView.findViewById(R.id.detail_item_tags_text)
|
||||
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))
|
||||
}
|
||||
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)
|
||||
resetCardButtons()
|
||||
maybeRenderMenu(context, notification, exists)
|
||||
maybeRenderAttachment(context, notification, exists)
|
||||
maybeRenderMenu(context, notification, attachmentFileStat)
|
||||
maybeRenderAttachment(context, notification, attachmentFileStat)
|
||||
maybeRenderIcon(context, notification, iconFileStat)
|
||||
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) {
|
||||
attachmentImageView.visibility = View.GONE
|
||||
attachmentBoxView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
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)
|
||||
maybeRenderAttachmentBox(context, notification, attachment, exists, image)
|
||||
maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, image)
|
||||
}
|
||||
|
||||
private fun maybeRenderMenu(context: Context, notification: Notification, exists: Boolean) {
|
||||
val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, exists) // Heavy lifting not during on-click
|
||||
private fun maybeRenderIcon(context: Context, notification: Notification, iconStat: FileInfo?) {
|
||||
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) {
|
||||
menuButton.setOnClickListener { menuButtonPopupMenu.show() }
|
||||
menuButton.visibility = View.VISIBLE
|
||||
|
@ -220,14 +238,14 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
|
|||
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) {
|
||||
attachmentBoxView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
attachmentInfoView.text = formatAttachmentDetails(context, attachment, exists)
|
||||
attachmentInfoView.text = formatAttachmentDetails(context, attachment, attachmentFileStat)
|
||||
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) {
|
||||
attachmentBoxView.setOnClickListener { attachmentBoxPopupMenu.show() }
|
||||
} else {
|
||||
|
@ -240,11 +258,12 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
|
|||
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)
|
||||
popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu)
|
||||
val attachment = notification.attachment // May be null
|
||||
val hasAttachment = attachment != null
|
||||
val attachmentExists = attachmentFileStat != null
|
||||
val hasClickLink = notification.click != ""
|
||||
val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download)
|
||||
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) {
|
||||
copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) }
|
||||
}
|
||||
openItem.isVisible = hasAttachment && exists
|
||||
downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress
|
||||
deleteItem.isVisible = hasAttachment && exists
|
||||
saveFileItem.isVisible = hasAttachment && exists
|
||||
openItem.isVisible = hasAttachment && attachmentExists
|
||||
downloadItem.isVisible = hasAttachment && !attachmentExists && !expired && !inProgress
|
||||
deleteItem.isVisible = hasAttachment && attachmentExists
|
||||
saveFileItem.isVisible = hasAttachment && attachmentExists
|
||||
copyUrlItem.isVisible = hasAttachment && !expired
|
||||
cancelItem.isVisible = hasAttachment && inProgress
|
||||
copyContentsItem.isVisible = notification.click != ""
|
||||
|
@ -282,8 +301,9 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
|
|||
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 exists = attachmentFileStat != null
|
||||
val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE
|
||||
val downloading = !exists && attachment.progress in 0..99
|
||||
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 outUri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
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 {
|
||||
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
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)
|
||||
return true
|
||||
}
|
||||
DownloadManager.enqueue(context, notification.id, userAction = true)
|
||||
DownloadManager.enqueue(context, notification.id, userAction = true, DownloadType.ATTACHMENT)
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -499,6 +519,10 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
|
|||
}
|
||||
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>() {
|
||||
|
@ -514,5 +538,6 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
|
|||
companion object {
|
||||
const val TAG = "NtfyDetailAdapter"
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import android.content.ContentResolver
|
|||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
|
@ -21,7 +20,7 @@ import io.heckel.ntfy.BuildConfig
|
|||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.Repository
|
||||
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.util.*
|
||||
import kotlinx.coroutines.*
|
||||
|
@ -396,7 +395,7 @@ class DetailSettingsActivity : AppCompatActivity() {
|
|||
return null
|
||||
}
|
||||
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?) {
|
||||
|
|
|
@ -34,6 +34,8 @@ import io.heckel.ntfy.db.Subscription
|
|||
import io.heckel.ntfy.firebase.FirebaseMessenger
|
||||
import io.heckel.ntfy.util.Log
|
||||
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.service.SubscriberService
|
||||
import io.heckel.ntfy.service.SubscriberServiceManager
|
||||
|
@ -456,7 +458,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
try {
|
||||
val user = repository.getUser(subscription.baseUrl) // May be null
|
||||
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) {
|
||||
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e)
|
||||
}
|
||||
|
|
|
@ -37,7 +37,10 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
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.text.DateFormat
|
||||
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(
|
||||
val filename: String,
|
||||
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)
|
||||
.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) }
|
||||
}
|
||||
|
|
|
@ -2,16 +2,21 @@ package io.heckel.ntfy.work
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DELETED
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.msg.DownloadIconWorker
|
||||
import io.heckel.ntfy.ui.DetailAdapter
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.fileStat
|
||||
import io.heckel.ntfy.util.topicShortUrl
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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
|
||||
deleteExpiredNotifications()
|
||||
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() {
|
||||
Log.d(TAG, "Deleting expired notifications")
|
||||
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
|
||||
Log.d(TAG, "[$logId] Hard deleting notifications older than $markDeletedOlderThanTimestamp")
|
||||
repository.removeNotificationsIfOlderThan(subscription.id, deleteOlderThanTimestamp)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
android:orientation="horizontal"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:focusable="true"
|
||||
android:paddingBottom="6dp" android:paddingTop="6dp">
|
||||
android:paddingBottom="6dp" android:paddingTop="6dp" android:paddingEnd="6dp">
|
||||
<TextView
|
||||
android:text="Sun, October 31, 2021, 10:43:12"
|
||||
android:layout_width="wrap_content"
|
||||
|
@ -47,13 +47,12 @@
|
|||
app:layout_constraintStart_toEndOf="@id/detail_item_priority_image"
|
||||
android:layout_marginStart="5dp"/>
|
||||
<ImageButton
|
||||
android:layout_width="46dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="26dp" app:srcCompat="@drawable/ic_more_horiz_gray_24dp"
|
||||
android:id="@+id/detail_item_menu_button"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="7dp"
|
||||
android:background="?android:attr/selectableItemBackground" android:paddingTop="-5dp"
|
||||
/>
|
||||
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="3dp"/>
|
||||
<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:layout_width="0dp"
|
||||
|
@ -64,8 +63,7 @@
|
|||
android:autoLink="web"
|
||||
app:layout_constraintTop_toBottomOf="@id/detail_item_title_text"
|
||||
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_constraintBottom_toTopOf="@id/detail_item_attachment_image" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="6dp"/>
|
||||
<TextView
|
||||
android:text="This is an optional title. It can also be a little longer but not too long."
|
||||
android:layout_width="0dp"
|
||||
|
@ -74,10 +72,9 @@
|
|||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
||||
android:autoLink="web"
|
||||
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="12dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
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
|
||||
android:layout_width="16dp"
|
||||
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: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"
|
||||
android:layout_marginStart="12dp" android:layout_marginEnd="12dp"
|
||||
android:layout_marginStart="12dp" android:layout_marginEnd="6dp"
|
||||
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"
|
||||
android:layout_marginBottom="3dp" app:layout_constraintBottom_toTopOf="@id/detail_item_tags_text"/>
|
||||
<TextView
|
||||
|
@ -102,7 +99,7 @@
|
|||
android:id="@+id/detail_item_tags_text"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
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_constraintBottom_toTopOf="@id/detail_item_attachment_file_box"
|
||||
app:layout_constraintHorizontal_bias="0.0" android:layout_marginTop="2dp"
|
||||
|
@ -111,7 +108,7 @@
|
|||
android:layout_width="match_parent"
|
||||
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"
|
||||
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:background="?android:attr/selectableItemBackground"
|
||||
android:clickable="true" android:focusable="true" android:padding="4dp" android:paddingStart="0dp">
|
||||
|
@ -193,5 +190,21 @@
|
|||
android:id="@+id/detail_item_padding_bottom"
|
||||
app:layout_constraintStart_toStartOf="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.cardview.widget.CardView>
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
package io.heckel.ntfy.firebase
|
||||
|
||||
import android.content.Intent
|
||||
import android.util.Base64
|
||||
import androidx.work.*
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.Attachment
|
||||
import io.heckel.ntfy.db.Icon
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.util.Log
|
||||
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.NotificationParser
|
||||
import io.heckel.ntfy.service.SubscriberService
|
||||
|
@ -90,6 +89,7 @@ class FirebaseService : FirebaseMessagingService() {
|
|||
val priority = data["priority"]?.toIntOrNull()
|
||||
val tags = data["tags"]
|
||||
val click = data["click"]
|
||||
val iconUrl = data["icon"]
|
||||
val actions = data["actions"] // JSON array as string, sigh ...
|
||||
val encoding = data["encoding"]
|
||||
val attachmentName = data["attachment_name"] ?: "attachment.bin"
|
||||
|
@ -124,6 +124,7 @@ class FirebaseService : FirebaseMessagingService() {
|
|||
url = attachmentUrl,
|
||||
)
|
||||
} else null
|
||||
val icon: Icon? = iconUrl?.let { Icon(url = it) }
|
||||
val notification = Notification(
|
||||
id = id,
|
||||
subscriptionId = subscription.id,
|
||||
|
@ -134,6 +135,7 @@ class FirebaseService : FirebaseMessagingService() {
|
|||
priority = toPriority(priority),
|
||||
tags = tags ?: "",
|
||||
click = click ?: "",
|
||||
icon = icon,
|
||||
actions = parser.parseActions(actions),
|
||||
attachment = attachment,
|
||||
notificationId = Random.nextInt(),
|
||||
|
|
|
@ -4,11 +4,13 @@ Features:
|
|||
* Polling is now done with since=<id> API, which makes deduping easier (#165)
|
||||
* Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket)
|
||||
* Move action buttons in notification cards (#236, thanks to @wunter8)
|
||||
* Icons can be set for each individual notification (#126, thanks to @wunter8)
|
||||
|
||||
Bugs:
|
||||
* 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)
|
||||
* 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:
|
||||
* Italian (thanks to @Genio2003)
|
||||
|
|
Loading…
Reference in a new issue