Merge branch 'main' of github.com:binwiederhier/ntfy-android into 303-update-notifications

This commit is contained in:
Philipp Heckel 2026-01-12 21:42:33 -05:00
commit 8d7e7eef03
60 changed files with 1107 additions and 216 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 17, "version": 17,
"identityHash": "e62fdd1a12610e3514eff4dc83dcc0b8", "identityHash": "3466bc18a5e477081c1cbd2defcb449f",
"entities": [ "entities": [
{ {
"tableName": "Subscription", "tableName": "Subscription",
@ -118,7 +118,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, `sequence_id` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `contentType` 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`))", "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, `contentType` 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",
@ -138,12 +138,6 @@
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
{
"fieldPath": "sequenceId",
"columnName": "sequence_id",
"affinity": "TEXT",
"notNull": true
},
{ {
"fieldPath": "title", "fieldPath": "title",
"columnName": "title", "columnName": "title",
@ -423,7 +417,7 @@
], ],
"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, 'e62fdd1a12610e3514eff4dc83dcc0b8')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3466bc18a5e477081c1cbd2defcb449f')"
] ]
} }
} }

View file

@ -176,7 +176,7 @@ class Backuper(val context: Context) {
} else { } else {
null null
} }
val icon = if (n.icon != null) { val icon = if (n.icon != null && !n.icon.url.isNullOrEmpty()) {
io.heckel.ntfy.db.Icon( io.heckel.ntfy.db.Icon(
url = n.icon.url, url = n.icon.url,
contentUri = n.icon.contentUri, contentUri = n.icon.contentUri,
@ -333,7 +333,7 @@ class Backuper(val context: Context) {
} else { } else {
null null
} }
val icon = if (n.icon != null) { val icon = if (n.icon != null && n.icon.hasValidUrl()) {
Icon( Icon(
url = n.icon.url, url = n.icon.url,
contentUri = n.icon.contentUri, contentUri = n.icon.contentUri,
@ -483,7 +483,7 @@ data class Attachment(
) )
data class Icon( data class Icon(
val url: String, // URL (mandatory, see ntfy server) val url: String?, // URL (nullable to handle corrupt backup files)
val contentUri: String?, // After it's downloaded, the content:// location val contentUri: String?, // After it's downloaded, the content:// location
) )

View file

@ -1,13 +1,31 @@
package io.heckel.ntfy.db package io.heckel.ntfy.db
import android.content.Context import android.content.Context
import androidx.room.* import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.Update
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import io.heckel.ntfy.service.NotAuthorizedException
import io.heckel.ntfy.service.WebSocketNotSupportedException
import io.heckel.ntfy.service.hasCause
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.lang.reflect.Type import java.lang.reflect.Type
import java.net.ConnectException
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)]) @Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)])
data class Subscription( data class Subscription(
@ -28,7 +46,7 @@ data class Subscription(
@Ignore val totalCount: Int = 0, // Total notifications @Ignore val totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications @Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp @Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE @Ignore val connectionDetails: ConnectionDetails = ConnectionDetails()
) { ) {
constructor( constructor(
id: Long, id: Long,
@ -64,7 +82,7 @@ data class Subscription(
totalCount = 0, totalCount = 0,
newCount = 0, newCount = 0,
lastActive = 0, lastActive = 0,
state = ConnectionState.NOT_APPLICABLE connectionDetails = ConnectionDetails()
) )
} }
@ -72,6 +90,36 @@ enum class ConnectionState {
NOT_APPLICABLE, CONNECTING, CONNECTED NOT_APPLICABLE, CONNECTING, CONNECTED
} }
/**
* Represents connection details for a specific baseUrl, including state and error info.
* This is not persisted to the database, but kept in memory.
*/
data class ConnectionDetails(
val state: ConnectionState = ConnectionState.NOT_APPLICABLE,
val error: Throwable? = null,
val nextRetryTime: Long = 0L
) {
fun getStackTraceString(): String {
return error?.stackTraceToString() ?: ""
}
fun hasError(): Boolean {
return error != null
}
fun isConnectionRefused(): Boolean {
return error?.hasCause<ConnectException>() ?: false
}
fun isWebSocketNotSupported(): Boolean {
return error?.hasCause<WebSocketNotSupportedException>() ?: false
}
fun isNotAuthorized(): Boolean {
return error?.hasCause<NotAuthorizedException>() ?: false
}
}
data class SubscriptionWithMetadata( data class SubscriptionWithMetadata(
val id: Long, val id: Long,
val baseUrl: String, val baseUrl: String,
@ -138,11 +186,13 @@ 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 (nullable to handle corrupt data from backup restore)
@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
) { ) {
@Ignore constructor(url:String) : @Ignore constructor(url: String) :
this(url, null) this(url, null)
fun hasValidUrl(): Boolean = !url.isNullOrEmpty()
} }
@Entity @Entity
@ -223,7 +273,7 @@ data class LogEntry(
} }
@androidx.room.Database( @androidx.room.Database(
version = 17, version = 18,
entities = [ entities = [
Subscription::class, Subscription::class,
Notification::class, Notification::class,
@ -268,6 +318,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_14_15) .addMigrations(MIGRATION_14_15)
.addMigrations(MIGRATION_15_16) .addMigrations(MIGRATION_15_16)
.addMigrations(MIGRATION_16_17) .addMigrations(MIGRATION_16_17)
.addMigrations(MIGRATION_17_18)
.fallbackToDestructiveMigration(true) .fallbackToDestructiveMigration(true)
.build() .build()
this.instance = instance this.instance = instance
@ -393,7 +444,16 @@ abstract class Database : RoomDatabase() {
} }
} }
// Fix corrupt icon data where icon_url is NULL but icon_contentUri is not NULL
// This caused IllegalStateException in CursorWindow.nativeGetString when Room tried to
// construct an Icon object with a null URL (Icon.url is non-nullable in Kotlin)
private val MIGRATION_16_17 = object : Migration(16, 17) { private val MIGRATION_16_17 = object : Migration(16, 17) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("UPDATE Notification SET icon_contentUri = NULL WHERE icon_url IS NULL AND icon_contentUri IS NOT NULL")
}
}
private val MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE Notification ADD COLUMN sequence_id TEXT NOT NULL DEFAULT ''") db.execSQL("ALTER TABLE Notification ADD COLUMN sequence_id TEXT NOT NULL DEFAULT ''")
db.execSQL("UPDATE Notification SET sequence_id = id WHERE sequence_id = ''") db.execSQL("UPDATE Notification SET sequence_id = id WHERE sequence_id = ''")

View file

@ -25,8 +25,9 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
private val clientCertificateDao = database.clientCertificateDao() private val clientCertificateDao = database.clientCertificateDao()
private val customHeaderDao = database.customHeaderDao() private val customHeaderDao = database.customHeaderDao()
private val connectionStates = ConcurrentHashMap<Long, ConnectionState>() private val connectionDetails = ConcurrentHashMap<String, ConnectionDetails>()
private val connectionStatesLiveData = MutableLiveData(connectionStates) private val connectionDetailsLiveData = MutableLiveData<Map<String, ConnectionDetails>>(connectionDetails)
private val connectionForceReconnectVersions = ConcurrentHashMap<String, Long>()
// TODO Move these into an ApplicationState singleton // TODO Move these into an ApplicationState singleton
val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ... val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ...
@ -40,7 +41,7 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
return subscriptionDao return subscriptionDao
.listFlow() .listFlow()
.asLiveData() .asLiveData()
.combineWith(connectionStatesLiveData) { subscriptionsWithMetadata, _ -> .combineWith(connectionDetailsLiveData) { subscriptionsWithMetadata, _ ->
toSubscriptionList(subscriptionsWithMetadata.orEmpty()) toSubscriptionList(subscriptionsWithMetadata.orEmpty())
} }
} }
@ -504,7 +505,6 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
private fun toSubscriptionList(list: List<SubscriptionWithMetadata>): List<Subscription> { private fun toSubscriptionList(list: List<SubscriptionWithMetadata>): List<Subscription> {
return list.map { s -> return list.map { s ->
val connectionState = connectionStates.getOrElse(s.id) { ConnectionState.NOT_APPLICABLE }
Subscription( Subscription(
id = s.id, id = s.id,
baseUrl = s.baseUrl, baseUrl = s.baseUrl,
@ -523,7 +523,7 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
totalCount = s.totalCount, totalCount = s.totalCount,
newCount = s.newCount, newCount = s.newCount,
lastActive = s.lastActive, lastActive = s.lastActive,
state = connectionState connectionDetails = connectionDetails[s.baseUrl] ?: ConnectionDetails()
) )
} }
} }
@ -550,30 +550,43 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
totalCount = s.totalCount, totalCount = s.totalCount,
newCount = s.newCount, newCount = s.newCount,
lastActive = s.lastActive, lastActive = s.lastActive,
state = getState(s.id) connectionDetails = connectionDetails[s.baseUrl] ?: ConnectionDetails()
) )
} }
fun updateState(subscriptionIds: Collection<Long>, newState: ConnectionState) { fun updateConnectionDetails(baseUrl: String, state: ConnectionState, error: Throwable? = null, nextRetryTime: Long = 0L) {
var changed = false val details = ConnectionDetails(state, error, nextRetryTime)
subscriptionIds.forEach { subscriptionId -> val current = connectionDetails[baseUrl]
val state = connectionStates.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE } if (current != details) {
if (state !== newState) { if (state == ConnectionState.NOT_APPLICABLE && error == null) {
changed = true connectionDetails.remove(baseUrl)
if (newState == ConnectionState.NOT_APPLICABLE) { } else {
connectionStates.remove(subscriptionId) connectionDetails[baseUrl] = details
} else {
connectionStates[subscriptionId] = newState
}
} }
} connectionDetailsLiveData.postValue(connectionDetails.toMap())
if (changed) { Log.d(TAG, "Connection details updated for $baseUrl: state=$state, error=${error?.message}, nextRetry=$nextRetryTime")
connectionStatesLiveData.postValue(connectionStates)
} }
} }
private fun getState(subscriptionId: Long): ConnectionState { fun getConnectionDetailsLiveData(): LiveData<Map<String, ConnectionDetails>> {
return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE } return connectionDetailsLiveData
}
fun getConnectionDetails(): Map<String, ConnectionDetails> {
return connectionDetails.toMap()
}
fun getConnectionDetailsForBaseUrl(baseUrl: String): ConnectionDetails? {
return connectionDetails[baseUrl]
}
fun getConnectionForceReconnectVersion(baseUrl: String): Long {
return connectionForceReconnectVersions[baseUrl] ?: 0L
}
fun incrementConnectionForceReconnectVersion(baseUrl: String) {
connectionForceReconnectVersions.compute(baseUrl) { _, current -> (current ?: 0L) + 1 }
Log.d(TAG, "Connection force reconnect version incremented for $baseUrl: ${connectionForceReconnectVersions[baseUrl]}")
} }
companion object { companion object {

View file

@ -5,6 +5,7 @@ import com.google.gson.Gson
import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User import io.heckel.ntfy.db.User
import io.heckel.ntfy.service.NotAuthorizedException
import io.heckel.ntfy.util.ALL_PRIORITIES import io.heckel.ntfy.util.ALL_PRIORITIES
import io.heckel.ntfy.util.HttpUtil import io.heckel.ntfy.util.HttpUtil
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
@ -14,10 +15,9 @@ import io.heckel.ntfy.util.topicUrlAuth
import io.heckel.ntfy.util.topicUrlJson import io.heckel.ntfy.util.topicUrlJson
import io.heckel.ntfy.util.topicUrlJsonPoll import io.heckel.ntfy.util.topicUrlJsonPoll
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okio.BufferedSource
import java.io.IOException import java.io.IOException
import java.net.URLEncoder import java.net.URLEncoder
@ -139,42 +139,25 @@ class ApiService(private val context: Context) {
baseUrl: String, baseUrl: String,
topics: String, topics: String,
since: String?, since: String?,
user: User?, user: User?
notify: (topic: String, Notification) -> Unit, ): Pair<Call, BufferedSource> {
fail: (Exception) -> Unit
): Call {
val sinceVal = since ?: "all" val sinceVal = since ?: "all"
val url = topicUrlJson(baseUrl, topics, sinceVal) val url = topicUrlJson(baseUrl, topics, sinceVal)
Log.d(TAG, "Opening subscription connection to $url") Log.d(TAG, "Opening subscription connection to $url")
val customHeaders = repository.getCustomHeaders(baseUrl) val customHeaders = repository.getCustomHeaders(baseUrl)
val request = HttpUtil.requestBuilder(url, user, customHeaders).build() val request = HttpUtil.requestBuilder(url, user, customHeaders).build()
val call = HttpUtil.subscriberClient(context, baseUrl).newCall(request) val call = HttpUtil.subscriberClient(context, baseUrl).newCall(request)
call.enqueue(object : Callback { val response = call.execute()
override fun onResponse(call: Call, response: Response) { if (!response.isSuccessful) {
try { val code = response.code
if (!response.isSuccessful) { val message = response.message
throw Exception("Unexpected response ${response.code} when subscribing to topic $url") response.close()
} if (code == 401 || code == 403) {
val source = response.body.source() throw NotAuthorizedException(code, message)
while (!source.exhausted()) {
val line = source.readUtf8Line() ?: throw Exception("Unexpected response for $url: line is null")
val notification = parser.parseWithTopic(line, subscriptionId = 0) // subscriptionId to be set downstream
if (notification != null) {
notify(notification.topic, notification.notification)
}
}
} catch (e: Exception) {
Log.e(TAG, "Connection to $url failed (1): ${e.message}", e)
fail(e)
}
} }
throw IOException("Unexpected response $code when subscribing to $url")
override fun onFailure(call: Call, e: IOException) { }
Log.e(TAG, "Connection to $url failed (2): ${e.message}", e) return Pair(call, response.body.source())
fail(e)
}
})
return call
} }
suspend fun checkAuth(baseUrl: String, topic: String, user: User?): Boolean { suspend fun checkAuth(baseUrl: String, topic: String, user: User?): Boolean {

View file

@ -36,6 +36,10 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
notification = repository.getNotification(notificationId) ?: return Result.failure() notification = repository.getNotification(notificationId) ?: return Result.failure()
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()
if (!icon.hasValidUrl()) {
Log.w(TAG, "Icon has no valid URL, skipping download")
return Result.failure()
}
try { try {
val iconFile = createIconFile(icon) val iconFile = createIconFile(icon)
val yesterdayTimestamp = Date().time - MAX_CACHE_MILLIS val yesterdayTimestamp = Date().time - MAX_CACHE_MILLIS
@ -58,12 +62,13 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
} }
private suspend fun downloadIcon(iconFile: File) { private suspend fun downloadIcon(iconFile: File) {
Log.d(TAG, "Downloading icon from ${icon.url}") val iconUrl = icon.url!! // Validated in doWork()
Log.d(TAG, "Downloading icon from $iconUrl")
try { try {
val user = repository.getUser(extractBaseUrl(icon.url)) val user = repository.getUser(extractBaseUrl(iconUrl))
val customHeaders = repository.getCustomHeaders(extractBaseUrl(icon.url)) val customHeaders = repository.getCustomHeaders(extractBaseUrl(iconUrl))
val request = HttpUtil.requestBuilder(icon.url, user, customHeaders).build() val request = HttpUtil.requestBuilder(iconUrl, user, customHeaders).build()
val client = HttpUtil.defaultClient(context, extractBaseUrl(icon.url)) val client = HttpUtil.defaultClient(context, extractBaseUrl(iconUrl))
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
Log.d(TAG, "Headers received: $response, Content-Length: ${response.headers["Content-Length"]}") Log.d(TAG, "Headers received: $response, Content-Length: ${response.headers["Content-Length"]}")
if (!response.isSuccessful) { if (!response.isSuccessful) {
@ -142,7 +147,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
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 hash = icon.url.sha256() val hash = icon.url!!.sha256() // URL validated in doWork()
return File(iconDir, hash) return File(iconDir, hash)
} }

View file

@ -78,7 +78,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
} }
} }
private fun shouldDownloadIcon(notification: Notification): Boolean { private fun shouldDownloadIcon(notification: Notification): Boolean {
return notification.icon != null return notification.icon?.hasValidUrl() == true
} }
private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {

View file

@ -1,11 +1,35 @@
package io.heckel.ntfy.service package io.heckel.ntfy.service
import okhttp3.internal.http2.StreamResetException
import java.io.EOFException
import java.net.ProtocolException
interface Connection { interface Connection {
fun start() fun start()
fun close() fun close()
fun since(): String? fun since(): String?
} }
/**
* Exception thrown when the server does not support WebSocket connections.
* This typically happens when the server returns a non-101 response during the WebSocket upgrade.
*/
class WebSocketNotSupportedException(
responseCode: Int,
responseMessage: String?,
cause: Throwable? = null
) : Exception("WebSocket upgrade failed with HTTP $responseCode: $responseMessage", cause)
/**
* Exception thrown when the server responds with HTTP 401/403
*/
class NotAuthorizedException(
responseCode: Int,
responseMessage: String?,
cause: Throwable? = null
) : Exception("User not authorized, HTTP $responseCode: $responseMessage", cause)
/** /**
* Represents a unique connection identifier that changes every time a * Represents a unique connection identifier that changes every time a
* connection needs to be re-established. * connection needs to be re-established.
@ -17,5 +41,38 @@ data class ConnectionId(
val credentialsHash: Int, // Hash of "username:password" or 0 if no user val credentialsHash: Int, // Hash of "username:password" or 0 if no user
val headersHash: Int, // Hash of sorted headers or 0 if none val headersHash: Int, // Hash of sorted headers or 0 if none
val trustedCertsHash: Int, // Hash of trusted certificates or 0 if none val trustedCertsHash: Int, // Hash of trusted certificates or 0 if none
val clientCertHash: Int // Hash of client certificate or 0 if none val clientCertHash: Int, // Hash of client certificate or 0 if none
val connectionForceReconnectVersion: Long // Incremented to force reconnection for this baseUrl
) )
fun isResponseCode(response: okhttp3.Response?, vararg codes: Int): Boolean {
val responseCode = response?.code ?: return false
return responseCode in codes
}
/**
* Returns true if the exception indicates the connection was broken normally
* (e.g., server closed connection). These errors should not be shown to the user.
*/
fun isConnectionBrokenException(t: Throwable): Boolean {
return t.hasCause<EOFException>() || t.hasCause<StreamResetException>()
}
/**
* ProtocolException is thrown by the OkHttp library when the WebSocket handshake fails.
*/
fun isProtocolException(t: Throwable): Boolean {
return t.hasCause<ProtocolException>()
}
/**
* Checks if the throwable or any of its causes is of the specified type.
*/
inline fun <reified T : Throwable> Throwable.hasCause(): Boolean {
var current: Throwable? = this
while (current != null) {
if (current is T) return true
current = current.cause
}
return false
}

View file

@ -1,12 +1,21 @@
package io.heckel.ntfy.service package io.heckel.ntfy.service
import io.heckel.ntfy.db.* import io.heckel.ntfy.db.ConnectionState
import io.heckel.ntfy.util.Log import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.db.User
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.Call import okhttp3.Call
import java.util.concurrent.atomic.AtomicBoolean
class JsonConnection( class JsonConnection(
private val connectionId: ConnectionId, private val connectionId: ConnectionId,
@ -15,17 +24,18 @@ class JsonConnection(
private val api: ApiService, private val api: ApiService,
private val user: User?, private val user: User?,
private val sinceId: String?, private val sinceId: String?,
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit, private val connectionDetailsListener: (String, ConnectionState, Throwable?, Long) -> Unit,
private val notificationListener: (Subscription, Notification) -> Unit, private val notificationListener: (Subscription, Notification) -> Unit,
private val serviceActive: () -> Boolean private val serviceActive: () -> Boolean
) : Connection { ) : Connection {
private val baseUrl = connectionId.baseUrl private val baseUrl = connectionId.baseUrl
private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds
private val subscriptionIds = topicsToSubscriptionIds.values
private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",") private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",")
private val url = topicUrl(baseUrl, topicsStr) private val url = topicUrl(baseUrl, topicsStr)
private val parser = NotificationParser()
private var since: String? = sinceId private var since: String? = sinceId
private var errorCount = 0
private lateinit var call: Call private lateinit var call: Call
private lateinit var job: Job private lateinit var job: Job
@ -33,51 +43,48 @@ class JsonConnection(
job = scope.launch(Dispatchers.IO) { job = scope.launch(Dispatchers.IO) {
Log.d(TAG, "[$url] Starting connection for subscriptions: $topicsToSubscriptionIds") Log.d(TAG, "[$url] Starting connection for subscriptions: $topicsToSubscriptionIds")
// Retry-loop: if the connection fails, we retry unless the job or service is cancelled/stopped
var retryMillis = 0L
while (isActive && serviceActive()) { while (isActive && serviceActive()) {
Log.d(TAG, "[$url] (Re-)starting connection for subscriptions: $topicsToSubscriptionIds") Log.d(TAG, "[$url] (Re-)starting connection for subscriptions: $topicsToSubscriptionIds")
val startTime = System.currentTimeMillis()
val notify = notify@ { topic: String, notification: Notification ->
since = notification.id
val subscriptionId = topicsToSubscriptionIds[topic] ?: return@notify
val subscription = repository.getSubscription(subscriptionId) ?: return@notify
val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
notificationListener(subscription, notificationWithSubscriptionId)
}
val failed = AtomicBoolean(false)
val fail = { _: Exception ->
failed.set(true)
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
}
}
// Call /json subscribe endpoint and loop until the call fails, is canceled,
// or the job or service are cancelled/stopped
try { try {
call = api.subscribe(baseUrl, topicsStr, since, user, notify, fail) val (newCall, source) = api.subscribe(baseUrl, topicsStr, since, user)
while (!failed.get() && !call.isCanceled() && isActive && serviceActive()) { call = newCall
stateChangeListener(subscriptionIds, ConnectionState.CONNECTED) if (errorCount > 0) {
Log.d(TAG,"[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=${serviceActive()}") errorCount = 0
delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled
} }
connectionDetailsListener(baseUrl, ConnectionState.CONNECTED, null, 0L)
// Blocking read loop: reads JSON lines until connection closes or is cancelled
while (isActive && serviceActive() && !source.exhausted()) {
val line = source.readUtf8Line() ?: break
val notificationWithTopic = parser.parseWithTopic(line, subscriptionId = 0)
if (notificationWithTopic != null) {
since = notificationWithTopic.notification.id
val topic = notificationWithTopic.topic
val subscriptionId = topicsToSubscriptionIds[topic] ?: continue
val subscription = repository.getSubscription(subscriptionId) ?: continue
val notification = notificationWithTopic.notification.copy(subscriptionId = subscription.id)
notificationListener(subscription, notification)
}
}
Log.d(TAG, "[$url] Connection closed cleanly")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "[$url] Connection failed: ${e.message}", e) if (!isActive) {
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection Log.d(TAG, "[$url] Connection cancelled")
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING) break
} }
} Log.d(TAG, "[$url] Connection broken, reconnecting ...")
errorCount++
// If we're not cancelled yet, wait little before retrying (incremental back-off) val retrySeconds = RETRY_SECONDS.getOrNull(errorCount-1) ?: RETRY_SECONDS.last()
if (isActive && serviceActive()) { val nextRetryTime = System.currentTimeMillis() + (retrySeconds * 1000L)
retryMillis = nextRetryMillis(retryMillis, startTime) val error = if (isConnectionBrokenException(e)) null else e
Log.d(TAG, "[$url] Connection failed, retrying connection in ${retryMillis / 1000}s ...") connectionDetailsListener(baseUrl, ConnectionState.CONNECTING, error, nextRetryTime)
delay(retryMillis) Log.w(TAG, "[$url] Retrying connection in ${retrySeconds}s ...")
delay(retrySeconds * 1000L)
} }
} }
Log.d(TAG, "[$url] Connection job SHUT DOWN") Log.d(TAG, "[$url] Connection job SHUT DOWN")
// FIXME: Do NOT update state here as this can lead to races; this leaks the subscription state map
} }
} }
@ -91,21 +98,8 @@ class JsonConnection(
if (this::call.isInitialized) call.cancel() if (this::call.isInitialized) call.cancel()
} }
private fun nextRetryMillis(retryMillis: Long, startTime: Long): Long {
val connectionDurationMillis = System.currentTimeMillis() - startTime
if (connectionDurationMillis > RETRY_RESET_AFTER_MILLIS) {
return RETRY_STEP_MILLIS
} else if (retryMillis + RETRY_STEP_MILLIS >= RETRY_MAX_MILLIS) {
return RETRY_MAX_MILLIS
}
return retryMillis + RETRY_STEP_MILLIS
}
companion object { companion object {
private const val TAG = "NtfySubscriberConn" private const val TAG = "NtfyJsonConnection"
private const val CONNECTION_LOOP_DELAY_MILLIS = 30_000L private val RETRY_SECONDS = listOf(5, 10, 15, 20, 30, 45, 60, 120)
private const val RETRY_STEP_MILLIS = 5_000L
private const val RETRY_MAX_MILLIS = 60_000L
private const val RETRY_RESET_AFTER_MILLIS = 60_000L // Must be larger than CONNECTION_LOOP_DELAY_MILLIS
} }
} }

View file

@ -115,11 +115,12 @@ class SubscriberService : Service() {
notificationManager = createNotificationChannel() notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text) serviceNotification = createNotification(title, text)
val notification = serviceNotification ?: return
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_SERVICE_ID, serviceNotification!!, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) startForeground(NOTIFICATION_SERVICE_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else { } else {
startForeground(NOTIFICATION_SERVICE_ID, serviceNotification) startForeground(NOTIFICATION_SERVICE_ID, notification)
} }
} catch (e: Exception) { } catch (e: Exception) {
// On Android 12+, starting a foreground service from the background is restricted. // On Android 12+, starting a foreground service from the background is restricted.
@ -201,7 +202,7 @@ class SubscriberService : Service() {
* It is guaranteed that only one of function is run at a time (see mutex above). * It is guaranteed that only one of function is run at a time (see mutex above).
*/ */
private suspend fun reallyRefreshConnections(scope: CoroutineScope) { private suspend fun reallyRefreshConnections(scope: CoroutineScope) {
// Group INSTANT subscriptions by base URL, there is only one connection per base URL // Group instant subscriptions by base URL, there is only one connection per base URL
val instantSubscriptions = repository.getSubscriptions().filter { s -> s.instant } val instantSubscriptions = repository.getSubscriptions().filter { s -> s.instant }
val activeConnectionIds = connections.keys().toList().toSet() val activeConnectionIds = connections.keys().toList().toSet()
val connectionProtocol = repository.getConnectionProtocol() val connectionProtocol = repository.getConnectionProtocol()
@ -219,6 +220,7 @@ class SubscriberService : Service() {
.hashCode() .hashCode()
val trustedCertsHash = repository.getTrustedCertificate(baseUrl)?.hashCode() ?: 0 val trustedCertsHash = repository.getTrustedCertificate(baseUrl)?.hashCode() ?: 0
val clientCertHash = repository.getClientCertificate(baseUrl)?.hashCode() ?: 0 val clientCertHash = repository.getClientCertificate(baseUrl)?.hashCode() ?: 0
val connectionForceReconnectVersion = repository.getConnectionForceReconnectVersion(baseUrl)
ConnectionId( ConnectionId(
baseUrl = baseUrl, baseUrl = baseUrl,
topicsToSubscriptionIds = subs.associate { s -> s.topic to s.id }, topicsToSubscriptionIds = subs.associate { s -> s.topic to s.id },
@ -226,7 +228,8 @@ class SubscriberService : Service() {
credentialsHash = credentialsHash, credentialsHash = credentialsHash,
headersHash = headersHash, headersHash = headersHash,
trustedCertsHash = trustedCertsHash, trustedCertsHash = trustedCertsHash,
clientCertHash = clientCertHash clientCertHash = clientCertHash,
connectionForceReconnectVersion = connectionForceReconnectVersion
) )
} }
.toSet() .toSet()
@ -261,9 +264,9 @@ class SubscriberService : Service() {
val connection = if (connectionId.connectionProtocol == Repository.CONNECTION_PROTOCOL_WS) { val connection = if (connectionId.connectionProtocol == Repository.CONNECTION_PROTOCOL_WS) {
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
val httpClient = HttpUtil.wsClient(this, connectionId.baseUrl) val httpClient = HttpUtil.wsClient(this, connectionId.baseUrl)
WsConnection(connectionId, repository, httpClient, user, customHeaders, since, ::onStateChanged, ::onNotificationReceived, alarmManager) WsConnection(connectionId, repository, httpClient, user, customHeaders, since, ::onConnectionDetailsChanged, ::onNotificationReceived, alarmManager)
} else { } else {
JsonConnection(connectionId, scope, repository, api, user, since, ::onStateChanged, ::onNotificationReceived, serviceActive) JsonConnection(connectionId, scope, repository, api, user, since, ::onConnectionDetailsChanged, ::onNotificationReceived, serviceActive)
} }
connections[connectionId] = connection connections[connectionId] = connection
connection.start() connection.start()
@ -304,8 +307,8 @@ class SubscriberService : Service() {
} }
} }
private fun onStateChanged(subscriptionIds: Collection<Long>, state: ConnectionState) { private fun onConnectionDetailsChanged(baseUrl: String, state: ConnectionState, throwable: Throwable?, nextRetryTime: Long) {
repository.updateState(subscriptionIds, state) repository.updateConnectionDetails(baseUrl, state, throwable, nextRetryTime)
} }
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) { private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) {

View file

@ -1,7 +1,6 @@
package io.heckel.ntfy.service package io.heckel.ntfy.service
import android.app.AlarmManager import android.app.AlarmManager
import android.content.Context
import android.os.Build import android.os.Build
import io.heckel.ntfy.db.ConnectionState import io.heckel.ntfy.db.ConnectionState
import io.heckel.ntfy.db.CustomHeader import io.heckel.ntfy.db.CustomHeader
@ -18,6 +17,7 @@ import okhttp3.OkHttpClient
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import java.net.ProtocolException
import java.util.Calendar import java.util.Calendar
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
@ -39,7 +39,7 @@ class WsConnection(
private val user: User?, private val user: User?,
private val customHeaders: List<CustomHeader>, private val customHeaders: List<CustomHeader>,
private val sinceId: String?, private val sinceId: String?,
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit, private val connectionDetailsListener: (String, ConnectionState, Throwable?, Long) -> Unit,
private val notificationListener: (Subscription, Notification) -> Unit, private val notificationListener: (Subscription, Notification) -> Unit,
private val alarmManager: AlarmManager private val alarmManager: AlarmManager
) : Connection { ) : Connection {
@ -55,7 +55,6 @@ class WsConnection(
private val since = AtomicReference<String?>(sinceId) private val since = AtomicReference<String?>(sinceId)
private val baseUrl = connectionId.baseUrl private val baseUrl = connectionId.baseUrl
private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds
private val subscriptionIds = topicsToSubscriptionIds.values
private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",") private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",")
private val shortUrl = topicShortUrl(baseUrl, topicsStr) private val shortUrl = topicShortUrl(baseUrl, topicsStr)
@ -141,7 +140,7 @@ class WsConnection(
if (errorCount > 0) { if (errorCount > 0) {
errorCount = 0 errorCount = 0
} }
stateChangeListener(subscriptionIds, ConnectionState.CONNECTED) connectionDetailsListener(baseUrl, ConnectionState.CONNECTED, null, 0L)
} }
} }
@ -181,10 +180,22 @@ class WsConnection(
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection marked as closed. Not retrying.") Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection marked as closed. Not retrying.")
return@synchronize return@synchronize
} }
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
state = State.Disconnected state = State.Disconnected
errorCount++ errorCount++
val retrySeconds = RETRY_SECONDS.getOrNull(errorCount) ?: RETRY_SECONDS.last() val retrySeconds = RETRY_SECONDS.getOrNull(errorCount-1) ?: RETRY_SECONDS.last()
val nextRetryTime = System.currentTimeMillis() + (retrySeconds * 1000L)
// Special cases:
// - Ignore broken connections in the UI, we don't want to show warning icons
// - Handle authentication errors
// - Handle servers that do not support WebSockets
val error = when {
isConnectionBrokenException(t) -> null
isProtocolException(t) -> WebSocketNotSupportedException(response!!.code, response.message, t)
isResponseCode(response, 401, 403) -> NotAuthorizedException(response!!.code, response.message, t)
else -> t
}
connectionDetailsListener(baseUrl, ConnectionState.CONNECTING, error, nextRetryTime)
scheduleReconnect(retrySeconds) scheduleReconnect(retrySeconds)
} }
} }

View file

@ -0,0 +1,275 @@
package io.heckel.ntfy.ui
import android.app.Dialog
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.HorizontalScrollView
import android.widget.TextView
import androidx.core.text.HtmlCompat
import androidx.fragment.app.DialogFragment
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.color.MaterialColors
import com.google.android.material.textfield.TextInputLayout
import io.heckel.ntfy.R
import io.heckel.ntfy.db.ConnectionDetails
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.copyToClipboard
import io.heckel.ntfy.util.shortUrl
class ConnectionErrorFragment : DialogFragment() {
private lateinit var repository: Repository
private var connectionDetails: Map<String, ConnectionDetails> = emptyMap()
private var selectedBaseUrl: String? = null
private var filterBaseUrl: String? = null
private lateinit var toolbar: MaterialToolbar
private lateinit var serverLayout: TextInputLayout
private lateinit var serverDropdown: AutoCompleteTextView
private lateinit var descriptionTextView: TextView
private lateinit var errorTextView: TextView
private lateinit var countdownTextView: TextView
private lateinit var detailsScrollView: HorizontalScrollView
private lateinit var stackTraceTextView: TextView
private val handler = Handler(Looper.getMainLooper())
private val countdownRunnable = object : Runnable {
override fun run() {
updateCountdown()
handler.postDelayed(this, 1000)
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
if (activity == null) {
throw IllegalStateException("Activity cannot be null")
}
// Get optional baseUrl filter from arguments
filterBaseUrl = arguments?.getString(ARG_BASE_URL)
// Dependencies
repository = Repository.getInstance(requireContext())
// Get connection details with errors, optionally filtered by baseUrl
val allDetails = repository.getConnectionDetails()
connectionDetails = if (filterBaseUrl != null) {
allDetails.filterKeys { it == filterBaseUrl }.filterValues { it.hasError() }
} else {
allDetails.filterValues { it.hasError() }
}
// Build root view
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_connection_error_dialog, null)
// Setup toolbar
toolbar = view.findViewById(R.id.connection_error_dialog_toolbar)
toolbar.setNavigationOnClickListener { dismiss() }
toolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.connection_error_dialog_action_retry -> {
// Retry all base URLs with errors
connectionDetails.filter { it.value.hasError() }.keys.forEach { baseUrl ->
repository.incrementConnectionForceReconnectVersion(baseUrl)
}
SubscriberServiceManager.refresh(requireContext())
true
}
R.id.connection_error_dialog_action_copy -> {
copyErrorToClipboard()
true
}
else -> false
}
}
// Tint menu icons to match toolbar text color
val iconColor = MaterialColors.getColor(requireContext(), R.attr.colorOnSurface, Color.BLACK)
toolbar.menu.findItem(R.id.connection_error_dialog_action_retry)?.icon?.setTint(iconColor)
toolbar.menu.findItem(R.id.connection_error_dialog_action_copy)?.icon?.setTint(iconColor)
// Get view references
serverLayout = view.findViewById(R.id.connection_error_dialog_server_layout)
serverDropdown = view.findViewById(R.id.connection_error_dialog_server_dropdown)
descriptionTextView = view.findViewById(R.id.connection_error_dialog_description)
errorTextView = view.findViewById(R.id.connection_error_dialog_error_text)
countdownTextView = view.findViewById(R.id.connection_error_dialog_countdown)
detailsScrollView = view.findViewById(R.id.connection_error_dialog_details_scroll)
stackTraceTextView = view.findViewById(R.id.connection_error_dialog_stack_trace)
// Setup server dropdown if multiple errors
val baseUrls = connectionDetails.keys.toList()
if (baseUrls.size > 1) {
serverLayout.visibility = View.VISIBLE
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, baseUrls)
serverDropdown.setAdapter(adapter)
serverDropdown.setText(baseUrls.first(), false)
serverDropdown.setOnItemClickListener { _, _, position, _ ->
selectedBaseUrl = baseUrls[position]
updateErrorDisplay()
}
} else {
serverLayout.visibility = View.GONE
}
// Select first error by default
selectedBaseUrl = baseUrls.firstOrNull()
updateErrorDisplay()
// Observe connection details to update when errors change
repository.getConnectionDetailsLiveData().observe(this) { details ->
connectionDetails = if (filterBaseUrl != null) {
details.filterKeys { it == filterBaseUrl }.filterValues { it.hasError() }
} else {
details.filterValues { it.hasError() }
}
// Close dialog if no more errors
if (connectionDetails.isEmpty()) {
dismiss()
return@observe
}
// Update dropdown if the list of errored URLs changed
val baseUrls = connectionDetails.keys.toList()
updateServerDropdown(baseUrls)
// If selected URL no longer has an error, switch to first available
if (selectedBaseUrl == null || !connectionDetails.containsKey(selectedBaseUrl)) {
selectedBaseUrl = baseUrls.firstOrNull()
if (baseUrls.size > 1) {
serverDropdown.setText(selectedBaseUrl ?: "", false)
}
}
updateErrorDisplay()
}
// Build dialog
val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog)
dialog.setContentView(view)
return dialog
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {
setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
// Start countdown timer
handler.post(countdownRunnable)
}
override fun onStop() {
super.onStop()
// Stop countdown timer
handler.removeCallbacks(countdownRunnable)
}
private fun updateServerDropdown(baseUrls: List<String>) {
if (baseUrls.size > 1) {
serverLayout.visibility = View.VISIBLE
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, baseUrls)
serverDropdown.setAdapter(adapter)
serverDropdown.setOnItemClickListener { _, _, position, _ ->
selectedBaseUrl = baseUrls[position]
updateErrorDisplay()
}
} else {
serverLayout.visibility = View.GONE
}
}
private fun updateErrorDisplay() {
val baseUrl = selectedBaseUrl ?: return
descriptionTextView.text = getString(R.string.connection_error_dialog_message, baseUrl)
val details = connectionDetails[baseUrl]
if (details != null && details.hasError()) {
errorTextView.text = when {
details.isConnectionRefused() -> getString(R.string.connection_error_dialog_connection_refused)
details.isWebSocketNotSupported() -> getString(R.string.connection_error_dialog_websocket_not_supported)
details.isNotAuthorized() -> getString(R.string.connection_error_dialog_not_authorized)
else -> getErrorDisplayText(details.error)
}
val stackTrace = details.getStackTraceString()
if (stackTrace.isNotEmpty()) {
stackTraceTextView.text = stackTrace
detailsScrollView.visibility = View.VISIBLE
} else {
detailsScrollView.visibility = View.GONE
}
} else {
errorTextView.visibility = View.GONE
detailsScrollView.visibility = View.GONE
}
updateCountdown()
}
private fun getErrorDisplayText(error: Throwable?): String {
if (error == null) {
return "" // This should not happen
}
val message = error.message
if (!message.isNullOrBlank()) {
return message
}
// If no message, return the simple class name (e.g., "IOException")
return error.javaClass.simpleName
}
private fun updateCountdown() {
val details = selectedBaseUrl?.let { connectionDetails[it] }
if (details != null && details.nextRetryTime > 0) {
val remainingMillis = details.nextRetryTime - System.currentTimeMillis()
if (remainingMillis > 0) {
val remainingSeconds = (remainingMillis / 1000).toInt()
countdownTextView.text = getString(R.string.connection_error_dialog_retry_countdown, remainingSeconds)
countdownTextView.visibility = View.VISIBLE
} else {
countdownTextView.text = getString(R.string.connection_error_dialog_retrying)
countdownTextView.visibility = View.VISIBLE
}
} else {
countdownTextView.visibility = View.GONE
}
}
private fun copyErrorToClipboard() {
val baseUrl = selectedBaseUrl ?: return
val details = connectionDetails[baseUrl] ?: return
val text = buildString {
appendLine("Server: $baseUrl")
appendLine("Error: ${getErrorDisplayText(details.error)}")
appendLine()
appendLine("Stack trace:")
append(details.getStackTraceString().ifEmpty { "No stack trace available" })
}
copyToClipboard(requireContext(), "connection error", text)
}
companion object {
const val TAG = "NtfyConnectionErrorFragment"
private const val ARG_BASE_URL = "base_url"
fun newInstance(baseUrl: String? = null): ConnectionErrorFragment {
val fragment = ConnectionErrorFragment()
if (baseUrl != null) {
val args = Bundle()
args.putString(ARG_BASE_URL, baseUrl)
fragment.arguments = args
}
return fragment
}
}
}

View file

@ -363,6 +363,11 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
SubscriberServiceManager.refresh(this) SubscriberServiceManager.refresh(this)
} }
// Observe connection details and update menu item visibility
repository.getConnectionDetailsLiveData().observe(this) { details ->
showHideConnectionErrorMenuItem(details)
}
// Mark this subscription as "open" so we don't receive notifications for it // Mark this subscription as "open" so we don't receive notifications for it
repository.detailViewSubscriptionId.set(subscriptionId) repository.detailViewSubscriptionId.set(subscriptionId)
@ -510,6 +515,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
showHideInstantMenuItems(subscriptionInstant) showHideInstantMenuItems(subscriptionInstant)
showHideMutedUntilMenuItems(subscriptionMutedUntil) showHideMutedUntilMenuItems(subscriptionMutedUntil)
showHideCopyMenuItems(subscription.baseUrl) showHideCopyMenuItems(subscription.baseUrl)
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
updateTitle(subscriptionDisplayName) updateTitle(subscriptionDisplayName)
} }
} }
@ -552,6 +558,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
showHideInstantMenuItems(subscriptionInstant) showHideInstantMenuItems(subscriptionInstant)
showHideMutedUntilMenuItems(subscriptionMutedUntil) showHideMutedUntilMenuItems(subscriptionMutedUntil)
showHideCopyMenuItems(subscriptionBaseUrl) showHideCopyMenuItems(subscriptionBaseUrl)
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
// Regularly check if "notification muted" time has passed // Regularly check if "notification muted" time has passed
// NOTE: This is done here, because then we know that we've initialized the menu items. // NOTE: This is done here, because then we know that we've initialized the menu items.
@ -605,6 +612,10 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
onInstantEnableClick(enable = false) onInstantEnableClick(enable = false)
true true
} }
R.id.detail_menu_connection_error -> {
onConnectionErrorClick()
true
}
R.id.detail_menu_copy_url -> { R.id.detail_menu_copy_url -> {
onCopyUrlClick() onCopyUrlClick()
true true
@ -670,6 +681,12 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
} }
} }
private fun onConnectionErrorClick() {
Log.d(TAG, "Showing connection error dialog for ${subscriptionBaseUrl}")
val connectionErrorFragment = ConnectionErrorFragment.newInstance(subscriptionBaseUrl)
connectionErrorFragment.show(supportFragmentManager, ConnectionErrorFragment.TAG)
}
override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) { override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
Log.d(TAG, "Setting subscription 'muted until' to $mutedUntilTimestamp") Log.d(TAG, "Setting subscription 'muted until' to $mutedUntilTimestamp")
@ -804,6 +821,18 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
} }
} }
private fun showHideConnectionErrorMenuItem(details: Map<String, io.heckel.ntfy.db.ConnectionDetails>) {
if (!this::menu.isInitialized) {
return
}
runOnUiThread {
val connectionErrorItem = menu.findItem(R.id.detail_menu_connection_error)
// Only show if there's an error for this subscription's base URL
val hasError = details[subscriptionBaseUrl]?.hasError() == true
connectionErrorItem?.isVisible = hasError
}
}
private fun updateTitle(subscriptionDisplayName: String) { private fun updateTitle(subscriptionDisplayName: String) {
runOnUiThread { runOnUiThread {
title = subscriptionDisplayName title = subscriptionDisplayName
@ -947,7 +976,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
} }
private fun finishActionMode() { private fun finishActionMode() {
actionMode!!.finish() actionMode?.finish()
endActionModeAndRedraw() endActionModeAndRedraw()
} }

View file

@ -254,6 +254,11 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
SubscriberServiceManager.refresh(this) SubscriberServiceManager.refresh(this)
} }
// Observe connection details and update menu item visibility
repository.getConnectionDetailsLiveData().observe(this) { details ->
showHideConnectionErrorMenuItem(details)
}
// Battery banner // Battery banner
val batteryBanner = findViewById<View>(R.id.main_banner_battery) // Banner visibility is toggled in onResume() val batteryBanner = findViewById<View>(R.id.main_banner_battery) // Banner visibility is toggled in onResume()
val dontAskAgainButton = findViewById<Button>(R.id.main_banner_battery_dontaskagain) val dontAskAgainButton = findViewById<Button>(R.id.main_banner_battery_dontaskagain)
@ -371,6 +376,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
showHideNotificationMenuItems() showHideNotificationMenuItems()
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
redrawList() redrawList()
} }
@ -488,6 +494,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
} }
showHideNotificationMenuItems() showHideNotificationMenuItems()
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
checkSubscriptionsMuted() // This is done here, because then we know that we've initialized the menu checkSubscriptionsMuted() // This is done here, because then we know that we've initialized the menu
return true return true
} }
@ -551,6 +558,17 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
} }
} }
private fun showHideConnectionErrorMenuItem(details: Map<String, io.heckel.ntfy.db.ConnectionDetails>) {
if (!this::menu.isInitialized) {
return
}
runOnUiThread {
val connectionErrorItem = menu.findItem(R.id.main_menu_connection_error)
val hasErrors = details.values.any { it.hasError() }
connectionErrorItem?.isVisible = hasErrors
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.main_menu_notifications_enabled -> { R.id.main_menu_notifications_enabled -> {
@ -565,6 +583,10 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
onNotificationSettingsClick(enable = true) onNotificationSettingsClick(enable = true)
true true
} }
R.id.main_menu_connection_error -> {
onConnectionErrorClick()
true
}
R.id.main_menu_settings -> { R.id.main_menu_settings -> {
startActivity(Intent(this, SettingsActivity::class.java)) startActivity(Intent(this, SettingsActivity::class.java))
true true
@ -608,6 +630,12 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
} }
} }
private fun onConnectionErrorClick() {
Log.d(TAG, "Showing connection error dialog")
val connectionErrorFragment = ConnectionErrorFragment.newInstance()
connectionErrorFragment.show(supportFragmentManager, ConnectionErrorFragment.TAG)
}
override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) { override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
repository.setGlobalMutedUntil(mutedUntilTimestamp) repository.setGlobalMutedUntil(mutedUntilTimestamp)
showHideNotificationMenuItems() showHideNotificationMenuItems()

View file

@ -75,6 +75,7 @@ class MainAdapter(
private val nameView: TextView = itemView.findViewById(R.id.main_item_text) private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
private val statusView: TextView = itemView.findViewById(R.id.main_item_status) private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
private val dateView: TextView = itemView.findViewById(R.id.main_item_date) private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
private val connectionErrorImageView: View = itemView.findViewById(R.id.main_item_connection_error_image)
private val notificationDisabledUntilImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_until_image) private val notificationDisabledUntilImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_until_image)
private val notificationDisabledForeverImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_forever_image) private val notificationDisabledForeverImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_forever_image)
private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image) private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image)
@ -91,7 +92,7 @@ class MainAdapter(
} else { } else {
context.getString(R.string.main_item_status_text_not_one, subscription.totalCount) context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
} }
if (subscription.instant && subscription.state == ConnectionState.CONNECTING) { if (subscription.instant && subscription.connectionDetails.state == ConnectionState.CONNECTING) {
statusMessage += ", " + context.getString(R.string.main_item_status_reconnecting) statusMessage += ", " + context.getString(R.string.main_item_status_reconnecting)
} }
val date = Date(subscription.lastActive * 1000) val date = Date(subscription.lastActive * 1000)
@ -119,6 +120,8 @@ class MainAdapter(
statusView.text = statusMessage statusView.text = statusMessage
dateView.text = dateText dateView.text = dateText
dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
val showConnectionError = subscription.instant && subscription.connectionDetails.hasError()
connectionErrorImageView.visibility = if (showConnectionError) View.VISIBLE else View.GONE
notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE
notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE
instantImageView.visibility = if (subscription.instant && BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE instantImageView.visibility = if (subscription.instant && BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE

View file

@ -20,6 +20,7 @@ import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.core.view.size import androidx.core.view.size
import androidx.core.view.get import androidx.core.view.get
@ -150,25 +151,25 @@ class ShareActivity : AppCompatActivity() {
} else { } else {
baseUrlsRaw.filterNot { it == appBaseUrl } baseUrlsRaw.filterNot { it == appBaseUrl }
} }
suggestedTopicsList.adapter = TopicAdapter(suggestedTopics) { topicUrl ->
try { withContext(Dispatchers.Main) {
val (baseUrl, topic) = splitTopicUrl(topicUrl) suggestedTopicsList.adapter = TopicAdapter(suggestedTopics) { topicUrl ->
val defaultUrl = defaultBaseUrl ?: appBaseUrl try {
topicText.text = topic val (baseUrl, topic) = splitTopicUrl(topicUrl)
if (baseUrl == defaultUrl) { val defaultUrl = defaultBaseUrl ?: appBaseUrl
useAnotherServerCheckbox.isChecked = false topicText.text = topic
} else { if (baseUrl == defaultUrl) {
useAnotherServerCheckbox.isChecked = true useAnotherServerCheckbox.isChecked = false
baseUrlText.setText(baseUrl) } else {
useAnotherServerCheckbox.isChecked = true
baseUrlText.setText(baseUrl)
}
} catch (e: Exception) {
Log.w(TAG, "Invalid topicUrl $topicUrl", e)
} }
} catch (e: Exception) {
Log.w(TAG, "Invalid topicUrl $topicUrl", e)
} }
}
// Add baseUrl auto-complete behavior // Add baseUrl auto-complete behavior
val activity = this@ShareActivity
activity.runOnUiThread {
initBaseUrlDropdown(baseUrls, baseUrlText, baseUrlLayout) initBaseUrlDropdown(baseUrls, baseUrlText, baseUrlLayout)
useAnotherServerCheckbox.isChecked = if (suggestedTopics.isNotEmpty()) { useAnotherServerCheckbox.isChecked = if (suggestedTopics.isNotEmpty()) {
try { try {

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#FFFFFF"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#757575"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

View file

@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/connection_error_dialog_app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:elevation="0dp"
app:liftOnScroll="false">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/connection_error_dialog_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
android:paddingStart="0dp"
android:paddingEnd="12dp"
app:navigationIcon="@drawable/ic_close_white_24dp"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/connection_error_dialog_title"
app:titleTextColor="?attr/colorOnSurface"
app:menu="@menu/menu_connection_error_dialog" />
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="?dialogPreferredPadding"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="16dp">
<!-- Server dropdown (Material 3 style, only visible when multiple servers have errors) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/connection_error_dialog_server_layout"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/common_service_url"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<AutoCompleteTextView
android:id="@+id/connection_error_dialog_server_dropdown"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:editable="false" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Description text (left aligned like UserFragment) -->
<TextView
android:id="@+id/connection_error_dialog_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:paddingTop="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_server_layout" />
<!-- Countdown text -->
<TextView
android:id="@+id/connection_error_dialog_countdown"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_description" />
<!-- Error message text (red) -->
<TextView
android:id="@+id/connection_error_dialog_error_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginStart="28dp"
android:paddingStart="0dp"
android:paddingEnd="4dp"
android:textColor="?attr/colorError"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_countdown" />
<!-- Error icon (top-aligned with error text) -->
<ImageView
android:id="@+id/connection_error_dialog_error_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginTop="2dp"
android:paddingStart="2dp"
android:paddingEnd="0dp"
app:srcCompat="@drawable/ic_error_red_24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/connection_error_dialog_error_text" />
<!-- Stack trace (scrollable horizontally, no word wrap) -->
<HorizontalScrollView
android:id="@+id/connection_error_dialog_details_scroll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fillViewport="true"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_error_text">
<ScrollView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxHeight="300dp">
<TextView
android:id="@+id/connection_error_dialog_stack_trace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorSurfaceVariant"
android:fontFamily="monospace"
android:padding="12dp"
android:scrollHorizontally="true"
android:singleLine="false"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorPrimary"
android:textSize="10sp" />
</ScrollView>
</HorizontalScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -22,7 +22,7 @@
android:layout_marginStart="12dp" app:layout_constraintStart_toEndOf="@+id/main_item_image" android:layout_marginStart="12dp" app:layout_constraintStart_toEndOf="@+id/main_item_image"
app:layout_constraintVertical_bias="0.0" android:textAppearance="@style/TextAppearance.AppCompat.Medium" app:layout_constraintVertical_bias="0.0" android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textColor="?android:attr/textColorPrimary" android:layout_marginTop="10dp" android:textColor="?android:attr/textColorPrimary" android:layout_marginTop="10dp"
app:layout_constraintEnd_toStartOf="@id/main_item_notification_disabled_until_image"/> app:layout_constraintEnd_toStartOf="@id/main_item_connection_error_image"/>
<TextView <TextView
android:text="89 notifications, reconnecting ... This may wrap in the case of UnifiedPush" android:text="89 notifications, reconnecting ... This may wrap in the case of UnifiedPush"
android:layout_width="0dp" android:layout_width="0dp"
@ -31,6 +31,14 @@
app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginBottom="10dp" app:layout_constrainedWidth="true" android:layout_marginBottom="10dp" app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@id/main_item_new" android:layout_marginEnd="10dp"/> app:layout_constraintEnd_toStartOf="@id/main_item_new" android:layout_marginEnd="10dp"/>
<ImageView
android:layout_width="20dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_warning_amber_24dp"
android:id="@+id/main_item_connection_error_image"
app:layout_constraintTop_toTopOf="@+id/main_item_text"
app:layout_constraintEnd_toStartOf="@+id/main_item_notification_disabled_until_image"
android:paddingTop="3dp" android:layout_marginEnd="3dp"
android:visibility="gone"/>
<ImageView <ImageView
android:layout_width="20dp" android:layout_width="20dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_time_gray_outline_24dp" android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_time_gray_outline_24dp"

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/connection_error_dialog_action_retry"
android:icon="@drawable/ic_refresh_white_24dp"
android:title="@string/connection_error_dialog_retry_now"
app:showAsAction="always" />
<item
android:id="@+id/connection_error_dialog_action_copy"
android:icon="@drawable/ic_content_copy_white_24dp"
android:title="@string/common_button_copy"
app:showAsAction="always" />
</menu>

View file

@ -1,6 +1,12 @@
<menu <menu
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/detail_menu_connection_error"
android:icon="@drawable/ic_warning_white_24dp"
android:title="@string/main_menu_connection_error"
android:visible="false"
app:showAsAction="always" />
<item <item
android:id="@+id/detail_menu_notifications_enabled" android:id="@+id/detail_menu_notifications_enabled"
android:icon="@drawable/ic_notifications_white_24dp" android:icon="@drawable/ic_notifications_white_24dp"

View file

@ -4,7 +4,7 @@
<item <item
android:id="@+id/detail_action_mode_copy" android:id="@+id/detail_action_mode_copy"
android:icon="@drawable/ic_content_copy_white_24dp" android:icon="@drawable/ic_content_copy_white_24dp"
android:title="@string/detail_action_mode_menu_copy" android:title="@string/common_button_copy"
app:iconTint="@android:color/white" /> app:iconTint="@android:color/white" />
<item <item
android:id="@+id/detail_action_mode_delete" android:id="@+id/detail_action_mode_delete"

View file

@ -1,6 +1,12 @@
<menu <menu
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/main_menu_connection_error"
android:icon="@drawable/ic_warning_white_24dp"
android:title="@string/main_menu_connection_error"
android:visible="false"
app:showAsAction="always" />
<item <item
android:id="@+id/main_menu_notifications_enabled" android:id="@+id/main_menu_notifications_enabled"
android:icon="@drawable/ic_notifications_white_24dp" android:icon="@drawable/ic_notifications_white_24dp"

View file

@ -85,7 +85,7 @@
<string name="notification_dialog_cancel">إلغاﺀ</string> <string name="notification_dialog_cancel">إلغاﺀ</string>
<string name="settings_notifications_header">الإشعارات</string> <string name="settings_notifications_header">الإشعارات</string>
<string name="detail_menu_unsubscribe">الغاء الاشتراك</string> <string name="detail_menu_unsubscribe">الغاء الاشتراك</string>
<string name="detail_action_mode_menu_copy">نسخ</string> <string name="common_button_copy">نسخ</string>
<string name="detail_action_mode_menu_delete">حذف</string> <string name="detail_action_mode_menu_delete">حذف</string>
<string name="notification_popup_action_open">فتح</string> <string name="notification_popup_action_open">فتح</string>
<string name="notification_popup_action_download">تنزيل</string> <string name="notification_popup_action_download">تنزيل</string>

View file

@ -119,7 +119,7 @@
<string name="detail_action_mode_delete_dialog_message">Наистина ли желаете избраните известия да бъдат безвъзвратно премахнати\?</string> <string name="detail_action_mode_delete_dialog_message">Наистина ли желаете избраните известия да бъдат безвъзвратно премахнати\?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Премахване</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Премахване</string>
<string name="detail_menu_unsubscribe">Отписване</string> <string name="detail_menu_unsubscribe">Отписване</string>
<string name="detail_action_mode_menu_copy">Копиране</string> <string name="common_button_copy">Копиране</string>
<string name="detail_action_mode_menu_delete">Премахване</string> <string name="detail_action_mode_menu_delete">Премахване</string>
<string name="detail_action_mode_delete_dialog_cancel">Отказ</string> <string name="detail_action_mode_delete_dialog_cancel">Отказ</string>
<string name="share_title">Споделяне</string> <string name="share_title">Споделяне</string>
@ -412,4 +412,22 @@
<string name="common_button_add">Добавяне</string> <string name="common_button_add">Добавяне</string>
<string name="common_button_save">Запазване</string> <string name="common_button_save">Запазване</string>
<string name="common_button_delete">Премахване</string> <string name="common_button_delete">Премахване</string>
<string name="common_service_url_placeholder">напр. https://ntfy.example.com</string>
<string name="common_button_next">Напред</string>
<string name="common_certificate_subject">Обект</string>
<string name="common_certificate_issuer">Издател</string>
<string name="common_certificate_fingerprint">Отпечатък SHA-256</string>
<string name="common_certificate_valid_from">Валиден от</string>
<string name="common_certificate_valid_until">Валиден до</string>
<string name="common_certificate_added_toast">Серетификатът е добавен</string>
<string name="common_certificate_deleted_toast">Серетификатът е премахнат</string>
<string name="add_dialog_error_ssl_untrusted">Серетификатът на сървъра не е доверен</string>
<string name="settings_advanced_certificates_title">Управление на сертификати</string>
<string name="settings_advanced_certificates_trusted_header">Доверени сертификати</string>
<string name="settings_advanced_certificates_trusted_add_title">Добавяне на доверен сертификат</string>
<string name="settings_advanced_certificates_trusted_add_summary">Добавяне на сертификат в довереното хранилище (PEM). При свързване към сървър на ntfy сертификатът ще е доверен.</string>
<string name="settings_advanced_certificates_client_header">Клиентски сертификат (mTLS)</string>
<string name="settings_advanced_certificates_client_item_summary">Клиентски сертификат, издаден от %1$s, изтича на %2$s, използва се за връзка с %3$s</string>
<string name="settings_advanced_certificates_client_item_summary_expired">Клиентски сертификат, издаден от %1$s, изтекъл, използва се за връзка с %2$s</string>
<string name="settings_advanced_certificates_client_add_title">Добавяне на клиентски сертификат</string>
</resources> </resources>

View file

@ -154,7 +154,7 @@
<string name="detail_menu_enable_instant">Activar entrega instantània</string> <string name="detail_menu_enable_instant">Activar entrega instantània</string>
<string name="detail_item_cannot_open_apk">Les aplicacions ja no poden ser instal·lades. Descarrega-les a través del navegador. Pots trobar més detalls a #531.</string> <string name="detail_item_cannot_open_apk">Les aplicacions ja no poden ser instal·lades. Descarrega-les a través del navegador. Pots trobar més detalls a #531.</string>
<string name="detail_menu_unsubscribe">Eliminar subscripció</string> <string name="detail_menu_unsubscribe">Eliminar subscripció</string>
<string name="detail_action_mode_menu_copy">Copiar</string> <string name="common_button_copy">Copiar</string>
<string name="detail_action_mode_delete_dialog_message">Eliminar definitivament la notificació\?</string> <string name="detail_action_mode_delete_dialog_message">Eliminar definitivament la notificació\?</string>
<string name="share_content_title">Vista prèvia del missatge</string> <string name="share_content_title">Vista prèvia del missatge</string>
<string name="share_content_text_hint">Afegeix contingut per compartir aquí</string> <string name="share_content_text_hint">Afegeix contingut per compartir aquí</string>

View file

@ -102,7 +102,7 @@
<string name="detail_menu_disable_instant">Vypnout okamžité doručení</string> <string name="detail_menu_disable_instant">Vypnout okamžité doručení</string>
<string name="detail_menu_settings">Nastavení odběru</string> <string name="detail_menu_settings">Nastavení odběru</string>
<string name="detail_menu_unsubscribe">Odhlásit se z odběru</string> <string name="detail_menu_unsubscribe">Odhlásit se z odběru</string>
<string name="detail_action_mode_menu_copy">Kopírovat</string> <string name="common_button_copy">Kopírovat</string>
<string name="detail_action_mode_menu_delete">Smazat</string> <string name="detail_action_mode_menu_delete">Smazat</string>
<string name="detail_action_mode_delete_dialog_message">Trvale odstranit vybraná oznámení\?</string> <string name="detail_action_mode_delete_dialog_message">Trvale odstranit vybraná oznámení\?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Odstranit natrvalo</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Odstranit natrvalo</string>

View file

@ -246,7 +246,7 @@
<string name="detail_test_message_error_unauthorized_anon">Nachricht kann nicht gesendet werden: Anonymes Senden nicht erlaubt.</string> <string name="detail_test_message_error_unauthorized_anon">Nachricht kann nicht gesendet werden: Anonymes Senden nicht erlaubt.</string>
<string name="detail_menu_test">Test-Benachrichtigung senden</string> <string name="detail_menu_test">Test-Benachrichtigung senden</string>
<string name="detail_menu_copy_url">Themen-Adresse kopieren</string> <string name="detail_menu_copy_url">Themen-Adresse kopieren</string>
<string name="detail_action_mode_menu_copy">Kopieren</string> <string name="common_button_copy">Kopieren</string>
<string name="detail_clear_dialog_permanently_delete">Endgültig löschen</string> <string name="detail_clear_dialog_permanently_delete">Endgültig löschen</string>
<string name="detail_clear_dialog_cancel">Abbrechen</string> <string name="detail_clear_dialog_cancel">Abbrechen</string>
<string name="detail_item_cannot_open_not_found">Anhang kann nicht geöffnet werden: Datei wurde gelöscht, oder es ist keine App installiert die diese Datei öffnen kann.</string> <string name="detail_item_cannot_open_not_found">Anhang kann nicht geöffnet werden: Datei wurde gelöscht, oder es ist keine App installiert die diese Datei öffnen kann.</string>
@ -368,12 +368,12 @@
<string name="settings_general_dynamic_colors_summary_disabled">Verwendung der ntfy-Themenfarben</string> <string name="settings_general_dynamic_colors_summary_disabled">Verwendung der ntfy-Themenfarben</string>
<string name="publish_dialog_title">Veröffentlichen unter %1$s</string> <string name="publish_dialog_title">Veröffentlichen unter %1$s</string>
<string name="publish_dialog_title_hint">Titel</string> <string name="publish_dialog_title_hint">Titel</string>
<string name="publish_dialog_title_placeholder">z. B. Jemand steht vor der Tür</string> <string name="publish_dialog_title_placeholder">z.B. Jemand steht vor der Tür</string>
<string name="settings_general_language_title">Sprache</string> <string name="settings_general_language_title">Sprache</string>
<string name="settings_general_language_summary_system">Systemstandard verwenden</string> <string name="settings_general_language_summary_system">Systemstandard verwenden</string>
<string name="settings_general_language_system_default">Systemstandard</string> <string name="settings_general_language_system_default">Systemstandard</string>
<string name="publish_dialog_message_hint">Nachricht</string> <string name="publish_dialog_message_hint">Nachricht</string>
<string name="publish_dialog_tags_placeholder">z. B. Warnung, Totenkopf</string> <string name="publish_dialog_tags_placeholder">z.B. Warnung, Totenkopf</string>
<string name="publish_dialog_priority_hint">Priorität</string> <string name="publish_dialog_priority_hint">Priorität</string>
<string name="publish_dialog_button_publish">Veröffentlichen</string> <string name="publish_dialog_button_publish">Veröffentlichen</string>
<string name="publish_dialog_error_sending">Nachricht kann nicht veröffentlicht werden: %1$s</string> <string name="publish_dialog_error_sending">Nachricht kann nicht veröffentlicht werden: %1$s</string>
@ -392,17 +392,17 @@
<string name="publish_dialog_chip_attach_file">Lokale Datei anhängen</string> <string name="publish_dialog_chip_attach_file">Lokale Datei anhängen</string>
<string name="publish_dialog_chip_phone_call">Telefonanruf</string> <string name="publish_dialog_chip_phone_call">Telefonanruf</string>
<string name="publish_dialog_click_url_hint">URL anklicken</string> <string name="publish_dialog_click_url_hint">URL anklicken</string>
<string name="publish_dialog_click_url_placeholder">z. B. https://example.com/alerts/1234</string> <string name="publish_dialog_click_url_placeholder">z.B. https://example.com/alerts/1234</string>
<string name="publish_dialog_email_hint">E-Mail</string> <string name="publish_dialog_email_hint">E-Mail</string>
<string name="publish_dialog_email_placeholder">z. B. phil@example.com</string> <string name="publish_dialog_email_placeholder">z.B. phil@example.com</string>
<string name="publish_dialog_delay_hint">Übertragungsverzögerung</string> <string name="publish_dialog_delay_hint">Übertragungsverzögerung</string>
<string name="publish_dialog_delay_placeholder">z. B. 30m, 1h, today 9pm (nur auf Englisch)</string> <string name="publish_dialog_delay_placeholder">z.B. 30m, 1h, today 9pm (nur auf Englisch)</string>
<string name="publish_dialog_attach_url_hint">Anhang-URL</string> <string name="publish_dialog_attach_url_hint">Anhang-URL</string>
<string name="publish_dialog_attach_url_placeholder">z. B. https://example.com/flowers.jpg</string> <string name="publish_dialog_attach_url_placeholder">z.B. https://example.com/flowers.jpg</string>
<string name="publish_dialog_attach_filename_hint">Dateiname der Anlage</string> <string name="publish_dialog_attach_filename_hint">Dateiname der Anlage</string>
<string name="publish_dialog_attach_filename_placeholder">z. B. Lilien.jpg</string> <string name="publish_dialog_attach_filename_placeholder">z.B. Lilien.jpg</string>
<string name="publish_dialog_phone_call_hint">Telefonanruf</string> <string name="publish_dialog_phone_call_hint">Telefonanruf</string>
<string name="publish_dialog_phone_call_placeholder">z. B. +1234567890</string> <string name="publish_dialog_phone_call_placeholder">z.B. +1234567890</string>
<string name="publish_dialog_docs_text">Beispiele und eine detaillierte Beschreibung aller Veröffentlichungsfunktionen findest du in der <a href="https://docs.ntfy.sh/publish/">Dokumentation</a>.</string> <string name="publish_dialog_docs_text">Beispiele und eine detaillierte Beschreibung aller Veröffentlichungsfunktionen findest du in der <a href="https://docs.ntfy.sh/publish/">Dokumentation</a>.</string>
<string name="message_bar_hint">Nachricht hier eingeben</string> <string name="message_bar_hint">Nachricht hier eingeben</string>
<string name="message_bar_publish_button_description">Nachricht veröffentlichen</string> <string name="message_bar_publish_button_description">Nachricht veröffentlichen</string>
@ -412,4 +412,6 @@
<string name="settings_general_message_bar_summary_enabled">Nachrichtenleiste unten in der Themenansicht</string> <string name="settings_general_message_bar_summary_enabled">Nachrichtenleiste unten in der Themenansicht</string>
<string name="settings_general_message_bar_summary_disabled">Knopf „Veröffentlichen“ unten in der Themenansicht</string> <string name="settings_general_message_bar_summary_disabled">Knopf „Veröffentlichen“ unten in der Themenansicht</string>
<string name="publish_dialog_tags_hint">Tags</string> <string name="publish_dialog_tags_hint">Tags</string>
<string name="common_button_next">Weiter</string>
<string name="common_service_url_placeholder">z.B. https://ntfy.example.com</string>
</resources> </resources>

View file

@ -93,7 +93,7 @@
<string name="detail_menu_copy_url">Copiar la dirección del tópico</string> <string name="detail_menu_copy_url">Copiar la dirección del tópico</string>
<string name="detail_menu_test">Enviar notificación de prueba</string> <string name="detail_menu_test">Enviar notificación de prueba</string>
<string name="detail_menu_clear">Borrar todas las notificaciones</string> <string name="detail_menu_clear">Borrar todas las notificaciones</string>
<string name="detail_action_mode_menu_copy">Copiar</string> <string name="common_button_copy">Copiar</string>
<string name="detail_action_mode_menu_delete">Eliminar</string> <string name="detail_action_mode_menu_delete">Eliminar</string>
<string name="detail_action_mode_delete_dialog_message">¿Eliminar permanentemente la(s) notificación(es) seleccionada(s)\?</string> <string name="detail_action_mode_delete_dialog_message">¿Eliminar permanentemente la(s) notificación(es) seleccionada(s)\?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Eliminar permanentemente</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Eliminar permanentemente</string>

View file

@ -163,7 +163,7 @@
<string name="detail_menu_clear">Kustuta kõik teavitused</string> <string name="detail_menu_clear">Kustuta kõik teavitused</string>
<string name="detail_menu_settings">Tellimuste seadistused</string> <string name="detail_menu_settings">Tellimuste seadistused</string>
<string name="detail_menu_unsubscribe">Lõpeta tellimus</string> <string name="detail_menu_unsubscribe">Lõpeta tellimus</string>
<string name="detail_action_mode_menu_copy">Kopeeri</string> <string name="common_button_copy">Kopeeri</string>
<string name="detail_action_mode_menu_delete">Kustuta</string> <string name="detail_action_mode_menu_delete">Kustuta</string>
<string name="detail_action_mode_delete_dialog_message">Kas kustutad valitud teavituse(d) jäädavalt?</string> <string name="detail_action_mode_delete_dialog_message">Kas kustutad valitud teavituse(d) jäädavalt?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Kustuta jäädavalt</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Kustuta jäädavalt</string>
@ -402,4 +402,49 @@
<string name="custom_headers_dialog_title_edit">Muuda päisekirjet</string> <string name="custom_headers_dialog_title_edit">Muuda päisekirjet</string>
<string name="custom_headers_dialog_title_add">Lisa sulle vajalik päisekirje</string> <string name="custom_headers_dialog_title_add">Lisa sulle vajalik päisekirje</string>
<string name="user_dialog_base_url_error_authorization_header_exists">Kui selle serveri jaoks on lisatud „Authorization“ kirje, siis eraldi kasutajat väärtustada ei saa</string> <string name="user_dialog_base_url_error_authorization_header_exists">Kui selle serveri jaoks on lisatud „Authorization“ kirje, siis eraldi kasutajat väärtustada ei saa</string>
<string name="common_button_next">Edasi</string>
<string name="common_service_url_placeholder">nt. https://ntfy.toredomeen.ee</string>
<string name="common_certificate_subject">Sertifikaadi nimi</string>
<string name="common_certificate_issuer">Väljaandja</string>
<string name="common_certificate_fingerprint">SHA-256 sõrmejälg</string>
<string name="common_certificate_valid_from">Kehtib alates</string>
<string name="common_certificate_valid_until">Kehtib kuni</string>
<string name="common_certificate_added_toast">Sertifikaat on lisatud</string>
<string name="common_certificate_deleted_toast">Sertifikaat on kustutatud</string>
<string name="add_dialog_error_ssl_untrusted">Serveri sertifikaat pole usaldusväärne</string>
<string name="settings_advanced_certificates_title">Halda sertifikaate</string>
<string name="settings_advanced_certificates_summary">Lisa sertifikaate usaldatavate sertifikaatide hoidlasse ja halda kliendisertifikaate mTLS-i jaoks</string>
<string name="settings_advanced_certificates_trusted_header">Usaldatav sertifikaat</string>
<string name="settings_advanced_certificates_trusted_item_summary">Kinnitatud sertifikaat, väljaandja %1$s, aegub %2$s, kasutusel %3$s ühenduse jaoks</string>
<string name="settings_advanced_certificates_trusted_item_summary_expired">Kinnitatud sertifikaat, väljaandja %1$s, aegunud, kasutusel %2$s ühenduse jaoks</string>
<string name="settings_advanced_certificates_trusted_add_title">Lisa usaldatav sertifikaat</string>
<string name="settings_advanced_certificates_trusted_add_summary">Impordi sertifikaat usaldatavate sertifikaatide hoidlasse (PEM). Ühendamisel ntfy serveriga on see sertifikaat usaldatud.</string>
<string name="trusted_certificate_dialog_title">Sertifikaadi üksikasjad</string>
<string name="trusted_certificate_dialog_title_unknown">Turvahoiatus</string>
<string name="trusted_certificate_dialog_title_add">Lisa usaldatav sertifikaat</string>
<string name="trusted_certificate_dialog_security_title">Sinu ühendus pole privaatne</string>
<string name="trusted_certificate_dialog_security_description">See serveri sertifikaat pole usaldatav. Kolmandad osapooled, sh võimalikud ründajad, võivad proovida sinu teavet varastada. Kui sa just täpselt ei tea, miks see sertifikaat pole usaldatav, siis palun ära jätka.</string>
<string name="trusted_certificate_dialog_description_add">Sa oled valinud sertifikaadifaili. Enne usaldatavate sertifikaatide hulka lisamist palun vaata hoolega üle kõik üksikasjad.</string>
<string name="trusted_certificate_dialog_description_view">See sertifikaat on kasutusel ühendamiseks allpool näidatud võrguaadressiga. Sa oled antud erandi käsitsi lisanud.</string>
<string name="trusted_certificate_dialog_description_page1">Sisesta teenuse võrguaadress, mille jaoks see sertifikaat saab olema kinnitatud. Seda sertifikaati loeme usaldusväärseks vaid antud võrguaadressi jaoks.</string>
<string name="trusted_certificate_dialog_expired_warning">Hoiatus: See sertifikaat on aegunud.</string>
<string name="trusted_certificate_dialog_not_yet_valid_warning">Hoiatus: See sertifikaat pole veel kehtiv.</string>
<string name="trusted_certificate_dialog_error_invalid_url">Vigane võrguaadress</string>
<string name="trusted_certificate_dialog_error_parse">Sertifikaadi laadimine ei õnnestu: %1$s</string>
<string name="trusted_certificate_dialog_button_trust">Usalda</string>
<string name="settings_advanced_certificates_client_header">Kliendisertifikaadid (mTLS)</string>
<string name="settings_advanced_certificates_client_item_summary">Kliendisertifikaat, väljaandja %1$s, aegub %2$s, kasutusel %3$s ühenduse jaoks</string>
<string name="settings_advanced_certificates_client_item_summary_expired">Kliendisertifikaat, väljaandja %1$s, aegunud, kasutusel %2$s ühenduse jaoks</string>
<string name="settings_advanced_certificates_client_add_title">Lisa kliendisertifikaat</string>
<string name="settings_advanced_certificates_client_add_summary">Impordi sertifikaat vastastikuseks TLS-i autentimiseks (PKCS#12). See sertifikaat saab olema kasutusel ühendamiseks serveriga.</string>
<string name="settings_advanced_certificates_error_invalid_cert">Vigane sertifikaadifail</string>
<string name="settings_advanced_certificates_error_invalid_p12">Vigane PKCS#12 fail</string>
<string name="client_certificate_dialog_title">Kliendisertifikaat</string>
<string name="client_certificate_dialog_title_add">Lisa kliendisertifikaat</string>
<string name="client_certificate_dialog_description_page1">Lisa teenuse võrguaadress, kus seda sertifikaati kasutatakse ja salasõna PKCS#12 faili jaoks.</string>
<string name="client_certificate_dialog_description_page2">Vaata üle sertifikaadi üksikasjad ja salvesta ta kasutamiseks kliendisertifikaadina. Seda sertifikaati kasutatakse autentimiseks serveris.</string>
<string name="client_certificate_dialog_password_hint">Salasõna</string>
<string name="client_certificate_dialog_error_wrong_password">Vale salasõna või vigane PKCS#12 fail</string>
<string name="client_certificate_dialog_error_invalid_p12_password">Vale või vigane salasõna või katkine PKCS#12 fail</string>
<string name="client_certificate_dialog_error_invalid_url">Vigane teenuse võrguaadress</string>
</resources> </resources>

View file

@ -63,7 +63,7 @@
<string name="detail_item_snack_undo">برگردان</string> <string name="detail_item_snack_undo">برگردان</string>
<string name="detail_item_download_info_deleted">حذف شده</string> <string name="detail_item_download_info_deleted">حذف شده</string>
<string name="detail_menu_unsubscribe">لغو اشتراک</string> <string name="detail_menu_unsubscribe">لغو اشتراک</string>
<string name="detail_action_mode_menu_copy">رونوشت</string> <string name="common_button_copy">رونوشت</string>
<string name="detail_action_mode_menu_delete">حذف</string> <string name="detail_action_mode_menu_delete">حذف</string>
<string name="detail_action_mode_delete_dialog_cancel">لغو</string> <string name="detail_action_mode_delete_dialog_cancel">لغو</string>
<string name="share_title">هم‌رسانی</string> <string name="share_title">هم‌رسانی</string>

View file

@ -183,7 +183,7 @@
<string name="settings_advanced_connection_protocol_entry_ws">WebSocketit</string> <string name="settings_advanced_connection_protocol_entry_ws">WebSocketit</string>
<string name="common_copied_to_clipboard">Kopioitu leikepöydälle</string> <string name="common_copied_to_clipboard">Kopioitu leikepöydälle</string>
<string name="channel_subscriber_notification_noinstant_text">Tilattu topikkiin</string> <string name="channel_subscriber_notification_noinstant_text">Tilattu topikkiin</string>
<string name="detail_action_mode_menu_copy">Kopioi</string> <string name="common_button_copy">Kopioi</string>
<string name="settings_advanced_connection_protocol_entry_jsonhttp">JSON stream yli HTTP</string> <string name="settings_advanced_connection_protocol_entry_jsonhttp">JSON stream yli HTTP</string>
<string name="user_dialog_title_add">Lisää käyttäjä</string> <string name="user_dialog_title_add">Lisää käyttäjä</string>
<string name="detail_item_download_info_deleted">Poistettu</string> <string name="detail_item_download_info_deleted">Poistettu</string>

View file

@ -131,7 +131,7 @@
<string name="detail_menu_notifications_enabled">Notifications activées</string> <string name="detail_menu_notifications_enabled">Notifications activées</string>
<string name="detail_menu_enable_instant">Activer la livraison instantanée</string> <string name="detail_menu_enable_instant">Activer la livraison instantanée</string>
<string name="detail_menu_clear">Effacer toutes les notifications</string> <string name="detail_menu_clear">Effacer toutes les notifications</string>
<string name="detail_action_mode_menu_copy">Copier</string> <string name="common_button_copy">Copier</string>
<string name="detail_settings_title">Paramètres d\'abonnement</string> <string name="detail_settings_title">Paramètres d\'abonnement</string>
<string name="share_title">Partager</string> <string name="share_title">Partager</string>
<string name="detail_menu_settings">Paramètres d\'abonnement</string> <string name="detail_menu_settings">Paramètres d\'abonnement</string>
@ -412,4 +412,49 @@
<string name="publish_dialog_title">Publier sur %1$s</string> <string name="publish_dialog_title">Publier sur %1$s</string>
<string name="publish_dialog_title_hint">Titre</string> <string name="publish_dialog_title_hint">Titre</string>
<string name="publish_dialog_delay_placeholder">e.g. 30m, 1h, today 9pm (en anglais uniquement)</string> <string name="publish_dialog_delay_placeholder">e.g. 30m, 1h, today 9pm (en anglais uniquement)</string>
<string name="common_service_url_placeholder">e.g. https://ntfy.example.com</string>
<string name="common_certificate_subject">Sujet</string>
<string name="common_certificate_issuer">Émetteur</string>
<string name="common_certificate_fingerprint">Empreinte du certificat (SHA-256)</string>
<string name="common_certificate_valid_from">Valide à partir du</string>
<string name="common_certificate_valid_until">Valide jusquau</string>
<string name="common_certificate_added_toast">Certificat ajouté</string>
<string name="common_certificate_deleted_toast">Certificat supprimé</string>
<string name="add_dialog_error_ssl_untrusted">Certificat du serveur non approuvé</string>
<string name="settings_advanced_certificates_title">Gérer les certificats</string>
<string name="settings_advanced_certificates_summary">Ajouter des certificats au magasin de confiance et gérer les certificats clients pour mTLS</string>
<string name="settings_advanced_certificates_trusted_header">Certificats de confiance</string>
<string name="settings_advanced_certificates_trusted_item_summary">Certificat épinglé, émis par %1$s, expiration le %2$s, utilisé pour les connexions à %3$s</string>
<string name="settings_advanced_certificates_trusted_item_summary_expired">Certificat épinglé, émis par %1$s, expiré, utilisé pour les connexions à %2$s</string>
<string name="settings_advanced_certificates_trusted_add_title">Ajouter un certificat de confiance</string>
<string name="settings_advanced_certificates_trusted_add_summary">Importer un certificat dans le magasin de confiance (PEM). Lors de la connexion au serveur ntfy, ce certificat sera considéré comme fiable.</string>
<string name="settings_advanced_certificates_client_header">Certificats clients (mTLS)</string>
<string name="settings_advanced_certificates_client_item_summary">Certificat client, émis par %1$s, expire le %2$s, utilisé pour les connexions à %3$s</string>
<string name="settings_advanced_certificates_client_item_summary_expired">Certificat client, émis par %1$s, expiré, utilisé pour les connexions à %2$s</string>
<string name="common_button_next">Suivant</string>
<string name="settings_advanced_certificates_client_add_title">Ajouter un certificat client</string>
<string name="settings_advanced_certificates_client_add_summary">Importer un certificat pour lauthentification mutuelle TLS (PKCS#12). Ce certificat sera utilisé lors de la connexion au serveur.</string>
<string name="settings_advanced_certificates_error_invalid_cert">Fichier de certificat invalide</string>
<string name="settings_advanced_certificates_error_invalid_p12">Fichier PKCS#12 invalide</string>
<string name="trusted_certificate_dialog_title">Détails du certificat</string>
<string name="trusted_certificate_dialog_title_unknown">Avertissement de sécurité</string>
<string name="trusted_certificate_dialog_title_add">Ajouter un certificat de confiance</string>
<string name="trusted_certificate_dialog_security_title">Votre connexion nest pas privée</string>
<string name="trusted_certificate_dialog_security_description">Le certificat du serveur nest pas approuvé. Des attaquants pourraient essayer de voler vos informations. Navancez pas sauf si vous savez pourquoi ce certificat nest pas approuvé.</string>
<string name="trusted_certificate_dialog_description_add">Vous avez sélectionné un fichier de certificat. Vérifiez les détails ci-dessous avant de lajouter à vos certificats de confiance.</string>
<string name="trusted_certificate_dialog_description_view">Ce certificat est utilisé pour les connexions à lURL du service ci-dessous. Vous avez ajouté cette exception manuellement.</string>
<string name="trusted_certificate_dialog_description_page1">Saisissez lURL du service à laquelle ce certificat doit être épinglé. Le certificat ne sera approuvé que pour cette URL.</string>
<string name="trusted_certificate_dialog_expired_warning">Attention : ce certificat a expiré.</string>
<string name="trusted_certificate_dialog_not_yet_valid_warning">Attention : ce certificat nest pas encore valide.</string>
<string name="trusted_certificate_dialog_error_invalid_url">URL invalide</string>
<string name="trusted_certificate_dialog_error_parse">Impossible de charger le certificat : %1$s</string>
<string name="trusted_certificate_dialog_button_trust">Approuver</string>
<string name="client_certificate_dialog_title">Certificat client</string>
<string name="client_certificate_dialog_title_add">Ajouter un certificat client</string>
<string name="client_certificate_dialog_description_page1">Saisissez lURL du service pour laquelle ce certificat doit être utilisé, ainsi que le mot de passe du fichier PKCS#12.</string>
<string name="client_certificate_dialog_description_page2">Vérifiez les détails du certificat et enregistrez pour ajouter ce certificat client. Ce certificat sera utilisé pour lauthentification auprès du serveur.</string>
<string name="client_certificate_dialog_password_hint">Mot de passe</string>
<string name="client_certificate_dialog_error_wrong_password">Mot de passe incorrect ou fichier PKCS#12 invalide</string>
<string name="client_certificate_dialog_error_invalid_p12_password">Mot de passe invalide ou fichier PKCS#12 corrompu</string>
<string name="client_certificate_dialog_error_invalid_url">URL du service invalide</string>
</resources> </resources>

View file

@ -169,7 +169,7 @@
<string name="detail_menu_clear">Limpar todas as notificacións</string> <string name="detail_menu_clear">Limpar todas as notificacións</string>
<string name="detail_menu_settings">Axustes da subscrición</string> <string name="detail_menu_settings">Axustes da subscrición</string>
<string name="detail_menu_unsubscribe">Retirar subscrición</string> <string name="detail_menu_unsubscribe">Retirar subscrición</string>
<string name="detail_action_mode_menu_copy">Copiar</string> <string name="common_button_copy">Copiar</string>
<string name="detail_action_mode_menu_delete">Eliminar</string> <string name="detail_action_mode_menu_delete">Eliminar</string>
<string name="share_menu_send">Compartir</string> <string name="share_menu_send">Compartir</string>
<string name="notification_dialog_muted_forever_toast_message">Notificacións acaladas</string> <string name="notification_dialog_muted_forever_toast_message">Notificacións acaladas</string>

View file

@ -112,7 +112,7 @@
<string name="detail_item_snack_undo">Visszavon</string> <string name="detail_item_snack_undo">Visszavon</string>
<string name="detail_item_download_info_deleted">törölve</string> <string name="detail_item_download_info_deleted">törölve</string>
<string name="detail_menu_unsubscribe">Leiratkozás</string> <string name="detail_menu_unsubscribe">Leiratkozás</string>
<string name="detail_action_mode_menu_copy">Másolás</string> <string name="common_button_copy">Másolás</string>
<string name="detail_action_mode_menu_delete">Törlés</string> <string name="detail_action_mode_menu_delete">Törlés</string>
<string name="share_menu_send">Megosztás</string> <string name="share_menu_send">Megosztás</string>
<string name="notification_dialog_30min">30 perc</string> <string name="notification_dialog_30min">30 perc</string>

View file

@ -98,7 +98,7 @@
<string name="detail_menu_clear">Hapus semua notifikasi</string> <string name="detail_menu_clear">Hapus semua notifikasi</string>
<string name="detail_menu_settings">Pengaturan langganan</string> <string name="detail_menu_settings">Pengaturan langganan</string>
<string name="detail_menu_unsubscribe">Batalkan langganan</string> <string name="detail_menu_unsubscribe">Batalkan langganan</string>
<string name="detail_action_mode_menu_copy">Salin</string> <string name="common_button_copy">Salin</string>
<string name="detail_action_mode_menu_delete">Hapus</string> <string name="detail_action_mode_menu_delete">Hapus</string>
<string name="detail_action_mode_delete_dialog_message">Hapus notifikasi yang dipilih secara permanen\?</string> <string name="detail_action_mode_delete_dialog_message">Hapus notifikasi yang dipilih secara permanen\?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Hapus secara permanen</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Hapus secara permanen</string>
@ -346,7 +346,7 @@
<string name="settings_advanced_exact_alarms_false">ntfy tidak dapat menjadwalkan alarm yang tepat. Alarm yang tepat diperlukan untuk menyambungkan kembali WebSockets di latar belakang. Klik untuk memberikan izin.</string> <string name="settings_advanced_exact_alarms_false">ntfy tidak dapat menjadwalkan alarm yang tepat. Alarm yang tepat diperlukan untuk menyambungkan kembali WebSockets di latar belakang. Klik untuk memberikan izin.</string>
<string name="publish_dialog_title">Terbitkan ke %1$s</string> <string name="publish_dialog_title">Terbitkan ke %1$s</string>
<string name="publish_dialog_title_hint">Judul</string> <string name="publish_dialog_title_hint">Judul</string>
<string name="publish_dialog_title_placeholder">Contoh: Ada orang di depan pintu</string> <string name="publish_dialog_title_placeholder">Contoh: Seseorang di depan pintu</string>
<string name="publish_dialog_message_hint">Pesan</string> <string name="publish_dialog_message_hint">Pesan</string>
<string name="publish_dialog_tags_hint">Label</string> <string name="publish_dialog_tags_hint">Label</string>
<string name="publish_dialog_tags_placeholder">Contoh: peringatan, tengkorak</string> <string name="publish_dialog_tags_placeholder">Contoh: peringatan, tengkorak</string>
@ -412,4 +412,49 @@
<string name="common_button_add">Tambah</string> <string name="common_button_add">Tambah</string>
<string name="common_button_save">Simpan</string> <string name="common_button_save">Simpan</string>
<string name="common_button_delete">Hapus</string> <string name="common_button_delete">Hapus</string>
<string name="common_button_next">Berikutnya</string>
<string name="common_service_url_placeholder">contoh: https://ntfy.example.com</string>
<string name="common_certificate_subject">Subjek</string>
<string name="common_certificate_issuer">Penerbit</string>
<string name="common_certificate_fingerprint">Sidik jari SHA-256</string>
<string name="common_certificate_valid_from">Berlaku mulai</string>
<string name="common_certificate_valid_until">Berlaku hingga</string>
<string name="common_certificate_added_toast">Sertifikat ditambahkan</string>
<string name="common_certificate_deleted_toast">Sertifikat dihapus</string>
<string name="add_dialog_error_ssl_untrusted">Sertifikat server tidak dipercaya</string>
<string name="settings_advanced_certificates_title">Kelola sertifikat</string>
<string name="settings_advanced_certificates_summary">Tambah sertifikat ke penyimpanan sertifikat tepercaya dan kelola sertifikat klien untuk mTLS</string>
<string name="settings_advanced_certificates_trusted_header">Sertifikat terpercaya</string>
<string name="settings_advanced_certificates_trusted_item_summary">Sertifikat disematkan, diterbitkan oleh %1$s, berlaku hingga %2$s, digunakan untuk menghubungkan ke %3$s</string>
<string name="settings_advanced_certificates_trusted_item_summary_expired">Sertifikat disematkan, diterbitkan oleh %1$s, telah kadaluwarsa, digunakan untuk menghubungkan ke %2$s</string>
<string name="settings_advanced_certificates_trusted_add_title">Tambah sertifikat tepercaya</string>
<string name="settings_advanced_certificates_trusted_add_summary">Impor sertifikat ke dalam penyimpanan sertifikat tepercaya (PEM). Saat terhubung ke server ntfy, sertifikat ini akan dianggap tepercaya.</string>
<string name="settings_advanced_certificates_client_header">Sertifikat klien (mTLS)</string>
<string name="settings_advanced_certificates_client_item_summary">Sertifikat klien, diterbitkan oleh %1$s, kedaluwarsa pada %2$s, digunakan untuk menghubungkan ke %3$s</string>
<string name="settings_advanced_certificates_client_item_summary_expired">Sertifikat klien, diterbitkan oleh %1$s, telah kadaluwarsa, digunakan untuk menghubungkan ke %2$s</string>
<string name="settings_advanced_certificates_client_add_title">Tambah sertifikat klien</string>
<string name="settings_advanced_certificates_client_add_summary">Impor sertifikat untuk pengautentikasi TLS bersama (PKCS#12). Sertifikat ini akan digunakan saat terhubung ke server.</string>
<string name="settings_advanced_certificates_error_invalid_cert">Berkas sertifikat tidak sah</string>
<string name="settings_advanced_certificates_error_invalid_p12">Berkas PKCS#12 tidak sah</string>
<string name="trusted_certificate_dialog_title">Rincian sertifikat</string>
<string name="trusted_certificate_dialog_title_unknown">Peringatan keamanan</string>
<string name="trusted_certificate_dialog_title_add">Tambah sertifikat tepercaya</string>
<string name="trusted_certificate_dialog_security_title">Sambungan Anda tidak aman</string>
<string name="trusted_certificate_dialog_security_description">Sertifikat server tidak terpercaya. Penyerang mungkin mencoba mencuri informasi Anda. Jangan lanjutkan kecuali Anda tahu mengapa sertifikat ini tidak terpercaya.</string>
<string name="trusted_certificate_dialog_description_add">Anda telah memilih berkas sertifikat. Periksa rinciannya di bawah ini sebelum menambahkannya ke daftar sertifikat tepercaya Anda.</string>
<string name="trusted_certificate_dialog_description_view">Sertifikat ini digunakan untuk menghubungkan ke URL layanan di bawah ini. Anda telah menambahkan pengecualian ini secara manual.</string>
<string name="trusted_certificate_dialog_description_page1">Masukkan URL layanan yang akan digunakan untuk menyematkan sertifikat ini. Sertifikat ini hanya akan dipercaya untuk URL tersebut.</string>
<string name="trusted_certificate_dialog_expired_warning">Peringatan: Sertifikat ini telah kadaluwarsa.</string>
<string name="trusted_certificate_dialog_not_yet_valid_warning">Peringatan: Sertifikat ini belum sah.</string>
<string name="trusted_certificate_dialog_error_invalid_url">URL tidak sah</string>
<string name="trusted_certificate_dialog_error_parse">Tidak dapat memuat sertifikat: %1$s</string>
<string name="trusted_certificate_dialog_button_trust">Percayai</string>
<string name="client_certificate_dialog_title">Sertifikat klien</string>
<string name="client_certificate_dialog_title_add">Tambah sertifikat klien</string>
<string name="client_certificate_dialog_description_page1">Masukkan URL layanan yang akan menggunakan sertifikat ini, dan kata sandi untuk berkas PKCS#12.</string>
<string name="client_certificate_dialog_description_page2">Periksa rincian sertifikat dan simpan untuk menambahkan sertifikat klien ini. Sertifikat ini akan digunakan untuk autentikasi dengan server.</string>
<string name="client_certificate_dialog_password_hint">Kata sandi</string>
<string name="client_certificate_dialog_error_wrong_password">Kata sandi salah atau berkas PKCS#12 tidak sah</string>
<string name="client_certificate_dialog_error_invalid_p12_password">Kata sandi tidak sah atau berkas PKCS#12 rusak</string>
<string name="client_certificate_dialog_error_invalid_url">URL layanan tidak sah</string>
</resources> </resources>

View file

@ -208,7 +208,7 @@
<string name="detail_item_saved_successfully">Salvato con nome \"%1$s\" nella cartella \"Downloads\"</string> <string name="detail_item_saved_successfully">Salvato con nome \"%1$s\" nella cartella \"Downloads\"</string>
<string name="detail_item_cannot_save">Impossibile salvare l\'allegato: %1$s</string> <string name="detail_item_cannot_save">Impossibile salvare l\'allegato: %1$s</string>
<string name="detail_menu_settings">Impostazioni iscrizione</string> <string name="detail_menu_settings">Impostazioni iscrizione</string>
<string name="detail_action_mode_menu_copy">Copia</string> <string name="common_button_copy">Copia</string>
<string name="settings_notifications_auto_delete_summary_never">Non eliminare mai automaticamente le notifiche</string> <string name="settings_notifications_auto_delete_summary_never">Non eliminare mai automaticamente le notifiche</string>
<string name="settings_notifications_auto_delete_summary_one_day">Elimina automaticamente le notiche dopo un giorno</string> <string name="settings_notifications_auto_delete_summary_one_day">Elimina automaticamente le notiche dopo un giorno</string>
<string name="settings_notifications_auto_delete_summary_three_days">Elimina automaticamente le notiche dopo 3 giorni</string> <string name="settings_notifications_auto_delete_summary_three_days">Elimina automaticamente le notiche dopo 3 giorni</string>

View file

@ -129,7 +129,7 @@
<string name="detail_menu_notifications_disabled_until">ההתראות מושתקות עד %1$s</string> <string name="detail_menu_notifications_disabled_until">ההתראות מושתקות עד %1$s</string>
<string name="detail_menu_test">שליחת התראת בדיקה</string> <string name="detail_menu_test">שליחת התראת בדיקה</string>
<string name="detail_menu_copy_url">העתקת כתובת הנושא</string> <string name="detail_menu_copy_url">העתקת כתובת הנושא</string>
<string name="detail_action_mode_menu_copy">העתקה</string> <string name="common_button_copy">העתקה</string>
<string name="share_title">שיתוף</string> <string name="share_title">שיתוף</string>
<string name="share_menu_send">שיתוף</string> <string name="share_menu_send">שיתוף</string>
<string name="share_content_text_hint">הוספת הקשר לשיתוף כאן</string> <string name="share_content_text_hint">הוספת הקשר לשיתוף כאן</string>

View file

@ -142,7 +142,7 @@
<string name="detail_menu_copy_url">トピックのアドレスをコピー</string> <string name="detail_menu_copy_url">トピックのアドレスをコピー</string>
<string name="detail_menu_settings">購読設定</string> <string name="detail_menu_settings">購読設定</string>
<string name="detail_menu_unsubscribe">購読解除</string> <string name="detail_menu_unsubscribe">購読解除</string>
<string name="detail_action_mode_menu_copy">コピー</string> <string name="common_button_copy">コピー</string>
<string name="detail_action_mode_delete_dialog_message">選択された通知を完全に削除しますか?</string> <string name="detail_action_mode_delete_dialog_message">選択された通知を完全に削除しますか?</string>
<string name="detail_action_mode_delete_dialog_cancel">キャンセル</string> <string name="detail_action_mode_delete_dialog_cancel">キャンセル</string>
<string name="detail_settings_title">購読設定</string> <string name="detail_settings_title">購読設定</string>
@ -158,7 +158,7 @@
<string name="share_content_file_text">ファイルが共有されました</string> <string name="share_content_file_text">ファイルが共有されました</string>
<string name="notification_dialog_forever">再開されるまで</string> <string name="notification_dialog_forever">再開されるまで</string>
<string name="settings_notifications_header">通知</string> <string name="settings_notifications_header">通知</string>
<string name="share_successful">メッセージが送信されました</string> <string name="share_successful">メッセージ配信済み</string>
<string name="notification_dialog_cancel">キャンセル</string> <string name="notification_dialog_cancel">キャンセル</string>
<string name="notification_dialog_title">通知をミュート</string> <string name="notification_dialog_title">通知をミュート</string>
<string name="share_suggested_topics">提案されたトピック</string> <string name="share_suggested_topics">提案されたトピック</string>
@ -352,12 +352,12 @@
<string name="publish_dialog_title_placeholder">例: 誰かがドアの前にいます</string> <string name="publish_dialog_title_placeholder">例: 誰かがドアの前にいます</string>
<string name="publish_dialog_message_hint">メッセージ</string> <string name="publish_dialog_message_hint">メッセージ</string>
<string name="publish_dialog_tags_hint">タグ</string> <string name="publish_dialog_tags_hint">タグ</string>
<string name="publish_dialog_tags_placeholder">例: 警告, ドクロ</string> <string name="publish_dialog_tags_placeholder">例: 警告ドクロ</string>
<string name="publish_dialog_priority_hint">優先度</string> <string name="publish_dialog_priority_hint">優先度</string>
<string name="publish_dialog_button_publish">配信</string> <string name="publish_dialog_button_publish">配信</string>
<string name="publish_dialog_error_sending">メッセージを配信できませんでした: %1$s</string> <string name="publish_dialog_error_sending">メッセージを配信できませんでした: %1$s</string>
<string name="publish_dialog_error_server">メッセージを配信できません: %1$s (code %2$d)</string> <string name="publish_dialog_error_server">メッセージを配信できません: %1$s (code %2$d)</string>
<string name="publish_dialog_message_published">メッセージ配信済</string> <string name="publish_dialog_message_published">メッセージ配信済</string>
<string name="publish_dialog_uploading">アップロード中: %1$s (%2$s / %3$s)</string> <string name="publish_dialog_uploading">アップロード中: %1$s (%2$s / %3$s)</string>
<string name="publish_dialog_upload_cancelled">アップロードがキャンセルされました</string> <string name="publish_dialog_upload_cancelled">アップロードがキャンセルされました</string>
<string name="publish_dialog_chip_title">タイトル</string> <string name="publish_dialog_chip_title">タイトル</string>
@ -412,4 +412,49 @@
<string name="common_button_add">追加</string> <string name="common_button_add">追加</string>
<string name="common_button_save">保存</string> <string name="common_button_save">保存</string>
<string name="common_button_delete">削除</string> <string name="common_button_delete">削除</string>
<string name="common_button_next">次へ</string>
<string name="common_service_url_placeholder">例: https://ntfy.example.com</string>
<string name="common_certificate_subject">証明対象</string>
<string name="common_certificate_issuer">発行者</string>
<string name="common_certificate_fingerprint">SHA-256フィンガープリント</string>
<string name="common_certificate_valid_from">発効日</string>
<string name="common_certificate_valid_until">有効期限</string>
<string name="common_certificate_added_toast">証明書が追加されました</string>
<string name="common_certificate_deleted_toast">証明書が削除されました</string>
<string name="add_dialog_error_ssl_untrusted">サービス証明書は信頼されていません</string>
<string name="settings_advanced_certificates_title">証明書を管理</string>
<string name="settings_advanced_certificates_summary">信頼ストアに証明書を追加してmTLS用クライアント証明書を管理する</string>
<string name="settings_advanced_certificates_trusted_header">信頼済み証明書</string>
<string name="settings_advanced_certificates_trusted_item_summary">ピン留め証明書、発行者 %1$s、有効期限 %2$s、%3$s との接続に使用</string>
<string name="settings_advanced_certificates_trusted_item_summary_expired">ピン留め証明書、発行者 %1$s、失効済み、%2$sとの接続に使用</string>
<string name="settings_advanced_certificates_trusted_add_title">信頼済み証明書を追加する</string>
<string name="settings_advanced_certificates_trusted_add_summary">信頼済みストアに証明書 (PEM) をインポートします。ntfyサーバーに接続する時、この証明書は信頼されます。</string>
<string name="settings_advanced_certificates_client_header">クライアント証明書 (mTLS)</string>
<string name="settings_advanced_certificates_client_item_summary">クライアント証明書、発行者 %1$s、有効期限 %2$s、%3$sとの接続に使用</string>
<string name="settings_advanced_certificates_client_item_summary_expired">クライアント証明書、発行者%1$s、失効済み、%2$sとの接続に使用</string>
<string name="settings_advanced_certificates_client_add_title">クライアント証明書を追加</string>
<string name="settings_advanced_certificates_client_add_summary">相互TLS認証 (PKCS#12) 用の証明書をインポートします。この証明書はサーバーに接続する際に使用されます。</string>
<string name="settings_advanced_certificates_error_invalid_cert">証明書ファイルが不正です</string>
<string name="settings_advanced_certificates_error_invalid_p12">PKCS#12ファイルが不正です</string>
<string name="trusted_certificate_dialog_title">証明書の詳細</string>
<string name="trusted_certificate_dialog_title_unknown">セキュリティ警告</string>
<string name="trusted_certificate_dialog_title_add">信頼済み証明書を追加</string>
<string name="trusted_certificate_dialog_security_title">接続がプライベートではありません</string>
<string name="trusted_certificate_dialog_security_description">サーバー証明書は信頼されていません。攻撃者があなたの情報を盗もうとしているかも知れません。この証明書が信頼されていない理由を知らないなら先に進まないで下さい。</string>
<string name="trusted_certificate_dialog_description_add">証明書ファイルを選択しました。信頼済み証明書に追加する前に以下の詳細を確認して下さい。</string>
<string name="trusted_certificate_dialog_description_view">この証明書は以下のサービスURLとの接続に使用されます。この例外は手動で追加されました。</string>
<string name="trusted_certificate_dialog_description_page1">この証明書をピン留めするサービスURLを入力してください。証明書はこのURLにおいてのみ信頼されます。</string>
<string name="trusted_certificate_dialog_expired_warning">警告:この証明書は失効しました。</string>
<string name="trusted_certificate_dialog_not_yet_valid_warning">警告:この証明書は発効前です。</string>
<string name="trusted_certificate_dialog_error_invalid_url">不正なURLです</string>
<string name="trusted_certificate_dialog_error_parse">証明書の読み込みに失敗しました:%1$s</string>
<string name="trusted_certificate_dialog_button_trust">信頼する</string>
<string name="client_certificate_dialog_title">クライアント証明書</string>
<string name="client_certificate_dialog_title_add">クライアント証明書を追加</string>
<string name="client_certificate_dialog_description_page1">この証明書が使用されるサービスURLとPKCS12#ファイルのパスワードを入力してください。</string>
<string name="client_certificate_dialog_description_page2">証明書の詳細を確認したのちクライアント証明書を追加してください。この証明書は該当するサーバーでの認証に使用されます。</string>
<string name="client_certificate_dialog_password_hint">パスワード</string>
<string name="client_certificate_dialog_error_wrong_password">パスワードあるいはPKCS#12ファイルが間違っています</string>
<string name="client_certificate_dialog_error_invalid_p12_password">パスワードが間違っているかPKCS#12ファイルが壊れています</string>
<string name="client_certificate_dialog_error_invalid_url">サービスURLが不正です</string>
</resources> </resources>

View file

@ -230,7 +230,7 @@
<string name="detail_item_saved_successfully">\"Downloads\" 폴더에 \"%1$s\"로 저장됨</string> <string name="detail_item_saved_successfully">\"Downloads\" 폴더에 \"%1$s\"로 저장됨</string>
<string name="detail_item_cannot_open_url">URL을 열 수 없습니다: %1$s</string> <string name="detail_item_cannot_open_url">URL을 열 수 없습니다: %1$s</string>
<string name="detail_item_download_info_downloading_x_percent">%1$d%% 다운로드됨</string> <string name="detail_item_download_info_downloading_x_percent">%1$d%% 다운로드됨</string>
<string name="detail_action_mode_menu_copy">복사</string> <string name="common_button_copy">복사</string>
<string name="detail_action_mode_menu_delete">삭제</string> <string name="detail_action_mode_menu_delete">삭제</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">영구 삭제</string> <string name="detail_action_mode_delete_dialog_permanently_delete">영구 삭제</string>
<string name="share_topic_title">공유하기</string> <string name="share_topic_title">공유하기</string>

View file

@ -236,7 +236,7 @@
<string name="detail_item_download_info_not_downloaded_expires_x">ikke nedlastet, utløper %1$s</string> <string name="detail_item_download_info_not_downloaded_expires_x">ikke nedlastet, utløper %1$s</string>
<string name="detail_menu_notifications_disabled_until">Varsler avslått til %1$s</string> <string name="detail_menu_notifications_disabled_until">Varsler avslått til %1$s</string>
<string name="detail_item_cannot_save">Kan ikke lagre vedlegg: %1$s</string> <string name="detail_item_cannot_save">Kan ikke lagre vedlegg: %1$s</string>
<string name="detail_action_mode_menu_copy">Kopier</string> <string name="common_button_copy">Kopier</string>
<string name="notification_popup_action_cancel">Avbryt</string> <string name="notification_popup_action_cancel">Avbryt</string>
<string name="notification_popup_file">%1$s <string name="notification_popup_file">%1$s
\nFil: %2$s</string> \nFil: %2$s</string>

View file

@ -90,7 +90,7 @@
<string name="detail_menu_test">Testmelding verzenden</string> <string name="detail_menu_test">Testmelding verzenden</string>
<string name="detail_menu_clear">Alle meldingen wissen</string> <string name="detail_menu_clear">Alle meldingen wissen</string>
<string name="detail_menu_settings">Abonnementsinstellingen</string> <string name="detail_menu_settings">Abonnementsinstellingen</string>
<string name="detail_action_mode_menu_copy">Kopiëren</string> <string name="common_button_copy">Kopiëren</string>
<string name="detail_action_mode_menu_delete">Verwijderen</string> <string name="detail_action_mode_menu_delete">Verwijderen</string>
<string name="detail_action_mode_delete_dialog_message">De geselecteerde melding(en) definitief verwijderen\?</string> <string name="detail_action_mode_delete_dialog_message">De geselecteerde melding(en) definitief verwijderen\?</string>
<string name="share_menu_send">Delen</string> <string name="share_menu_send">Delen</string>

View file

@ -28,7 +28,7 @@
<string name="user_dialog_username_hint">Nazwa użytkownika</string> <string name="user_dialog_username_hint">Nazwa użytkownika</string>
<string name="notification_dialog_muted_until_toast_message">Powiadomienia wyciszone przez %1$s</string> <string name="notification_dialog_muted_until_toast_message">Powiadomienia wyciszone przez %1$s</string>
<string name="detail_item_menu_download">Pobierz plik</string> <string name="detail_item_menu_download">Pobierz plik</string>
<string name="detail_action_mode_menu_copy">Kopiuj</string> <string name="common_button_copy">Kopiuj</string>
<string name="detail_menu_clear">Wyczyść powiadomienia</string> <string name="detail_menu_clear">Wyczyść powiadomienia</string>
<string name="detail_item_menu_delete">Usuń plik</string> <string name="detail_item_menu_delete">Usuń plik</string>
<string name="detail_item_menu_cancel">Anuluj pobieranie</string> <string name="detail_item_menu_cancel">Anuluj pobieranie</string>

View file

@ -89,7 +89,7 @@
<string name="detail_menu_clear">Limpar todas notificações</string> <string name="detail_menu_clear">Limpar todas notificações</string>
<string name="detail_menu_settings">Configurações de inscrição</string> <string name="detail_menu_settings">Configurações de inscrição</string>
<string name="detail_menu_unsubscribe">Desinscrever</string> <string name="detail_menu_unsubscribe">Desinscrever</string>
<string name="detail_action_mode_menu_copy">Copiar</string> <string name="common_button_copy">Copiar</string>
<string name="detail_action_mode_menu_delete">Apagar</string> <string name="detail_action_mode_menu_delete">Apagar</string>
<string name="detail_action_mode_delete_dialog_message">Apagar as notificações selecionadas permanentemente?</string> <string name="detail_action_mode_delete_dialog_message">Apagar as notificações selecionadas permanentemente?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Apagar permanentemente</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Apagar permanentemente</string>

View file

@ -244,7 +244,7 @@
<string name="detail_item_menu_copy_contents">Copiar notificação</string> <string name="detail_item_menu_copy_contents">Copiar notificação</string>
<string name="notification_dialog_30min">30 minutos</string> <string name="notification_dialog_30min">30 minutos</string>
<string name="notification_popup_action_open">Abrir</string> <string name="notification_popup_action_open">Abrir</string>
<string name="detail_action_mode_menu_copy">Copiar</string> <string name="common_button_copy">Copiar</string>
<string name="share_content_text_hint">Adicione conteúdo a partilhar aqui</string> <string name="share_content_text_hint">Adicione conteúdo a partilhar aqui</string>
<string name="share_content_file_error">Não foi possível ler as informações do ficheiro: %1$s</string> <string name="share_content_file_error">Não foi possível ler as informações do ficheiro: %1$s</string>
<string name="notification_dialog_muted_forever_toast_message">Notificações silenciadas</string> <string name="notification_dialog_muted_forever_toast_message">Notificações silenciadas</string>

View file

@ -195,7 +195,7 @@
<string name="detail_menu_copy_url">Copiază adresa topicului</string> <string name="detail_menu_copy_url">Copiază adresa topicului</string>
<string name="detail_menu_clear">Șterge toate notificările</string> <string name="detail_menu_clear">Șterge toate notificările</string>
<string name="detail_menu_unsubscribe">Dezabonează-te</string> <string name="detail_menu_unsubscribe">Dezabonează-te</string>
<string name="detail_action_mode_menu_copy">Copiază</string> <string name="common_button_copy">Copiază</string>
<string name="detail_menu_settings">Setări abonament</string> <string name="detail_menu_settings">Setări abonament</string>
<string name="detail_action_mode_menu_delete">Șterge</string> <string name="detail_action_mode_menu_delete">Șterge</string>
<string name="detail_action_mode_delete_dialog_message">Șterge notificarea/notificările selectate permanent\?</string> <string name="detail_action_mode_delete_dialog_message">Șterge notificarea/notificările selectate permanent\?</string>

View file

@ -34,7 +34,7 @@
<string name="detail_menu_clear">Очистить все уведомления</string> <string name="detail_menu_clear">Очистить все уведомления</string>
<string name="detail_menu_settings">Настройки подписок</string> <string name="detail_menu_settings">Настройки подписок</string>
<string name="detail_menu_unsubscribe">Отписаться</string> <string name="detail_menu_unsubscribe">Отписаться</string>
<string name="detail_action_mode_menu_copy">Скопировать</string> <string name="common_button_copy">Скопировать</string>
<string name="detail_action_mode_menu_delete">Удалить</string> <string name="detail_action_mode_menu_delete">Удалить</string>
<string name="detail_action_mode_delete_dialog_message">Удалить выбранные уведомления навсегда\?</string> <string name="detail_action_mode_delete_dialog_message">Удалить выбранные уведомления навсегда\?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Удалить навсегда</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Удалить навсегда</string>

View file

@ -302,7 +302,7 @@
<string name="detail_item_cannot_save">Nie je možné uložiť prílohu: %1$s</string> <string name="detail_item_cannot_save">Nie je možné uložiť prílohu: %1$s</string>
<string name="detail_item_download_info_not_downloaded_expires_x">nestiahnuté, vyprší %1$s</string> <string name="detail_item_download_info_not_downloaded_expires_x">nestiahnuté, vyprší %1$s</string>
<string name="detail_menu_copy_url">Kopírovať adresu témy</string> <string name="detail_menu_copy_url">Kopírovať adresu témy</string>
<string name="detail_action_mode_menu_copy">Kopírovať</string> <string name="common_button_copy">Kopírovať</string>
<string name="settings_notifications_priority_default">predvolená</string> <string name="settings_notifications_priority_default">predvolená</string>
<string name="notification_dialog_show_all">Zobraziť všetky oznámenia</string> <string name="notification_dialog_show_all">Zobraziť všetky oznámenia</string>
<string name="notification_popup_file_download_successful">%1$s <string name="notification_popup_file_download_successful">%1$s

View file

@ -104,7 +104,7 @@
<string name="detail_item_cannot_open_url">Kan inte öppna URL: %1$s</string> <string name="detail_item_cannot_open_url">Kan inte öppna URL: %1$s</string>
<string name="detail_menu_clear">Rensa alla notifikationer</string> <string name="detail_menu_clear">Rensa alla notifikationer</string>
<string name="detail_menu_test">Skicka testnotifikation</string> <string name="detail_menu_test">Skicka testnotifikation</string>
<string name="detail_action_mode_menu_copy">Kopiera</string> <string name="common_button_copy">Kopiera</string>
<string name="detail_action_mode_menu_delete">Ta bort</string> <string name="detail_action_mode_menu_delete">Ta bort</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Ta bort permanent</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Ta bort permanent</string>
<string name="detail_action_mode_delete_dialog_cancel">Avbryt</string> <string name="detail_action_mode_delete_dialog_cancel">Avbryt</string>

View file

@ -10,7 +10,7 @@
<string name="detail_item_download_info_deleted">நீக்கப்பட்டது</string> <string name="detail_item_download_info_deleted">நீக்கப்பட்டது</string>
<string name="channel_subscriber_service_name">சந்தா சேவை</string> <string name="channel_subscriber_service_name">சந்தா சேவை</string>
<string name="common_priority_min_name">குறை முன்னுரிமை</string> <string name="common_priority_min_name">குறை முன்னுரிமை</string>
<string name="detail_action_mode_menu_copy">பிரதி</string> <string name="common_button_copy">பிரதி</string>
<string name="common_priority_default_name">இயல்புநிலை முன்னுரிமை</string> <string name="common_priority_default_name">இயல்புநிலை முன்னுரிமை</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">நிரந்தரமாக நீக்கு</string> <string name="detail_action_mode_delete_dialog_permanently_delete">நிரந்தரமாக நீக்கு</string>
<string name="notification_dialog_cancel">ரத்து செய்</string> <string name="notification_dialog_cancel">ரத்து செய்</string>

View file

@ -221,7 +221,7 @@
<string name="settings_notifications_auto_delete_three_months">3 ay sonra</string> <string name="settings_notifications_auto_delete_three_months">3 ay sonra</string>
<string name="detail_menu_test">Test bildirimi gönder</string> <string name="detail_menu_test">Test bildirimi gönder</string>
<string name="detail_menu_unsubscribe">Abonelikten çık</string> <string name="detail_menu_unsubscribe">Abonelikten çık</string>
<string name="detail_action_mode_menu_copy">Kopyala</string> <string name="common_button_copy">Kopyala</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Kalıcı olarak sil</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Kalıcı olarak sil</string>
<string name="share_title">Paylaş</string> <string name="share_title">Paylaş</string>
<string name="share_content_image_text">Sizinle bir resim paylaşıldı</string> <string name="share_content_image_text">Sizinle bir resim paylaşıldı</string>

View file

@ -128,7 +128,7 @@
<string name="main_banner_websocket_text">Перехід на WebSockets є рекомендованим способом підключення до вашого сервера, який може подовжити час автономної роботи, але може вимагати <a href="https://ntfy.sh/docs/config/#nginxapache2caddy">додаткової конфігурації вашого проксі</a>. Це можна вимкнути в налаштуваннях.</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_title">Необхідно ввійти</string>
<string name="add_dialog_login_new_user">Новий користувач</string> <string name="add_dialog_login_new_user">Новий користувач</string>
<string name="detail_action_mode_menu_copy">Копія</string> <string name="common_button_copy">Копія</string>
<string name="add_dialog_button_login">Авторизуватися</string> <string name="add_dialog_button_login">Авторизуватися</string>
<string name="add_dialog_error_connection_failed">Помилка підключення: %1$s</string> <string name="add_dialog_error_connection_failed">Помилка підключення: %1$s</string>
<string name="add_dialog_login_description">Ця тема потребує авторизації. Будь ласка, введіть ім\'я користувача та пароль.</string> <string name="add_dialog_login_description">Ця тема потребує авторизації. Будь ласка, введіть ім\'я користувача та пароль.</string>

View file

@ -116,7 +116,7 @@
<string name="detail_menu_copy_url">Mavzu manzilini nusxalash</string> <string name="detail_menu_copy_url">Mavzu manzilini nusxalash</string>
<string name="detail_menu_settings">Obuna sozlamalari</string> <string name="detail_menu_settings">Obuna sozlamalari</string>
<string name="detail_menu_unsubscribe">Obunani bekor qilish</string> <string name="detail_menu_unsubscribe">Obunani bekor qilish</string>
<string name="detail_action_mode_menu_copy">Nusxalash</string> <string name="common_button_copy">Nusxalash</string>
<string name="detail_action_mode_menu_delete">Ochirish</string> <string name="detail_action_mode_menu_delete">Ochirish</string>
<string name="detail_action_mode_delete_dialog_message">Tanlangan bildirishnoma(lar) butunlay ochirilsinmi?</string> <string name="detail_action_mode_delete_dialog_message">Tanlangan bildirishnoma(lar) butunlay ochirilsinmi?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Butunlay ochirish</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Butunlay ochirish</string>

View file

@ -152,7 +152,7 @@
<string name="detail_menu_clear">Xóa tất cả thông báo</string> <string name="detail_menu_clear">Xóa tất cả thông báo</string>
<string name="detail_menu_settings">Cài đặt đăng kí</string> <string name="detail_menu_settings">Cài đặt đăng kí</string>
<string name="detail_menu_unsubscribe">Hủy đăng kí</string> <string name="detail_menu_unsubscribe">Hủy đăng kí</string>
<string name="detail_action_mode_menu_copy">Sao chép</string> <string name="common_button_copy">Sao chép</string>
<string name="detail_action_mode_menu_delete">Xóa</string> <string name="detail_action_mode_menu_delete">Xóa</string>
<string name="detail_action_mode_delete_dialog_message">Xóa vĩnh viễn thông báo đã chọn?</string> <string name="detail_action_mode_delete_dialog_message">Xóa vĩnh viễn thông báo đã chọn?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Xóa vĩnh viễn</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Xóa vĩnh viễn</string>

View file

@ -97,7 +97,7 @@
<string name="detail_menu_clear">清空通知</string> <string name="detail_menu_clear">清空通知</string>
<string name="detail_menu_settings">订阅设置</string> <string name="detail_menu_settings">订阅设置</string>
<string name="detail_menu_unsubscribe">删除订阅</string> <string name="detail_menu_unsubscribe">删除订阅</string>
<string name="detail_action_mode_menu_copy">复制</string> <string name="common_button_copy">复制</string>
<string name="detail_action_mode_menu_delete">删除</string> <string name="detail_action_mode_menu_delete">删除</string>
<string name="detail_action_mode_delete_dialog_message">确认永久删除选中的通知吗?</string> <string name="detail_action_mode_delete_dialog_message">确认永久删除选中的通知吗?</string>
<string name="detail_action_mode_delete_dialog_permanently_delete">永久删除</string> <string name="detail_action_mode_delete_dialog_permanently_delete">永久删除</string>

View file

@ -113,7 +113,7 @@
<string name="detail_menu_enable_instant">啓用即時通知</string> <string name="detail_menu_enable_instant">啓用即時通知</string>
<string name="detail_menu_disable_instant">關閉即時通知</string> <string name="detail_menu_disable_instant">關閉即時通知</string>
<string name="detail_menu_clear">清除所有通知</string> <string name="detail_menu_clear">清除所有通知</string>
<string name="detail_action_mode_menu_copy">複製</string> <string name="common_button_copy">複製</string>
<string name="detail_action_mode_menu_delete">刪除</string> <string name="detail_action_mode_menu_delete">刪除</string>
<string name="share_menu_send">傳送</string> <string name="share_menu_send">傳送</string>
<string name="share_content_title">預覽</string> <string name="share_content_title">預覽</string>

View file

@ -6,6 +6,7 @@
<string name="common_button_add">Add</string> <string name="common_button_add">Add</string>
<string name="common_button_save">Save</string> <string name="common_button_save">Save</string>
<string name="common_button_delete">Delete</string> <string name="common_button_delete">Delete</string>
<string name="common_button_copy">Copy</string>
<string name="common_copied_to_clipboard">Copied to clipboard</string> <string name="common_copied_to_clipboard">Copied to clipboard</string>
<string name="common_service_url">Service URL</string> <string name="common_service_url">Service URL</string>
<string name="common_service_url_placeholder">e.g. https://ntfy.example.com</string> <string name="common_service_url_placeholder">e.g. https://ntfy.example.com</string>
@ -53,6 +54,7 @@
<!-- Main activity: Action bar --> <!-- Main activity: Action bar -->
<string name="main_action_bar_title">Subscribed topics</string> <string name="main_action_bar_title">Subscribed topics</string>
<string name="main_menu_connection_error">Connection error</string>
<string name="main_menu_notifications_enabled">Notifications on</string> <string name="main_menu_notifications_enabled">Notifications on</string>
<string name="main_menu_notifications_disabled_forever">Notifications muted</string> <string name="main_menu_notifications_disabled_forever">Notifications muted</string>
<string name="main_menu_notifications_disabled_until">Notifications muted until %1$s</string> <string name="main_menu_notifications_disabled_until">Notifications muted until %1$s</string>
@ -203,7 +205,6 @@
<string name="detail_menu_unsubscribe">Unsubscribe</string> <string name="detail_menu_unsubscribe">Unsubscribe</string>
<!-- Detail activity: Action mode --> <!-- Detail activity: Action mode -->
<string name="detail_action_mode_menu_copy">Copy</string>
<string name="detail_action_mode_menu_delete">Delete</string> <string name="detail_action_mode_menu_delete">Delete</string>
<string name="detail_action_mode_delete_dialog_message"> <string name="detail_action_mode_delete_dialog_message">
Delete the selected notification(s) permanently? Delete the selected notification(s) permanently?
@ -288,6 +289,16 @@
<string name="notification_dialog_tomorrow">Until tomorrow</string> <string name="notification_dialog_tomorrow">Until tomorrow</string>
<string name="notification_dialog_forever">Until resumed</string> <string name="notification_dialog_forever">Until resumed</string>
<!-- Connection error dialog -->
<string name="connection_error_dialog_title">Connection error</string>
<string name="connection_error_dialog_message">There was a problem connecting to %1$s. The app will keep trying to reconnect in the background.</string>
<string name="connection_error_dialog_connection_refused">Connection refused. The server may be down or the address may be incorrect.</string>
<string name="connection_error_dialog_websocket_not_supported">WebSocket not supported. The server may not support WebSocket connections, or the address may be incorrect.</string>
<string name="connection_error_dialog_not_authorized">Not authorized. The server returned a HTTP 401/403 response. Please check if your username and password are correct.</string>
<string name="connection_error_dialog_retry_now">Retry now</string>
<string name="connection_error_dialog_retry_countdown">Retrying in %1$ds…</string>
<string name="connection_error_dialog_retrying">Retrying…</string>
<!-- Notification popup --> <!-- Notification popup -->
<string name="notification_popup_action_open">Open</string> <string name="notification_popup_action_open">Open</string>
<string name="notification_popup_action_browse">Browse</string> <string name="notification_popup_action_browse">Browse</string>

View file

@ -1,5 +1,9 @@
Features: Features:
* Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149) * Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149)
* Connection error dialog to help diagnose connection issues
Maintenance + bug fixes: Maintenance + bug fixes:
* Use server-specific user for attachment downloads (#1529, thanks to @ManInDark for reporting) * Use server-specific user for attachment downloads (#1529, thanks to @ManInDark for reporting)
* Fix crash in sharing dialog (thanks to @rogeliodh)
* Fix crash when exiting multi-delete in detail view
* Fix potential crashes with icon downloader and backuper