From ecefdd3df6d75893e0f6b430b23154b5b55e6ed8 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sat, 16 Jul 2022 14:32:09 -0600 Subject: [PATCH 01/22] notification icons --- .../io.heckel.ntfy.db.Database/11.json | 44 +++- .../io.heckel.ntfy.db.Database/12.json | 30 ++- .../java/io/heckel/ntfy/backup/Backuper.kt | 32 +++ .../main/java/io/heckel/ntfy/db/Database.kt | 19 ++ .../main/java/io/heckel/ntfy/db/Repository.kt | 4 + ...dWorker.kt => DownloadAttachmentWorker.kt} | 14 +- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 193 ++++++++++++++++++ .../io/heckel/ntfy/msg/DownloadManager.kt | 65 +++++- .../main/java/io/heckel/ntfy/msg/Message.kt | 8 + .../heckel/ntfy/msg/NotificationDispatcher.kt | 24 ++- .../io/heckel/ntfy/msg/NotificationParser.kt | 9 + .../io/heckel/ntfy/msg/NotificationService.kt | 10 +- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 8 +- .../heckel/ntfy/ui/DetailSettingsActivity.kt | 5 +- .../java/io/heckel/ntfy/work/DeleteWorker.kt | 26 +++ app/src/main/res/values/strings.xml | 1 + 16 files changed, 457 insertions(+), 35 deletions(-) rename app/src/main/java/io/heckel/ntfy/msg/{DownloadWorker.kt => DownloadAttachmentWorker.kt} (93%) create mode 100644 app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt diff --git a/app/schemas/io.heckel.ntfy.db.Database/11.json b/app/schemas/io.heckel.ntfy.db.Database/11.json index b54336c..8d8e064 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/11.json +++ b/app/schemas/io.heckel.ntfy.db.Database/11.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 11, - "identityHash": "31f8e6a2032d1d404fad4307abf23e1b", + "identityHash": "5a061926458ed65c80431be0a69a2450", "entities": [ { "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": [ { "fieldPath": "id", @@ -50,6 +50,12 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "icon", "columnName": "icon", @@ -67,6 +73,12 @@ "columnName": "upConnectorToken", "affinity": "TEXT", "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false } ], "primaryKey": { @@ -100,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_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": [ { "fieldPath": "id", @@ -175,6 +187,30 @@ "affinity": "INTEGER", "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", "columnName": "attachment_name", @@ -314,7 +350,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, '31f8e6a2032d1d404fad4307abf23e1b')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5a061926458ed65c80431be0a69a2450')" ] } } \ No newline at end of file diff --git a/app/schemas/io.heckel.ntfy.db.Database/12.json b/app/schemas/io.heckel.ntfy.db.Database/12.json index 8b1a443..31f125c 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/12.json +++ b/app/schemas/io.heckel.ntfy.db.Database/12.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 12, - "identityHash": "9363ad5196e88862acceb1bb9ee91124", + "identityHash": "5a061926458ed65c80431be0a69a2450", "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_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": [ { "fieldPath": "id", @@ -187,6 +187,30 @@ "affinity": "INTEGER", "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", "columnName": "attachment_name", @@ -326,7 +350,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, '5a061926458ed65c80431be0a69a2450')" ] } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 9ce3069..49c6464 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -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,16 @@ class Backuper(val context: Context) { } else { 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( id = n.id, subscriptionId = n.subscriptionId, @@ -159,6 +170,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 +278,16 @@ class Backuper(val context: Context) { } else { 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( id = n.id, subscriptionId = n.subscriptionId, @@ -276,6 +298,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 +370,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?, val attachment: Attachment?, val deleted: Boolean @@ -377,6 +401,14 @@ 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 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( val baseUrl: String, val username: String, diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index f5ddb44..c56fa95 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -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?, @Embedded(prefix = "attachment_") val attachment: Attachment?, @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_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 data class Action( @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) { 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_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 <> ''") fun listDeletedWithAttachments(): List + @Query("SELECT * FROM notification WHERE deleted = 1 AND icon_contentUri <> ''") + fun listDeletedWithIcons(): List + @Insert(onConflict = OnConflictStrategy.IGNORE) fun add(notification: Notification) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 9f13656..ed45597 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -92,6 +92,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas return notificationDao.listDeletedWithAttachments() } + fun getDeletedNotificationsWithIcons(): List { + return notificationDao.listDeletedWithIcons() + } + fun getNotificationsLiveData(subscriptionId: Long): LiveData> { return notificationDao.listFlow(subscriptionId).asLiveData() } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt similarity index 93% rename from app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt rename to app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt index df97a81..bc22e9d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt @@ -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,13 @@ 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 = 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) @@ -102,8 +106,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,7 +186,7 @@ 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 } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt new file mode 100644 index 0000000..e4535a5 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -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 + } +} diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt index 4a6739c..68cfb85 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt @@ -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 +} diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index 04289ae..bc3799c 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -13,6 +13,7 @@ data class Message( val priority: Int?, val tags: List?, val click: String?, + val icon: MessageIcon?, val actions: List?, val title: String?, val message: String, @@ -43,4 +44,11 @@ data class MessageAction( val extras: Map?, // used in "broadcast" action ) +@Keep +data class MessageIcon( + val url: String, + val type: String?, + val size: Long?, +) + const val MESSAGE_ENCODING_BASE64 = "base64" diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 2aec2c1..88593f7 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -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 } @@ -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 { if (subscription.upAppId != null) { diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 0faaf23..644cc47 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -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 @@ -31,6 +32,13 @@ class NotificationParser { url = message.attachment.url, ) } 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) { message.actions.map { a -> Action( @@ -59,6 +67,7 @@ class NotificationParser { priority = toPriority(message.priority), tags = joinTags(message.tags), click = message.click ?: "", + icon = icon, actions = actions, attachment = attachment, notificationId = notificationId, diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index 846e10b..eb6dec6 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -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 && supportedImage(notification.icon.type)) 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 diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index de1ea77..46c0925 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -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.* @@ -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 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 +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) return true } - DownloadManager.enqueue(context, notification.id, userAction = true) + DownloadManager.enqueue(context, notification.id, userAction = true, DownloadType.ATTACHMENT) return true } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt index 5082708..91b41d1 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt @@ -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?) { diff --git a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt index b9d7cbe..b5706d7 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -27,6 +27,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 +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() { Log.d(TAG, "Deleting expired notifications") val repository = Repository.getInstance(applicationContext) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 466cd40..2e46612 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,6 +165,7 @@ download failed download failed, link expired download failed, link expires %1$s + Could not download icon: %1$s Notifications on From 12bd524cbd6742cfc047310744a988a6128929c8 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sun, 17 Jul 2022 15:40:51 -0600 Subject: [PATCH 02/22] change icon in message from object to string --- app/src/main/java/io/heckel/ntfy/msg/Message.kt | 9 +-------- .../java/io/heckel/ntfy/msg/NotificationParser.kt | 14 +++++++------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index bc3799c..8de0ab8 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -13,7 +13,7 @@ data class Message( val priority: Int?, val tags: List?, val click: String?, - val icon: MessageIcon?, + val icon: String?, val actions: List?, val title: String?, val message: String, @@ -44,11 +44,4 @@ data class MessageAction( val extras: Map?, // used in "broadcast" action ) -@Keep -data class MessageIcon( - val url: String, - val type: String?, - val size: Long?, -) - const val MESSAGE_ENCODING_BASE64 = "base64" diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 644cc47..d26f5d0 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -32,13 +32,6 @@ class NotificationParser { url = message.attachment.url, ) } 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) { message.actions.map { a -> Action( @@ -57,6 +50,13 @@ class NotificationParser { ) } } else null + val icon: Icon? = if (message.icon != null) { + Icon( + url = message.icon, + size = null, + type = null + ) + } else null val notification = Notification( id = message.id, subscriptionId = subscriptionId, From 94fe8766f1f4814cbf776fb4272d55a9fdbb0368 Mon Sep 17 00:00:00 2001 From: Adrian Wahyudi Date: Fri, 19 Aug 2022 12:40:45 +0000 Subject: [PATCH 03/22] Translated using Weblate (Indonesian) Currently translated at 100.0% (317 of 317 strings) Translation: ntfy/Android app Translate-URL: https://hosted.weblate.org/projects/ntfy/android/id/ --- app/src/main/res/values-in/strings.xml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 97f2c96..4e2b23c 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -19,11 +19,11 @@ \n%2$s Tidak dapat memuat ulang langganan: %1$s Topik berlangganan - Notifikasi nyala + Notifikasi menyala Notifikasi dibisukan Notifikasi dibisukan sampai %1$s Pengaturan - Laporkan sebuah kutu + Laporkan sebuah bug Baca dokumentasi Beri nilai aplikasi ⭐ Batalkan langganan @@ -35,7 +35,7 @@ Tekan + untuk membuat atau berlangganan ke sebuah topik. Setelah itu Anda menerima notifikasi pada perangkat Anda saat mengirim pesan via PUT atau POST. Instruksi rinci tersedia di ntfy.sh, dan dalam dokumentasi. Langganan ini dikelola oleh %1$s melalui UnifiedPush - Pengoptimalan baterai untuk aplikasi seharusnya mati supaya masalah pengiriman notifikasi dapat dihindari. + Pengoptimalan baterai untuk aplikasi sebaiknya dimatikan supaya masalah pengiriman notifikasi dapat dihindari. Tanya nanti Abaikan Perbaiki sekarang @@ -54,7 +54,7 @@ Login gagal. Pengguna %1$s tidak diizinkan. Pengguna baru Anda belum menerima notifikasi apa pun. - Untuk mengirimkan notifikasi ke topik ini, tinggal PUT atau POST ke URL topik. + Untuk mengirimkan notifikasi ke topik ini, lakukan PUT atau POST ke URL topik. Contoh (menggunakan curl):
$ curl -d \"Hai\" %1$s
Instruksi rinci tersedia di ntfy.sh, dan dalam dokumentasi. Hapus semua notifikasi di topik ini\? @@ -68,7 +68,7 @@ Tidak dapat mengirimkan pesan: Penerbitan anonim tidak diizinkan. Tidak dapat mengirimkan pesan: Lampiran terlalu besar. Disalin ke papan klip - Pengiriman instan nyala + Pengiriman instan menyala Pengiriman instan mati Tanda: %1$s Notifikasi dihapus @@ -165,8 +165,8 @@ Digunakan oleh topik %1$s Tambahkan pengguna baru Mode gelap - Mode terang nyala - Mode gelap nyala. Apakah Anda seorang vampir\? + Mode terang menyala + Mode gelap menyala. Apakah Anda seorang vampir\? Gunakan bawaan sistem Mode terang Mode gelap @@ -236,7 +236,7 @@ %1$d notifikasi Ini adalah notifikasi uji coba dari aplikasi Android ntfy. Ini memiliki tingkat prioritas %1$d. Jika Anda kirim yang lain, itu mungkin kelihatan berbeda. Tidak dapat mengirimkan pesan: Pengguna \"%1$s\" tidak diizinkan. - Notifikasi nyala + Notifikasi menyala %1$s \nFile: %2$s, terunduh Disimpan sebagai \"%1$s\" dalam folder \"Downloads\" @@ -289,7 +289,7 @@ Pemulihan gagal: %1$s Tingkat lanjut Aplikasi dapat menerima notifikasi yang datang sebagai siaran - Salin catatan ke papn klip, atau unggah ke nopaste.net (dimiliki oleh penulis ntfy). Nama host dan topik dapat disensor, notifikasi tidak akan disensor. + Salin catatan ke papan klip, atau unggah ke nopaste.net (dimiliki oleh penulis ntfy). Nama host dan topik dapat disensor, notifikasi tidak akan disensor. Disalin ke papan klip Kata Sandi Hapus pengguna From 8197e1a936963907600d19b594da1daad2af1dc8 Mon Sep 17 00:00:00 2001 From: MaggoShton Date: Sat, 20 Aug 2022 17:13:08 +0000 Subject: [PATCH 04/22] Translated using Weblate (Russian) Currently translated at 89.9% (285 of 317 strings) Translation: ntfy/Android app Translate-URL: https://hosted.weblate.org/projects/ntfy/android/ru/ --- app/src/main/res/values-ru/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 620f18b..79ad11a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -51,7 +51,7 @@ Через один день Через неделю Через месяц - Чкрез три месяца + Через три месяца Общие Сервер по умолчанию %1$s (по умолчанию) @@ -306,4 +306,4 @@ Подписан на шесть тем с мгновенной доставкой Подписан на пять тем Подписан на шесть тем - + \ No newline at end of file From a2ae6e4c21c1076e23e6ce313ad87a623fa71a72 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Thu, 25 Aug 2022 21:58:37 -0600 Subject: [PATCH 05/22] remove icon size and type --- .../io.heckel.ntfy.db.Database/11.json | 44 ++---------------- .../io.heckel.ntfy.db.Database/12.json | 18 ++------ .../java/io/heckel/ntfy/backup/Backuper.kt | 7 --- .../main/java/io/heckel/ntfy/db/Database.kt | 8 +--- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 45 +++---------------- .../heckel/ntfy/msg/NotificationDispatcher.kt | 13 +----- .../io/heckel/ntfy/msg/NotificationParser.kt | 4 +- .../io/heckel/ntfy/msg/NotificationService.kt | 2 +- 8 files changed, 18 insertions(+), 123 deletions(-) diff --git a/app/schemas/io.heckel.ntfy.db.Database/11.json b/app/schemas/io.heckel.ntfy.db.Database/11.json index 8d8e064..b54336c 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/11.json +++ b/app/schemas/io.heckel.ntfy.db.Database/11.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 11, - "identityHash": "5a061926458ed65c80431be0a69a2450", + "identityHash": "31f8e6a2032d1d404fad4307abf23e1b", "entities": [ { "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, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` 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, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -50,12 +50,6 @@ "affinity": "INTEGER", "notNull": true }, - { - "fieldPath": "lastNotificationId", - "columnName": "lastNotificationId", - "affinity": "TEXT", - "notNull": false - }, { "fieldPath": "icon", "columnName": "icon", @@ -73,12 +67,6 @@ "columnName": "upConnectorToken", "affinity": "TEXT", "notNull": false - }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT", - "notNull": false } ], "primaryKey": { @@ -112,7 +100,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, `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`))", + "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`))", "fields": [ { "fieldPath": "id", @@ -187,30 +175,6 @@ "affinity": "INTEGER", "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", "columnName": "attachment_name", @@ -350,7 +314,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, '5a061926458ed65c80431be0a69a2450')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '31f8e6a2032d1d404fad4307abf23e1b')" ] } } \ No newline at end of file diff --git a/app/schemas/io.heckel.ntfy.db.Database/12.json b/app/schemas/io.heckel.ntfy.db.Database/12.json index 31f125c..e9e36a1 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/12.json +++ b/app/schemas/io.heckel.ntfy.db.Database/12.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 12, - "identityHash": "5a061926458ed65c80431be0a69a2450", + "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, `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`))", + "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", @@ -193,18 +193,6 @@ "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", @@ -350,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, '5a061926458ed65c80431be0a69a2450')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd230005f4d9824ba9aa34c61003bdcbb')" ] } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 49c6464..e0f8f92 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -152,8 +152,6 @@ class Backuper(val context: Context) { 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 { @@ -281,8 +279,6 @@ class Backuper(val context: Context) { val icon = if (n.icon != null) { Icon( url = n.icon.url, - type = n.icon.type, - size = n.icon.size, contentUri = n.icon.contentUri, ) } else { @@ -403,10 +399,7 @@ data class Attachment( 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( diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index c56fa95..e267a45 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -95,12 +95,10 @@ 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) + constructor(url:String) : + this(url, null) } @Entity @@ -282,8 +280,6 @@ abstract class Database : RoomDatabase() { 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_type TEXT") - db.execSQL("ALTER TABLE Notification ADD COLUMN icon_size INT") db.execSQL("ALTER TABLE Notification ADD COLUMN icon_contentUri TEXT") } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index e4535a5..2521f43 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -23,7 +23,7 @@ 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 + .callTimeout(1, TimeUnit.MINUTES) // Total timeout for entire request .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) @@ -69,8 +69,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) if (!response.isSuccessful || response.body == null) { throw Exception("Unexpected response: ${response.code}") } - save(updateIconFromResponse(response)) - if (shouldAbortDownload()) { + if (shouldAbortDownload(response)) { Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting") return } @@ -85,7 +84,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) val downloadLimit = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { repository.getAutoDownloadMaxSize() } else { - null + MAX_ICON_DOWNLOAD_SIZE.toLong() } outFile.use { fileOut -> val fileIn = response.body!!.byteStream() @@ -102,48 +101,14 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) } 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() @@ -166,9 +131,9 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) repository.updateNotification(notification) } - private fun shouldAbortDownload(): Boolean { + private fun shouldAbortDownload(response: Response): Boolean { val maxAutoDownloadSize = MAX_ICON_DOWNLOAD_SIZE - val size = icon.size ?: return false // Don't abort if size unknown + val size = response.headers["Content-Length"]?.toLongOrNull() ?: return false // Don't abort here if size unknown return size > maxAutoDownloadSize } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 88593f7..c763b44 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -60,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 -> { @@ -73,15 +72,7 @@ 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 + return notification.icon != null } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index d26f5d0..877a3be 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -52,9 +52,7 @@ class NotificationParser { } else null val icon: Icon? = if (message.icon != null) { Icon( - url = message.icon, - size = null, - type = null + url = message.icon ) } else null val notification = Notification( diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index eb6dec6..a35ff8d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -96,7 +96,7 @@ 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 && supportedImage(notification.icon.type)) notification.icon.contentUri?.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 { From c7edb50ebc21a226287d486a49166cf07727db70 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sat, 27 Aug 2022 12:11:27 -0600 Subject: [PATCH 06/22] cache notification icons and delete after 24 hours --- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 30 ++++++++++++++----- app/src/main/java/io/heckel/ntfy/util/Util.kt | 8 +++++ .../java/io/heckel/ntfy/work/DeleteWorker.kt | 21 +++++++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index 2521f43..aa74157 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -14,7 +14,7 @@ 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 io.heckel.ntfy.util.stringToHash import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -44,7 +44,17 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure() icon = notification.icon ?: return Result.failure() try { - downloadIcon() + val iconFile = createIconFile(icon) + if (!iconFile.exists()) { + downloadIcon(iconFile) + } else { + Log.d(TAG, "Loading icon from cache: ${icon.url}") + val iconUri = createIconUri(iconFile) + this.uri = iconUri // Required for cleanup in onStopped() + save(icon.copy( + contentUri = iconUri.toString() + )) + } } catch (e: Exception) { failed(e) } @@ -56,7 +66,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) maybeDeleteFile() } - private fun downloadIcon() { + private fun downloadIcon(iconFile: File) { Log.d(TAG, "Downloading icon from ${icon.url}") try { @@ -74,7 +84,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) return } val resolver = applicationContext.contentResolver - val uri = createUri(notification) + val uri = createIconUri(iconFile) this.uri = uri // Required for cleanup in onStopped() Log.d(TAG, "Starting download to content URI: $uri") @@ -137,13 +147,17 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) return size > maxAutoDownloadSize } - private fun createUri(notification: Notification): Uri { + 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 file = ensureSafeNewFile(iconDir, notification.id) - return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file) + val hash = stringToHash(icon.url) + return File(iconDir, hash) + } + + private fun createIconUri(iconFile: File): Uri { + return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, iconFile) } companion object { @@ -152,7 +166,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) const val MAX_ICON_DOWNLOAD_SIZE = 300000 private const val TAG = "NtfyIconDownload" - private const val ICON_CACHE_DIR = "icons" + const val ICON_CACHE_DIR = "icons" private const val BUFFER_SIZE = 8 * 1024 } } diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index a6a3197..59d1df3 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -38,6 +38,7 @@ import okhttp3.RequestBody import okio.BufferedSink import okio.source import java.io.* +import java.security.MessageDigest import java.security.SecureRandom import java.text.DateFormat import java.text.StringCharacterIterator @@ -469,3 +470,10 @@ fun copyToClipboard(context: Context, notification: Notification) { .makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG) .show() } + +fun stringToHash(s: String): String { + val bytes = s.toByteArray(); + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(bytes) + return digest.fold("") { str, it -> str + "%02x".format(it) } +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt index b5706d7..ddeee83 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -7,11 +7,14 @@ 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.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. @@ -30,6 +33,7 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx deleteExpiredIcons() // Before notifications, so we will also catch manually deleted notifications deleteExpiredAttachments() // Before notifications, so we will also catch manually deleted notifications deleteExpiredNotifications() + cleanIconCache() return@withContext Result.success() } } @@ -85,6 +89,23 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx } } + private fun cleanIconCache() { + Log.d(DeleteWorker.TAG, "Cleaning icons older than 24 hours from cache") + val iconDir = File(applicationContext.cacheDir, DownloadIconWorker.ICON_CACHE_DIR) + if (iconDir.exists()) { + for (f: File in iconDir.listFiles()) { + var lastModified = f.lastModified() + var today = Date() + + var diffInHours = ((today.time - lastModified) / (1000 * 60 * 60)) + if (diffInHours > 24) { + Log.d(DeleteWorker.TAG, "Deleting cached icon: ${f.name}") + f.delete() + } + } + } + } + private suspend fun deleteExpiredNotifications() { Log.d(TAG, "Deleting expired notifications") val repository = Repository.getInstance(applicationContext) From f68bb5f379d2a9a0d9e37ad9505181fa4582da6c Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sun, 28 Aug 2022 14:52:35 -0600 Subject: [PATCH 07/22] delete unreferenced icons periodically and download updates every 24 hours --- .../main/java/io/heckel/ntfy/db/Database.kt | 10 ++++- .../main/java/io/heckel/ntfy/db/Repository.kt | 12 +++++- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 8 ++-- .../io/heckel/ntfy/msg/NotificationParser.kt | 6 +-- app/src/main/java/io/heckel/ntfy/util/Util.kt | 5 +-- .../java/io/heckel/ntfy/work/DeleteWorker.kt | 40 +++++-------------- app/src/main/res/values/strings.xml | 1 - 7 files changed, 35 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index e267a45..3ab4ca5 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -380,8 +380,14 @@ interface NotificationDao { @Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''") fun listDeletedWithAttachments(): List - @Query("SELECT * FROM notification WHERE deleted = 1 AND icon_contentUri <> ''") - fun listDeletedWithIcons(): List + @Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted != 1 AND icon_contentUri <> ''") + fun listActiveIconUris(): List + + @Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted = 1 AND icon_contentUri <> ''") + fun listDeletedIconUris(): List + + @Query("UPDATE notification SET icon_contentUri = null WHERE icon_contentUri = :uri") + fun clearIconUri(uri: String) @Insert(onConflict = OnConflictStrategy.IGNORE) fun add(notification: Notification) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index ed45597..a39f617 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -92,8 +92,16 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas return notificationDao.listDeletedWithAttachments() } - fun getDeletedNotificationsWithIcons(): List { - return notificationDao.listDeletedWithIcons() + fun getActiveIconUris(): Set { + return notificationDao.listActiveIconUris().toSet() + } + + fun getDeletedIconUris(): Set { + return notificationDao.listDeletedIconUris().toSet() + } + + fun clearIconUri(uri: String) { + notificationDao.clearIconUri(uri) } fun getNotificationsLiveData(subscriptionId: Long): LiveData> { diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index aa74157..9242743 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -14,11 +14,12 @@ 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.stringToHash +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) { @@ -45,7 +46,8 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) icon = notification.icon ?: return Result.failure() try { val iconFile = createIconFile(icon) - if (!iconFile.exists()) { + val yesterdayTimestamp = Date().time - 1000*60*60*24 // now Unix timestamp - 24 hours + if (!iconFile.exists() || iconFile.lastModified() < yesterdayTimestamp) { downloadIcon(iconFile) } else { Log.d(TAG, "Loading icon from cache: ${icon.url}") @@ -152,7 +154,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) if (!iconDir.exists() && !iconDir.mkdirs()) { throw Exception("Cannot create cache directory for icons: $iconDir") } - val hash = stringToHash(icon.url) + val hash = icon.url.sha256() return File(iconDir, hash) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 877a3be..60b2e3a 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -50,11 +50,7 @@ class NotificationParser { ) } } else null - val icon: Icon? = if (message.icon != null) { - Icon( - url = message.icon - ) - } else null + val icon: Icon? = if (message.icon != null) Icon(url = message.icon) else null val notification = Notification( id = message.id, subscriptionId = subscriptionId, diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 59d1df3..a3702e1 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -471,9 +471,8 @@ fun copyToClipboard(context: Context, notification: Notification) { .show() } -fun stringToHash(s: String): String { - val bytes = s.toByteArray(); +fun String.sha256(): String { val md = MessageDigest.getInstance("SHA-256") - val digest = md.digest(bytes) + val digest = md.digest(this.toByteArray()) return digest.fold("") { str, it -> str + "%02x".format(it) } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt index ddeee83..7d611cb 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -33,7 +33,6 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx deleteExpiredIcons() // Before notifications, so we will also catch manually deleted notifications deleteExpiredAttachments() // Before notifications, so we will also catch manually deleted notifications deleteExpiredNotifications() - cleanIconCache() return@withContext Result.success() } } @@ -68,40 +67,19 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx Log.d(TAG, "Deleting icons for deleted notifications") val resolver = applicationContext.contentResolver val repository = Repository.getInstance(applicationContext) - val notifications = repository.getDeletedNotificationsWithIcons() - notifications.forEach { notification -> + val activeIconUris = repository.getActiveIconUris() + val expiredIconUris = repository.getDeletedIconUris() + val urisToDelete = expiredIconUris.minus(activeIconUris) + urisToDelete.forEach { uri -> 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 + val deleted = resolver.delete(Uri.parse(uri), null, null) > 0 if (!deleted) { - Log.w(TAG, "Unable to delete icon for notification ${notification.id}") + Log.w(TAG, "Unable to delete icon at $uri") } - val newIcon = icon.copy( - contentUri = null, - ) - val newNotification = notification.copy(icon = newIcon) - repository.updateNotification(newNotification) + + repository.clearIconUri(uri) } catch (e: Exception) { - Log.w(TAG, "Failed to delete icon for notification: ${e.message}", e) - } - } - } - - private fun cleanIconCache() { - Log.d(DeleteWorker.TAG, "Cleaning icons older than 24 hours from cache") - val iconDir = File(applicationContext.cacheDir, DownloadIconWorker.ICON_CACHE_DIR) - if (iconDir.exists()) { - for (f: File in iconDir.listFiles()) { - var lastModified = f.lastModified() - var today = Date() - - var diffInHours = ((today.time - lastModified) / (1000 * 60 * 60)) - if (diffInHours > 24) { - Log.d(DeleteWorker.TAG, "Deleting cached icon: ${f.name}") - f.delete() - } + Log.w(TAG, "Failed to delete icon: ${e.message}", e) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e46612..466cd40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,7 +165,6 @@ download failed download failed, link expired download failed, link expires %1$s - Could not download icon: %1$s Notifications on From 80cc0118527ee8387d8f2c7ba331cc485ddb35ef Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sun, 28 Aug 2022 20:34:39 -0600 Subject: [PATCH 08/22] improved icon cache clean up --- .../java/io/heckel/ntfy/work/DeleteWorker.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt index 7d611cb..8e8923f 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -2,6 +2,7 @@ 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 @@ -10,6 +11,7 @@ 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 @@ -65,18 +67,22 @@ 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 activeIconUris = repository.getActiveIconUris() - val expiredIconUris = repository.getDeletedIconUris() - val urisToDelete = expiredIconUris.minus(activeIconUris) - urisToDelete.forEach { uri -> + 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 } + val filenamesToDelete = allIconFilenames.minus(activeIconFilenames) + filenamesToDelete.forEach { filename -> try { - val deleted = resolver.delete(Uri.parse(uri), null, null) > 0 + val file = File(iconDir, filename) + val deleted = file.delete() if (!deleted) { - Log.w(TAG, "Unable to delete icon at $uri") + 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) From 3b240a3208af1df89f65875c03d6b585fdc663d6 Mon Sep 17 00:00:00 2001 From: Jos Vooges Date: Tue, 30 Aug 2022 20:18:53 +0000 Subject: [PATCH 09/22] Translated using Weblate (Dutch) Currently translated at 100.0% (317 of 317 strings) Translation: ntfy/Android app Translate-URL: https://hosted.weblate.org/projects/ntfy/android/nl/ --- app/src/main/res/values-nl/strings.xml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 84799a1..1be5225 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -65,7 +65,7 @@ Beoordeel de app ⭐ %1$d melding Afmelden van de geselecteerde onderwerp(en) en alle meldingen definitief verwijderen\? - %1$s (UnifiedPush) + %1$s (Unified Push) Het lijkt erop dat u nog geen abonnementen heeft. Permanent verwijderen Dit abonnement wordt beheerd door %1$s via UnifiedPush @@ -73,7 +73,7 @@ Afwijzen Gebruiker verwijderen Annuleren - WebSockets + WebSocket Versie Over Verbindingsprotocol @@ -164,8 +164,8 @@ Logs verwijderen Logs verwijderd Je kunt hier een gebruiker toevoegen. Alle onderwerpen voor de opgegeven server zullen deze gebruiker gebruiken. - Gebruik WebSockets om verbinding te maken met de server. Dit wordt de standaard in juni 2022. - Gebruik een JSON stream via HTTP om verbinding te maken met de server. Deze methode is verouderd en wordt in juni 2022 verwijderd. + Gebruik WebSockets om verbinding te maken met de server. Dit is de aangeraden methode, maar deze kan extra configuratie in uw proxy vereisen. + Gebruik een JSON stream via HTTP om verbinding te maken met de server. Deze methode is getest maar kan meer batterij verbruiken. Upload en kopieer link (gecensureerd) Deze onderwerpen/hostnamen zijn vervangen met fruitnamen zodat je het log kunt delen zonder zorgen: \n @@ -317,4 +317,14 @@ Stel een icoon in wat zal worden weergegeven in notificaties Abonnementen icoon (tap om te verwijderen) Gebruikt globale instelling - + %1$s (standaard) + Schermnaam + Zet een schermnaam voor dit abonnement. Laat het veld leeg om de standaard naam te kiezen (%1$s). + Over + Onderwerp URL + Gekopieerd naar klembord + Kies service URL + Service URL verwijderen + Nu inschakelen + WebSockets is de aangeraden manier om te verbinden met uw server en kan batterij verbruik verminderen. Het kan extra configuratie in uw proxy vereisen. Dit kan omgeschakeld worden in de instellingen. + \ No newline at end of file From f08747cade91d9d4031d945ac9adfb53cc28a13a Mon Sep 17 00:00:00 2001 From: la-ninpre Date: Wed, 31 Aug 2022 18:56:50 +0000 Subject: [PATCH 10/22] Translated using Weblate (Russian) Currently translated at 90.2% (286 of 317 strings) Translation: ntfy/Android app Translate-URL: https://hosted.weblate.org/projects/ntfy/android/ru/ --- app/src/main/res/values-ru/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 79ad11a..259fa29 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -194,7 +194,7 @@ Скачивается %1$s, %2$d%% \n%3$s %1$s -\nФайл: %2$s, скачен +\nФайл: %2$s, скачан %1$s \nФайл: %2$s, не удалось скачать Приостановить уведомления @@ -306,4 +306,5 @@ Подписан на шесть тем с мгновенной доставкой Подписан на пять тем Подписан на шесть тем + Включить сейчас \ No newline at end of file From fcce44bef109075d8cab59131837633293559e67 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Mon, 5 Sep 2022 16:52:26 -0600 Subject: [PATCH 11/22] clean up old db queries and DownloadIconWorker --- app/src/main/java/io/heckel/ntfy/db/Database.kt | 3 --- .../main/java/io/heckel/ntfy/db/Repository.kt | 4 ---- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 17 ++++++++++------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 3ab4ca5..62daf0a 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -383,9 +383,6 @@ interface NotificationDao { @Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted != 1 AND icon_contentUri <> ''") fun listActiveIconUris(): List - @Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted = 1 AND icon_contentUri <> ''") - fun listDeletedIconUris(): List - @Query("UPDATE notification SET icon_contentUri = null WHERE icon_contentUri = :uri") fun clearIconUri(uri: String) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index a39f617..cee2985 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -96,10 +96,6 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas return notificationDao.listActiveIconUris().toSet() } - fun getDeletedIconUris(): Set { - return notificationDao.listDeletedIconUris().toSet() - } - fun clearIconUri(uri: String) { notificationDao.clearIconUri(uri) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index 9242743..9da973c 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -90,14 +90,9 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) 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 { - MAX_ICON_DOWNLOAD_SIZE.toLong() - } + val downloadLimit = getDownloadLimit() outFile.use { fileOut -> val fileIn = response.body!!.byteStream() val buffer = ByteArray(BUFFER_SIZE) @@ -144,11 +139,19 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) } private fun shouldAbortDownload(response: Response): Boolean { - val maxAutoDownloadSize = MAX_ICON_DOWNLOAD_SIZE + 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) { + repository.getAutoDownloadMaxSize() + } else { + MAX_ICON_DOWNLOAD_SIZE.toLong() + } + } + private fun createIconFile(icon: Icon): File { val iconDir = File(context.cacheDir, ICON_CACHE_DIR) if (!iconDir.exists() && !iconDir.mkdirs()) { From 433fad761a7f3777e8a622f01c2428606d6eaaf7 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Tue, 6 Sep 2022 21:42:40 -0600 Subject: [PATCH 12/22] remove attachment download limit if userAction --- .../io/heckel/ntfy/msg/DownloadAttachmentWorker.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt index bc22e9d..cffd591 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt @@ -82,11 +82,7 @@ class DownloadAttachmentWorker(private val context: Context, params: WorkerParam 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 = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { - repository.getAutoDownloadMaxSize() - } else { - null - } + val downloadLimit = getDownloadLimit(userAction) outFile.use { fileOut -> val fileIn = response.body!!.byteStream() val buffer = ByteArray(BUFFER_SIZE) @@ -192,6 +188,14 @@ class DownloadAttachmentWorker(private val context: Context, params: WorkerParam } } + 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()) { From 35ea6de6f89697c9bb9ebf31166101ed2efd150e Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 7 Sep 2022 16:51:09 -0400 Subject: [PATCH 13/22] Fix FirebaseService --- .../play/java/io/heckel/ntfy/firebase/FirebaseService.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt index ec1e130..8e200bc 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -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(), From 132a18e12cacab450ccabe8a51d6fe5ebdc4a16c Mon Sep 17 00:00:00 2001 From: Vladimir Kopitsa Date: Thu, 8 Sep 2022 14:22:18 +0000 Subject: [PATCH 14/22] Translated using Weblate (Ukrainian) Currently translated at 66.6% (2 of 3 strings) Translation: ntfy/Android app (Fastlane metadata) Translate-URL: https://hosted.weblate.org/projects/ntfy/android-fastlane/uk/ --- fastlane/metadata/android/uk/short_description.txt | 1 + fastlane/metadata/android/uk/title.txt | 1 + 2 files changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/uk/short_description.txt create mode 100644 fastlane/metadata/android/uk/title.txt diff --git a/fastlane/metadata/android/uk/short_description.txt b/fastlane/metadata/android/uk/short_description.txt new file mode 100644 index 0000000..10f66d4 --- /dev/null +++ b/fastlane/metadata/android/uk/short_description.txt @@ -0,0 +1 @@ +Надсилайте сповіщення на свій телефон за допомогою запитів PUT/POST diff --git a/fastlane/metadata/android/uk/title.txt b/fastlane/metadata/android/uk/title.txt new file mode 100644 index 0000000..70d5de0 --- /dev/null +++ b/fastlane/metadata/android/uk/title.txt @@ -0,0 +1 @@ +ntfy - PUT/POST на ваш телефон From 65a7131b2b368a1d4fd1ce546b93042980e980f9 Mon Sep 17 00:00:00 2001 From: Vladimir Kopitsa Date: Fri, 9 Sep 2022 06:33:09 +0000 Subject: [PATCH 15/22] Translated using Weblate (Ukrainian) Currently translated at 100.0% (317 of 317 strings) Translation: ntfy/Android app Translate-URL: https://hosted.weblate.org/projects/ntfy/android/uk/ --- app/src/main/res/values-uk/strings.xml | 319 +++++++++++++++++++++++++ 1 file changed, 319 insertions(+) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8275835..905475d 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -8,4 +8,323 @@ Абонементна Послуга Очікую вхідні сповіщення Підписався на теми миттєвої доставки + Відображуване ім\'я + %1$s (за умовчанням) + Встановіть спеціальну відображувану назву для цієї підписки. Залиште поле порожнім для умовчання (%1$s). + URL служби + Введіть URL-адреси служби нижче, щоб підписатися на теми з інших серверів. + Неможливо відкрити URL: %1$s + Скасувати завантаження + Поділитися з + Автоматичне видалення сповіщень через місяць + Сервер за замовчуванням + %1$s не вдалося: %2$s + Сповіщення + Сповіщення вимкнено до відновлення + Сповіщення вимкнено до %1$s + Темний режим + Темний режим увімкнено. Ви вампір\? + Використовувати систему за умовчанням + Світловий режим + Помилка резервного копіювання: %1$s + Відновити з файлу + Просунутий + Копіювати/завантажувати журнали + Налаштування каналу + Перевизначення режиму \"Не турбувати\" (DND), звуки тощо. + Автоматичне завантаження вкладень до %1$s + Ніколи нічого не завантажуйте автоматично + Якщо менше 100 кБ + Експортуйте конфігурацію, сповіщення та користувачів + Резервне копіювання та відновлення + Резервне копіювання в файл + Все + Все, крім користувачів + Лише налаштування + Підписався на п\'ять тем миттєвої доставки + Підписався на шість тем миттєвої доставки + Підписався на п\'ять тем + Підписався на шість тем + Не вдалося оновити %1$d підписок +\n +\n%2$s + Налаштування + Повідомити про помилку + Відписатися + Скасувати підписку на вибрані теми та остаточно видалити всі сповіщення\? + Видалити назавжди + Скасувати + %1$d сповіщення + %1$d сповіщень + повторне підключення… + %1$s (UnifiedPush) + вчора + Додати підписку + Схоже, у вас ще немає жодної підписки. + Детальні інструкції доступні на ntfy.sh і в документах. + Цією підпискою керує %1$s через UnifiedPush + Щоб уникнути проблем із доставляння сповіщень, оптимізацію акумулятора слід вимкнути. + Запитайте пізніше + Відхилити + Виправ зараз + Підпишіться на тему + Теми можуть не бути захищені паролем, тому виберіть назву, яку важко вгадати. Після підписки ви можете PUT/POST сповіщення. + Назва теми, наприклад phils_alerts + Використовуйте інший сервер + Миттєва доставка в режимі дрімання + Забезпечує миттєву доставку повідомлень, навіть якщо пристрій неактивний. + Миттєва доставка завжди ввімкнена для хостів, відмінних від %1$s. + Скасувати + Підпишіться + Назад + Скасувати + Скасувати підписку на цю тему та видалити всі отримані сповіщення\? + Скасувати + Зберегти файл + Копіювати URL + видалено + Відписатися + Видалити вибрані сповіщення назавжди\? + Скасувати + Поділіться + %1$s +\nФайл: %2$s, не вдалося завантажити + Використовується темою %1$s + за допомогою глобальних налаштувань + Підписався на теми + Підписався на одну тему миттєвої доставки + Підписався на дві теми моментальної доставки + Підписався на одну тему + Підписано на %1$d тем + Підписався на три теми моментальної доставки + Підписався на чотири теми миттєвої доставки + Підписано на %1$d тем миттєвої доставки + Підписався на дві теми + Все в актуальному стані + Отримано %1$d сповіщень + Пароль + Ніколи + Програми не можуть отримувати сповіщення як трансляції + Журнали запису + Пароль + Пароль (незмінний, якщо залишити порожнім) + Скасувати + Сповіщення вимкнено + Сповіщення вимкнено до %1$s + Сповіщення вимкнено до %1$s + Підписався на три теми + Підписався на чотири теми + Підписані теми + Читайте докуменацію + Показати всі сповіщення + Сповіщення ввімкнено + за замовчуванням + Не вдалося оновити підписку: %1$s + Оцініть програму ⭐ + Це тестове сповіщення від програми ntfy для Android. Він має рівень пріоритету %1$d. Якщо ви надішлете інший, він може виглядати інакше. + Натисніть +, щоб створити тему або підписатися на неї. Після цього ви отримуєте сповіщення на свій пристрій, коли надсилаєте повідомлення через PUT або POST. + Ім\'я користувача + Скопійовано в буфер обміну + Перехід на WebSockets є рекомендованим способом підключення до вашого сервера, який може подовжити час автономної роботи, але може вимагати додаткової конфігурації вашого проксі. Це можна вимкнути в налаштуваннях. + Необхідно ввійти + Новий користувач + Копія + Авторизуватися + Помилка підключення: %1$s + Ця тема потребує авторизації. Будь ласка, введіть ім\'я користувача та пароль. + Помилка логіну. Користувач %1$s не авторизований. + Детальні інструкції доступні на ntfy.sh і в документах. + Видалити всі сповіщення в цій темі\? + Неможливо зберегти вкладення: %1$s + Не вдалося завантажити вкладений файл: %1$s + Приклад (з використанням curl):
$ curl -d \"Hi\" %1$s
+ не завантажено, закінчується %1$s + Не вдається надіслати повідомлення: вкладення завелике. + Миттєва доставка включена + Теги: %1$s + не завантажено, термін дії посилання закінчився + Ви ще не отримали сповіщень щодо цієї теми. + Щоб надіслати сповіщення до цієї теми, просто PUT або POST за URL-адресою теми. + Підписався на тему %1$s + Неможливо відкрити вкладення: %1$s + видалено, посилання діє %1$s + До завтра + %1$d%% завантажено + видалено, термін дії посилання закінчився + Видалити + Видалити остаточно + Вам надали доступ до файлу + 30 хвилин + Переглядати + %1$s +\nФайл: %2$s + Неможливо прочитати інформацію про файл: %1$s + 8 годин + До відновлення + Показувати сповіщення, якщо пріоритет 5 (макс.) + Будь-який пріоритет + Низький пріоритет і вище + Завантаження %1$s, %2$d%% +\n%3$s + Програми можуть отримувати вхідні сповіщення як трансляції + Вимкнути сповіщення + Використовується темами %1$s + Автоматичне видалення сповіщень через тиждень + низький + Якщо менше 10 Мб + Через один день + Якщо менше 50 Мб + Видалити сповіщення + Автоматичне видалення сповіщень через 3 дні + Керувати користувачами + Використання системи за замовчуванням + Світловий режим включений + Трансляція повідомлень + Додавання/видалення користувачів для захищених тем + Додати нового користувача + Створіть нового користувача для нового сервера + Темний режим + Не вдалося відновити: %1$s + Резервну копію створено + Імпортуйте конфігурацію, сповіщення та користувачів + Відновлено успішно + Скопіюйте журнали в буфер обміну або завантажте на nopaste.net (належить автору ntfy). Імена хостів і теми можуть бути піддані цензурі, сповіщення – ніколи. + Копіювати в буфер обміну (цензуровано) + Копіювати в буфер обміну + Завантаження журналу… + Ці теми/імена хостів було замінено назвами фруктів, тож ви можете ділитися журналом без хвилювань: +\n +\n%1$s +\n +\nПаролі очищаються, але не відображаються тут. + Завантажте та скопіюйте посилання + Завантажити та скопіювати посилання (цензуровано) + Журнали скопійовано в буфер обміну + Видаліть раніше записані журнали та почніть спочатку + Використовуйте WebSockets для підключення до сервера. Це рекомендований метод, але може знадобитися додаткова конфігурація вашого проксі. + Зберегти + Значок підписки + Сповіщення надходять миттєво. Потрібна служба переднього плану та споживає більше акумулятора. + Сповіщення доставляються за допомогою Firebase. Доставка може бути відкладена, але споживає менше акумулятора. + URL теми + Ім\'я користувача + Про + Скопійовано в буфер обміну + Ви можете змінити ім\'я користувача/пароль для вибраного користувача або видалити його. + Додати користувача + Ви можете додати користувача тут. Усі теми для даного сервера використовуватимуть цього користувача. + Видалити користувача + Виберіть URL-адресу служби + Очистити URL-адресу служби + Видалити остаточно + Видалити остаточно + Тест: Ви можете встановити назву, якщо хочете. + Неможливо надіслати повідомлення: %1$s + Неможливо надіслати повідомлення: анонімна публікація заборонена. + Неможливо надіслати повідомлення: користувач \"%1$s\" не авторизований. + Миттєва доставка вимкнена + Сповіщення видалено + Скасувати + Відкрити файл + Видалити файл + Завантажити файл + URL-адресу скопійовано в буфер обміну + Копіювати сповіщення + Сповіщення скопійовано в буфер обміну + Збережено як \"%1$s\" у папці \"Завантаження\" + Не вдається відкрити або завантажити вкладений файл. Термін дії посилання закінчився, і локальний файл не знайдено. + Неможливо відкрити вкладення: файл, можливо, видалено, або жодна встановлена програма не може відкрити файл. + Неможливо видалити вкладення: %1$s + не завантажено + не вдалося завантажити + не вдалося завантажити, термін дії посилання закінчився + не вдалося завантажити, термін дії посилання закінчився %1$s + Сповіщення ввімкнено + Сповіщення вимкнено + Увімкнути миттєву доставку + Вимкніть миттєву доставку + Надіслати тестове сповіщення + Копіювати адресу теми + Очистити всі сповіщення + Налаштування підписки + Налаштування підписки + Поділіться + Попередній перегляд повідомлення + Додайте вміст, щоб поділитися тут + З вами поділилися зображенням + Не вдається прочитати зображення: %1$s + Пропоновані теми + Повідомлення опубліковано + Вимкнути сповіщення + Скасувати + Зберегти + Сповіщення відновлено + Сповіщення вимкнено + Сповіщення вимкнено до %1$s + 1 година + 2 години + Відчинено + Завантажити + Скасувати + %1$s +\nФайл: %2$s, завантажено + Налаштування + Показано всі сповіщення + Мінімальний пріоритет + Показано всі сповіщення + Показувати сповіщення, якщо пріоритет %1$d (%2$s) або вище + Пріоритет за замовчуванням і вище + Високий пріоритет і вище + Тільки максимальний пріоритет + Мін + високій + макс + Завантажити вкладення + Автоматичне завантаження всіх вкладень + Ніколи не завантажуйте вкладені файли автоматично + Автоматичне завантаження всього + Якщо менше 500 кБ + Якщо менше 1 Мб + Якщо менше 5 Мб + Ніколи автоматично не видаляйте сповіщення + Автоматичне видалення сповіщень через один день + Автоматичне видалення сповіщень через 3 місяці + Через 3 дні + Через тиждень + Через місяць + Через 3 міс + Додати користувача + Загальний + Введіть кореневу URL-адресу свого сервера, щоб використовувати свій власний сервер як стандартний під час підписки на нові теми та/або спільного доступу до тем. + %1$s (за умовчанням) + Користувачі + Не використовується жодною темою + Додайте користувачів + Реєстрація (до 1000 записів) на пристрої… + Увімкніть ведення журналів, щоб пізніше ви могли поділитися журналами для діагностики проблем. + Журнали завантажено та URL-адресу скопійовано + Не вдалося завантажити журнали: %1$s + Не було відредаговано жодної теми/імена хостів. Може у вас немає підписки\? + ОК + Очистити журнали + Журнали видалено + Протокол підключення + Потік JSON через HTTP + WebSockets + Про + Версія + ntfy %1$s (%2$s) + Скопійовано в буфер обміну + Миттєва доставка + Зовнішній вигляд + Установіть піктограму, яка відображатиметься в сповіщеннях + Значок підписки (натисніть, щоб видалити) + Значок, який відображається в сповіщеннях для цієї теми + Не вдалося зберегти значок: %1$s + Використовуйте глобальні налаштування + Редагувати користувача + Запитайте пізніше + Відхилити + Увімкнути зараз + Використовуйте потік JSON через HTTP для підключення до сервера. Цей метод перевірено в боях, але може споживати більше заряду батареї. \ No newline at end of file From 5aac0e17fee5c7b7b15f021248755c879aa641d9 Mon Sep 17 00:00:00 2001 From: Vladimir Kopitsa Date: Thu, 8 Sep 2022 14:24:03 +0000 Subject: [PATCH 16/22] Translated using Weblate (Ukrainian) Currently translated at 100.0% (3 of 3 strings) Translation: ntfy/Android app (Fastlane metadata) Translate-URL: https://hosted.weblate.org/projects/ntfy/android-fastlane/uk/ --- .../metadata/android/uk/full_description.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 fastlane/metadata/android/uk/full_description.txt diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt new file mode 100644 index 0000000..76a4253 --- /dev/null +++ b/fastlane/metadata/android/uk/full_description.txt @@ -0,0 +1,17 @@ +Надсилайте сповіщення на свій телефон із будь-якого сценарію Bash або PowerShell або з власної програми за допомогою запитів PUT/POST, напр. через curl у Linux або Invoke-WebRequest. + +ntfy — це Android-клієнт для https://ntfy.sh, безкоштовного HTTP-сервісу pub-sub з відкритим кодом. Ви можете підписатися на теми в цій програмі, а потім публікувати повідомлення через простий HTTP API. + +Використання: +* Повідомте себе, коли довготривалий процес завершено +* Пропонуйте запит телефону, якщо не вдалося створити резервну копію +* Сповіщення, коли хтось входить на ваш сервер + +приклад: + +$ curl -d "Ваше резервне копіювання виконано" ntfy.sh/mytopic + +Додаткові приклади та інструкції з використання можна знайти тут: +* Веб-сайт: https://ntfy.sh +* GitHub (сервер): https://github.com/binwiederhier/ntfy +* GitHub (програма для Android): https://github.com/binwiederhier/ntfy-android From f6ce3af473f5fc586a55717545f0a4eb5f10621f Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 9 Sep 2022 14:14:03 -0400 Subject: [PATCH 17/22] Layouting icon Ahhhh --- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 44 +++++++++++++------ .../main/res/layout/fragment_detail_item.xml | 15 ++++--- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index 46c0925..e5de368 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -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 attachmentExists = if (attachment?.contentUri != null) fileExists(context, attachment.contentUri) else false + val iconExists = if (notification.icon?.contentUri != null) fileExists(context, notification.icon.contentUri) else false renderPriority(context, notification) resetCardButtons() - maybeRenderMenu(context, notification, exists) - maybeRenderAttachment(context, notification, exists) + maybeRenderMenu(context, notification, attachmentExists) + maybeRenderAttachment(context, notification, attachmentExists) + maybeRenderIcon(context, notification, iconExists) 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, attachmentExists: Boolean) { 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 && attachmentExists && supportedImage(attachment.type) maybeRenderAttachmentImage(context, attachment, image) - maybeRenderAttachmentBox(context, notification, attachment, exists, image) + maybeRenderAttachmentBox(context, notification, attachment, attachmentExists, 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, iconExists: Boolean) { + if (notification.icon == null || !iconExists) { + 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, attachmentExists: Boolean) { + val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, attachmentExists) // Heavy lifting not during on-click if (menuButtonPopupMenu != null) { menuButton.setOnClickListener { menuButtonPopupMenu.show() } menuButton.visibility = View.VISIBLE @@ -240,7 +258,7 @@ 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, attachmentExists: Boolean): PopupMenu? { val popup = PopupMenu(context, anchor) popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) val attachment = notification.attachment // May be null @@ -266,10 +284,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 != "" diff --git a/app/src/main/res/layout/fragment_detail_item.xml b/app/src/main/res/layout/fragment_detail_item.xml index 3a33599..3d625b9 100644 --- a/app/src/main/res/layout/fragment_detail_item.xml +++ b/app/src/main/res/layout/fragment_detail_item.xml @@ -51,9 +51,9 @@ 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:layout_marginEnd="7dp" android:background="?android:attr/selectableItemBackground" android:paddingTop="-5dp" - /> + app:layout_constraintEnd_toStartOf="@id/detail_item_icon"/> + app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_image" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="12dp"/> + app:layout_constraintTop_toBottomOf="@+id/detail_item_date_text" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="12dp"/> + + From f2492904ea0e4e2d628e9e4ed2fe936086340cd2 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 10 Sep 2022 15:24:53 -0400 Subject: [PATCH 18/22] Do not crash preview if icon/attachment too large --- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 7 ++-- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 41 +++++++++++-------- app/src/main/java/io/heckel/ntfy/util/Util.kt | 14 ++++++- .../java/io/heckel/ntfy/work/DeleteWorker.kt | 4 +- .../metadata/android/en-US/changelog/28.txt | 2 + 5 files changed, 42 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index 9da973c..578176a 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -98,7 +98,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) val buffer = ByteArray(BUFFER_SIZE) var bytes = fileIn.read(buffer) while (bytes >= 0) { - if (downloadLimit != null && bytesCopied > downloadLimit) { + if (bytesCopied > downloadLimit) { throw Exception("Icon is longer than max download size.") } fileOut.write(buffer, 0, bytes) @@ -106,10 +106,9 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) bytes = fileIn.read(buffer) } } + // TODO: Resize icon if >5MB, so it can be previewed. Right now it'll just not be shown. Log.d(TAG, "Icon download: successful response, proceeding with download") - save(icon.copy( - contentUri = uri.toString() - )) + save(icon.copy(contentUri = uri.toString())) } } catch (e: Exception) { failed(e) diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index e5de368..40e167b 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -131,13 +131,13 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context)) } val attachment = notification.attachment - val attachmentExists = if (attachment?.contentUri != null) fileExists(context, attachment.contentUri) else false - val iconExists = if (notification.icon?.contentUri != null) fileExists(context, notification.icon.contentUri) else false + val attachmentFileStat = maybeFileStat(context, attachment?.contentUri) + val iconFileStat = maybeFileStat(context, notification.icon?.contentUri) renderPriority(context, notification) resetCardButtons() - maybeRenderMenu(context, notification, attachmentExists) - maybeRenderAttachment(context, notification, attachmentExists) - maybeRenderIcon(context, notification, iconExists) + maybeRenderMenu(context, notification, attachmentFileStat) + maybeRenderAttachment(context, notification, attachmentFileStat) + maybeRenderIcon(context, notification, iconFileStat) maybeRenderActions(context, notification) } @@ -165,20 +165,20 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } } - private fun maybeRenderAttachment(context: Context, notification: Notification, attachmentExists: 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 && attachmentExists && supportedImage(attachment.type) + val image = attachment.contentUri != null && supportedImage(attachment.type) && previewableImage(attachmentFileStat) maybeRenderAttachmentImage(context, attachment, image) - maybeRenderAttachmentBox(context, notification, attachment, attachmentExists, image) + maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, image) } - private fun maybeRenderIcon(context: Context, notification: Notification, iconExists: Boolean) { - if (notification.icon == null || !iconExists) { + private fun maybeRenderIcon(context: Context, notification: Notification, iconStat: FileInfo?) { + if (notification.icon == null || !previewableImage(iconStat)) { iconView.visibility = View.GONE return } @@ -192,8 +192,8 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } } - private fun maybeRenderMenu(context: Context, notification: Notification, attachmentExists: Boolean) { - val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, attachmentExists) // Heavy lifting not during on-click + 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 @@ -238,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 { @@ -258,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, attachmentExists: 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) @@ -300,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) @@ -517,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() { @@ -532,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." } } diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index a3702e1..ca69cdf 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -37,7 +37,9 @@ 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 @@ -260,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, @@ -475,4 +485,4 @@ 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) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt index 8e8923f..01fdebd 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -71,7 +71,7 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx 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 } + val allIconFilenames = iconDir.listFiles()?.map{ file -> file.name }.orEmpty() val filenamesToDelete = allIconFilenames.minus(activeIconFilenames) filenamesToDelete.forEach { filename -> try { @@ -80,7 +80,6 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx if (!deleted) { Log.w(TAG, "Unable to delete icon: $filename") } - val uri = FileProvider.getUriForFile(applicationContext, DownloadIconWorker.FILE_PROVIDER_AUTHORITY, file).toString() repository.clearIconUri(uri) @@ -115,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) - } } diff --git a/fastlane/metadata/android/en-US/changelog/28.txt b/fastlane/metadata/android/en-US/changelog/28.txt index 27be690..459b0c1 100644 --- a/fastlane/metadata/android/en-US/changelog/28.txt +++ b/fastlane/metadata/android/en-US/changelog/28.txt @@ -4,11 +4,13 @@ Features: * Polling is now done with since= 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) From 89d12cb3ced57e05ddf95398423461bec87e0f8a Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 10 Sep 2022 16:06:14 -0400 Subject: [PATCH 19/22] Layouting --- .../main/res/layout/fragment_detail_item.xml | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/layout/fragment_detail_item.xml b/app/src/main/res/layout/fragment_detail_item.xml index 3d625b9..758e03c 100644 --- a/app/src/main/res/layout/fragment_detail_item.xml +++ b/app/src/main/res/layout/fragment_detail_item.xml @@ -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"> + app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="3dp"/> + app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_image" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="6dp"/> + 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"/> @@ -193,8 +192,19 @@ app:layout_constraintTop_toBottomOf="@id/detail_item_actions_wrapper" app:layout_constraintBottom_toBottomOf="parent"/> + 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"/> From a66fd623748146dfe3f432606fbafe2b639a4be9 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 10 Sep 2022 23:18:11 -0400 Subject: [PATCH 20/22] Limit download size for icons to 5 MB --- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index 578176a..253ee23 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -2,15 +2,10 @@ 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 @@ -46,16 +41,14 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) icon = notification.icon ?: return Result.failure() try { val iconFile = createIconFile(icon) - val yesterdayTimestamp = Date().time - 1000*60*60*24 // now Unix timestamp - 24 hours + val yesterdayTimestamp = Date().time - MAX_CACHE_MILLIS if (!iconFile.exists() || iconFile.lastModified() < yesterdayTimestamp) { downloadIcon(iconFile) } else { - Log.d(TAG, "Loading icon from cache: ${icon.url}") + 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() - )) + save(icon.copy(contentUri = iconUri.toString())) } } catch (e: Exception) { failed(e) @@ -70,18 +63,16 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) 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, "Download: headers received: $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}") - } - if (shouldAbortDownload(response)) { + } else if (shouldAbortDownload(response)) { Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting") return } @@ -106,7 +97,6 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) bytes = fileIn.read(buffer) } } - // TODO: Resize icon if >5MB, so it can be previewed. Right now it'll just not be shown. Log.d(TAG, "Icon download: successful response, proceeding with download") save(icon.copy(contentUri = uri.toString())) } @@ -145,9 +135,9 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) private fun getDownloadLimit(): Long { return if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { - repository.getAutoDownloadMaxSize() + Math.min(repository.getAutoDownloadMaxSize(), MAX_ICON_DOWNLOAD_BYTES) } else { - MAX_ICON_DOWNLOAD_SIZE.toLong() + DEFAULT_MAX_ICON_DOWNLOAD_BYTES } } @@ -167,10 +157,12 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) 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 + 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" - const val ICON_CACHE_DIR = "icons" private const val BUFFER_SIZE = 8 * 1024 } } From 3e3189fdf2e212247e3b2f25dc9b10949418afc0 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 11 Sep 2022 15:28:47 -0400 Subject: [PATCH 21/22] Download icons when adding subscription --- app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 2d7865d..d946fc6 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -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) } From c5701e5d98c91945bece5e8c50f5212fa1d38c75 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 11 Sep 2022 15:33:36 -0400 Subject: [PATCH 22/22] Ukranian translation --- fastlane/metadata/android/en-US/changelog/28.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/fastlane/metadata/android/en-US/changelog/28.txt b/fastlane/metadata/android/en-US/changelog/28.txt index 459b0c1..2c8aef9 100644 --- a/fastlane/metadata/android/en-US/changelog/28.txt +++ b/fastlane/metadata/android/en-US/changelog/28.txt @@ -15,5 +15,6 @@ Bugs: Additional translations: * Italian (thanks to @Genio2003) * Dutch (thanks to @SchoNie) +* Ukranian (thanks to @v.kopitsa) Thank you to @wunter8 for proactively picking up some Android tickets, and fixing them! You rock!