All the things; this should be it
This commit is contained in:
parent
c772d15043
commit
3abd0b3da9
23 changed files with 677 additions and 335 deletions
|
@ -2,7 +2,7 @@
|
|||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 7,
|
||||
"identityHash": "eda2cb9740c4542f24462779eb6ff81d",
|
||||
"identityHash": "ecb1b85b2ae822dc62b2843620368477",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
|
@ -65,7 +65,6 @@
|
|||
"baseUrl",
|
||||
"topic"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
||||
},
|
||||
{
|
||||
|
@ -74,7 +73,6 @@
|
|||
"columnNames": [
|
||||
"upConnectorToken"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
|
||||
}
|
||||
],
|
||||
|
@ -198,38 +196,6 @@
|
|||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"baseUrl"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Log",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)",
|
||||
|
@ -284,7 +250,7 @@
|
|||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eda2cb9740c4542f24462779eb6ff81d')"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ecb1b85b2ae822dc62b2843620368477')"
|
||||
]
|
||||
}
|
||||
}
|
290
app/schemas/io.heckel.ntfy.db.Database/8.json
Normal file
290
app/schemas/io.heckel.ntfy.db.Database/8.json
Normal file
|
@ -0,0 +1,290 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "eda2cb9740c4542f24462779eb6ff81d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "topic",
|
||||
"columnName": "topic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Subscription_baseUrl_topic",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"baseUrl",
|
||||
"topic"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
||||
},
|
||||
{
|
||||
"name": "index_Subscription_upConnectorToken",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"upConnectorToken"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Notification",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"baseUrl"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Log",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tag",
|
||||
"columnName": "tag",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "level",
|
||||
"columnName": "level",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "exception",
|
||||
"columnName": "exception",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eda2cb9740c4542f24462779eb6ff81d')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -100,7 +100,7 @@ data class LogEntry(
|
|||
this(0, timestamp, tag, level, message, exception)
|
||||
}
|
||||
|
||||
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 7)
|
||||
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 8)
|
||||
abstract class Database : RoomDatabase() {
|
||||
abstract fun subscriptionDao(): SubscriptionDao
|
||||
abstract fun notificationDao(): NotificationDao
|
||||
|
@ -121,6 +121,7 @@ abstract class Database : RoomDatabase() {
|
|||
.addMigrations(MIGRATION_4_5)
|
||||
.addMigrations(MIGRATION_5_6)
|
||||
.addMigrations(MIGRATION_6_7)
|
||||
.addMigrations(MIGRATION_7_8)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
this.instance = instance
|
||||
|
@ -184,6 +185,12 @@ abstract class Database : RoomDatabase() {
|
|||
db.execSQL("CREATE TABLE Log (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp INT NOT NULL, tag TEXT NOT NULL, level INT NOT NULL, message TEXT NOT NULL, exception TEXT)")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE User (baseUrl TEXT NOT NULL, username TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -219,16 +219,6 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
|
|||
return sharedPrefs.getInt(SHARED_PREFS_DARK_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
}
|
||||
|
||||
fun getWakelockEnabled(): Boolean {
|
||||
return sharedPrefs.getBoolean(SHARED_PREFS_WAKELOCK_ENABLED, false) // Disabled by default!
|
||||
}
|
||||
|
||||
fun setWakelockEnabled(enabled: Boolean) {
|
||||
sharedPrefs.edit()
|
||||
.putBoolean(SHARED_PREFS_WAKELOCK_ENABLED, enabled)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun setConnectionProtocol(connectionProtocol: String) {
|
||||
if (connectionProtocol == CONNECTION_PROTOCOL_JSONHTTP) {
|
||||
sharedPrefs.edit()
|
||||
|
|
|
@ -6,8 +6,6 @@ import io.heckel.ntfy.BuildConfig
|
|||
import io.heckel.ntfy.db.Database
|
||||
import io.heckel.ntfy.db.LogDao
|
||||
import io.heckel.ntfy.db.LogEntry
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.util.isIgnoringBatteryOptimizations
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -34,15 +32,19 @@ class Log(private val logsDao: LogDao) {
|
|||
}
|
||||
}
|
||||
|
||||
fun getFormatted(): String {
|
||||
return prependDeviceInfo(formatEntries(scrubEntries(logsDao.getAll())))
|
||||
fun getFormatted(scrub: Boolean): String {
|
||||
return if (scrub) {
|
||||
prependDeviceInfo(formatEntries(scrubEntries(logsDao.getAll())), scrubLine = true)
|
||||
} else {
|
||||
prependDeviceInfo(formatEntries(logsDao.getAll()), scrubLine = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prependDeviceInfo(s: String): String {
|
||||
private fun prependDeviceInfo(s: String, scrubLine: Boolean): String {
|
||||
val maybeScrubLine = if (scrubLine) "Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑.\n" else ""
|
||||
return """
|
||||
This is a log of the ntfy Android app. The log shows up to 2,000 lines.
|
||||
Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑.
|
||||
|
||||
This is a log of the ntfy Android app. The log shows up to 1,000 entries.
|
||||
$maybeScrubLine
|
||||
Device info:
|
||||
--
|
||||
ntfy: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR})
|
||||
|
@ -116,7 +118,7 @@ class Log(private val logsDao: LogDao) {
|
|||
companion object {
|
||||
private const val TAG = "NtfyLog"
|
||||
private const val PRUNE_EVERY = 100
|
||||
private const val ENTRIES_MAX = 2000
|
||||
private const val ENTRIES_MAX = 1000
|
||||
private val IGNORE_TERMS = listOf("ntfy.sh")
|
||||
private val REPLACE_TERMS = listOf(
|
||||
"banana", "kiwi", "lemon", "coconut", "avocado", "orange", "apple", "peach"
|
||||
|
@ -153,8 +155,12 @@ class Log(private val logsDao: LogDao) {
|
|||
return getInstance()?.record?.get() ?: false
|
||||
}
|
||||
|
||||
fun getFormatted(): String {
|
||||
return getInstance()?.getFormatted() ?: "(no logs)"
|
||||
fun getFormatted(scrub: Boolean): String {
|
||||
return getInstance()?.getFormatted(scrub) ?: "(no logs)"
|
||||
}
|
||||
|
||||
fun getScrubTerms(): Map<String, String> {
|
||||
return getInstance()?.scrubTerms!!.toMap()
|
||||
}
|
||||
|
||||
fun deleteAll() {
|
||||
|
|
|
@ -47,6 +47,9 @@ class ApiService {
|
|||
builder.addHeader("X-Delay", delay)
|
||||
}
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
throw UnauthorizedException(user)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("Unexpected response ${response.code} when publishing to $url")
|
||||
}
|
||||
|
@ -132,6 +135,8 @@ class ApiService {
|
|||
}
|
||||
}
|
||||
|
||||
class UnauthorizedException(val user: User?) : Exception()
|
||||
|
||||
companion object {
|
||||
val USER_AGENT = "ntfy/${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}; Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT})"
|
||||
private const val TAG = "NtfyApiService"
|
||||
|
|
|
@ -68,6 +68,8 @@ class BroadcastService(private val ctx: Context) {
|
|||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val repository = Repository.getInstance(ctx)
|
||||
val user = repository.getUser(baseUrl) // May be null
|
||||
try {
|
||||
Log.d(TAG, "Publishing message $intent")
|
||||
api.publish(
|
||||
baseUrl = baseUrl,
|
||||
topic = topic,
|
||||
|
@ -78,6 +80,9 @@ class BroadcastService(private val ctx: Context) {
|
|||
tags = splitTags(tags),
|
||||
delay = delay
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to publish message: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,9 +21,11 @@ import io.heckel.ntfy.msg.ApiService
|
|||
import io.heckel.ntfy.msg.NotificationDispatcher
|
||||
import io.heckel.ntfy.ui.MainActivity
|
||||
import io.heckel.ntfy.util.topicUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
|
@ -63,16 +65,16 @@ class SubscriberService : Service() {
|
|||
private val api = ApiService()
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var serviceNotification: Notification? = null
|
||||
private val refreshMutex = Mutex() // Ensure refreshConnections() is only run one at a time
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand executed with startId: $startId")
|
||||
if (intent != null) {
|
||||
val action = intent.action
|
||||
Log.d(TAG, "using an intent with action $action")
|
||||
when (action) {
|
||||
Log.d(TAG, "using an intent with action ${intent.action}")
|
||||
when (intent.action) {
|
||||
Action.START.name -> startService()
|
||||
Action.STOP.name -> stopService()
|
||||
else -> Log.e(TAG, "This should never happen. No action in the received intent")
|
||||
else -> Log.w(TAG, "This should never happen. No action in the received intent")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "with a null intent. It has been probably restarted by the system.")
|
||||
|
@ -116,9 +118,6 @@ class SubscriberService : Service() {
|
|||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)
|
||||
}
|
||||
if (repository.getWakelockEnabled()) {
|
||||
wakeLock?.acquire()
|
||||
}
|
||||
refreshConnections()
|
||||
}
|
||||
|
||||
|
@ -148,8 +147,25 @@ class SubscriberService : Service() {
|
|||
saveServiceState(this, ServiceState.STOPPED)
|
||||
}
|
||||
|
||||
private fun refreshConnections() =
|
||||
private fun refreshConnections() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (!refreshMutex.tryLock()) {
|
||||
Log.d(TAG, "Refreshing subscriptions already in progress. Skipping.")
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
reallyRefreshConnections(this)
|
||||
} finally {
|
||||
refreshMutex.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start/stop connections based on the desired state
|
||||
* It is guaranteed that only one of function is run at a time (see mutex above).
|
||||
*/
|
||||
private suspend fun reallyRefreshConnections(scope: CoroutineScope) {
|
||||
// Group INSTANT subscriptions by base URL, there is only one connection per base URL
|
||||
val instantSubscriptions = repository.getSubscriptions()
|
||||
.filter { s -> s.instant }
|
||||
|
@ -158,9 +174,18 @@ class SubscriberService : Service() {
|
|||
.groupBy { s -> ConnectionId(s.baseUrl, emptyMap()) }
|
||||
.map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }) }
|
||||
.toSet()
|
||||
val newConnectionIds = desiredConnectionIds subtract activeConnectionIds
|
||||
val obsoleteConnectionIds = activeConnectionIds subtract desiredConnectionIds
|
||||
val newConnectionIds = desiredConnectionIds.subtract(activeConnectionIds)
|
||||
val obsoleteConnectionIds = activeConnectionIds.subtract(desiredConnectionIds)
|
||||
val match = activeConnectionIds == desiredConnectionIds
|
||||
val newSinceByBaseUrl = connections
|
||||
.map { e ->
|
||||
// Get last message timestamp to determine new ?since= param; set to $last+1 if it
|
||||
// is defined to avoid retrieving old messages. See comment below too.
|
||||
val lastMessage = e.value.since()
|
||||
val newSince = if (lastMessage > 0) lastMessage+1 else 0
|
||||
e.key.baseUrl to newSince
|
||||
}
|
||||
.toMap()
|
||||
|
||||
Log.d(TAG, "Refreshing subscriptions")
|
||||
Log.d(TAG, "- Desired connections: $desiredConnectionIds")
|
||||
|
@ -171,24 +196,22 @@ class SubscriberService : Service() {
|
|||
|
||||
if (match) {
|
||||
Log.d(TAG, "- No action required.")
|
||||
return@launch
|
||||
return
|
||||
}
|
||||
|
||||
// Open new connections
|
||||
newConnectionIds.forEach { connectionId ->
|
||||
// FIXME since !!!
|
||||
// IMPORTANT: Do NOT request old messages for new connections; we call poll() in MainActivity to
|
||||
// retrieve old messages. This is important, so we don't download attachments from old messages.
|
||||
|
||||
// Do NOT request old messages for new connections; we'll call poll() in MainActivity.
|
||||
// This is important, so we don't download attachments from old messages, which is not desired.
|
||||
|
||||
val since = System.currentTimeMillis()/1000
|
||||
val since = newSinceByBaseUrl[connectionId.baseUrl] ?: (System.currentTimeMillis() / 1000)
|
||||
val serviceActive = { -> isServiceStarted }
|
||||
val user = repository.getUser(connectionId.baseUrl)
|
||||
val connection = if (repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS) {
|
||||
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
|
||||
WsConnection(connectionId, repository, user, since, ::onStateChanged, ::onNotificationReceived, alarmManager)
|
||||
} else {
|
||||
JsonConnection(connectionId, this, repository, api, user, since, ::onStateChanged, ::onNotificationReceived, serviceActive)
|
||||
JsonConnection(connectionId, scope, repository, api, user, since, ::onStateChanged, ::onNotificationReceived, serviceActive)
|
||||
}
|
||||
connections[connectionId] = connection
|
||||
connection.start()
|
||||
|
@ -202,7 +225,6 @@ class SubscriberService : Service() {
|
|||
|
||||
// Update foreground service notification popup
|
||||
if (connections.size > 0) {
|
||||
synchronized(this) {
|
||||
val title = getString(R.string.channel_subscriber_notification_title)
|
||||
val text = if (BuildConfig.FIREBASE_AVAILABLE) {
|
||||
when (instantSubscriptions.size) {
|
||||
|
@ -225,18 +247,15 @@ class SubscriberService : Service() {
|
|||
notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStateChanged(subscriptionIds: Collection<Long>, state: ConnectionState) {
|
||||
repository.updateState(subscriptionIds, state)
|
||||
}
|
||||
|
||||
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) {
|
||||
// If permanent wakelock is not enabled, still take the wakelock while notifications are being dispatched
|
||||
if (!repository.getWakelockEnabled()) {
|
||||
// Wakelock while notifications are being dispatched
|
||||
// Wakelocks are reference counted by default so that should work neatly here
|
||||
wakeLock?.acquire(10*60*1000L /*10 minutes*/)
|
||||
}
|
||||
wakeLock?.acquire(NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS)
|
||||
|
||||
val url = topicUrl(subscription.baseUrl, subscription.topic)
|
||||
Log.d(TAG, "[$url] Received notification: $notification")
|
||||
|
@ -245,8 +264,6 @@ class SubscriberService : Service() {
|
|||
Log.d(TAG, "[$url] Dispatching notification $notification")
|
||||
dispatcher.dispatch(subscription, notification)
|
||||
}
|
||||
|
||||
if (!repository.getWakelockEnabled()) {
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
|
@ -254,7 +271,6 @@ class SubscriberService : Service() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(): NotificationManager? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
@ -337,6 +353,7 @@ class SubscriberService : Service() {
|
|||
private const val WAKE_LOCK_TAG = "SubscriberService:lock"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber"
|
||||
private const val NOTIFICATION_SERVICE_ID = 2586
|
||||
private const val NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS = 10*60*1000L /*10 minutes*/
|
||||
private const val SHARED_PREFS_ID = "SubscriberService"
|
||||
private const val SHARED_PREFS_SERVICE_STATE = "ServiceState"
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ class SubscriberServiceManager(private val context: Context) {
|
|||
Log.d(TAG, "Enqueuing work to refresh subscriber service")
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
|
||||
workManager.enqueue(startServiceRequest)
|
||||
workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
|
||||
}
|
||||
|
||||
fun restart() {
|
||||
|
@ -59,6 +59,7 @@ class SubscriberServiceManager(private val context: Context) {
|
|||
|
||||
companion object {
|
||||
const val TAG = "NtfySubscriberMgr"
|
||||
const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||
|
||||
fun refresh(context: Context) {
|
||||
val manager = SubscriberServiceManager(context)
|
||||
|
|
|
@ -43,43 +43,49 @@ class WsConnection(
|
|||
.build()
|
||||
private var errorCount = 0
|
||||
private var webSocket: WebSocket? = null
|
||||
private val listenerId = AtomicLong(0)
|
||||
private var state: State? = null
|
||||
private var closed = false
|
||||
|
||||
private var since: Long = sinceTime
|
||||
private val globalId = GLOBAL_ID.incrementAndGet()
|
||||
private val listenerId = AtomicLong(0)
|
||||
|
||||
private val since = AtomicLong(sinceTime)
|
||||
private val baseUrl = connectionId.baseUrl
|
||||
private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds
|
||||
private val subscriptionIds = topicsToSubscriptionIds.values
|
||||
private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",")
|
||||
private val shortUrl = topicShortUrl(baseUrl, topicsStr)
|
||||
|
||||
init {
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId): New connection with global ID $globalId")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun start() {
|
||||
if (closed || state == State.Connecting || state == State.Connected) {
|
||||
Log.d(TAG,"$shortUrl: Not (re-)starting, because connection is marked closed/connecting/connected")
|
||||
Log.d(TAG,"$shortUrl (gid=$globalId): Not (re-)starting, because connection is marked closed/connecting/connected")
|
||||
return
|
||||
}
|
||||
if (webSocket != null) {
|
||||
webSocket!!.close(WS_CLOSE_NORMAL, "")
|
||||
}
|
||||
state = State.Connecting
|
||||
val nextId = listenerId.incrementAndGet()
|
||||
val sinceVal = if (since == 0L) "all" else since.toString()
|
||||
val nextListenerId = listenerId.incrementAndGet()
|
||||
val sinceVal = if (since.get() == 0L) "all" else since.get().toString()
|
||||
val urlWithSince = topicUrlWs(baseUrl, topicsStr, sinceVal)
|
||||
val request = requestBuilder(urlWithSince, user).build()
|
||||
Log.d(TAG, "$shortUrl: Opening $urlWithSince with listener ID $nextId ...")
|
||||
webSocket = client.newWebSocket(request, Listener(nextId))
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId): Opening $urlWithSince with listener ID $nextListenerId ...")
|
||||
webSocket = client.newWebSocket(request, Listener(nextListenerId))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun close() {
|
||||
closed = true
|
||||
if (webSocket == null) {
|
||||
Log.d(TAG,"$shortUrl: Not closing existing connection, because there is no active web socket")
|
||||
Log.d(TAG,"$shortUrl (gid=$globalId): Not closing existing connection, because there is no active web socket")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "$shortUrl: Closing existing connection")
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId): Closing connection")
|
||||
state = State.Disconnected
|
||||
webSocket!!.close(WS_CLOSE_NORMAL, "")
|
||||
webSocket = null
|
||||
|
@ -87,23 +93,23 @@ class WsConnection(
|
|||
|
||||
@Synchronized
|
||||
override fun since(): Long {
|
||||
return since
|
||||
return since.get()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun scheduleReconnect(seconds: Int) {
|
||||
if (closed || state == State.Connecting || state == State.Connected) {
|
||||
Log.d(TAG,"$shortUrl: Not rescheduling connection, because connection is marked closed/connecting/connected")
|
||||
Log.d(TAG,"$shortUrl (gid=$globalId): Not rescheduling connection, because connection is marked closed/connecting/connected")
|
||||
return
|
||||
}
|
||||
state = State.Scheduled
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
Log.d(TAG,"$shortUrl: Scheduling a restart in $seconds seconds (via alarm manager)")
|
||||
Log.d(TAG,"$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via alarm manager)")
|
||||
val reconnectTime = Calendar.getInstance()
|
||||
reconnectTime.add(Calendar.SECOND, seconds)
|
||||
alarmManager.setExact(AlarmManager.RTC_WAKEUP, reconnectTime.timeInMillis, RECONNECT_TAG, { start() }, null)
|
||||
} else {
|
||||
Log.d(TAG, "$shortUrl: Scheduling a restart in $seconds seconds (via handler)")
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via handler)")
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
handler.postDelayed({ start() }, TimeUnit.SECONDS.toMillis(seconds.toLong()))
|
||||
}
|
||||
|
@ -112,7 +118,7 @@ class WsConnection(
|
|||
private inner class Listener(private val id: Long) : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
synchronize("onOpen") {
|
||||
Log.d(TAG, "$shortUrl (listener $id): Opened connection")
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Opened connection")
|
||||
state = State.Connected
|
||||
if (errorCount > 0) {
|
||||
errorCount = 0
|
||||
|
@ -123,10 +129,10 @@ class WsConnection(
|
|||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
synchronize("onMessage") {
|
||||
Log.d(TAG, "$shortUrl (listener $id): Received message: $text")
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Received message: $text")
|
||||
val notificationWithTopic = parser.parseWithTopic(text, subscriptionId = 0, notificationId = Random.nextInt())
|
||||
if (notificationWithTopic == null) {
|
||||
Log.d(TAG, "$shortUrl (listener $id): Irrelevant or unknown message. Discarding.")
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Irrelevant or unknown message. Discarding.")
|
||||
return@synchronize
|
||||
}
|
||||
val topic = notificationWithTopic.topic
|
||||
|
@ -135,13 +141,13 @@ class WsConnection(
|
|||
val subscription = repository.getSubscription(subscriptionId) ?: return@synchronize
|
||||
val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
|
||||
notificationListener(subscription, notificationWithSubscriptionId)
|
||||
since = notification.timestamp
|
||||
since.set(notification.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
synchronize("onClosed") {
|
||||
Log.w(TAG, "$shortUrl (listener $id): Closed connection")
|
||||
Log.w(TAG, "$shortUrl (gid=$globalId, lid=$id): Closed connection")
|
||||
state = State.Disconnected
|
||||
}
|
||||
}
|
||||
|
@ -149,12 +155,12 @@ class WsConnection(
|
|||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
synchronize("onFailure") {
|
||||
if (response == null) {
|
||||
Log.e(TAG, "$shortUrl (listener $id): Connection failed (response is null): ${t.message}", t)
|
||||
Log.e(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection failed (response is null): ${t.message}", t)
|
||||
} else {
|
||||
Log.e(TAG, "$shortUrl (listener $id): Connection failed (response code ${response.code}, message: ${response.message}): ${t.message}", t)
|
||||
Log.e(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection failed (response code ${response.code}, message: ${response.message}): ${t.message}", t)
|
||||
}
|
||||
if (closed) {
|
||||
Log.d(TAG, "$shortUrl (listener $id): Connection marked as closed. Not retrying.")
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection marked as closed. Not retrying.")
|
||||
return@synchronize
|
||||
}
|
||||
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
|
||||
|
@ -170,7 +176,7 @@ class WsConnection(
|
|||
if (listenerId.get() == id) {
|
||||
fn()
|
||||
} else {
|
||||
Log.w(TAG, "$shortUrl (listener $id): Skipping synchronized block '$tag', because listener ID does not match ${listenerId.get()}")
|
||||
Log.w(TAG, "$shortUrl (gid=$globalId, lid=$id): Skipping synchronized block '$tag', because listener ID does not match ${listenerId.get()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -185,5 +191,6 @@ class WsConnection(
|
|||
private const val RECONNECT_TAG = "WsReconnect"
|
||||
private const val WS_CLOSE_NORMAL = 1000
|
||||
private val RETRY_SECONDS = listOf(5, 10, 15, 20, 30, 45, 60, 120)
|
||||
private val GLOBAL_ID = AtomicLong(0)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,14 +46,16 @@ class AddFragment : DialogFragment() {
|
|||
private lateinit var subscribeInstantDeliveryCheckbox: CheckBox
|
||||
private lateinit var subscribeInstantDeliveryDescription: View
|
||||
private lateinit var subscribeProgress: ProgressBar
|
||||
private lateinit var subscribeErrorImage: View
|
||||
private lateinit var subscribeErrorText: TextView
|
||||
private lateinit var subscribeErrorTextImage: View
|
||||
|
||||
// Login page
|
||||
private lateinit var users: List<User>
|
||||
private lateinit var loginUsernameText: TextInputEditText
|
||||
private lateinit var loginPasswordText: TextInputEditText
|
||||
private lateinit var loginProgress: ProgressBar
|
||||
private lateinit var loginErrorImage: View
|
||||
private lateinit var loginErrorText: TextView
|
||||
private lateinit var loginErrorTextImage: View
|
||||
|
||||
private lateinit var baseUrls: List<String> // List of base URLs already used, excluding app_base_url
|
||||
|
||||
|
@ -84,22 +86,26 @@ class AddFragment : DialogFragment() {
|
|||
loginView.visibility = View.GONE
|
||||
|
||||
// Fields for "subscribe page"
|
||||
subscribeTopicText = view.findViewById(R.id.add_dialog_topic_text)
|
||||
subscribeBaseUrlLayout = view.findViewById(R.id.add_dialog_base_url_layout)
|
||||
subscribeBaseUrlText = view.findViewById(R.id.add_dialog_base_url_text)
|
||||
subscribeInstantDeliveryBox = view.findViewById(R.id.add_dialog_instant_delivery_box)
|
||||
subscribeInstantDeliveryCheckbox = view.findViewById(R.id.add_dialog_instant_delivery_checkbox)
|
||||
subscribeInstantDeliveryDescription = view.findViewById(R.id.add_dialog_instant_delivery_description)
|
||||
subscribeUseAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox)
|
||||
subscribeUseAnotherServerDescription = view.findViewById(R.id.add_dialog_use_another_server_description)
|
||||
subscribeProgress = view.findViewById(R.id.add_dialog_progress)
|
||||
subscribeErrorImage = view.findViewById(R.id.add_dialog_error_image)
|
||||
subscribeTopicText = view.findViewById(R.id.add_dialog_subscribe_topic_text)
|
||||
subscribeBaseUrlLayout = view.findViewById(R.id.add_dialog_subscribe_base_url_layout)
|
||||
subscribeBaseUrlText = view.findViewById(R.id.add_dialog_subscribe_base_url_text)
|
||||
subscribeInstantDeliveryBox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_box)
|
||||
subscribeInstantDeliveryCheckbox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_checkbox)
|
||||
subscribeInstantDeliveryDescription = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_description)
|
||||
subscribeUseAnotherServerCheckbox = view.findViewById(R.id.add_dialog_subscribe_use_another_server_checkbox)
|
||||
subscribeUseAnotherServerDescription = view.findViewById(R.id.add_dialog_subscribe_use_another_server_description)
|
||||
subscribeProgress = view.findViewById(R.id.add_dialog_subscribe_progress)
|
||||
subscribeErrorText = view.findViewById(R.id.add_dialog_subscribe_error_text)
|
||||
subscribeErrorText.visibility = View.GONE
|
||||
subscribeErrorTextImage = view.findViewById(R.id.add_dialog_subscribe_error_text_image)
|
||||
subscribeErrorTextImage.visibility = View.GONE
|
||||
|
||||
// Fields for "login page"
|
||||
loginUsernameText = view.findViewById(R.id.add_dialog_login_username)
|
||||
loginPasswordText = view.findViewById(R.id.add_dialog_login_password)
|
||||
loginProgress = view.findViewById(R.id.add_dialog_login_progress)
|
||||
loginErrorImage = view.findViewById(R.id.add_dialog_login_error_image)
|
||||
loginErrorText = view.findViewById(R.id.add_dialog_login_error_text)
|
||||
loginErrorTextImage = view.findViewById(R.id.add_dialog_login_error_text_image)
|
||||
|
||||
// Set "Use another server" description based on flavor
|
||||
subscribeUseAnotherServerDescription.text = if (BuildConfig.FIREBASE_AVAILABLE) {
|
||||
|
@ -268,7 +274,8 @@ class AddFragment : DialogFragment() {
|
|||
|
||||
private fun checkReadAndMaybeShowLogin(baseUrl: String, topic: String) {
|
||||
subscribeProgress.visibility = View.VISIBLE
|
||||
subscribeErrorImage.visibility = View.GONE
|
||||
subscribeErrorText.visibility = View.GONE
|
||||
subscribeErrorTextImage.visibility = View.GONE
|
||||
enableSubscribeView(false)
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
|
@ -280,7 +287,7 @@ class AddFragment : DialogFragment() {
|
|||
} else {
|
||||
if (user != null) {
|
||||
Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, but user already exists")
|
||||
showToastAndReenableSubscribeView(getString(R.string.add_dialog_login_error_not_authorized))
|
||||
showErrorAndReenableSubscribeView(getString(R.string.add_dialog_login_error_not_authorized))
|
||||
} else {
|
||||
Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog")
|
||||
val activity = activity ?: return@launch // We may have pressed "Cancel"
|
||||
|
@ -291,26 +298,26 @@ class AddFragment : DialogFragment() {
|
|||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Connection to topic failed: ${e.message}", e)
|
||||
showToastAndReenableSubscribeView(e.message)
|
||||
showErrorAndReenableSubscribeView(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showToastAndReenableSubscribeView(message: String?) {
|
||||
private fun showErrorAndReenableSubscribeView(message: String?) {
|
||||
val activity = activity ?: return // We may have pressed "Cancel"
|
||||
activity.runOnUiThread {
|
||||
subscribeProgress.visibility = View.GONE
|
||||
subscribeErrorImage.visibility = View.VISIBLE
|
||||
subscribeErrorText.visibility = View.VISIBLE
|
||||
subscribeErrorText.text = message
|
||||
subscribeErrorTextImage.visibility = View.VISIBLE
|
||||
enableSubscribeView(true)
|
||||
Toast
|
||||
.makeText(context, message, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginAndMaybeDismiss(baseUrl: String, topic: String) {
|
||||
loginProgress.visibility = View.VISIBLE
|
||||
loginErrorImage.visibility = View.GONE
|
||||
loginErrorText.visibility = View.GONE
|
||||
loginErrorTextImage.visibility = View.GONE
|
||||
enableLoginView(false)
|
||||
val user = User(
|
||||
baseUrl = baseUrl,
|
||||
|
@ -327,24 +334,23 @@ class AddFragment : DialogFragment() {
|
|||
dismissDialog()
|
||||
} else {
|
||||
Log.w(TAG, "Access not allowed for user ${user.username} to topic ${topicUrl(baseUrl, topic)}")
|
||||
showToastAndReenableLoginView(getString(R.string.add_dialog_login_error_not_authorized))
|
||||
showErrorAndReenableLoginView(getString(R.string.add_dialog_login_error_not_authorized))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Connection to topic failed during login: ${e.message}", e)
|
||||
showToastAndReenableLoginView(e.message)
|
||||
showErrorAndReenableLoginView(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showToastAndReenableLoginView(message: String?) {
|
||||
private fun showErrorAndReenableLoginView(message: String?) {
|
||||
val activity = activity ?: return // We may have pressed "Cancel"
|
||||
activity.runOnUiThread {
|
||||
loginProgress.visibility = View.GONE
|
||||
loginErrorImage.visibility = View.VISIBLE
|
||||
loginErrorText.visibility = View.VISIBLE
|
||||
loginErrorText.text = message
|
||||
loginErrorTextImage.visibility = View.VISIBLE
|
||||
enableLoginView(true)
|
||||
Toast
|
||||
.makeText(context, message, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -448,7 +454,8 @@ class AddFragment : DialogFragment() {
|
|||
|
||||
private fun resetSubscribeView() {
|
||||
subscribeProgress.visibility = View.GONE
|
||||
subscribeErrorImage.visibility = View.GONE
|
||||
subscribeErrorText.visibility = View.GONE
|
||||
subscribeErrorTextImage.visibility = View.GONE
|
||||
enableSubscribeView(true)
|
||||
}
|
||||
|
||||
|
@ -464,7 +471,8 @@ class AddFragment : DialogFragment() {
|
|||
|
||||
private fun resetLoginView() {
|
||||
loginProgress.visibility = View.GONE
|
||||
loginErrorImage.visibility = View.GONE
|
||||
loginErrorText.visibility = View.GONE
|
||||
loginErrorTextImage.visibility = View.GONE
|
||||
loginUsernameText.visibility = View.VISIBLE
|
||||
loginUsernameText.text?.clear()
|
||||
loginPasswordText.visibility = View.VISIBLE
|
||||
|
|
|
@ -281,8 +281,17 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
|
|||
api.publish(subscriptionBaseUrl, subscriptionTopic, user, message, title, priority, tags, delay = "")
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread {
|
||||
val message = if (e is ApiService.UnauthorizedException) {
|
||||
if (e.user != null) {
|
||||
getString(R.string.detail_test_message_error_unauthorized_user, e.user.username)
|
||||
} else {
|
||||
getString(R.string.detail_test_message_error_unauthorized_anon)
|
||||
}
|
||||
} else {
|
||||
getString(R.string.detail_test_message_error, e.message)
|
||||
}
|
||||
Toast
|
||||
.makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG)
|
||||
.makeText(this@DetailActivity, message, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,8 @@ import kotlinx.coroutines.launch
|
|||
|
||||
/**
|
||||
* Subscription settings
|
||||
*
|
||||
* THIS IS CURRENTLY UNUSED.
|
||||
*/
|
||||
class DetailSettingsActivity : AppCompatActivity() {
|
||||
private lateinit var repository: Repository
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlertDialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
@ -98,14 +100,9 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
pref: Preference
|
||||
): Boolean {
|
||||
// Instantiate the new Fragment
|
||||
val args = pref.extras
|
||||
val fragment = supportFragmentManager.fragmentFactory.instantiate(
|
||||
classLoader,
|
||||
pref.fragment!!
|
||||
).apply {
|
||||
arguments = args
|
||||
setTargetFragment(caller, 0)
|
||||
}
|
||||
val fragmentClass = pref.fragment ?: return false
|
||||
val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, fragmentClass)
|
||||
fragment.arguments = pref.extras
|
||||
|
||||
// Replace the existing Fragment with the new Fragment
|
||||
supportFragmentManager.beginTransaction()
|
||||
|
@ -118,7 +115,6 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
if (fragment is UserSettingsFragment) {
|
||||
userSettingsFragment = fragment
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -331,8 +327,10 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
exportLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
|
||||
exportLogs?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v ->
|
||||
when (v) {
|
||||
EXPORT_LOGS_COPY -> copyLogsToClipboard()
|
||||
EXPORT_LOGS_UPLOAD -> uploadLogsToNopaste()
|
||||
EXPORT_LOGS_COPY_ORIGINAL -> copyLogsToClipboard(scrub = false)
|
||||
EXPORT_LOGS_COPY_SCRUBBED -> copyLogsToClipboard(scrub = true)
|
||||
EXPORT_LOGS_UPLOAD_ORIGINAL -> uploadLogsToNopaste(scrub = false)
|
||||
EXPORT_LOGS_UPLOAD_SCRUBBED -> uploadLogsToNopaste(scrub = true)
|
||||
}
|
||||
false
|
||||
}
|
||||
|
@ -368,6 +366,15 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
getString(R.string.settings_advanced_record_logs_summary_disabled)
|
||||
}
|
||||
}
|
||||
recordLogsEnabled?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
repository.getSubscriptions().forEach { s ->
|
||||
Log.addScrubTerm(shortUrl(s.baseUrl), Log.TermType.Domain)
|
||||
Log.addScrubTerm(s.topic)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Connection protocol
|
||||
val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return
|
||||
|
@ -390,27 +397,6 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
}
|
||||
}
|
||||
|
||||
// Permanent wakelock enabled
|
||||
val wakelockEnabledPrefId = context?.getString(R.string.settings_advanced_wakelock_key) ?: return
|
||||
val wakelockEnabled: SwitchPreference? = findPreference(wakelockEnabledPrefId)
|
||||
wakelockEnabled?.isChecked = repository.getWakelockEnabled()
|
||||
wakelockEnabled?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putBoolean(key: String?, value: Boolean) {
|
||||
repository.setWakelockEnabled(value)
|
||||
restartService()
|
||||
}
|
||||
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
|
||||
return repository.getWakelockEnabled()
|
||||
}
|
||||
}
|
||||
wakelockEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
|
||||
if (pref.isChecked) {
|
||||
getString(R.string.settings_advanced_wakelock_summary_enabled)
|
||||
} else {
|
||||
getString(R.string.settings_advanced_wakelock_summary_disabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Version
|
||||
val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return
|
||||
val versionPref: Preference? = findPreference(versionPrefId)
|
||||
|
@ -441,25 +427,29 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
serviceManager.restart() // Service will auto-restart
|
||||
}
|
||||
|
||||
private fun copyLogsToClipboard() {
|
||||
private fun copyLogsToClipboard(scrub: Boolean) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val log = Log.getFormatted()
|
||||
val log = Log.getFormatted(scrub = scrub)
|
||||
val context = context ?: return@launch
|
||||
requireActivity().runOnUiThread {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("ntfy logs", log)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (scrub) {
|
||||
showScrubDialog(getString(R.string.settings_advanced_export_logs_copied_logs))
|
||||
} else {
|
||||
Toast
|
||||
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_logs), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadLogsToNopaste() {
|
||||
private fun uploadLogsToNopaste(scrub: Boolean) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Log.d(TAG, "Uploading log to $EXPORT_LOGS_UPLOAD_URL ...")
|
||||
val log = Log.getFormatted()
|
||||
val log = Log.getFormatted(scrub = scrub)
|
||||
if (log.length > EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD) {
|
||||
requireActivity().runOnUiThread {
|
||||
Toast
|
||||
|
@ -492,11 +482,15 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("logs URL", resp.url)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (scrub) {
|
||||
showScrubDialog(getString(R.string.settings_advanced_export_logs_copied_url))
|
||||
} else {
|
||||
Toast
|
||||
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_url), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error uploading logs", e)
|
||||
val context = context ?: return@launch
|
||||
|
@ -509,6 +503,22 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
}
|
||||
}
|
||||
|
||||
private fun showScrubDialog(title: String) {
|
||||
val scrubbed = Log.getScrubTerms()
|
||||
val scrubbedText = if (scrubbed.isNotEmpty()) {
|
||||
val scrubTerms = scrubbed.map { e -> "${e.key} -> ${e.value}"}.joinToString(separator = "\n")
|
||||
getString(R.string.settings_advanced_export_logs_scrub_dialog_text, scrubTerms)
|
||||
} else {
|
||||
getString(R.string.settings_advanced_export_logs_scrub_dialog_empty)
|
||||
}
|
||||
val dialog = AlertDialog.Builder(activity)
|
||||
.setTitle(title)
|
||||
.setMessage(scrubbedText)
|
||||
.setPositiveButton(R.string.settings_advanced_export_logs_scrub_dialog_button_ok) { _, _ -> /* Nothing */ }
|
||||
.create()
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun deleteLogs() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Log.deleteAll()
|
||||
|
@ -657,8 +667,10 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
private const val TITLE_TAG = "title"
|
||||
private const val REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD = 2586
|
||||
private const val AUTO_DOWNLOAD_SELECTION_NOT_SET = -99L
|
||||
private const val EXPORT_LOGS_COPY = "copy"
|
||||
private const val EXPORT_LOGS_UPLOAD = "upload"
|
||||
private const val EXPORT_LOGS_COPY_ORIGINAL = "copy_original"
|
||||
private const val EXPORT_LOGS_COPY_SCRUBBED = "copy_scrubbed"
|
||||
private const val EXPORT_LOGS_UPLOAD_ORIGINAL = "upload_original"
|
||||
private const val EXPORT_LOGS_UPLOAD_SCRUBBED = "upload_scrubbed"
|
||||
private const val EXPORT_LOGS_UPLOAD_URL = "https://nopaste.net/?f=json" // Run by binwiederhier; see https://github.com/binwiederhier/pcopy
|
||||
private const val EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD = 100 * 1024 // Show "Uploading ..." if log larger than X
|
||||
}
|
||||
|
|
|
@ -118,6 +118,16 @@ class UserFragment : DialogFragment() {
|
|||
usernameView.addTextChangedListener(textWatcher)
|
||||
passwordView.addTextChangedListener(textWatcher)
|
||||
|
||||
// Focus
|
||||
if (user != null) {
|
||||
usernameView.requestFocus()
|
||||
if (usernameView.text != null) {
|
||||
usernameView.setSelection(usernameView.text!!.length)
|
||||
}
|
||||
} else {
|
||||
baseUrlView.requestFocus()
|
||||
}
|
||||
|
||||
// Validate now!
|
||||
validateInput()
|
||||
}
|
||||
|
|
|
@ -12,9 +12,9 @@
|
|||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:id="@+id/add_dialog_subscribe_view"
|
||||
android:visibility="gone">
|
||||
android:visibility="visible">
|
||||
<TextView
|
||||
android:id="@+id/add_dialog_title_text"
|
||||
android:id="@+id/add_dialog_subscribe_title_text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="16dp"
|
||||
|
@ -24,52 +24,51 @@
|
|||
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/add_dialog_error_image"/>
|
||||
/>
|
||||
|
||||
<ProgressBar
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:id="@+id/add_dialog_progress"
|
||||
android:id="@+id/add_dialog_subscribe_progress"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/add_dialog_error_image"
|
||||
app:layout_constraintBottom_toTopOf="@+id/add_dialog_description_below"
|
||||
app:layout_constraintBottom_toTopOf="@+id/add_dialog_subscribe_description"
|
||||
android:indeterminate="true" android:layout_marginBottom="5dp" android:visibility="gone"/>
|
||||
<TextView
|
||||
android:text="@string/add_dialog_description_below"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_description_below"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_description"
|
||||
android:paddingStart="4dp" android:paddingTop="3dp" app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_title_text"/>
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_title_text"/>
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/add_dialog_topic_text"
|
||||
android:id="@+id/add_dialog_subscribe_topic_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:hint="@string/add_dialog_topic_name_hint"
|
||||
android:importantForAutofill="no"
|
||||
android:maxLines="1" android:inputType="text" android:maxLength="64"
|
||||
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_description_below"/>
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_description"/>
|
||||
<CheckBox
|
||||
android:text="@string/add_dialog_use_another_server"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_use_another_server_checkbox"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_use_another_server_checkbox"
|
||||
android:layout_marginStart="-3dp" app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_topic_text"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_topic_text"
|
||||
android:layout_marginTop="-3dp"/>
|
||||
<TextView
|
||||
android:text="@string/add_dialog_use_another_server_description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_use_another_server_description"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_use_another_server_description"
|
||||
android:paddingStart="4dp" android:paddingTop="0dp"
|
||||
android:visibility="gone" app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_use_another_server_checkbox"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_use_another_server_checkbox"
|
||||
android:layout_marginTop="-5dp"/>
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense.ExposedDropdownMenu"
|
||||
android:id="@+id/add_dialog_base_url_layout"
|
||||
android:id="@+id/add_dialog_subscribe_base_url_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:layout_margin="0dp"
|
||||
|
@ -80,11 +79,11 @@
|
|||
app:hintEnabled="false"
|
||||
app:boxBackgroundColor="@android:color/transparent" app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_use_another_server_description">
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_use_another_server_description">
|
||||
<AutoCompleteTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/add_dialog_base_url_text"
|
||||
android:id="@+id/add_dialog_subscribe_base_url_text"
|
||||
android:hint="@string/app_base_url"
|
||||
android:maxLines="1"
|
||||
android:layout_marginTop="0dp"
|
||||
|
@ -102,19 +101,19 @@
|
|||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_instant_delivery_box"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_instant_delivery_box"
|
||||
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_base_url_layout" android:layout_marginTop="-3dp">
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_base_url_layout" android:layout_marginTop="-3dp">
|
||||
<CheckBox
|
||||
android:text="@string/add_dialog_instant_delivery"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_instant_delivery_checkbox"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_instant_delivery_checkbox"
|
||||
android:layout_marginTop="-8dp" android:layout_marginBottom="-5dp"
|
||||
android:layout_marginStart="-3dp"/>
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp" app:srcCompat="@drawable/ic_bolt_gray_24dp"
|
||||
android:id="@+id/add_dialog_instant_image"
|
||||
android:id="@+id/add_dialog_subscribe_instant_image"
|
||||
app:layout_constraintTop_toTopOf="@+id/main_item_text"
|
||||
app:layout_constraintEnd_toStartOf="@+id/main_item_date" android:paddingTop="3dp"
|
||||
android:layout_marginTop="3dp"/>
|
||||
|
@ -122,25 +121,31 @@
|
|||
<TextView
|
||||
android:text="@string/add_dialog_instant_delivery_description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_instant_delivery_description"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_instant_delivery_description"
|
||||
android:paddingStart="4dp" android:paddingTop="0dp"
|
||||
android:visibility="gone" app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_instant_delivery_box"/>
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_instant_delivery_box"/>
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp" app:srcCompat="@drawable/ic_error_black_24dp"
|
||||
android:id="@+id/add_dialog_error_image"
|
||||
app:layout_constraintBottom_toTopOf="@+id/add_dialog_description_below"
|
||||
android:layout_marginBottom="5dp" app:layout_constraintEnd_toStartOf="@+id/add_dialog_progress"
|
||||
app:layout_constraintStart_toEndOf="@+id/add_dialog_title_text" android:visibility="gone"/>
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp" app:srcCompat="@drawable/ic_error_red_24dp"
|
||||
android:id="@+id/add_dialog_subscribe_error_text_image"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/add_dialog_subscribe_error_text" android:layout_marginTop="1dp"/>
|
||||
<TextView
|
||||
android:text="Unable to resolve host example.com"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_error_text"
|
||||
android:paddingStart="4dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_instant_delivery_description" android:paddingEnd="4dp" android:textColor="@color/primaryDangerButtonColor" app:layout_constraintStart_toEndOf="@id/add_dialog_subscribe_error_text_image" android:layout_marginTop="5dp" tools:visibility="gone"/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:id="@+id/add_dialog_login_view"
|
||||
android:visibility="visible"
|
||||
android:visibility="gone"
|
||||
>
|
||||
<TextView
|
||||
android:id="@+id/add_dialog_login_title"
|
||||
|
@ -153,7 +158,7 @@
|
|||
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/add_dialog_login_error_image"/>
|
||||
/>
|
||||
<TextView
|
||||
android:text="@string/add_dialog_login_description"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -177,22 +182,26 @@
|
|||
android:maxLines="1" android:inputType="textPassword" app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_username"/>
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp" app:srcCompat="@drawable/ic_error_red_24dp"
|
||||
android:id="@+id/add_dialog_login_error_text_image"
|
||||
android:visibility="visible"
|
||||
app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toBottomOf="@+id/add_dialog_login_error_text" app:layout_constraintTop_toTopOf="@+id/add_dialog_login_error_text"/>
|
||||
<TextView
|
||||
android:text="Login failed. User not authorized."
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content" android:id="@+id/add_dialog_login_error_text"
|
||||
android:paddingStart="4dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_password" android:paddingEnd="4dp" android:textColor="@color/primaryDangerButtonColor" app:layout_constraintStart_toEndOf="@id/add_dialog_login_error_text_image"/>
|
||||
<ProgressBar
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp"
|
||||
android:id="@+id/add_dialog_login_progress"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/add_dialog_login_error_image"
|
||||
app:layout_constraintBottom_toTopOf="@+id/add_dialog_login_description"
|
||||
android:indeterminate="true" android:layout_marginBottom="5dp"/>
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp" app:srcCompat="@drawable/ic_error_black_24dp"
|
||||
android:id="@+id/add_dialog_login_error_image"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@+id/add_dialog_login_description"
|
||||
app:layout_constraintEnd_toStartOf="@id/add_dialog_login_progress" android:layout_marginBottom="5dp"
|
||||
app:layout_constraintStart_toEndOf="@+id/add_dialog_login_title"/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</LinearLayout>
|
||||
|
|
|
@ -91,7 +91,7 @@
|
|||
<string name="add_dialog_instant_delivery">Instant delivery in doze mode</string>
|
||||
<string name="add_dialog_instant_delivery_description">
|
||||
Ensures that messages are immediately delivered, even if the device is inactive or in doze mode.
|
||||
This requires a foreground service and consumes a little more power.
|
||||
This requires a foreground service.
|
||||
</string>
|
||||
<string name="add_dialog_button_cancel">Cancel</string>
|
||||
<string name="add_dialog_button_subscribe">Subscribe</string>
|
||||
|
@ -127,7 +127,9 @@
|
|||
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It has a priority of %1$d.
|
||||
If you send another one, it may look different.
|
||||
</string>
|
||||
<string name="detail_test_message_error">Could not send test message: %1$s</string>
|
||||
<string name="detail_test_message_error">Cannot send message: %1$s</string>
|
||||
<string name="detail_test_message_error_unauthorized_anon">Cannot send message: Anonymous publishing not allowed</string>
|
||||
<string name="detail_test_message_error_unauthorized_user">Cannot send message: User %1$s not authorized</string>
|
||||
<string name="detail_copied_to_clipboard_message">Copied to clipboard</string>
|
||||
<string name="detail_instant_delivery_enabled">Instant delivery enabled</string>
|
||||
<string name="detail_instant_delivery_disabled">Instant delivery disabled</string>
|
||||
|
@ -178,13 +180,7 @@
|
|||
|
||||
<!-- Detail settings -->
|
||||
<string name="detail_settings_title">Subscription settings</string>
|
||||
<string name="detail_settings_auth_header">Login</string>
|
||||
<string name="detail_settings_auth_header_summary">For topics that require a login, you may pick the login user here. You can add/edit users in the main settings.</string>
|
||||
<string name="detail_settings_auth_user_key">SubscriptionAuthUserKey</string>
|
||||
<string name="detail_settings_auth_user_title">Login user</string>
|
||||
<string name="detail_settings_auth_user_entry_anon">Anonymous login</string>
|
||||
<string name="detail_settings_auth_user_summary_none">No user selected to log in to the topic</string>
|
||||
<string name="detail_settings_auth_user_summary_user_x">User %1$s selected as a login user</string>
|
||||
<!-- ... -->
|
||||
|
||||
<!-- Notification dialog -->
|
||||
<string name="notification_dialog_title">Pause notifications</string>
|
||||
|
@ -278,17 +274,22 @@
|
|||
<string name="settings_advanced_broadcast_summary_disabled">Apps cannot receive notifications as broadcasts</string>
|
||||
<string name="settings_advanced_record_logs_key">RecordLogs</string>
|
||||
<string name="settings_advanced_record_logs_title">Record logs</string>
|
||||
<string name="settings_advanced_record_logs_summary_enabled">Logs are currently being recorded to your device. Up to 2,000 log lines are stored.</string>
|
||||
<string name="settings_advanced_record_logs_summary_enabled">Logs are currently being recorded to your device. Up to 1,000 log entries are stored.</string>
|
||||
<string name="settings_advanced_record_logs_summary_disabled">Enable log recording, so you can share the logs later. This is useful for diagnosing issues.</string>
|
||||
<string name="settings_advanced_export_logs_key">ExportLogs</string>
|
||||
<string name="settings_advanced_export_logs_title">Copy/upload logs</string>
|
||||
<string name="settings_advanced_export_logs_summary">Copy logs to the clipboard, or upload to nopaste.net (owned by ntfy author). Hostnames and topics are scrubbed, notifications are not.</string>
|
||||
<string name="settings_advanced_export_logs_entry_copy">Copy to clipboard</string>
|
||||
<string name="settings_advanced_export_logs_entry_upload">Upload to nopaste.net</string>
|
||||
<string name="settings_advanced_export_logs_summary">Copy logs to the clipboard, or upload to nopaste.net (owned by ntfy author). Hostnames and topics can be censored, notifications will never be.</string>
|
||||
<string name="settings_advanced_export_logs_entry_copy_original">Copy to clipboard</string>
|
||||
<string name="settings_advanced_export_logs_entry_copy_scrubbed">Copy to clipboard (censored)</string>
|
||||
<string name="settings_advanced_export_logs_entry_upload_original">Upload & copy link</string>
|
||||
<string name="settings_advanced_export_logs_entry_upload_scrubbed">Upload & copy link (censored)</string>
|
||||
<string name="settings_advanced_export_logs_copied_logs">Logs copied to clipboard</string>
|
||||
<string name="settings_advanced_export_logs_uploading">Uploading log …</string>
|
||||
<string name="settings_advanced_export_logs_copied_url">URL copied to clipboard</string>
|
||||
<string name="settings_advanced_export_logs_copied_url">Logs uploaded & URL copied</string>
|
||||
<string name="settings_advanced_export_logs_error_uploading">Error uploading logs: %1$s</string>
|
||||
<string name="settings_advanced_export_logs_scrub_dialog_text">The following topics/hostnames were replaced with fruit names, so you can share the log without worry:\n\n%1$s</string>
|
||||
<string name="settings_advanced_export_logs_scrub_dialog_empty">No topics/hostnames were redacted, maybe you don\'t have any subscriptions?</string>
|
||||
<string name="settings_advanced_export_logs_scrub_dialog_button_ok">Got it</string>
|
||||
<string name="settings_advanced_clear_logs_key">ClearLogs</string>
|
||||
<string name="settings_advanced_clear_logs_title">Clear logs</string>
|
||||
<string name="settings_advanced_clear_logs_summary">Delete previously recorded logs, and start over</string>
|
||||
|
@ -300,10 +301,6 @@
|
|||
<string name="settings_advanced_connection_protocol_summary_ws">Use WebSockets to connect to the server. This option is experimental. Let us know if it consumes less battery or is unstable.</string>
|
||||
<string name="settings_advanced_connection_protocol_entry_jsonhttp">JSON stream over HTTP</string>
|
||||
<string name="settings_advanced_connection_protocol_entry_ws">WebSockets (experimental)</string>
|
||||
<string name="settings_advanced_wakelock_key">WakelockEnabled</string>
|
||||
<string name="settings_advanced_wakelock_title">Permanent wakelock</string>
|
||||
<string name="settings_advanced_wakelock_summary_enabled">Prevents app from sleeping to ensure timely notification delivery. This consumes a lot of battery, but some devices require this.</string>
|
||||
<string name="settings_advanced_wakelock_summary_disabled">Allows app to enter sleep mode. This may negatively impact notification delivery. It depends on the device.</string>
|
||||
<string name="settings_about_header">About</string>
|
||||
<string name="settings_about_version_key">Version</string>
|
||||
<string name="settings_about_version_title">Version</string>
|
||||
|
|
|
@ -62,12 +62,16 @@
|
|||
<item>ws</item>
|
||||
</string-array>
|
||||
<string-array name="settings_advanced_export_logs_entries">
|
||||
<item>@string/settings_advanced_export_logs_entry_copy</item>
|
||||
<item>@string/settings_advanced_export_logs_entry_upload</item>
|
||||
<item>@string/settings_advanced_export_logs_entry_copy_original</item>
|
||||
<item>@string/settings_advanced_export_logs_entry_copy_scrubbed</item>
|
||||
<item>@string/settings_advanced_export_logs_entry_upload_original</item>
|
||||
<item>@string/settings_advanced_export_logs_entry_upload_scrubbed</item>
|
||||
</string-array>
|
||||
<string-array name="settings_advanced_export_logs_values">
|
||||
<item>copy</item>
|
||||
<item>upload</item>
|
||||
<item>copy_original</item>
|
||||
<item>copy_scrubbed</item>
|
||||
<item>upload_original</item>
|
||||
<item>upload_scrubbed</item>
|
||||
</string-array>
|
||||
<string-array name="settings_appearance_dark_mode_entries">
|
||||
<item>@string/settings_appearance_dark_mode_entry_system</item>
|
||||
|
|
|
@ -1,12 +1,3 @@
|
|||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
app:title="@string/detail_settings_title">
|
||||
<PreferenceCategory
|
||||
app:title="@string/detail_settings_auth_header"
|
||||
app:summary="@string/detail_settings_auth_header_summary"
|
||||
app:layout="@layout/preference_category_material_edited">
|
||||
<ListPreference
|
||||
app:key="@string/detail_settings_auth_user_key"
|
||||
app:title="@string/detail_settings_auth_user_title"
|
||||
app:summary="@string/detail_settings_auth_user_summary_none"/>
|
||||
</PreferenceCategory>
|
||||
</PreferenceScreen>
|
||||
|
|
|
@ -74,10 +74,6 @@
|
|||
app:key="@string/settings_advanced_clear_logs_key"
|
||||
app:title="@string/settings_advanced_clear_logs_title"
|
||||
app:summary="@string/settings_advanced_clear_logs_summary"/>
|
||||
<SwitchPreference
|
||||
app:key="@string/settings_advanced_wakelock_key"
|
||||
app:title="@string/settings_advanced_wakelock_title"
|
||||
app:enabled="true"/>
|
||||
</PreferenceCategory>
|
||||
<PreferenceCategory app:title="@string/settings_experimental_header">
|
||||
<ListPreference
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
Before Width: | Height: | Size: 266 B |
|
@ -1,3 +1,14 @@
|
|||
Features:
|
||||
* Support auth / access control (#19, thanks to @cmeis, @gedw99, @karmanyaahm,
|
||||
@Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher)
|
||||
* Export/upload log now allows censored/uncensored logs (no ticket)
|
||||
* Removed wake lock (except for notification dispatching, no ticket)
|
||||
|
||||
Bug fixes:
|
||||
* Fix download issues on SDK 29 "Movement not allowed" (#116, thanks Jakob)
|
||||
* Fix for Android 12 crashes (#124, thanks @eskilop)
|
||||
* Fix WebSocket retry logic bug with multiple servers (no ticket)
|
||||
* Fix race in refresh logic leading to duplicate connections (no ticket)
|
||||
|
||||
Notes:
|
||||
* Foundational work for per-subscription settings
|
||||
|
|
Loading…
Reference in a new issue