diff --git a/app/schemas/io.heckel.ntfy.db.Database/12.json b/app/schemas/io.heckel.ntfy.db.Database/12.json index 31f125c..ce0c4d8 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..62daf0a 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") } } @@ -384,8 +380,11 @@ 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("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..cee2985 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,12 @@ 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 clearIconUri(uri: String) { + notificationDao.clearIconUri(uri) } fun getNotificationsLiveData(subscriptionId: Long): LiveData> { 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()) { 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..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,28 +2,24 @@ 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 io.heckel.ntfy.util.sha256 import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import java.io.File +import java.util.Date import java.util.concurrent.TimeUnit class DownloadIconWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { private val client = OkHttpClient.Builder() - .callTimeout(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) @@ -44,7 +40,16 @@ 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) + val yesterdayTimestamp = Date().time - MAX_CACHE_MILLIS + if (!iconFile.exists() || iconFile.lastModified() < yesterdayTimestamp) { + downloadIcon(iconFile) + } else { + Log.d(TAG, "Loading icon from cache: $iconFile") + val iconUri = createIconUri(iconFile) + this.uri = iconUri // Required for cleanup in onStopped() + save(icon.copy(contentUri = iconUri.toString())) + } } catch (e: Exception) { failed(e) } @@ -56,43 +61,35 @@ 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 { 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}") - } - save(updateIconFromResponse(response)) - if (shouldAbortDownload()) { + } else if (shouldAbortDownload(response)) { Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting") 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") - 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 - } + val downloadLimit = getDownloadLimit() outFile.use { fileOut -> val fileIn = response.body!!.byteStream() val buffer = ByteArray(BUFFER_SIZE) var bytes = fileIn.read(buffer) while (bytes >= 0) { - if (downloadLimit != null && bytesCopied > downloadLimit) { + if (bytesCopied > downloadLimit) { throw Exception("Icon is longer than max download size.") } fileOut.write(buffer, 0, bytes) @@ -101,49 +98,13 @@ 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() - )) + save(icon.copy(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,28 +127,42 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) 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 + private fun shouldAbortDownload(response: Response): Boolean { + val maxAutoDownloadSize = getDownloadLimit() + val size = response.headers["Content-Length"]?.toLongOrNull() ?: return false // Don't abort here if size unknown return size > maxAutoDownloadSize } - private fun createUri(notification: Notification): Uri { + private fun getDownloadLimit(): Long { + return if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { + Math.min(repository.getAutoDownloadMaxSize(), MAX_ICON_DOWNLOAD_BYTES) + } else { + DEFAULT_MAX_ICON_DOWNLOAD_BYTES + } + } + + private fun createIconFile(icon: Icon): File { val iconDir = File(context.cacheDir, ICON_CACHE_DIR) if (!iconDir.exists() && !iconDir.mkdirs()) { throw Exception("Cannot create cache directory for icons: $iconDir") } - val file = ensureSafeNewFile(iconDir, notification.id) - return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file) + val hash = icon.url.sha256() + return File(iconDir, hash) + } + + private fun createIconUri(iconFile: File): Uri { + return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, iconFile) } companion object { const val INPUT_DATA_ID = "id" const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml - const val 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" - 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/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index bc3799c..cb52e84 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, 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 644cc47..10cd32a 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -57,6 +57,7 @@ class NotificationParser { ) } } else null + val icon: Icon? = if (message.icon != null) Icon(url = message.icon) else null val notification = Notification( id = message.id, subscriptionId = subscriptionId, 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 { 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..40e167b 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 attachmentFileStat = maybeFileStat(context, attachment?.contentUri) + val iconFileStat = maybeFileStat(context, notification.icon?.contentUri) renderPriority(context, notification) resetCardButtons() - maybeRenderMenu(context, notification, exists) - maybeRenderAttachment(context, notification, exists) + maybeRenderMenu(context, notification, attachmentFileStat) + maybeRenderAttachment(context, notification, attachmentFileStat) + maybeRenderIcon(context, notification, iconFileStat) maybeRenderActions(context, notification) } @@ -162,20 +165,35 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } } - private fun maybeRenderAttachment(context: Context, notification: Notification, exists: Boolean) { + private fun maybeRenderAttachment(context: Context, notification: Notification, attachmentFileStat: FileInfo?) { if (notification.attachment == null) { attachmentImageView.visibility = View.GONE attachmentBoxView.visibility = View.GONE return } val attachment = notification.attachment - val image = attachment.contentUri != null && exists && supportedImage(attachment.type) + val image = attachment.contentUri != null && supportedImage(attachment.type) && previewableImage(attachmentFileStat) maybeRenderAttachmentImage(context, attachment, image) - maybeRenderAttachmentBox(context, notification, attachment, exists, image) + maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, image) } - private fun maybeRenderMenu(context: Context, notification: Notification, exists: Boolean) { - val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, exists) // Heavy lifting not during on-click + private fun maybeRenderIcon(context: Context, notification: Notification, iconStat: FileInfo?) { + if (notification.icon == null || !previewableImage(iconStat)) { + iconView.visibility = View.GONE + return + } + try { + val icon = notification.icon + val bitmap = icon.contentUri?.readBitmapFromUri(context) ?: throw Exception("uri empty") + iconView.setImageBitmap(bitmap) + iconView.visibility = View.VISIBLE + } catch (_: Exception) { + iconView.visibility = View.GONE + } + } + + private fun maybeRenderMenu(context: Context, notification: Notification, attachmentFileStat: FileInfo?) { + val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, attachmentFileStat) // Heavy lifting not during on-click if (menuButtonPopupMenu != null) { menuButton.setOnClickListener { menuButtonPopupMenu.show() } menuButton.visibility = View.VISIBLE @@ -220,14 +238,14 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: return button } - private fun maybeRenderAttachmentBox(context: Context, notification: Notification, attachment: Attachment, exists: Boolean, image: Boolean) { + private fun maybeRenderAttachmentBox(context: Context, notification: Notification, attachment: Attachment, attachmentFileStat: FileInfo?, image: Boolean) { if (image) { attachmentBoxView.visibility = View.GONE return } - attachmentInfoView.text = formatAttachmentDetails(context, attachment, exists) + attachmentInfoView.text = formatAttachmentDetails(context, attachment, attachmentFileStat) attachmentIconView.setImageResource(mimeTypeToIconResource(attachment.type)) - val attachmentBoxPopupMenu = maybeCreateMenuPopup(context, attachmentBoxView, notification, exists) // Heavy lifting not during on-click + val attachmentBoxPopupMenu = maybeCreateMenuPopup(context, attachmentBoxView, notification, attachmentFileStat) // Heavy lifting not during on-click if (attachmentBoxPopupMenu != null) { attachmentBoxView.setOnClickListener { attachmentBoxPopupMenu.show() } } else { @@ -240,11 +258,12 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: attachmentBoxView.visibility = View.VISIBLE } - private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification, exists: Boolean): PopupMenu? { + private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification, attachmentFileStat: FileInfo?): PopupMenu? { val popup = PopupMenu(context, anchor) popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) val attachment = notification.attachment // May be null val hasAttachment = attachment != null + val attachmentExists = attachmentFileStat != null val hasClickLink = notification.click != "" val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel) @@ -266,10 +285,10 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: if (hasClickLink) { copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) } } - openItem.isVisible = hasAttachment && exists - downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress - deleteItem.isVisible = hasAttachment && exists - saveFileItem.isVisible = hasAttachment && exists + openItem.isVisible = hasAttachment && attachmentExists + downloadItem.isVisible = hasAttachment && !attachmentExists && !expired && !inProgress + deleteItem.isVisible = hasAttachment && attachmentExists + saveFileItem.isVisible = hasAttachment && attachmentExists copyUrlItem.isVisible = hasAttachment && !expired cancelItem.isVisible = hasAttachment && inProgress copyContentsItem.isVisible = notification.click != "" @@ -282,8 +301,9 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: return popup } - private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String { + private fun formatAttachmentDetails(context: Context, attachment: Attachment, attachmentFileStat: FileInfo?): String { val name = attachment.name + val exists = attachmentFileStat != null val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE val downloading = !exists && attachment.progress in 0..99 val deleted = !exists && (attachment.progress == ATTACHMENT_PROGRESS_DONE || attachment.progress == ATTACHMENT_PROGRESS_DELETED) @@ -499,6 +519,10 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } context.sendBroadcast(intent) } + + private fun previewableImage(fileStat: FileInfo?): Boolean { + return if (fileStat != null) fileStat.size <= IMAGE_PREVIEW_MAX_BYTES else false + } } object TopicDiffCallback : DiffUtil.ItemCallback() { @@ -514,5 +538,6 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: companion object { const val TAG = "NtfyDetailAdapter" const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876 + const val IMAGE_PREVIEW_MAX_BYTES = 5 * 1024 * 1024 // Too large images crash the app with "Canvas: trying to draw too large(233280000bytes) bitmap." } } 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) } 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..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,10 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody import okio.BufferedSink import okio.source -import java.io.* +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.security.MessageDigest import java.security.SecureRandom import java.text.DateFormat import java.text.StringCharacterIterator @@ -259,6 +262,14 @@ fun fileStat(context: Context, contentUri: Uri?): FileInfo { } } +fun maybeFileStat(context: Context, contentUri: String?): FileInfo? { + return try { + fileStat(context, Uri.parse(contentUri)) // Throws if the file does not exist + } catch (_: Exception) { + null + } +} + data class FileInfo( val filename: String, val size: Long, @@ -469,3 +480,9 @@ fun copyToClipboard(context: Context, notification: Notification) { .makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG) .show() } + +fun String.sha256(): String { + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(this.toByteArray()) + return digest.fold("") { str, it -> str + "%02x".format(it) } +} 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..01fdebd 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -2,16 +2,21 @@ package io.heckel.ntfy.work import android.content.Context import android.net.Uri +import androidx.core.content.FileProvider import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DELETED import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.msg.DownloadIconWorker import io.heckel.ntfy.ui.DetailAdapter import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.fileStat import io.heckel.ntfy.util.topicShortUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.File +import java.util.* /** * Deletes notifications marked for deletion and attachments for deleted notifications. @@ -62,25 +67,24 @@ 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 -> + val activeIconUris = repository.getActiveIconUris() + val activeIconFilenames = activeIconUris.map{ fileStat(applicationContext, Uri.parse(it)).filename }.toSet() + val iconDir = File(applicationContext.cacheDir, DownloadIconWorker.ICON_CACHE_DIR) + val allIconFilenames = iconDir.listFiles()?.map{ file -> file.name }.orEmpty() + val filenamesToDelete = allIconFilenames.minus(activeIconFilenames) + filenamesToDelete.forEach { filename -> try { - val 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 file = File(iconDir, filename) + val deleted = file.delete() if (!deleted) { - Log.w(TAG, "Unable to delete icon for notification ${notification.id}") + Log.w(TAG, "Unable to delete icon: $filename") } - val newIcon = icon.copy( - contentUri = null, - ) - val newNotification = notification.copy(icon = newIcon) - repository.updateNotification(newNotification) + val uri = FileProvider.getUriForFile(applicationContext, + DownloadIconWorker.FILE_PROVIDER_AUTHORITY, file).toString() + repository.clearIconUri(uri) } catch (e: Exception) { - Log.w(TAG, "Failed to delete icon for notification: ${e.message}", e) + Log.w(TAG, "Failed to delete icon: ${e.message}", e) } } } @@ -110,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/app/src/main/res/layout/fragment_detail_item.xml b/app/src/main/res/layout/fragment_detail_item.xml index 3a33599..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,5 +190,21 @@ android:id="@+id/detail_item_padding_bottom" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/detail_item_actions_wrapper" app:layout_constraintBottom_toBottomOf="parent"/> + + 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 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 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 620f18b..259fa29 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 (по умолчанию) @@ -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 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 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(), diff --git a/fastlane/metadata/android/en-US/changelog/28.txt b/fastlane/metadata/android/en-US/changelog/28.txt index 27be690..2c8aef9 100644 --- a/fastlane/metadata/android/en-US/changelog/28.txt +++ b/fastlane/metadata/android/en-US/changelog/28.txt @@ -4,14 +4,17 @@ 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) * 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! 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 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 на ваш телефон