Merge branch 'main' of github.com:binwiederhier/ntfy-android into 303-update-notifications
This commit is contained in:
commit
8d7e7eef03
60 changed files with 1107 additions and 216 deletions
|
|
@ -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')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = ''")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
275
app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt
Normal file
275
app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
11
app/src/main/res/drawable/ic_refresh_white_24dp.xml
Normal file
11
app/src/main/res/drawable/ic_refresh_white_24dp.xml
Normal 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>
|
||||||
9
app/src/main/res/drawable/ic_warning_gray_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_warning_gray_24dp.xml
Normal 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>
|
||||||
9
app/src/main/res/drawable/ic_warning_white_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_warning_white_24dp.xml
Normal 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>
|
||||||
157
app/src/main/res/layout/fragment_connection_error_dialog.xml
Normal file
157
app/src/main/res/layout/fragment_connection_error_dialog.xml
Normal 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>
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
14
app/src/main/res/menu/menu_connection_error_dialog.xml
Normal file
14
app/src/main/res/menu/menu_connection_error_dialog.xml
Normal 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>
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 jusqu’au</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 l’authentification 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 n’est pas privée</string>
|
||||||
|
<string name="trusted_certificate_dialog_security_description">Le certificat du serveur n’est pas approuvé. Des attaquants pourraient essayer de voler vos informations. N’avancez pas sauf si vous savez pourquoi ce certificat n’est 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 l’ajouter à vos certificats de confiance.</string>
|
||||||
|
<string name="trusted_certificate_dialog_description_view">Ce certificat est utilisé pour les connexions à l’URL du service ci-dessous. Vous avez ajouté cette exception manuellement.</string>
|
||||||
|
<string name="trusted_certificate_dialog_description_page1">Saisissez l’URL 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 n’est 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 l’URL 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 l’authentification 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">O‘chirish</string>
|
<string name="detail_action_mode_menu_delete">O‘chirish</string>
|
||||||
<string name="detail_action_mode_delete_dialog_message">Tanlangan bildirishnoma(lar) butunlay o‘chirilsinmi?</string>
|
<string name="detail_action_mode_delete_dialog_message">Tanlangan bildirishnoma(lar) butunlay o‘chirilsinmi?</string>
|
||||||
<string name="detail_action_mode_delete_dialog_permanently_delete">Butunlay o‘chirish</string>
|
<string name="detail_action_mode_delete_dialog_permanently_delete">Butunlay o‘chirish</string>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue