Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Hunter Kehoe 2022-09-12 10:04:28 -06:00
commit dc2cfe567d
24 changed files with 557 additions and 183 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 12, "version": 12,
"identityHash": "5a061926458ed65c80431be0a69a2450", "identityHash": "d230005f4d9824ba9aa34c61003bdcbb",
"entities": [ "entities": [
{ {
"tableName": "Subscription", "tableName": "Subscription",
@ -112,7 +112,7 @@
}, },
{ {
"tableName": "Notification", "tableName": "Notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -193,18 +193,6 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": false "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", "fieldPath": "icon.contentUri",
"columnName": "icon_contentUri", "columnName": "icon_contentUri",
@ -350,7 +338,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5a061926458ed65c80431be0a69a2450')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd230005f4d9824ba9aa34c61003bdcbb')"
] ]
} }
} }

View file

@ -152,8 +152,6 @@ class Backuper(val context: Context) {
val icon = if (n.icon != null) { val icon = if (n.icon != null) {
io.heckel.ntfy.db.Icon( io.heckel.ntfy.db.Icon(
url = n.icon.url, url = n.icon.url,
type = n.icon.type,
size = n.icon.size,
contentUri = n.icon.contentUri, contentUri = n.icon.contentUri,
) )
} else { } else {
@ -281,8 +279,6 @@ class Backuper(val context: Context) {
val icon = if (n.icon != null) { val icon = if (n.icon != null) {
Icon( Icon(
url = n.icon.url, url = n.icon.url,
type = n.icon.type,
size = n.icon.size,
contentUri = n.icon.contentUri, contentUri = n.icon.contentUri,
) )
} else { } else {
@ -403,10 +399,7 @@ data class Attachment(
data class Icon( data class Icon(
val url: String, // URL (mandatory, see ntfy server) 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 contentUri: String?, // After it's downloaded, the content:// location
val progress: Int, // Progress during download, -1 if not downloaded
) )
data class User( data class User(

View file

@ -95,12 +95,10 @@ const val ATTACHMENT_PROGRESS_DONE = 100
@Entity @Entity
data class Icon( data class Icon(
@ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server) @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 @ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location
) { ) {
constructor(url:String, type: String?, size: Long?) : constructor(url:String) :
this(url, type, size, null) this(url, null)
} }
@Entity @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 lastNotificationId TEXT")
db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT") db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT")
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_url TEXT") // Room limitation: Has to be nullable for @Embedded db.execSQL("ALTER TABLE Notification ADD COLUMN icon_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") 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 <> ''") @Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''")
fun listDeletedWithAttachments(): List<Notification> fun listDeletedWithAttachments(): List<Notification>
@Query("SELECT * FROM notification WHERE deleted = 1 AND icon_contentUri <> ''") @Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted != 1 AND icon_contentUri <> ''")
fun listDeletedWithIcons(): List<Notification> fun listActiveIconUris(): List<String>
@Query("UPDATE notification SET icon_contentUri = null WHERE icon_contentUri = :uri")
fun clearIconUri(uri: String)
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun add(notification: Notification) fun add(notification: Notification)

View file

@ -92,8 +92,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
return notificationDao.listDeletedWithAttachments() return notificationDao.listDeletedWithAttachments()
} }
fun getDeletedNotificationsWithIcons(): List<Notification> { fun getActiveIconUris(): Set<String> {
return notificationDao.listDeletedWithIcons() return notificationDao.listActiveIconUris().toSet()
}
fun clearIconUri(uri: String) {
notificationDao.clearIconUri(uri)
} }
fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> { fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> {

View file

@ -82,11 +82,7 @@ class DownloadAttachmentWorker(private val context: Context, params: WorkerParam
Log.d(TAG, "Starting download to content URI: $uri") Log.d(TAG, "Starting download to content URI: $uri")
var bytesCopied: Long = 0 var bytesCopied: Long = 0
val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
val downloadLimit = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { val downloadLimit = getDownloadLimit(userAction)
repository.getAutoDownloadMaxSize()
} else {
null
}
outFile.use { fileOut -> outFile.use { fileOut ->
val fileIn = response.body!!.byteStream() val fileIn = response.body!!.byteStream()
val buffer = ByteArray(BUFFER_SIZE) val buffer = ByteArray(BUFFER_SIZE)
@ -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 { private fun createUri(notification: Notification): Uri {
val attachmentDir = File(context.cacheDir, ATTACHMENT_CACHE_DIR) val attachmentDir = File(context.cacheDir, ATTACHMENT_CACHE_DIR)
if (!attachmentDir.exists() && !attachmentDir.mkdirs()) { if (!attachmentDir.exists() && !attachmentDir.mkdirs()) {

View file

@ -2,28 +2,24 @@ package io.heckel.ntfy.msg
import android.content.Context import android.content.Context
import android.net.Uri 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.core.content.FileProvider
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.* import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.ensureSafeNewFile import io.heckel.ntfy.util.sha256
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import java.io.File import java.io.File
import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class DownloadIconWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { class DownloadIconWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request .callTimeout(1, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(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() subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
icon = notification.icon ?: return Result.failure() icon = notification.icon ?: return Result.failure()
try { 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) { } catch (e: Exception) {
failed(e) failed(e)
} }
@ -56,43 +61,35 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
maybeDeleteFile() maybeDeleteFile()
} }
private fun downloadIcon() { private fun downloadIcon(iconFile: File) {
Log.d(TAG, "Downloading icon from ${icon.url}") Log.d(TAG, "Downloading icon from ${icon.url}")
try { try {
val request = Request.Builder() val request = Request.Builder()
.url(icon.url) .url(icon.url)
.addHeader("User-Agent", ApiService.USER_AGENT) .addHeader("User-Agent", ApiService.USER_AGENT)
.build() .build()
client.newCall(request).execute().use { response -> 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) { if (!response.isSuccessful || response.body == null) {
throw Exception("Unexpected response: ${response.code}") throw Exception("Unexpected response: ${response.code}")
} } else if (shouldAbortDownload(response)) {
save(updateIconFromResponse(response))
if (shouldAbortDownload()) {
Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting") Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting")
return return
} }
val resolver = applicationContext.contentResolver val resolver = applicationContext.contentResolver
val uri = createUri(notification) val uri = createIconUri(iconFile)
this.uri = uri // Required for cleanup in onStopped() this.uri = uri // Required for cleanup in onStopped()
Log.d(TAG, "Starting download to content URI: $uri") Log.d(TAG, "Starting download to content URI: $uri")
val contentLength = response.headers["Content-Length"]?.toLongOrNull()
var bytesCopied: Long = 0 var bytesCopied: Long = 0
val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
val downloadLimit = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { val downloadLimit = getDownloadLimit()
repository.getAutoDownloadMaxSize()
} else {
null
}
outFile.use { fileOut -> outFile.use { fileOut ->
val fileIn = response.body!!.byteStream() val fileIn = response.body!!.byteStream()
val buffer = ByteArray(BUFFER_SIZE) val buffer = ByteArray(BUFFER_SIZE)
var bytes = fileIn.read(buffer) var bytes = fileIn.read(buffer)
while (bytes >= 0) { while (bytes >= 0) {
if (downloadLimit != null && bytesCopied > downloadLimit) { if (bytesCopied > downloadLimit) {
throw Exception("Icon is longer than max download size.") throw Exception("Icon is longer than max download size.")
} }
fileOut.write(buffer, 0, bytes) 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") Log.d(TAG, "Icon download: successful response, proceeding with download")
save(icon.copy( save(icon.copy(contentUri = uri.toString()))
size = bytesCopied,
contentUri = uri.toString()
))
} }
} catch (e: Exception) { } catch (e: Exception) {
failed(e) 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) { private fun failed(e: Exception) {
Log.w(TAG, "Icon download failed", e) Log.w(TAG, "Icon download failed", e)
maybeDeleteFile() maybeDeleteFile()
@ -166,28 +127,42 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
repository.updateNotification(notification) repository.updateNotification(notification)
} }
private fun shouldAbortDownload(): Boolean { private fun shouldAbortDownload(response: Response): Boolean {
val maxAutoDownloadSize = MAX_ICON_DOWNLOAD_SIZE val maxAutoDownloadSize = getDownloadLimit()
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 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) val iconDir = File(context.cacheDir, ICON_CACHE_DIR)
if (!iconDir.exists() && !iconDir.mkdirs()) { if (!iconDir.exists() && !iconDir.mkdirs()) {
throw Exception("Cannot create cache directory for icons: $iconDir") throw Exception("Cannot create cache directory for icons: $iconDir")
} }
val file = ensureSafeNewFile(iconDir, notification.id) val hash = icon.url.sha256()
return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file) return File(iconDir, hash)
}
private fun createIconUri(iconFile: File): Uri {
return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, iconFile)
} }
companion object { companion object {
const val INPUT_DATA_ID = "id" const val INPUT_DATA_ID = "id"
const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml 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 TAG = "NtfyIconDownload"
private const val ICON_CACHE_DIR = "icons"
private const val BUFFER_SIZE = 8 * 1024 private const val BUFFER_SIZE = 8 * 1024
} }
} }

View file

@ -13,7 +13,7 @@ data class Message(
val priority: Int?, val priority: Int?,
val tags: List<String>?, val tags: List<String>?,
val click: String?, val click: String?,
val icon: MessageIcon?, val icon: String?,
val actions: List<MessageAction>?, val actions: List<MessageAction>?,
val title: String?, val title: String?,
val message: String, val message: String,

View file

@ -60,8 +60,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
Log.d(TAG, "Attachment already expired at ${attachment.expires}, not downloading") Log.d(TAG, "Attachment already expired at ${attachment.expires}, not downloading")
return false return false
} }
val maxAutoDownloadSize = repository.getAutoDownloadMaxSize() when (val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()) {
when (maxAutoDownloadSize) {
Repository.AUTO_DOWNLOAD_ALWAYS -> return true Repository.AUTO_DOWNLOAD_ALWAYS -> return true
Repository.AUTO_DOWNLOAD_NEVER -> return false Repository.AUTO_DOWNLOAD_NEVER -> return false
else -> { else -> {
@ -73,15 +72,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
} }
} }
private fun shouldDownloadIcon(notification: Notification): Boolean { private fun shouldDownloadIcon(notification: Notification): Boolean {
if (notification.icon == null) { return notification.icon != null
return false
}
val icon = notification.icon
val maxIconDownloadSize = DownloadIconWorker.MAX_ICON_DOWNLOAD_SIZE
if (icon.size == null) {
return true // DownloadWorker will bail out if attachment is too large!
}
return icon.size <= maxIconDownloadSize
} }
private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {

View file

@ -57,6 +57,7 @@ class NotificationParser {
) )
} }
} else null } else null
val icon: Icon? = if (message.icon != null) Icon(url = message.icon) else null
val notification = Notification( val notification = Notification(
id = message.id, id = message.id,
subscriptionId = subscriptionId, subscriptionId = subscriptionId,

View file

@ -96,7 +96,7 @@ class NotificationService(val context: Context) {
val contentUri = notification.attachment?.contentUri val contentUri = notification.attachment?.contentUri
val isSupportedImage = supportedImage(notification.attachment?.type) val isSupportedImage = supportedImage(notification.attachment?.type)
val subscriptionIcon = if (subscription.icon != null) subscription.icon.readBitmapFromUriOrNull(context) else null val subscriptionIcon = if (subscription.icon != null) subscription.icon.readBitmapFromUriOrNull(context) else null
val notificationIcon = if (notification.icon != null && supportedImage(notification.icon.type)) notification.icon.contentUri?.readBitmapFromUriOrNull(context) else null val notificationIcon = if (notification.icon != null) notification.icon.contentUri?.readBitmapFromUriOrNull(context) else null
val largeIcon = notificationIcon ?: subscriptionIcon val largeIcon = notificationIcon ?: subscriptionIcon
if (contentUri != null && isSupportedImage) { if (contentUri != null && isSupportedImage) {
try { try {

View file

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

View file

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

View file

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

View file

@ -2,16 +2,21 @@ package io.heckel.ntfy.work
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.content.FileProvider
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DELETED import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DELETED
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.msg.DownloadIconWorker
import io.heckel.ntfy.ui.DetailAdapter import io.heckel.ntfy.ui.DetailAdapter
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.fileStat
import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.util.topicShortUrl
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.util.*
/** /**
* Deletes notifications marked for deletion and attachments for deleted notifications. * Deletes notifications marked for deletion and attachments for deleted notifications.
@ -62,25 +67,24 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
private fun deleteExpiredIcons() { private fun deleteExpiredIcons() {
Log.d(TAG, "Deleting icons for deleted notifications") Log.d(TAG, "Deleting icons for deleted notifications")
val resolver = applicationContext.contentResolver
val repository = Repository.getInstance(applicationContext) val repository = Repository.getInstance(applicationContext)
val notifications = repository.getDeletedNotificationsWithIcons() val activeIconUris = repository.getActiveIconUris()
notifications.forEach { notification -> 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 { try {
val icon = notification.icon ?: return val file = File(iconDir, filename)
val contentUri = Uri.parse(icon.contentUri ?: return) val deleted = file.delete()
Log.d(TAG, "Deleting icon for notification ${notification.id}: ${icon.contentUri} (${icon.url})")
val deleted = resolver.delete(contentUri, null, null) > 0
if (!deleted) { 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( val uri = FileProvider.getUriForFile(applicationContext,
contentUri = null, DownloadIconWorker.FILE_PROVIDER_AUTHORITY, file).toString()
) repository.clearIconUri(uri)
val newNotification = notification.copy(icon = newIcon)
repository.updateNotification(newNotification)
} catch (e: Exception) { } 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 val deleteOlderThanTimestamp = (System.currentTimeMillis()/1000) - HARD_DELETE_AFTER_SECONDS
Log.d(TAG, "[$logId] Hard deleting notifications older than $markDeletedOlderThanTimestamp") Log.d(TAG, "[$logId] Hard deleting notifications older than $markDeletedOlderThanTimestamp")
repository.removeNotificationsIfOlderThan(subscription.id, deleteOlderThanTimestamp) repository.removeNotificationsIfOlderThan(subscription.id, deleteOlderThanTimestamp)
} }
} }

View file

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

View file

@ -19,11 +19,11 @@
\n%2$s</string> \n%2$s</string>
<string name="refresh_message_error_one">Tidak dapat memuat ulang langganan: %1$s</string> <string name="refresh_message_error_one">Tidak dapat memuat ulang langganan: %1$s</string>
<string name="main_action_bar_title">Topik berlangganan</string> <string name="main_action_bar_title">Topik berlangganan</string>
<string name="main_menu_notifications_enabled">Notifikasi nyala</string> <string name="main_menu_notifications_enabled">Notifikasi menyala</string>
<string name="main_menu_notifications_disabled_forever">Notifikasi dibisukan</string> <string name="main_menu_notifications_disabled_forever">Notifikasi dibisukan</string>
<string name="main_menu_notifications_disabled_until">Notifikasi dibisukan sampai %1$s</string> <string name="main_menu_notifications_disabled_until">Notifikasi dibisukan sampai %1$s</string>
<string name="main_menu_settings_title">Pengaturan</string> <string name="main_menu_settings_title">Pengaturan</string>
<string name="main_menu_report_bug_title">Laporkan sebuah kutu</string> <string name="main_menu_report_bug_title">Laporkan sebuah bug</string>
<string name="main_menu_docs_title">Baca dokumentasi</string> <string name="main_menu_docs_title">Baca dokumentasi</string>
<string name="main_menu_rate_title">Beri nilai aplikasi ⭐</string> <string name="main_menu_rate_title">Beri nilai aplikasi ⭐</string>
<string name="main_action_mode_menu_unsubscribe">Batalkan langganan</string> <string name="main_action_mode_menu_unsubscribe">Batalkan langganan</string>
@ -35,7 +35,7 @@
<string name="main_how_to_intro">Tekan + untuk membuat atau berlangganan ke sebuah topik. Setelah itu Anda menerima notifikasi pada perangkat Anda saat mengirim pesan via PUT atau POST.</string> <string name="main_how_to_intro">Tekan + untuk membuat atau berlangganan ke sebuah topik. Setelah itu Anda menerima notifikasi pada perangkat Anda saat mengirim pesan via PUT atau POST.</string>
<string name="main_how_to_link">Instruksi rinci tersedia di ntfy.sh, dan dalam dokumentasi.</string> <string name="main_how_to_link">Instruksi rinci tersedia di ntfy.sh, dan dalam dokumentasi.</string>
<string name="main_unified_push_toast">Langganan ini dikelola oleh %1$s melalui UnifiedPush</string> <string name="main_unified_push_toast">Langganan ini dikelola oleh %1$s melalui UnifiedPush</string>
<string name="main_banner_battery_text">Pengoptimalan baterai untuk aplikasi seharusnya mati supaya masalah pengiriman notifikasi dapat dihindari.</string> <string name="main_banner_battery_text">Pengoptimalan baterai untuk aplikasi sebaiknya dimatikan supaya masalah pengiriman notifikasi dapat dihindari.</string>
<string name="main_banner_battery_button_remind_later">Tanya nanti</string> <string name="main_banner_battery_button_remind_later">Tanya nanti</string>
<string name="main_banner_battery_button_dismiss">Abaikan</string> <string name="main_banner_battery_button_dismiss">Abaikan</string>
<string name="main_banner_battery_button_fix_now">Perbaiki sekarang</string> <string name="main_banner_battery_button_fix_now">Perbaiki sekarang</string>
@ -54,7 +54,7 @@
<string name="add_dialog_login_error_not_authorized">Login gagal. Pengguna %1$s tidak diizinkan.</string> <string name="add_dialog_login_error_not_authorized">Login gagal. Pengguna %1$s tidak diizinkan.</string>
<string name="add_dialog_login_new_user">Pengguna baru</string> <string name="add_dialog_login_new_user">Pengguna baru</string>
<string name="detail_no_notifications_text">Anda belum menerima notifikasi apa pun.</string> <string name="detail_no_notifications_text">Anda belum menerima notifikasi apa pun.</string>
<string name="detail_how_to_intro">Untuk mengirimkan notifikasi ke topik ini, tinggal PUT atau POST ke URL topik.</string> <string name="detail_how_to_intro">Untuk mengirimkan notifikasi ke topik ini, lakukan PUT atau POST ke URL topik.</string>
<string name="detail_how_to_example">Contoh (menggunakan curl):<br/><tt>$ curl -d \"Hai\" %1$s</tt></string> <string name="detail_how_to_example">Contoh (menggunakan curl):<br/><tt>$ curl -d \"Hai\" %1$s</tt></string>
<string name="detail_how_to_link">Instruksi rinci tersedia di ntfy.sh, dan dalam dokumentasi.</string> <string name="detail_how_to_link">Instruksi rinci tersedia di ntfy.sh, dan dalam dokumentasi.</string>
<string name="detail_clear_dialog_message">Hapus semua notifikasi di topik ini\?</string> <string name="detail_clear_dialog_message">Hapus semua notifikasi di topik ini\?</string>
@ -68,7 +68,7 @@
<string name="detail_test_message_error_unauthorized_anon">Tidak dapat mengirimkan pesan: Penerbitan anonim tidak diizinkan.</string> <string name="detail_test_message_error_unauthorized_anon">Tidak dapat mengirimkan pesan: Penerbitan anonim tidak diizinkan.</string>
<string name="detail_test_message_error_too_large">Tidak dapat mengirimkan pesan: Lampiran terlalu besar.</string> <string name="detail_test_message_error_too_large">Tidak dapat mengirimkan pesan: Lampiran terlalu besar.</string>
<string name="detail_copied_to_clipboard_message">Disalin ke papan klip</string> <string name="detail_copied_to_clipboard_message">Disalin ke papan klip</string>
<string name="detail_instant_delivery_enabled">Pengiriman instan nyala</string> <string name="detail_instant_delivery_enabled">Pengiriman instan menyala</string>
<string name="detail_instant_delivery_disabled">Pengiriman instan mati</string> <string name="detail_instant_delivery_disabled">Pengiriman instan mati</string>
<string name="detail_item_tags">Tanda: %1$s</string> <string name="detail_item_tags">Tanda: %1$s</string>
<string name="detail_item_snack_deleted">Notifikasi dihapus</string> <string name="detail_item_snack_deleted">Notifikasi dihapus</string>
@ -165,8 +165,8 @@
<string name="settings_general_users_prefs_user_used_by_many">Digunakan oleh topik %1$s</string> <string name="settings_general_users_prefs_user_used_by_many">Digunakan oleh topik %1$s</string>
<string name="settings_general_users_prefs_user_add_title">Tambahkan pengguna baru</string> <string name="settings_general_users_prefs_user_add_title">Tambahkan pengguna baru</string>
<string name="settings_general_dark_mode_title">Mode gelap</string> <string name="settings_general_dark_mode_title">Mode gelap</string>
<string name="settings_general_dark_mode_summary_light">Mode terang nyala</string> <string name="settings_general_dark_mode_summary_light">Mode terang menyala</string>
<string name="settings_general_dark_mode_summary_dark">Mode gelap nyala. Apakah Anda seorang vampir\?</string> <string name="settings_general_dark_mode_summary_dark">Mode gelap menyala. Apakah Anda seorang vampir\?</string>
<string name="settings_general_dark_mode_entry_system">Gunakan bawaan sistem</string> <string name="settings_general_dark_mode_entry_system">Gunakan bawaan sistem</string>
<string name="settings_general_dark_mode_entry_light">Mode terang</string> <string name="settings_general_dark_mode_entry_light">Mode terang</string>
<string name="settings_general_dark_mode_entry_dark">Mode gelap</string> <string name="settings_general_dark_mode_entry_dark">Mode gelap</string>
@ -236,7 +236,7 @@
<string name="main_item_status_text_not_one">%1$d notifikasi</string> <string name="main_item_status_text_not_one">%1$d notifikasi</string>
<string name="detail_test_message">Ini adalah notifikasi uji coba dari aplikasi Android ntfy. Ini memiliki tingkat prioritas %1$d. Jika Anda kirim yang lain, itu mungkin kelihatan berbeda.</string> <string name="detail_test_message">Ini adalah notifikasi uji coba dari aplikasi Android ntfy. Ini memiliki tingkat prioritas %1$d. Jika Anda kirim yang lain, itu mungkin kelihatan berbeda.</string>
<string name="detail_test_message_error_unauthorized_user">Tidak dapat mengirimkan pesan: Pengguna \"%1$s\" tidak diizinkan.</string> <string name="detail_test_message_error_unauthorized_user">Tidak dapat mengirimkan pesan: Pengguna \"%1$s\" tidak diizinkan.</string>
<string name="detail_menu_notifications_enabled">Notifikasi nyala</string> <string name="detail_menu_notifications_enabled">Notifikasi menyala</string>
<string name="notification_popup_file_download_successful">%1$s <string name="notification_popup_file_download_successful">%1$s
\nFile: %2$s, terunduh</string> \nFile: %2$s, terunduh</string>
<string name="detail_item_saved_successfully">Disimpan sebagai \"%1$s\" dalam folder \"Downloads\"</string> <string name="detail_item_saved_successfully">Disimpan sebagai \"%1$s\" dalam folder \"Downloads\"</string>
@ -289,7 +289,7 @@
<string name="settings_backup_restore_restore_failed">Pemulihan gagal: %1$s</string> <string name="settings_backup_restore_restore_failed">Pemulihan gagal: %1$s</string>
<string name="settings_advanced_header">Tingkat lanjut</string> <string name="settings_advanced_header">Tingkat lanjut</string>
<string name="settings_advanced_broadcast_summary_enabled">Aplikasi dapat menerima notifikasi yang datang sebagai siaran</string> <string name="settings_advanced_broadcast_summary_enabled">Aplikasi dapat menerima notifikasi yang datang sebagai siaran</string>
<string name="settings_advanced_export_logs_summary">Salin catatan ke papn klip, atau unggah ke nopaste.net (dimiliki oleh penulis ntfy). Nama host dan topik dapat disensor, notifikasi tidak akan disensor.</string> <string name="settings_advanced_export_logs_summary">Salin catatan ke papan klip, atau unggah ke nopaste.net (dimiliki oleh penulis ntfy). Nama host dan topik dapat disensor, notifikasi tidak akan disensor.</string>
<string name="settings_about_version_copied_to_clipboard_message">Disalin ke papan klip</string> <string name="settings_about_version_copied_to_clipboard_message">Disalin ke papan klip</string>
<string name="user_dialog_password_hint_add">Kata Sandi</string> <string name="user_dialog_password_hint_add">Kata Sandi</string>
<string name="user_dialog_button_delete">Hapus pengguna</string> <string name="user_dialog_button_delete">Hapus pengguna</string>

View file

@ -65,7 +65,7 @@
<string name="main_menu_rate_title">Beoordeel de app ⭐</string> <string name="main_menu_rate_title">Beoordeel de app ⭐</string>
<string name="main_item_status_text_one">%1$d melding</string> <string name="main_item_status_text_one">%1$d melding</string>
<string name="main_action_mode_delete_dialog_message">Afmelden van de geselecteerde onderwerp(en) en alle meldingen definitief verwijderen\?</string> <string name="main_action_mode_delete_dialog_message">Afmelden van de geselecteerde onderwerp(en) en alle meldingen definitief verwijderen\?</string>
<string name="main_item_status_unified_push">%1$s (UnifiedPush)</string> <string name="main_item_status_unified_push">%1$s (Unified Push)</string>
<string name="main_no_subscriptions_text">Het lijkt erop dat u nog geen abonnementen heeft.</string> <string name="main_no_subscriptions_text">Het lijkt erop dat u nog geen abonnementen heeft.</string>
<string name="main_action_mode_delete_dialog_permanently_delete">Permanent verwijderen</string> <string name="main_action_mode_delete_dialog_permanently_delete">Permanent verwijderen</string>
<string name="main_unified_push_toast">Dit abonnement wordt beheerd door %1$s via UnifiedPush</string> <string name="main_unified_push_toast">Dit abonnement wordt beheerd door %1$s via UnifiedPush</string>
@ -73,7 +73,7 @@
<string name="main_banner_websocket_button_dismiss">Afwijzen</string> <string name="main_banner_websocket_button_dismiss">Afwijzen</string>
<string name="user_dialog_button_delete">Gebruiker verwijderen</string> <string name="user_dialog_button_delete">Gebruiker verwijderen</string>
<string name="user_dialog_button_cancel">Annuleren</string> <string name="user_dialog_button_cancel">Annuleren</string>
<string name="settings_advanced_connection_protocol_entry_ws">WebSockets</string> <string name="settings_advanced_connection_protocol_entry_ws">WebSocket</string>
<string name="settings_about_version_title">Versie</string> <string name="settings_about_version_title">Versie</string>
<string name="settings_about_header">Over</string> <string name="settings_about_header">Over</string>
<string name="settings_advanced_connection_protocol_title">Verbindingsprotocol</string> <string name="settings_advanced_connection_protocol_title">Verbindingsprotocol</string>
@ -164,8 +164,8 @@
<string name="settings_advanced_clear_logs_title">Logs verwijderen</string> <string name="settings_advanced_clear_logs_title">Logs verwijderen</string>
<string name="settings_advanced_clear_logs_deleted_toast">Logs verwijderd</string> <string name="settings_advanced_clear_logs_deleted_toast">Logs verwijderd</string>
<string name="user_dialog_description_add">Je kunt hier een gebruiker toevoegen. Alle onderwerpen voor de opgegeven server zullen deze gebruiker gebruiken.</string> <string name="user_dialog_description_add">Je kunt hier een gebruiker toevoegen. Alle onderwerpen voor de opgegeven server zullen deze gebruiker gebruiken.</string>
<string name="settings_advanced_connection_protocol_summary_ws">Gebruik WebSockets om verbinding te maken met de server. Dit wordt de standaard in juni 2022.</string> <string name="settings_advanced_connection_protocol_summary_ws">Gebruik WebSockets om verbinding te maken met de server. Dit is de aangeraden methode, maar deze kan extra configuratie in uw proxy vereisen.</string>
<string name="settings_advanced_connection_protocol_summary_jsonhttp">Gebruik een JSON stream via HTTP om verbinding te maken met de server. Deze methode is verouderd en wordt in juni 2022 verwijderd.</string> <string name="settings_advanced_connection_protocol_summary_jsonhttp">Gebruik een JSON stream via HTTP om verbinding te maken met de server. Deze methode is getest maar kan meer batterij verbruiken.</string>
<string name="settings_advanced_export_logs_entry_upload_scrubbed">Upload en kopieer link (gecensureerd)</string> <string name="settings_advanced_export_logs_entry_upload_scrubbed">Upload en kopieer link (gecensureerd)</string>
<string name="settings_advanced_export_logs_scrub_dialog_text">Deze onderwerpen/hostnamen zijn vervangen met fruitnamen zodat je het log kunt delen zonder zorgen: <string name="settings_advanced_export_logs_scrub_dialog_text">Deze onderwerpen/hostnamen zijn vervangen met fruitnamen zodat je het log kunt delen zonder zorgen:
\n \n
@ -317,4 +317,14 @@
<string name="detail_settings_appearance_icon_set_summary">Stel een icoon in wat zal worden weergegeven in notificaties</string> <string name="detail_settings_appearance_icon_set_summary">Stel een icoon in wat zal worden weergegeven in notificaties</string>
<string name="detail_settings_appearance_icon_remove_title">Abonnementen icoon (tap om te verwijderen)</string> <string name="detail_settings_appearance_icon_remove_title">Abonnementen icoon (tap om te verwijderen)</string>
<string name="detail_settings_global_setting_suffix">Gebruikt globale instelling</string> <string name="detail_settings_global_setting_suffix">Gebruikt globale instelling</string>
</resources> <string name="detail_settings_appearance_display_name_default_summary">%1$s (standaard)</string>
<string name="detail_settings_appearance_display_name_title">Schermnaam</string>
<string name="detail_settings_appearance_display_name_message">Zet een schermnaam voor dit abonnement. Laat het veld leeg om de standaard naam te kiezen (%1$s).</string>
<string name="detail_settings_about_header">Over</string>
<string name="detail_settings_about_topic_url_title">Onderwerp URL</string>
<string name="detail_settings_about_topic_url_copied_to_clipboard_message">Gekopieerd naar klembord</string>
<string name="add_dialog_base_urls_dropdown_choose">Kies service URL</string>
<string name="add_dialog_base_urls_dropdown_clear">Service URL verwijderen</string>
<string name="main_banner_websocket_button_enable_now">Nu inschakelen</string>
<string name="main_banner_websocket_text">WebSockets is de aangeraden manier om te verbinden met uw server en kan batterij verbruik verminderen. Het kan <a href="https://ntfy.sh/docs/config/#nginxapache2caddy"> extra configuratie in uw proxy</a> vereisen. Dit kan omgeschakeld worden in de instellingen.</string>
</resources>

View file

@ -51,7 +51,7 @@
<string name="settings_notifications_auto_delete_one_day">Через один день</string> <string name="settings_notifications_auto_delete_one_day">Через один день</string>
<string name="settings_notifications_auto_delete_one_week">Через неделю</string> <string name="settings_notifications_auto_delete_one_week">Через неделю</string>
<string name="settings_notifications_auto_delete_one_month">Через месяц</string> <string name="settings_notifications_auto_delete_one_month">Через месяц</string>
<string name="settings_notifications_auto_delete_three_months">Чкрез три месяца</string> <string name="settings_notifications_auto_delete_three_months">Через три месяца</string>
<string name="settings_general_header">Общие</string> <string name="settings_general_header">Общие</string>
<string name="settings_general_default_base_url_title">Сервер по умолчанию</string> <string name="settings_general_default_base_url_title">Сервер по умолчанию</string>
<string name="settings_general_default_base_url_default_summary">%1$s (по умолчанию)</string> <string name="settings_general_default_base_url_default_summary">%1$s (по умолчанию)</string>
@ -194,7 +194,7 @@
<string name="notification_popup_file_downloading">Скачивается %1$s, %2$d%% <string name="notification_popup_file_downloading">Скачивается %1$s, %2$d%%
\n%3$s</string> \n%3$s</string>
<string name="notification_popup_file_download_successful">%1$s <string name="notification_popup_file_download_successful">%1$s
\nФайл: %2$s, скачен</string> \nФайл: %2$s, скачан</string>
<string name="notification_popup_file_download_failed">%1$s <string name="notification_popup_file_download_failed">%1$s
\nФайл: %2$s, не удалось скачать</string> \nФайл: %2$s, не удалось скачать</string>
<string name="settings_notifications_muted_until_title">Приостановить уведомления</string> <string name="settings_notifications_muted_until_title">Приостановить уведомления</string>
@ -306,4 +306,5 @@
<string name="channel_subscriber_notification_instant_text_six">Подписан на шесть тем с мгновенной доставкой</string> <string name="channel_subscriber_notification_instant_text_six">Подписан на шесть тем с мгновенной доставкой</string>
<string name="channel_subscriber_notification_noinstant_text_five">Подписан на пять тем</string> <string name="channel_subscriber_notification_noinstant_text_five">Подписан на пять тем</string>
<string name="channel_subscriber_notification_noinstant_text_six">Подписан на шесть тем</string> <string name="channel_subscriber_notification_noinstant_text_six">Подписан на шесть тем</string>
</resources> <string name="main_banner_websocket_button_enable_now">Включить сейчас</string>
</resources>

View file

@ -8,4 +8,323 @@
<string name="channel_subscriber_service_name">Абонементна Послуга</string> <string name="channel_subscriber_service_name">Абонементна Послуга</string>
<string name="channel_subscriber_notification_title">Очікую вхідні сповіщення</string> <string name="channel_subscriber_notification_title">Очікую вхідні сповіщення</string>
<string name="channel_subscriber_notification_instant_text">Підписався на теми миттєвої доставки</string> <string name="channel_subscriber_notification_instant_text">Підписався на теми миттєвої доставки</string>
<string name="detail_settings_appearance_display_name_title">Відображуване ім\'я</string>
<string name="detail_settings_appearance_display_name_default_summary">%1$s (за умовчанням)</string>
<string name="detail_settings_appearance_display_name_message">Встановіть спеціальну відображувану назву для цієї підписки. Залиште поле порожнім для умовчання (%1$s).</string>
<string name="user_dialog_base_url_hint">URL служби</string>
<string name="add_dialog_use_another_server_description">Введіть URL-адреси служби нижче, щоб підписатися на теми з інших серверів.</string>
<string name="detail_item_cannot_open_url">Неможливо відкрити URL: %1$s</string>
<string name="detail_item_menu_cancel">Скасувати завантаження</string>
<string name="share_topic_title">Поділитися з</string>
<string name="settings_notifications_auto_delete_summary_one_month">Автоматичне видалення сповіщень через місяць</string>
<string name="settings_general_default_base_url_title">Сервер за замовчуванням</string>
<string name="notification_popup_user_action_failed">%1$s не вдалося: %2$s</string>
<string name="settings_notifications_header">Сповіщення</string>
<string name="settings_notifications_muted_until_forever">Сповіщення вимкнено до відновлення</string>
<string name="settings_notifications_muted_until_x">Сповіщення вимкнено до %1$s</string>
<string name="settings_general_dark_mode_title">Темний режим</string>
<string name="settings_general_dark_mode_summary_dark">Темний режим увімкнено. Ви вампір\?</string>
<string name="settings_general_dark_mode_entry_system">Використовувати систему за умовчанням</string>
<string name="settings_general_dark_mode_entry_light">Світловий режим</string>
<string name="settings_backup_restore_backup_failed">Помилка резервного копіювання: %1$s</string>
<string name="settings_backup_restore_restore_title">Відновити з файлу</string>
<string name="settings_advanced_header">Просунутий</string>
<string name="settings_advanced_export_logs_title">Копіювати/завантажувати журнали</string>
<string name="settings_notifications_channel_prefs_title">Налаштування каналу</string>
<string name="settings_notifications_channel_prefs_summary">Перевизначення режиму \"Не турбувати\" (DND), звуки тощо.</string>
<string name="settings_notifications_auto_download_summary_smaller_than_x">Автоматичне завантаження вкладень до %1$s</string>
<string name="settings_notifications_auto_download_never">Ніколи нічого не завантажуйте автоматично</string>
<string name="settings_notifications_auto_download_100k">Якщо менше 100 кБ</string>
<string name="settings_backup_restore_backup_summary">Експортуйте конфігурацію, сповіщення та користувачів</string>
<string name="settings_backup_restore_header">Резервне копіювання та відновлення</string>
<string name="settings_backup_restore_backup_title">Резервне копіювання в файл</string>
<string name="settings_backup_restore_backup_entry_everything">Все</string>
<string name="settings_backup_restore_backup_entry_everything_no_users">Все, крім користувачів</string>
<string name="settings_backup_restore_backup_entry_settings_only">Лише налаштування</string>
<string name="channel_subscriber_notification_instant_text_five">Підписався на п\'ять тем миттєвої доставки</string>
<string name="channel_subscriber_notification_instant_text_six">Підписався на шість тем миттєвої доставки</string>
<string name="channel_subscriber_notification_noinstant_text_five">Підписався на п\'ять тем</string>
<string name="channel_subscriber_notification_noinstant_text_six">Підписався на шість тем</string>
<string name="refresh_message_error">Не вдалося оновити %1$d підписок
\n
\n%2$s</string>
<string name="main_menu_settings_title">Налаштування</string>
<string name="main_menu_report_bug_title">Повідомити про помилку</string>
<string name="main_action_mode_menu_unsubscribe">Відписатися</string>
<string name="main_action_mode_delete_dialog_message">Скасувати підписку на вибрані теми та остаточно видалити всі сповіщення\?</string>
<string name="main_action_mode_delete_dialog_permanently_delete">Видалити назавжди</string>
<string name="main_action_mode_delete_dialog_cancel">Скасувати</string>
<string name="main_item_status_text_one">%1$d сповіщення</string>
<string name="main_item_status_text_not_one">%1$d сповіщень</string>
<string name="main_item_status_reconnecting">повторне підключення…</string>
<string name="main_item_status_unified_push">%1$s (UnifiedPush)</string>
<string name="main_item_date_yesterday">вчора</string>
<string name="main_add_button_description">Додати підписку</string>
<string name="main_no_subscriptions_text">Схоже, у вас ще немає жодної підписки.</string>
<string name="main_how_to_link">Детальні інструкції доступні на ntfy.sh і в документах.</string>
<string name="main_unified_push_toast">Цією підпискою керує %1$s через UnifiedPush</string>
<string name="main_banner_battery_text">Щоб уникнути проблем із доставляння сповіщень, оптимізацію акумулятора слід вимкнути.</string>
<string name="main_banner_battery_button_remind_later">Запитайте пізніше</string>
<string name="main_banner_battery_button_dismiss">Відхилити</string>
<string name="main_banner_battery_button_fix_now">Виправ зараз</string>
<string name="add_dialog_title">Підпишіться на тему</string>
<string name="add_dialog_description_below">Теми можуть не бути захищені паролем, тому виберіть назву, яку важко вгадати. Після підписки ви можете PUT/POST сповіщення.</string>
<string name="add_dialog_topic_name_hint">Назва теми, наприклад phils_alerts</string>
<string name="add_dialog_use_another_server">Використовуйте інший сервер</string>
<string name="add_dialog_instant_delivery">Миттєва доставка в режимі дрімання</string>
<string name="add_dialog_instant_delivery_description">Забезпечує миттєву доставку повідомлень, навіть якщо пристрій неактивний.</string>
<string name="add_dialog_foreground_description">Миттєва доставка завжди ввімкнена для хостів, відмінних від %1$s.</string>
<string name="add_dialog_button_cancel">Скасувати</string>
<string name="add_dialog_button_subscribe">Підпишіться</string>
<string name="add_dialog_button_back">Назад</string>
<string name="detail_clear_dialog_cancel">Скасувати</string>
<string name="detail_delete_dialog_message">Скасувати підписку на цю тему та видалити всі отримані сповіщення\?</string>
<string name="detail_delete_dialog_cancel">Скасувати</string>
<string name="detail_item_menu_save_file">Зберегти файл</string>
<string name="detail_item_menu_copy_url">Копіювати URL</string>
<string name="detail_item_download_info_deleted">видалено</string>
<string name="detail_menu_unsubscribe">Відписатися</string>
<string name="detail_action_mode_delete_dialog_message">Видалити вибрані сповіщення назавжди\?</string>
<string name="detail_action_mode_delete_dialog_cancel">Скасувати</string>
<string name="share_menu_send">Поділіться</string>
<string name="notification_popup_file_download_failed">%1$s
\nФайл: %2$s, не вдалося завантажити</string>
<string name="settings_general_users_prefs_user_used_by_one">Використовується темою %1$s</string>
<string name="detail_settings_global_setting_suffix">за допомогою глобальних налаштувань</string>
<string name="channel_subscriber_notification_noinstant_text">Підписався на теми</string>
<string name="channel_subscriber_notification_instant_text_one">Підписався на одну тему миттєвої доставки</string>
<string name="channel_subscriber_notification_instant_text_two">Підписався на дві теми моментальної доставки</string>
<string name="channel_subscriber_notification_noinstant_text_one">Підписався на одну тему</string>
<string name="channel_subscriber_notification_noinstant_text_more">Підписано на %1$d тем</string>
<string name="channel_subscriber_notification_instant_text_three">Підписався на три теми моментальної доставки</string>
<string name="channel_subscriber_notification_instant_text_four">Підписався на чотири теми миттєвої доставки</string>
<string name="channel_subscriber_notification_instant_text_more">Підписано на %1$d тем миттєвої доставки</string>
<string name="channel_subscriber_notification_noinstant_text_two">Підписався на дві теми</string>
<string name="refresh_message_no_results">Все в актуальному стані</string>
<string name="refresh_message_result">Отримано %1$d сповіщень</string>
<string name="add_dialog_login_password_hint">Пароль</string>
<string name="settings_notifications_auto_delete_never">Ніколи</string>
<string name="settings_advanced_broadcast_summary_disabled">Програми не можуть отримувати сповіщення як трансляції</string>
<string name="settings_advanced_record_logs_title">Журнали запису</string>
<string name="user_dialog_password_hint_add">Пароль</string>
<string name="user_dialog_password_hint_edit">Пароль (незмінний, якщо залишити порожнім)</string>
<string name="user_dialog_button_cancel">Скасувати</string>
<string name="main_menu_notifications_disabled_forever">Сповіщення вимкнено</string>
<string name="detail_menu_notifications_disabled_until">Сповіщення вимкнено до %1$s</string>
<string name="main_menu_notifications_disabled_until">Сповіщення вимкнено до %1$s</string>
<string name="channel_subscriber_notification_noinstant_text_three">Підписався на три теми</string>
<string name="channel_subscriber_notification_noinstant_text_four">Підписався на чотири теми</string>
<string name="main_action_bar_title">Підписані теми</string>
<string name="main_menu_docs_title">Читайте докуменацію</string>
<string name="notification_dialog_show_all">Показати всі сповіщення</string>
<string name="main_menu_notifications_enabled">Сповіщення ввімкнено</string>
<string name="settings_notifications_priority_default">за замовчуванням</string>
<string name="refresh_message_error_one">Не вдалося оновити підписку: %1$s</string>
<string name="main_menu_rate_title">Оцініть програму ⭐</string>
<string name="detail_test_message">Це тестове сповіщення від програми ntfy для Android. Він має рівень пріоритету %1$d. Якщо ви надішлете інший, він може виглядати інакше.</string>
<string name="main_how_to_intro">Натисніть +, щоб створити тему або підписатися на неї. Після цього ви отримуєте сповіщення на свій пристрій, коли надсилаєте повідомлення через PUT або POST.</string>
<string name="add_dialog_login_username_hint">Ім\'я користувача</string>
<string name="detail_copied_to_clipboard_message">Скопійовано в буфер обміну</string>
<string name="main_banner_websocket_text">Перехід на WebSockets є рекомендованим способом підключення до вашого сервера, який може подовжити час автономної роботи, але може вимагати <a href="https://ntfy.sh/docs/config/#nginxapache2caddy">додаткової конфігурації вашого проксі</a>. Це можна вимкнути в налаштуваннях.</string>
<string name="add_dialog_login_title">Необхідно ввійти</string>
<string name="add_dialog_login_new_user">Новий користувач</string>
<string name="detail_action_mode_menu_copy">Копія</string>
<string name="add_dialog_button_login">Авторизуватися</string>
<string name="add_dialog_error_connection_failed">Помилка підключення: %1$s</string>
<string name="add_dialog_login_description">Ця тема потребує авторизації. Будь ласка, введіть ім\'я користувача та пароль.</string>
<string name="add_dialog_login_error_not_authorized">Помилка логіну. Користувач %1$s не авторизований.</string>
<string name="detail_how_to_link">Детальні інструкції доступні на ntfy.sh і в документах.</string>
<string name="detail_clear_dialog_message">Видалити всі сповіщення в цій темі\?</string>
<string name="detail_item_cannot_save">Неможливо зберегти вкладення: %1$s</string>
<string name="detail_item_download_failed">Не вдалося завантажити вкладений файл: %1$s</string>
<string name="detail_how_to_example">Приклад (з використанням curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt></string>
<string name="detail_item_download_info_not_downloaded_expires_x">не завантажено, закінчується %1$s</string>
<string name="detail_test_message_error_too_large">Не вдається надіслати повідомлення: вкладення завелике.</string>
<string name="detail_instant_delivery_enabled">Миттєва доставка включена</string>
<string name="detail_item_tags">Теги: %1$s</string>
<string name="detail_item_download_info_not_downloaded_expired">не завантажено, термін дії посилання закінчився</string>
<string name="detail_no_notifications_text">Ви ще не отримали сповіщень щодо цієї теми.</string>
<string name="detail_how_to_intro">Щоб надіслати сповіщення до цієї теми, просто PUT або POST за URL-адресою теми.</string>
<string name="detail_deep_link_subscribed_toast_message">Підписався на тему %1$s</string>
<string name="detail_item_cannot_open">Неможливо відкрити вкладення: %1$s</string>
<string name="detail_item_download_info_deleted_expires_x">видалено, посилання діє %1$s</string>
<string name="notification_dialog_tomorrow">До завтра</string>
<string name="detail_item_download_info_downloading_x_percent">%1$d%% завантажено</string>
<string name="detail_item_download_info_deleted_expired">видалено, термін дії посилання закінчився</string>
<string name="detail_action_mode_menu_delete">Видалити</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Видалити остаточно</string>
<string name="share_content_file_text">Вам надали доступ до файлу</string>
<string name="notification_dialog_30min">30 хвилин</string>
<string name="notification_popup_action_browse">Переглядати</string>
<string name="notification_popup_file">%1$s
\nФайл: %2$s</string>
<string name="share_content_file_error">Неможливо прочитати інформацію про файл: %1$s</string>
<string name="notification_dialog_8h">8 годин</string>
<string name="notification_dialog_forever">До відновлення</string>
<string name="settings_notifications_min_priority_summary_max">Показувати сповіщення, якщо пріоритет 5 (макс.)</string>
<string name="settings_notifications_min_priority_min">Будь-який пріоритет</string>
<string name="settings_notifications_min_priority_low">Низький пріоритет і вище</string>
<string name="notification_popup_file_downloading">Завантаження %1$s, %2$d%%
\n%3$s</string>
<string name="settings_advanced_broadcast_summary_enabled">Програми можуть отримувати вхідні сповіщення як трансляції</string>
<string name="settings_notifications_muted_until_title">Вимкнути сповіщення</string>
<string name="settings_general_users_prefs_user_used_by_many">Використовується темами %1$s</string>
<string name="settings_notifications_auto_delete_summary_one_week">Автоматичне видалення сповіщень через тиждень</string>
<string name="settings_notifications_priority_low">низький</string>
<string name="settings_notifications_auto_download_10m">Якщо менше 10 Мб</string>
<string name="settings_notifications_auto_delete_one_day">Через один день</string>
<string name="settings_notifications_auto_download_50m">Якщо менше 50 Мб</string>
<string name="settings_notifications_auto_delete_title">Видалити сповіщення</string>
<string name="settings_notifications_auto_delete_summary_three_days">Автоматичне видалення сповіщень через 3 дні</string>
<string name="settings_general_users_title">Керувати користувачами</string>
<string name="settings_general_dark_mode_summary_system">Використання системи за замовчуванням</string>
<string name="settings_general_dark_mode_summary_light">Світловий режим включений</string>
<string name="settings_advanced_broadcast_title">Трансляція повідомлень</string>
<string name="settings_general_users_summary">Додавання/видалення користувачів для захищених тем</string>
<string name="settings_general_users_prefs_user_add_title">Додати нового користувача</string>
<string name="settings_general_users_prefs_user_add_summary">Створіть нового користувача для нового сервера</string>
<string name="settings_general_dark_mode_entry_dark">Темний режим</string>
<string name="settings_backup_restore_restore_failed">Не вдалося відновити: %1$s</string>
<string name="settings_backup_restore_backup_successful">Резервну копію створено</string>
<string name="settings_backup_restore_restore_summary">Імпортуйте конфігурацію, сповіщення та користувачів</string>
<string name="settings_backup_restore_restore_successful">Відновлено успішно</string>
<string name="settings_advanced_export_logs_summary">Скопіюйте журнали в буфер обміну або завантажте на nopaste.net (належить автору ntfy). Імена хостів і теми можуть бути піддані цензурі, сповіщення ніколи.</string>
<string name="settings_advanced_export_logs_entry_copy_scrubbed">Копіювати в буфер обміну (цензуровано)</string>
<string name="settings_advanced_export_logs_entry_copy_original">Копіювати в буфер обміну</string>
<string name="settings_advanced_export_logs_uploading">Завантаження журналу…</string>
<string name="settings_advanced_export_logs_scrub_dialog_text">Ці теми/імена хостів було замінено назвами фруктів, тож ви можете ділитися журналом без хвилювань:
\n
\n%1$s
\n
\nПаролі очищаються, але не відображаються тут.</string>
<string name="settings_advanced_export_logs_entry_upload_original">Завантажте та скопіюйте посилання</string>
<string name="settings_advanced_export_logs_entry_upload_scrubbed">Завантажити та скопіювати посилання (цензуровано)</string>
<string name="settings_advanced_export_logs_copied_logs">Журнали скопійовано в буфер обміну</string>
<string name="settings_advanced_clear_logs_summary">Видаліть раніше записані журнали та почніть спочатку</string>
<string name="settings_advanced_connection_protocol_summary_ws">Використовуйте WebSockets для підключення до сервера. Це рекомендований метод, але може знадобитися додаткова конфігурація вашого проксі.</string>
<string name="user_dialog_button_save">Зберегти</string>
<string name="detail_settings_appearance_icon_set_title">Значок підписки</string>
<string name="detail_settings_notifications_instant_summary_on">Сповіщення надходять миттєво. Потрібна служба переднього плану та споживає більше акумулятора.</string>
<string name="detail_settings_notifications_instant_summary_off">Сповіщення доставляються за допомогою Firebase. Доставка може бути відкладена, але споживає менше акумулятора.</string>
<string name="detail_settings_about_topic_url_title">URL теми</string>
<string name="user_dialog_username_hint">Ім\'я користувача</string>
<string name="detail_settings_about_header">Про</string>
<string name="detail_settings_about_topic_url_copied_to_clipboard_message">Скопійовано в буфер обміну</string>
<string name="user_dialog_description_edit">Ви можете змінити ім\'я користувача/пароль для вибраного користувача або видалити його.</string>
<string name="user_dialog_button_add">Додати користувача</string>
<string name="user_dialog_description_add">Ви можете додати користувача тут. Усі теми для даного сервера використовуватимуть цього користувача.</string>
<string name="user_dialog_button_delete">Видалити користувача</string>
<string name="add_dialog_base_urls_dropdown_choose">Виберіть URL-адресу служби</string>
<string name="add_dialog_base_urls_dropdown_clear">Очистити URL-адресу служби</string>
<string name="detail_clear_dialog_permanently_delete">Видалити остаточно</string>
<string name="detail_delete_dialog_permanently_delete">Видалити остаточно</string>
<string name="detail_test_title">Тест: Ви можете встановити назву, якщо хочете.</string>
<string name="detail_test_message_error">Неможливо надіслати повідомлення: %1$s</string>
<string name="detail_test_message_error_unauthorized_anon">Неможливо надіслати повідомлення: анонімна публікація заборонена.</string>
<string name="detail_test_message_error_unauthorized_user">Неможливо надіслати повідомлення: користувач \"%1$s\" не авторизований.</string>
<string name="detail_instant_delivery_disabled">Миттєва доставка вимкнена</string>
<string name="detail_item_snack_deleted">Сповіщення видалено</string>
<string name="detail_item_snack_undo">Скасувати</string>
<string name="detail_item_menu_open">Відкрити файл</string>
<string name="detail_item_menu_delete">Видалити файл</string>
<string name="detail_item_menu_download">Завантажити файл</string>
<string name="detail_item_menu_copy_url_copied">URL-адресу скопійовано в буфер обміну</string>
<string name="detail_item_menu_copy_contents">Копіювати сповіщення</string>
<string name="detail_item_menu_copy_contents_copied">Сповіщення скопійовано в буфер обміну</string>
<string name="detail_item_saved_successfully">Збережено як \"%1$s\" у папці \"Завантаження\"</string>
<string name="detail_item_cannot_download">Не вдається відкрити або завантажити вкладений файл. Термін дії посилання закінчився, і локальний файл не знайдено.</string>
<string name="detail_item_cannot_open_not_found">Неможливо відкрити вкладення: файл, можливо, видалено, або жодна встановлена програма не може відкрити файл.</string>
<string name="detail_item_cannot_delete">Неможливо видалити вкладення: %1$s</string>
<string name="detail_item_download_info_not_downloaded">не завантажено</string>
<string name="detail_item_download_info_download_failed">не вдалося завантажити</string>
<string name="detail_item_download_info_download_failed_expired">не вдалося завантажити, термін дії посилання закінчився</string>
<string name="detail_item_download_info_download_failed_expires_x">не вдалося завантажити, термін дії посилання закінчився %1$s</string>
<string name="detail_menu_notifications_enabled">Сповіщення ввімкнено</string>
<string name="detail_menu_notifications_disabled_forever">Сповіщення вимкнено</string>
<string name="detail_menu_enable_instant">Увімкнути миттєву доставку</string>
<string name="detail_menu_disable_instant">Вимкніть миттєву доставку</string>
<string name="detail_menu_test">Надіслати тестове сповіщення</string>
<string name="detail_menu_copy_url">Копіювати адресу теми</string>
<string name="detail_menu_clear">Очистити всі сповіщення</string>
<string name="detail_menu_settings">Налаштування підписки</string>
<string name="detail_settings_title">Налаштування підписки</string>
<string name="share_title">Поділіться</string>
<string name="share_content_title">Попередній перегляд повідомлення</string>
<string name="share_content_text_hint">Додайте вміст, щоб поділитися тут</string>
<string name="share_content_image_text">З вами поділилися зображенням</string>
<string name="share_content_image_error">Не вдається прочитати зображення: %1$s</string>
<string name="share_suggested_topics">Пропоновані теми</string>
<string name="share_successful">Повідомлення опубліковано</string>
<string name="notification_dialog_title">Вимкнути сповіщення</string>
<string name="notification_dialog_cancel">Скасувати</string>
<string name="notification_dialog_save">Зберегти</string>
<string name="notification_dialog_enabled_toast_message">Сповіщення відновлено</string>
<string name="notification_dialog_muted_forever_toast_message">Сповіщення вимкнено</string>
<string name="notification_dialog_muted_until_toast_message">Сповіщення вимкнено до %1$s</string>
<string name="notification_dialog_1h">1 година</string>
<string name="notification_dialog_2h">2 години</string>
<string name="notification_popup_action_open">Відчинено</string>
<string name="notification_popup_action_download">Завантажити</string>
<string name="notification_popup_action_cancel">Скасувати</string>
<string name="notification_popup_file_download_successful">%1$s
\nФайл: %2$s, завантажено</string>
<string name="settings_title">Налаштування</string>
<string name="settings_notifications_muted_until_show_all">Показано всі сповіщення</string>
<string name="settings_notifications_min_priority_title">Мінімальний пріоритет</string>
<string name="settings_notifications_min_priority_summary_any">Показано всі сповіщення</string>
<string name="settings_notifications_min_priority_summary_x_or_higher">Показувати сповіщення, якщо пріоритет %1$d (%2$s) або вище</string>
<string name="settings_notifications_min_priority_default">Пріоритет за замовчуванням і вище</string>
<string name="settings_notifications_min_priority_high">Високий пріоритет і вище</string>
<string name="settings_notifications_min_priority_max">Тільки максимальний пріоритет</string>
<string name="settings_notifications_priority_min">Мін</string>
<string name="settings_notifications_priority_high">високій</string>
<string name="settings_notifications_priority_max">макс</string>
<string name="settings_notifications_auto_download_title">Завантажити вкладення</string>
<string name="settings_notifications_auto_download_summary_always">Автоматичне завантаження всіх вкладень</string>
<string name="settings_notifications_auto_download_summary_never">Ніколи не завантажуйте вкладені файли автоматично</string>
<string name="settings_notifications_auto_download_always">Автоматичне завантаження всього</string>
<string name="settings_notifications_auto_download_500k">Якщо менше 500 кБ</string>
<string name="settings_notifications_auto_download_1m">Якщо менше 1 Мб</string>
<string name="settings_notifications_auto_download_5m">Якщо менше 5 Мб</string>
<string name="settings_notifications_auto_delete_summary_never">Ніколи автоматично не видаляйте сповіщення</string>
<string name="settings_notifications_auto_delete_summary_one_day">Автоматичне видалення сповіщень через один день</string>
<string name="settings_notifications_auto_delete_summary_three_months">Автоматичне видалення сповіщень через 3 місяці</string>
<string name="settings_notifications_auto_delete_three_days">Через 3 дні</string>
<string name="settings_notifications_auto_delete_one_week">Через тиждень</string>
<string name="settings_notifications_auto_delete_one_month">Через місяць</string>
<string name="settings_notifications_auto_delete_three_months">Через 3 міс</string>
<string name="user_dialog_title_add">Додати користувача</string>
<string name="settings_general_header">Загальний</string>
<string name="settings_general_default_base_url_message">Введіть кореневу URL-адресу свого сервера, щоб використовувати свій власний сервер як стандартний під час підписки на нові теми та/або спільного доступу до тем.</string>
<string name="settings_general_default_base_url_default_summary">%1$s (за умовчанням)</string>
<string name="settings_general_users_prefs_title">Користувачі</string>
<string name="settings_general_users_prefs_user_not_used">Не використовується жодною темою</string>
<string name="settings_general_users_prefs_user_add">Додайте користувачів</string>
<string name="settings_advanced_record_logs_summary_enabled">Реєстрація (до 1000 записів) на пристрої…</string>
<string name="settings_advanced_record_logs_summary_disabled">Увімкніть ведення журналів, щоб пізніше ви могли поділитися журналами для діагностики проблем.</string>
<string name="settings_advanced_export_logs_copied_url">Журнали завантажено та URL-адресу скопійовано</string>
<string name="settings_advanced_export_logs_error_uploading">Не вдалося завантажити журнали: %1$s</string>
<string name="settings_advanced_export_logs_scrub_dialog_empty">Не було відредаговано жодної теми/імена хостів. Може у вас немає підписки\?</string>
<string name="settings_advanced_export_logs_scrub_dialog_button_ok">ОК</string>
<string name="settings_advanced_clear_logs_title">Очистити журнали</string>
<string name="settings_advanced_clear_logs_deleted_toast">Журнали видалено</string>
<string name="settings_advanced_connection_protocol_title">Протокол підключення</string>
<string name="settings_advanced_connection_protocol_entry_jsonhttp">Потік JSON через HTTP</string>
<string name="settings_advanced_connection_protocol_entry_ws">WebSockets</string>
<string name="settings_about_header">Про</string>
<string name="settings_about_version_title">Версія</string>
<string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
<string name="settings_about_version_copied_to_clipboard_message">Скопійовано в буфер обміну</string>
<string name="detail_settings_notifications_instant_title">Миттєва доставка</string>
<string name="detail_settings_appearance_header">Зовнішній вигляд</string>
<string name="detail_settings_appearance_icon_set_summary">Установіть піктограму, яка відображатиметься в сповіщеннях</string>
<string name="detail_settings_appearance_icon_remove_title">Значок підписки (натисніть, щоб видалити)</string>
<string name="detail_settings_appearance_icon_remove_summary">Значок, який відображається в сповіщеннях для цієї теми</string>
<string name="detail_settings_appearance_icon_error_saving">Не вдалося зберегти значок: %1$s</string>
<string name="detail_settings_global_setting_title">Використовуйте глобальні налаштування</string>
<string name="user_dialog_title_edit">Редагувати користувача</string>
<string name="main_banner_websocket_button_remind_later">Запитайте пізніше</string>
<string name="main_banner_websocket_button_dismiss">Відхилити</string>
<string name="main_banner_websocket_button_enable_now">Увімкнути зараз</string>
<string name="settings_advanced_connection_protocol_summary_jsonhttp">Використовуйте потік JSON через HTTP для підключення до сервера. Цей метод перевірено в боях, але може споживати більше заряду батареї.</string>
</resources> </resources>

View file

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

View file

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

View file

@ -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

View file

@ -0,0 +1 @@
Надсилайте сповіщення на свій телефон за допомогою запитів PUT/POST

View file

@ -0,0 +1 @@
ntfy - PUT/POST на ваш телефон