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

View file

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

View file

@ -95,12 +95,10 @@ const val ATTACHMENT_PROGRESS_DONE = 100
@Entity
data class Icon(
@ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server)
@ColumnInfo(name = "type") val type: String?, // MIME type
@ColumnInfo(name = "size") val size: Long?, // Size in bytes
@ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location
) {
constructor(url:String, type: String?, size: Long?) :
this(url, type, size, null)
constructor(url:String) :
this(url, null)
}
@Entity
@ -282,8 +280,6 @@ abstract class Database : RoomDatabase() {
db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId TEXT")
db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT")
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_url TEXT") // Room limitation: Has to be nullable for @Embedded
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_type TEXT")
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_size INT")
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_contentUri TEXT")
}
}
@ -384,8 +380,11 @@ interface NotificationDao {
@Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''")
fun listDeletedWithAttachments(): List<Notification>
@Query("SELECT * FROM notification WHERE deleted = 1 AND icon_contentUri <> ''")
fun listDeletedWithIcons(): List<Notification>
@Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted != 1 AND icon_contentUri <> ''")
fun listActiveIconUris(): List<String>
@Query("UPDATE notification SET icon_contentUri = null WHERE icon_contentUri = :uri")
fun clearIconUri(uri: String)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun add(notification: Notification)

View file

@ -92,8 +92,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
return notificationDao.listDeletedWithAttachments()
}
fun getDeletedNotificationsWithIcons(): List<Notification> {
return notificationDao.listDeletedWithIcons()
fun getActiveIconUris(): Set<String> {
return notificationDao.listActiveIconUris().toSet()
}
fun clearIconUri(uri: String) {
notificationDao.clearIconUri(uri)
}
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")
var bytesCopied: Long = 0
val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
val downloadLimit = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) {
repository.getAutoDownloadMaxSize()
} else {
null
}
val downloadLimit = getDownloadLimit(userAction)
outFile.use { fileOut ->
val fileIn = response.body!!.byteStream()
val buffer = ByteArray(BUFFER_SIZE)
@ -192,6 +188,14 @@ class DownloadAttachmentWorker(private val context: Context, params: WorkerParam
}
}
private fun getDownloadLimit(userAction: Boolean): Long? {
return if (userAction || repository.getAutoDownloadMaxSize() == Repository.AUTO_DOWNLOAD_ALWAYS) {
null
} else {
repository.getAutoDownloadMaxSize()
}
}
private fun createUri(notification: Notification): Uri {
val attachmentDir = File(context.cacheDir, ATTACHMENT_CACHE_DIR)
if (!attachmentDir.exists() && !attachmentDir.mkdirs()) {

View file

@ -2,28 +2,24 @@ package io.heckel.ntfy.msg
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.ensureSafeNewFile
import io.heckel.ntfy.util.sha256
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.util.Date
import java.util.concurrent.TimeUnit
class DownloadIconWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request
.callTimeout(1, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
@ -44,7 +40,16 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
icon = notification.icon ?: return Result.failure()
try {
downloadIcon()
val iconFile = createIconFile(icon)
val yesterdayTimestamp = Date().time - MAX_CACHE_MILLIS
if (!iconFile.exists() || iconFile.lastModified() < yesterdayTimestamp) {
downloadIcon(iconFile)
} else {
Log.d(TAG, "Loading icon from cache: $iconFile")
val iconUri = createIconUri(iconFile)
this.uri = iconUri // Required for cleanup in onStopped()
save(icon.copy(contentUri = iconUri.toString()))
}
} catch (e: Exception) {
failed(e)
}
@ -56,43 +61,35 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
maybeDeleteFile()
}
private fun downloadIcon() {
private fun downloadIcon(iconFile: File) {
Log.d(TAG, "Downloading icon from ${icon.url}")
try {
val request = Request.Builder()
.url(icon.url)
.addHeader("User-Agent", ApiService.USER_AGENT)
.build()
client.newCall(request).execute().use { response ->
Log.d(TAG, "Download: headers received: $response")
Log.d(TAG, "Headers received: $response, Content-Length: ${response.headers["Content-Length"]}")
if (!response.isSuccessful || response.body == null) {
throw Exception("Unexpected response: ${response.code}")
}
save(updateIconFromResponse(response))
if (shouldAbortDownload()) {
} else if (shouldAbortDownload(response)) {
Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting")
return
}
val resolver = applicationContext.contentResolver
val uri = createUri(notification)
val uri = createIconUri(iconFile)
this.uri = uri // Required for cleanup in onStopped()
Log.d(TAG, "Starting download to content URI: $uri")
val contentLength = response.headers["Content-Length"]?.toLongOrNull()
var bytesCopied: Long = 0
val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
val downloadLimit = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) {
repository.getAutoDownloadMaxSize()
} else {
null
}
val downloadLimit = getDownloadLimit()
outFile.use { fileOut ->
val fileIn = response.body!!.byteStream()
val buffer = ByteArray(BUFFER_SIZE)
var bytes = fileIn.read(buffer)
while (bytes >= 0) {
if (downloadLimit != null && bytesCopied > downloadLimit) {
if (bytesCopied > downloadLimit) {
throw Exception("Icon is longer than max download size.")
}
fileOut.write(buffer, 0, bytes)
@ -101,49 +98,13 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
}
}
Log.d(TAG, "Icon download: successful response, proceeding with download")
save(icon.copy(
size = bytesCopied,
contentUri = uri.toString()
))
save(icon.copy(contentUri = uri.toString()))
}
} catch (e: Exception) {
failed(e)
// Toast in a Worker: https://stackoverflow.com/a/56428145/1440785
val handler = Handler(Looper.getMainLooper())
handler.postDelayed({
Toast
.makeText(context, context.getString(R.string.detail_item_icon_download_failed, e.message), Toast.LENGTH_LONG)
.show()
}, 200)
}
}
private fun updateIconFromResponse(response: Response): Icon {
val size = if (response.headers["Content-Length"]?.toLongOrNull() != null) {
Log.d(TAG, "We got the long! icon here")
response.headers["Content-Length"]?.toLong()
} else {
icon.size // May be null!
}
val mimeType = if (response.headers["Content-Type"] != null) {
response.headers["Content-Type"]
} else {
val ext = MimeTypeMap.getFileExtensionFromUrl(icon.url)
if (ext != null) {
val typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
typeFromExt ?: icon.type // May be null!
} else {
icon.type // May be null!
}
}
Log.d(TAG, "New icon size: $size, type: $mimeType")
return icon.copy(
size = size,
type = mimeType
)
}
private fun failed(e: Exception) {
Log.w(TAG, "Icon download failed", e)
maybeDeleteFile()
@ -166,28 +127,42 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
repository.updateNotification(notification)
}
private fun shouldAbortDownload(): Boolean {
val maxAutoDownloadSize = MAX_ICON_DOWNLOAD_SIZE
val size = icon.size ?: return false // Don't abort if size unknown
private fun shouldAbortDownload(response: Response): Boolean {
val maxAutoDownloadSize = getDownloadLimit()
val size = response.headers["Content-Length"]?.toLongOrNull() ?: return false // Don't abort here if size unknown
return size > maxAutoDownloadSize
}
private fun createUri(notification: Notification): Uri {
private fun getDownloadLimit(): Long {
return if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) {
Math.min(repository.getAutoDownloadMaxSize(), MAX_ICON_DOWNLOAD_BYTES)
} else {
DEFAULT_MAX_ICON_DOWNLOAD_BYTES
}
}
private fun createIconFile(icon: Icon): File {
val iconDir = File(context.cacheDir, ICON_CACHE_DIR)
if (!iconDir.exists() && !iconDir.mkdirs()) {
throw Exception("Cannot create cache directory for icons: $iconDir")
}
val file = ensureSafeNewFile(iconDir, notification.id)
return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file)
val hash = icon.url.sha256()
return File(iconDir, hash)
}
private fun createIconUri(iconFile: File): Uri {
return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, iconFile)
}
companion object {
const val INPUT_DATA_ID = "id"
const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml
const val MAX_ICON_DOWNLOAD_SIZE = 300000
const val DEFAULT_MAX_ICON_DOWNLOAD_BYTES = 307_200L // 300 KB
const val MAX_ICON_DOWNLOAD_BYTES = 5_242_880L // 5 MB
const val MAX_CACHE_MILLIS = 1000*60*60*24 // 24 hours
const val ICON_CACHE_DIR = "icons"
private const val TAG = "NtfyIconDownload"
private const val ICON_CACHE_DIR = "icons"
private const val BUFFER_SIZE = 8 * 1024
}
}

View file

@ -13,7 +13,7 @@ data class Message(
val priority: Int?,
val tags: List<String>?,
val click: String?,
val icon: MessageIcon?,
val icon: String?,
val actions: List<MessageAction>?,
val title: 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")
return false
}
val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()
when (maxAutoDownloadSize) {
when (val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()) {
Repository.AUTO_DOWNLOAD_ALWAYS -> return true
Repository.AUTO_DOWNLOAD_NEVER -> return false
else -> {
@ -73,15 +72,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
}
}
private fun shouldDownloadIcon(notification: Notification): Boolean {
if (notification.icon == null) {
return false
}
val icon = notification.icon
val maxIconDownloadSize = DownloadIconWorker.MAX_ICON_DOWNLOAD_SIZE
if (icon.size == null) {
return true // DownloadWorker will bail out if attachment is too large!
}
return icon.size <= maxIconDownloadSize
return notification.icon != null
}
private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,11 +19,11 @@
\n%2$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_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_until">Notifikasi dibisukan sampai %1$s</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_rate_title">Beri nilai aplikasi ⭐</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_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_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_dismiss">Abaikan</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_new_user">Pengguna baru</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_link">Instruksi rinci tersedia di ntfy.sh, dan dalam dokumentasi.</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_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_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_item_tags">Tanda: %1$s</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_add_title">Tambahkan pengguna baru</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_dark">Mode gelap nyala. Apakah Anda seorang vampir\?</string>
<string name="settings_general_dark_mode_summary_light">Mode terang menyala</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_light">Mode terang</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="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_menu_notifications_enabled">Notifikasi nyala</string>
<string name="detail_menu_notifications_enabled">Notifikasi menyala</string>
<string name="notification_popup_file_download_successful">%1$s
\nFile: %2$s, terunduh</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_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_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="user_dialog_password_hint_add">Kata Sandi</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_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_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_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>
@ -73,7 +73,7 @@
<string name="main_banner_websocket_button_dismiss">Afwijzen</string>
<string name="user_dialog_button_delete">Gebruiker verwijderen</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_header">Over</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_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="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_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_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 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_scrub_dialog_text">Deze onderwerpen/hostnamen zijn vervangen met fruitnamen zodat je het log kunt delen zonder zorgen:
\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_remove_title">Abonnementen icoon (tap om te verwijderen)</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_week">Через неделю</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_default_base_url_title">Сервер по умолчанию</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%%
\n%3$s</string>
<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
\nФайл: %2$s, не удалось скачать</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_noinstant_text_five">Подписан на пять тем</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_notification_title">Очікую вхідні сповіщення</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>

View file

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

View file

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

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 на ваш телефон