All the things; this should be it

This commit is contained in:
Philipp Heckel 2022-02-04 19:52:34 -05:00
parent c772d15043
commit 3abd0b3da9
23 changed files with 677 additions and 335 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 7, "version": 7,
"identityHash": "eda2cb9740c4542f24462779eb6ff81d", "identityHash": "ecb1b85b2ae822dc62b2843620368477",
"entities": [ "entities": [
{ {
"tableName": "Subscription", "tableName": "Subscription",
@ -65,7 +65,6 @@
"baseUrl", "baseUrl",
"topic" "topic"
], ],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)" "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
}, },
{ {
@ -74,7 +73,6 @@
"columnNames": [ "columnNames": [
"upConnectorToken" "upConnectorToken"
], ],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)" "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
} }
], ],
@ -198,38 +196,6 @@
"indices": [], "indices": [],
"foreignKeys": [] "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", "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)", "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": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eda2cb9740c4542f24462779eb6ff81d')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ecb1b85b2ae822dc62b2843620368477')"
] ]
} }
} }

View 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')"
]
}
}

View file

@ -100,7 +100,7 @@ data class LogEntry(
this(0, timestamp, tag, level, message, exception) 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 class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao abstract fun notificationDao(): NotificationDao
@ -121,6 +121,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_4_5) .addMigrations(MIGRATION_4_5)
.addMigrations(MIGRATION_5_6) .addMigrations(MIGRATION_5_6)
.addMigrations(MIGRATION_6_7) .addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
this.instance = instance 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)") 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))")
}
}
} }
} }
@ -243,7 +250,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,s.upAppId, s.upConnectorToken, s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount, COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive

View file

@ -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) 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) { fun setConnectionProtocol(connectionProtocol: String) {
if (connectionProtocol == CONNECTION_PROTOCOL_JSONHTTP) { if (connectionProtocol == CONNECTION_PROTOCOL_JSONHTTP) {
sharedPrefs.edit() sharedPrefs.edit()

View file

@ -6,8 +6,6 @@ import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Database import io.heckel.ntfy.db.Database
import io.heckel.ntfy.db.LogDao import io.heckel.ntfy.db.LogDao
import io.heckel.ntfy.db.LogEntry 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.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -34,15 +32,19 @@ class Log(private val logsDao: LogDao) {
} }
} }
fun getFormatted(): String { fun getFormatted(scrub: Boolean): String {
return prependDeviceInfo(formatEntries(scrubEntries(logsDao.getAll()))) 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 """ return """
This is a log of the ntfy Android app. The log shows up to 2,000 lines. This is a log of the ntfy Android app. The log shows up to 1,000 entries.
Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑. $maybeScrubLine
Device info: Device info:
-- --
ntfy: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}) ntfy: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR})
@ -116,7 +118,7 @@ class Log(private val logsDao: LogDao) {
companion object { companion object {
private const val TAG = "NtfyLog" private const val TAG = "NtfyLog"
private const val PRUNE_EVERY = 100 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 IGNORE_TERMS = listOf("ntfy.sh")
private val REPLACE_TERMS = listOf( private val REPLACE_TERMS = listOf(
"banana", "kiwi", "lemon", "coconut", "avocado", "orange", "apple", "peach" "banana", "kiwi", "lemon", "coconut", "avocado", "orange", "apple", "peach"
@ -153,8 +155,12 @@ class Log(private val logsDao: LogDao) {
return getInstance()?.record?.get() ?: false return getInstance()?.record?.get() ?: false
} }
fun getFormatted(): String { fun getFormatted(scrub: Boolean): String {
return getInstance()?.getFormatted() ?: "(no logs)" return getInstance()?.getFormatted(scrub) ?: "(no logs)"
}
fun getScrubTerms(): Map<String, String> {
return getInstance()?.scrubTerms!!.toMap()
} }
fun deleteAll() { fun deleteAll() {

View file

@ -47,6 +47,9 @@ class ApiService {
builder.addHeader("X-Delay", delay) builder.addHeader("X-Delay", delay)
} }
client.newCall(builder.build()).execute().use { response -> client.newCall(builder.build()).execute().use { response ->
if (response.code == 401 || response.code == 403) {
throw UnauthorizedException(user)
}
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw Exception("Unexpected response ${response.code} when publishing to $url") throw Exception("Unexpected response ${response.code} when publishing to $url")
} }
@ -132,6 +135,8 @@ class ApiService {
} }
} }
class UnauthorizedException(val user: User?) : Exception()
companion object { companion object {
val USER_AGENT = "ntfy/${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}; Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT})" val USER_AGENT = "ntfy/${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}; Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT})"
private const val TAG = "NtfyApiService" private const val TAG = "NtfyApiService"

View file

@ -68,16 +68,21 @@ class BroadcastService(private val ctx: Context) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
val repository = Repository.getInstance(ctx) val repository = Repository.getInstance(ctx)
val user = repository.getUser(baseUrl) // May be null val user = repository.getUser(baseUrl) // May be null
api.publish( try {
baseUrl = baseUrl, Log.d(TAG, "Publishing message $intent")
topic = topic, api.publish(
user = user, baseUrl = baseUrl,
message = message, topic = topic,
title = title, user = user,
priority = priority, message = message,
tags = splitTags(tags), title = title,
delay = delay priority = priority,
) tags = splitTags(tags),
delay = delay
)
} catch (e: Exception) {
Log.w(TAG, "Unable to publish message: ${e.message}", e)
}
} }
} }

View file

@ -21,9 +21,11 @@ import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
@ -63,16 +65,16 @@ class SubscriberService : Service() {
private val api = ApiService() private val api = ApiService()
private var notificationManager: NotificationManager? = null private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = 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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand executed with startId: $startId") Log.d(TAG, "onStartCommand executed with startId: $startId")
if (intent != null) { if (intent != null) {
val action = intent.action Log.d(TAG, "using an intent with action ${intent.action}")
Log.d(TAG, "using an intent with action $action") when (intent.action) {
when (action) {
Action.START.name -> startService() Action.START.name -> startService()
Action.STOP.name -> stopService() 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 { } else {
Log.d(TAG, "with a null intent. It has been probably restarted by the system.") 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 { wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG) newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)
} }
if (repository.getWakelockEnabled()) {
wakeLock?.acquire()
}
refreshConnections() refreshConnections()
} }
@ -148,95 +147,115 @@ class SubscriberService : Service() {
saveServiceState(this, ServiceState.STOPPED) saveServiceState(this, ServiceState.STOPPED)
} }
private fun refreshConnections() = private fun refreshConnections() {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
// Group INSTANT subscriptions by base URL, there is only one connection per base URL if (!refreshMutex.tryLock()) {
val instantSubscriptions = repository.getSubscriptions() Log.d(TAG, "Refreshing subscriptions already in progress. Skipping.")
.filter { s -> s.instant }
val activeConnectionIds = connections.keys().toList().toSet()
val desiredConnectionIds = instantSubscriptions // Set<ConnectionId>
.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 match = activeConnectionIds == desiredConnectionIds
Log.d(TAG, "Refreshing subscriptions")
Log.d(TAG, "- Desired connections: $desiredConnectionIds")
Log.d(TAG, "- Active connections: $activeConnectionIds")
Log.d(TAG, "- New connections: $newConnectionIds")
Log.d(TAG, "- Obsolete connections: $obsoleteConnectionIds")
Log.d(TAG, "- Match? --> $match")
if (match) {
Log.d(TAG, "- No action required.")
return@launch return@launch
} }
try {
// Open new connections reallyRefreshConnections(this)
newConnectionIds.forEach { connectionId -> } finally {
// FIXME since !!! refreshMutex.unlock()
// 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 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)
}
connections[connectionId] = connection
connection.start()
}
// Close connections without subscriptions
obsoleteConnectionIds.forEach { connectionId ->
val connection = connections.remove(connectionId)
connection?.close()
}
// 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) {
1 -> getString(R.string.channel_subscriber_notification_instant_text_one)
2 -> getString(R.string.channel_subscriber_notification_instant_text_two)
3 -> getString(R.string.channel_subscriber_notification_instant_text_three)
4 -> getString(R.string.channel_subscriber_notification_instant_text_four)
else -> getString(R.string.channel_subscriber_notification_instant_text_more, instantSubscriptions.size)
}
} else {
when (instantSubscriptions.size) {
1 -> getString(R.string.channel_subscriber_notification_noinstant_text_one)
2 -> getString(R.string.channel_subscriber_notification_noinstant_text_two)
3 -> getString(R.string.channel_subscriber_notification_noinstant_text_three)
4 -> getString(R.string.channel_subscriber_notification_noinstant_text_four)
else -> getString(R.string.channel_subscriber_notification_noinstant_text_more, instantSubscriptions.size)
}
}
serviceNotification = createNotification(title, text)
notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification)
}
} }
} }
}
/**
* 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 }
val activeConnectionIds = connections.keys().toList().toSet()
val desiredConnectionIds = instantSubscriptions // Set<ConnectionId>
.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 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")
Log.d(TAG, "- Active connections: $activeConnectionIds")
Log.d(TAG, "- New connections: $newConnectionIds")
Log.d(TAG, "- Obsolete connections: $obsoleteConnectionIds")
Log.d(TAG, "- Match? --> $match")
if (match) {
Log.d(TAG, "- No action required.")
return
}
// Open new connections
newConnectionIds.forEach { connectionId ->
// 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.
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, scope, repository, api, user, since, ::onStateChanged, ::onNotificationReceived, serviceActive)
}
connections[connectionId] = connection
connection.start()
}
// Close connections without subscriptions
obsoleteConnectionIds.forEach { connectionId ->
val connection = connections.remove(connectionId)
connection?.close()
}
// Update foreground service notification popup
if (connections.size > 0) {
val title = getString(R.string.channel_subscriber_notification_title)
val text = if (BuildConfig.FIREBASE_AVAILABLE) {
when (instantSubscriptions.size) {
1 -> getString(R.string.channel_subscriber_notification_instant_text_one)
2 -> getString(R.string.channel_subscriber_notification_instant_text_two)
3 -> getString(R.string.channel_subscriber_notification_instant_text_three)
4 -> getString(R.string.channel_subscriber_notification_instant_text_four)
else -> getString(R.string.channel_subscriber_notification_instant_text_more, instantSubscriptions.size)
}
} else {
when (instantSubscriptions.size) {
1 -> getString(R.string.channel_subscriber_notification_noinstant_text_one)
2 -> getString(R.string.channel_subscriber_notification_noinstant_text_two)
3 -> getString(R.string.channel_subscriber_notification_noinstant_text_three)
4 -> getString(R.string.channel_subscriber_notification_noinstant_text_four)
else -> getString(R.string.channel_subscriber_notification_noinstant_text_more, instantSubscriptions.size)
}
}
serviceNotification = createNotification(title, text)
notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification)
}
}
private fun onStateChanged(subscriptionIds: Collection<Long>, state: ConnectionState) { private fun onStateChanged(subscriptionIds: Collection<Long>, state: ConnectionState) {
repository.updateState(subscriptionIds, state) repository.updateState(subscriptionIds, state)
} }
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) { 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 // Wakelock while notifications are being dispatched
if (!repository.getWakelockEnabled()) { // Wakelocks are reference counted by default so that should work neatly here
// Wakelocks are reference counted by default so that should work neatly here wakeLock?.acquire(NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS)
wakeLock?.acquire(10*60*1000L /*10 minutes*/)
}
val url = topicUrl(subscription.baseUrl, subscription.topic) val url = topicUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "[$url] Received notification: $notification") Log.d(TAG, "[$url] Received notification: $notification")
@ -245,12 +264,9 @@ class SubscriberService : Service() {
Log.d(TAG, "[$url] Dispatching notification $notification") Log.d(TAG, "[$url] Dispatching notification $notification")
dispatcher.dispatch(subscription, notification) dispatcher.dispatch(subscription, notification)
} }
wakeLock?.let {
if (!repository.getWakelockEnabled()) { if (it.isHeld) {
wakeLock?.let { it.release()
if (it.isHeld) {
it.release()
}
} }
} }
} }
@ -337,6 +353,7 @@ class SubscriberService : Service() {
private const val WAKE_LOCK_TAG = "SubscriberService:lock" private const val WAKE_LOCK_TAG = "SubscriberService:lock"
private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber" private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber"
private const val NOTIFICATION_SERVICE_ID = 2586 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_ID = "SubscriberService"
private const val SHARED_PREFS_SERVICE_STATE = "ServiceState" private const val SHARED_PREFS_SERVICE_STATE = "ServiceState"

View file

@ -21,7 +21,7 @@ class SubscriberServiceManager(private val context: Context) {
Log.d(TAG, "Enqueuing work to refresh subscriber service") Log.d(TAG, "Enqueuing work to refresh subscriber service")
val workManager = WorkManager.getInstance(context) val workManager = WorkManager.getInstance(context)
val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build() val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
workManager.enqueue(startServiceRequest) workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
} }
fun restart() { fun restart() {
@ -59,6 +59,7 @@ class SubscriberServiceManager(private val context: Context) {
companion object { companion object {
const val TAG = "NtfySubscriberMgr" const val TAG = "NtfySubscriberMgr"
const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
fun refresh(context: Context) { fun refresh(context: Context) {
val manager = SubscriberServiceManager(context) val manager = SubscriberServiceManager(context)

View file

@ -43,43 +43,49 @@ class WsConnection(
.build() .build()
private var errorCount = 0 private var errorCount = 0
private var webSocket: WebSocket? = null private var webSocket: WebSocket? = null
private val listenerId = AtomicLong(0)
private var state: State? = null private var state: State? = null
private var closed = false 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 baseUrl = connectionId.baseUrl
private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds
private val subscriptionIds = topicsToSubscriptionIds.values 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)
init {
Log.d(TAG, "$shortUrl (gid=$globalId): New connection with global ID $globalId")
}
@Synchronized @Synchronized
override fun start() { override fun start() {
if (closed || state == State.Connecting || state == State.Connected) { 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 return
} }
if (webSocket != null) { if (webSocket != null) {
webSocket!!.close(WS_CLOSE_NORMAL, "") webSocket!!.close(WS_CLOSE_NORMAL, "")
} }
state = State.Connecting state = State.Connecting
val nextId = listenerId.incrementAndGet() val nextListenerId = listenerId.incrementAndGet()
val sinceVal = if (since == 0L) "all" else since.toString() val sinceVal = if (since.get() == 0L) "all" else since.get().toString()
val urlWithSince = topicUrlWs(baseUrl, topicsStr, sinceVal) val urlWithSince = topicUrlWs(baseUrl, topicsStr, sinceVal)
val request = requestBuilder(urlWithSince, user).build() val request = requestBuilder(urlWithSince, user).build()
Log.d(TAG, "$shortUrl: Opening $urlWithSince with listener ID $nextId ...") Log.d(TAG, "$shortUrl (gid=$globalId): Opening $urlWithSince with listener ID $nextListenerId ...")
webSocket = client.newWebSocket(request, Listener(nextId)) webSocket = client.newWebSocket(request, Listener(nextListenerId))
} }
@Synchronized @Synchronized
override fun close() { override fun close() {
closed = true closed = true
if (webSocket == null) { 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 return
} }
Log.d(TAG, "$shortUrl: Closing existing connection") Log.d(TAG, "$shortUrl (gid=$globalId): Closing connection")
state = State.Disconnected state = State.Disconnected
webSocket!!.close(WS_CLOSE_NORMAL, "") webSocket!!.close(WS_CLOSE_NORMAL, "")
webSocket = null webSocket = null
@ -87,23 +93,23 @@ class WsConnection(
@Synchronized @Synchronized
override fun since(): Long { override fun since(): Long {
return since return since.get()
} }
@Synchronized @Synchronized
fun scheduleReconnect(seconds: Int) { fun scheduleReconnect(seconds: Int) {
if (closed || state == State.Connecting || state == State.Connected) { 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 return
} }
state = State.Scheduled state = State.Scheduled
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 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() val reconnectTime = Calendar.getInstance()
reconnectTime.add(Calendar.SECOND, seconds) reconnectTime.add(Calendar.SECOND, seconds)
alarmManager.setExact(AlarmManager.RTC_WAKEUP, reconnectTime.timeInMillis, RECONNECT_TAG, { start() }, null) alarmManager.setExact(AlarmManager.RTC_WAKEUP, reconnectTime.timeInMillis, RECONNECT_TAG, { start() }, null)
} else { } 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()) val handler = Handler(Looper.getMainLooper())
handler.postDelayed({ start() }, TimeUnit.SECONDS.toMillis(seconds.toLong())) handler.postDelayed({ start() }, TimeUnit.SECONDS.toMillis(seconds.toLong()))
} }
@ -112,7 +118,7 @@ class WsConnection(
private inner class Listener(private val id: Long) : WebSocketListener() { private inner class Listener(private val id: Long) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
synchronize("onOpen") { synchronize("onOpen") {
Log.d(TAG, "$shortUrl (listener $id): Opened connection") Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Opened connection")
state = State.Connected state = State.Connected
if (errorCount > 0) { if (errorCount > 0) {
errorCount = 0 errorCount = 0
@ -123,10 +129,10 @@ class WsConnection(
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
synchronize("onMessage") { 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()) val notificationWithTopic = parser.parseWithTopic(text, subscriptionId = 0, notificationId = Random.nextInt())
if (notificationWithTopic == null) { 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 return@synchronize
} }
val topic = notificationWithTopic.topic val topic = notificationWithTopic.topic
@ -135,13 +141,13 @@ class WsConnection(
val subscription = repository.getSubscription(subscriptionId) ?: return@synchronize val subscription = repository.getSubscription(subscriptionId) ?: return@synchronize
val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id) val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
notificationListener(subscription, notificationWithSubscriptionId) notificationListener(subscription, notificationWithSubscriptionId)
since = notification.timestamp since.set(notification.timestamp)
} }
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
synchronize("onClosed") { synchronize("onClosed") {
Log.w(TAG, "$shortUrl (listener $id): Closed connection") Log.w(TAG, "$shortUrl (gid=$globalId, lid=$id): Closed connection")
state = State.Disconnected state = State.Disconnected
} }
} }
@ -149,12 +155,12 @@ class WsConnection(
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
synchronize("onFailure") { synchronize("onFailure") {
if (response == null) { 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 { } 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) { 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 return@synchronize
} }
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING) stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
@ -170,7 +176,7 @@ class WsConnection(
if (listenerId.get() == id) { if (listenerId.get() == id) {
fn() fn()
} else { } 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 RECONNECT_TAG = "WsReconnect"
private const val WS_CLOSE_NORMAL = 1000 private const val WS_CLOSE_NORMAL = 1000
private val RETRY_SECONDS = listOf(5, 10, 15, 20, 30, 45, 60, 120) private val RETRY_SECONDS = listOf(5, 10, 15, 20, 30, 45, 60, 120)
private val GLOBAL_ID = AtomicLong(0)
} }
} }

View file

@ -46,14 +46,16 @@ class AddFragment : DialogFragment() {
private lateinit var subscribeInstantDeliveryCheckbox: CheckBox private lateinit var subscribeInstantDeliveryCheckbox: CheckBox
private lateinit var subscribeInstantDeliveryDescription: View private lateinit var subscribeInstantDeliveryDescription: View
private lateinit var subscribeProgress: ProgressBar private lateinit var subscribeProgress: ProgressBar
private lateinit var subscribeErrorImage: View private lateinit var subscribeErrorText: TextView
private lateinit var subscribeErrorTextImage: View
// Login page // Login page
private lateinit var users: List<User> private lateinit var users: List<User>
private lateinit var loginUsernameText: TextInputEditText private lateinit var loginUsernameText: TextInputEditText
private lateinit var loginPasswordText: TextInputEditText private lateinit var loginPasswordText: TextInputEditText
private lateinit var loginProgress: ProgressBar 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 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 loginView.visibility = View.GONE
// Fields for "subscribe page" // Fields for "subscribe page"
subscribeTopicText = view.findViewById(R.id.add_dialog_topic_text) subscribeTopicText = view.findViewById(R.id.add_dialog_subscribe_topic_text)
subscribeBaseUrlLayout = view.findViewById(R.id.add_dialog_base_url_layout) subscribeBaseUrlLayout = view.findViewById(R.id.add_dialog_subscribe_base_url_layout)
subscribeBaseUrlText = view.findViewById(R.id.add_dialog_base_url_text) subscribeBaseUrlText = view.findViewById(R.id.add_dialog_subscribe_base_url_text)
subscribeInstantDeliveryBox = view.findViewById(R.id.add_dialog_instant_delivery_box) subscribeInstantDeliveryBox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_box)
subscribeInstantDeliveryCheckbox = view.findViewById(R.id.add_dialog_instant_delivery_checkbox) subscribeInstantDeliveryCheckbox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_checkbox)
subscribeInstantDeliveryDescription = view.findViewById(R.id.add_dialog_instant_delivery_description) subscribeInstantDeliveryDescription = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_description)
subscribeUseAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox) subscribeUseAnotherServerCheckbox = view.findViewById(R.id.add_dialog_subscribe_use_another_server_checkbox)
subscribeUseAnotherServerDescription = view.findViewById(R.id.add_dialog_use_another_server_description) subscribeUseAnotherServerDescription = view.findViewById(R.id.add_dialog_subscribe_use_another_server_description)
subscribeProgress = view.findViewById(R.id.add_dialog_progress) subscribeProgress = view.findViewById(R.id.add_dialog_subscribe_progress)
subscribeErrorImage = view.findViewById(R.id.add_dialog_error_image) 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" // Fields for "login page"
loginUsernameText = view.findViewById(R.id.add_dialog_login_username) loginUsernameText = view.findViewById(R.id.add_dialog_login_username)
loginPasswordText = view.findViewById(R.id.add_dialog_login_password) loginPasswordText = view.findViewById(R.id.add_dialog_login_password)
loginProgress = view.findViewById(R.id.add_dialog_login_progress) 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 // Set "Use another server" description based on flavor
subscribeUseAnotherServerDescription.text = if (BuildConfig.FIREBASE_AVAILABLE) { subscribeUseAnotherServerDescription.text = if (BuildConfig.FIREBASE_AVAILABLE) {
@ -268,7 +274,8 @@ class AddFragment : DialogFragment() {
private fun checkReadAndMaybeShowLogin(baseUrl: String, topic: String) { private fun checkReadAndMaybeShowLogin(baseUrl: String, topic: String) {
subscribeProgress.visibility = View.VISIBLE subscribeProgress.visibility = View.VISIBLE
subscribeErrorImage.visibility = View.GONE subscribeErrorText.visibility = View.GONE
subscribeErrorTextImage.visibility = View.GONE
enableSubscribeView(false) enableSubscribeView(false)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
@ -280,7 +287,7 @@ class AddFragment : DialogFragment() {
} else { } else {
if (user != null) { if (user != null) {
Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, but user already exists") 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 { } else {
Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog") Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog")
val activity = activity ?: return@launch // We may have pressed "Cancel" val activity = activity ?: return@launch // We may have pressed "Cancel"
@ -291,26 +298,26 @@ class AddFragment : DialogFragment() {
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Connection to topic failed: ${e.message}", e) 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" val activity = activity ?: return // We may have pressed "Cancel"
activity.runOnUiThread { activity.runOnUiThread {
subscribeProgress.visibility = View.GONE subscribeProgress.visibility = View.GONE
subscribeErrorImage.visibility = View.VISIBLE subscribeErrorText.visibility = View.VISIBLE
subscribeErrorText.text = message
subscribeErrorTextImage.visibility = View.VISIBLE
enableSubscribeView(true) enableSubscribeView(true)
Toast
.makeText(context, message, Toast.LENGTH_LONG)
.show()
} }
} }
private fun loginAndMaybeDismiss(baseUrl: String, topic: String) { private fun loginAndMaybeDismiss(baseUrl: String, topic: String) {
loginProgress.visibility = View.VISIBLE loginProgress.visibility = View.VISIBLE
loginErrorImage.visibility = View.GONE loginErrorText.visibility = View.GONE
loginErrorTextImage.visibility = View.GONE
enableLoginView(false) enableLoginView(false)
val user = User( val user = User(
baseUrl = baseUrl, baseUrl = baseUrl,
@ -327,24 +334,23 @@ class AddFragment : DialogFragment() {
dismissDialog() dismissDialog()
} else { } else {
Log.w(TAG, "Access not allowed for user ${user.username} to topic ${topicUrl(baseUrl, topic)}") 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) { } catch (e: Exception) {
Log.w(TAG, "Connection to topic failed during login: ${e.message}", e) 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" val activity = activity ?: return // We may have pressed "Cancel"
activity.runOnUiThread { activity.runOnUiThread {
loginProgress.visibility = View.GONE loginProgress.visibility = View.GONE
loginErrorImage.visibility = View.VISIBLE loginErrorText.visibility = View.VISIBLE
loginErrorText.text = message
loginErrorTextImage.visibility = View.VISIBLE
enableLoginView(true) enableLoginView(true)
Toast
.makeText(context, message, Toast.LENGTH_LONG)
.show()
} }
} }
@ -448,7 +454,8 @@ class AddFragment : DialogFragment() {
private fun resetSubscribeView() { private fun resetSubscribeView() {
subscribeProgress.visibility = View.GONE subscribeProgress.visibility = View.GONE
subscribeErrorImage.visibility = View.GONE subscribeErrorText.visibility = View.GONE
subscribeErrorTextImage.visibility = View.GONE
enableSubscribeView(true) enableSubscribeView(true)
} }
@ -464,7 +471,8 @@ class AddFragment : DialogFragment() {
private fun resetLoginView() { private fun resetLoginView() {
loginProgress.visibility = View.GONE loginProgress.visibility = View.GONE
loginErrorImage.visibility = View.GONE loginErrorText.visibility = View.GONE
loginErrorTextImage.visibility = View.GONE
loginUsernameText.visibility = View.VISIBLE loginUsernameText.visibility = View.VISIBLE
loginUsernameText.text?.clear() loginUsernameText.text?.clear()
loginPasswordText.visibility = View.VISIBLE loginPasswordText.visibility = View.VISIBLE

View file

@ -281,8 +281,17 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
api.publish(subscriptionBaseUrl, subscriptionTopic, user, message, title, priority, tags, delay = "") api.publish(subscriptionBaseUrl, subscriptionTopic, user, message, title, priority, tags, delay = "")
} catch (e: Exception) { } catch (e: Exception) {
runOnUiThread { 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 Toast
.makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG) .makeText(this@DetailActivity, message, Toast.LENGTH_LONG)
.show() .show()
} }
} }

View file

@ -14,6 +14,8 @@ import kotlinx.coroutines.launch
/** /**
* Subscription settings * Subscription settings
*
* THIS IS CURRENTLY UNUSED.
*/ */
class DetailSettingsActivity : AppCompatActivity() { class DetailSettingsActivity : AppCompatActivity() {
private lateinit var repository: Repository private lateinit var repository: Repository

View file

@ -1,9 +1,11 @@
package io.heckel.ntfy.ui package io.heckel.ntfy.ui
import android.Manifest import android.Manifest
import android.app.AlertDialog
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.DialogInterface
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@ -98,14 +100,9 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
pref: Preference pref: Preference
): Boolean { ): Boolean {
// Instantiate the new Fragment // Instantiate the new Fragment
val args = pref.extras val fragmentClass = pref.fragment ?: return false
val fragment = supportFragmentManager.fragmentFactory.instantiate( val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, fragmentClass)
classLoader, fragment.arguments = pref.extras
pref.fragment!!
).apply {
arguments = args
setTargetFragment(caller, 0)
}
// Replace the existing Fragment with the new Fragment // Replace the existing Fragment with the new Fragment
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
@ -118,7 +115,6 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
if (fragment is UserSettingsFragment) { if (fragment is UserSettingsFragment) {
userSettingsFragment = fragment userSettingsFragment = fragment
} }
return true return true
} }
@ -331,8 +327,10 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
exportLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting exportLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
exportLogs?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v -> exportLogs?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v ->
when (v) { when (v) {
EXPORT_LOGS_COPY -> copyLogsToClipboard() EXPORT_LOGS_COPY_ORIGINAL -> copyLogsToClipboard(scrub = false)
EXPORT_LOGS_UPLOAD -> uploadLogsToNopaste() EXPORT_LOGS_COPY_SCRUBBED -> copyLogsToClipboard(scrub = true)
EXPORT_LOGS_UPLOAD_ORIGINAL -> uploadLogsToNopaste(scrub = false)
EXPORT_LOGS_UPLOAD_SCRUBBED -> uploadLogsToNopaste(scrub = true)
} }
false false
} }
@ -368,6 +366,15 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
getString(R.string.settings_advanced_record_logs_summary_disabled) 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 // Connection protocol
val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return 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 // Version
val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return
val versionPref: Preference? = findPreference(versionPrefId) val versionPref: Preference? = findPreference(versionPrefId)
@ -441,25 +427,29 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
serviceManager.restart() // Service will auto-restart serviceManager.restart() // Service will auto-restart
} }
private fun copyLogsToClipboard() { private fun copyLogsToClipboard(scrub: Boolean) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val log = Log.getFormatted() val log = Log.getFormatted(scrub = scrub)
val context = context ?: return@launch val context = context ?: return@launch
requireActivity().runOnUiThread { requireActivity().runOnUiThread {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("ntfy logs", log) val clip = ClipData.newPlainText("ntfy logs", log)
clipboard.setPrimaryClip(clip) clipboard.setPrimaryClip(clip)
Toast if (scrub) {
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_logs), Toast.LENGTH_LONG) showScrubDialog(getString(R.string.settings_advanced_export_logs_copied_logs))
.show() } 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) { lifecycleScope.launch(Dispatchers.IO) {
Log.d(TAG, "Uploading log to $EXPORT_LOGS_UPLOAD_URL ...") 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) { if (log.length > EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD) {
requireActivity().runOnUiThread { requireActivity().runOnUiThread {
Toast Toast
@ -492,9 +482,13 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("logs URL", resp.url) val clip = ClipData.newPlainText("logs URL", resp.url)
clipboard.setPrimaryClip(clip) clipboard.setPrimaryClip(clip)
Toast if (scrub) {
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_url), Toast.LENGTH_LONG) showScrubDialog(getString(R.string.settings_advanced_export_logs_copied_url))
.show() } else {
Toast
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_url), Toast.LENGTH_LONG)
.show()
}
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -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() { private fun deleteLogs() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
Log.deleteAll() Log.deleteAll()
@ -657,8 +667,10 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
private const val TITLE_TAG = "title" private const val TITLE_TAG = "title"
private const val REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD = 2586 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 AUTO_DOWNLOAD_SELECTION_NOT_SET = -99L
private const val EXPORT_LOGS_COPY = "copy" private const val EXPORT_LOGS_COPY_ORIGINAL = "copy_original"
private const val EXPORT_LOGS_UPLOAD = "upload" 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_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 private const val EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD = 100 * 1024 // Show "Uploading ..." if log larger than X
} }

View file

@ -118,6 +118,16 @@ class UserFragment : DialogFragment() {
usernameView.addTextChangedListener(textWatcher) usernameView.addTextChangedListener(textWatcher)
passwordView.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! // Validate now!
validateInput() validateInput()
} }

View file

@ -12,9 +12,9 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="horizontal" android:orientation="horizontal"
android:id="@+id/add_dialog_subscribe_view" android:id="@+id/add_dialog_subscribe_view"
android:visibility="gone"> android:visibility="visible">
<TextView <TextView
android:id="@+id/add_dialog_title_text" android:id="@+id/add_dialog_subscribe_title_text"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingTop="16dp" android:paddingTop="16dp"
@ -24,52 +24,51 @@
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp" android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/add_dialog_error_image"/> />
<ProgressBar <ProgressBar
style="?android:attr/progressBarStyle" style="?android:attr/progressBarStyle"
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="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_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/add_dialog_error_image" app:layout_constraintBottom_toTopOf="@+id/add_dialog_subscribe_description"
app:layout_constraintBottom_toTopOf="@+id/add_dialog_description_below"
android:indeterminate="true" android:layout_marginBottom="5dp" android:visibility="gone"/> android:indeterminate="true" android:layout_marginBottom="5dp" android:visibility="gone"/>
<TextView <TextView
android:text="@string/add_dialog_description_below" android:text="@string/add_dialog_description_below"
android:layout_width="match_parent" 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" android:paddingStart="4dp" android:paddingTop="3dp" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="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 <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_width="match_parent"
android:layout_height="wrap_content" android:hint="@string/add_dialog_topic_name_hint" android:layout_height="wrap_content" android:hint="@string/add_dialog_topic_name_hint"
android:importantForAutofill="no" android:importantForAutofill="no"
android:maxLines="1" android:inputType="text" android:maxLength="64" android:maxLines="1" android:inputType="text" android:maxLength="64"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" 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 <CheckBox
android:text="@string/add_dialog_use_another_server" android:text="@string/add_dialog_use_another_server"
android:layout_width="match_parent" 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" android:layout_marginStart="-3dp" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="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"/> android:layout_marginTop="-3dp"/>
<TextView <TextView
android:text="@string/add_dialog_use_another_server_description" android:text="@string/add_dialog_use_another_server_description"
android:layout_width="match_parent" 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:paddingStart="4dp" android:paddingTop="0dp"
android:visibility="gone" app:layout_constraintStart_toStartOf="parent" android:visibility="gone" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="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"/> android:layout_marginTop="-5dp"/>
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense.ExposedDropdownMenu" 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_width="match_parent"
android:layout_height="40dp" android:layout_height="40dp"
android:layout_margin="0dp" android:layout_margin="0dp"
@ -80,11 +79,11 @@
app:hintEnabled="false" app:hintEnabled="false"
app:boxBackgroundColor="@android:color/transparent" app:layout_constraintStart_toStartOf="parent" app:boxBackgroundColor="@android:color/transparent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="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 <AutoCompleteTextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" 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:hint="@string/app_base_url"
android:maxLines="1" android:maxLines="1"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
@ -102,19 +101,19 @@
<LinearLayout <LinearLayout
android:orientation="horizontal" android:orientation="horizontal"
android:layout_width="match_parent" 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_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 <CheckBox
android:text="@string/add_dialog_instant_delivery" android:text="@string/add_dialog_instant_delivery"
android:layout_width="wrap_content" 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_marginTop="-8dp" android:layout_marginBottom="-5dp"
android:layout_marginStart="-3dp"/> android:layout_marginStart="-3dp"/>
<ImageView <ImageView
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_bolt_gray_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_constraintTop_toTopOf="@+id/main_item_text"
app:layout_constraintEnd_toStartOf="@+id/main_item_date" android:paddingTop="3dp" app:layout_constraintEnd_toStartOf="@+id/main_item_date" android:paddingTop="3dp"
android:layout_marginTop="3dp"/> android:layout_marginTop="3dp"/>
@ -122,25 +121,31 @@
<TextView <TextView
android:text="@string/add_dialog_instant_delivery_description" android:text="@string/add_dialog_instant_delivery_description"
android:layout_width="match_parent" 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:paddingStart="4dp" android:paddingTop="0dp"
android:visibility="gone" app:layout_constraintStart_toStartOf="parent" android:visibility="gone" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="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 <ImageView
android:layout_width="24dp" android:layout_width="20dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_error_black_24dp" android:layout_height="20dp" app:srcCompat="@drawable/ic_error_red_24dp"
android:id="@+id/add_dialog_error_image" android:id="@+id/add_dialog_subscribe_error_text_image"
app:layout_constraintBottom_toTopOf="@+id/add_dialog_description_below" android:visibility="gone"
android:layout_marginBottom="5dp" app:layout_constraintEnd_toStartOf="@+id/add_dialog_progress" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/add_dialog_subscribe_error_text" android:layout_marginTop="1dp"/>
app:layout_constraintStart_toEndOf="@+id/add_dialog_title_text" android:visibility="gone"/> <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>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="horizontal" android:orientation="horizontal"
android:id="@+id/add_dialog_login_view" android:id="@+id/add_dialog_login_view"
android:visibility="visible" android:visibility="gone"
> >
<TextView <TextView
android:id="@+id/add_dialog_login_title" android:id="@+id/add_dialog_login_title"
@ -153,7 +158,7 @@
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp" android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/add_dialog_login_error_image"/> />
<TextView <TextView
android:text="@string/add_dialog_login_description" android:text="@string/add_dialog_login_description"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -177,22 +182,26 @@
android:maxLines="1" android:inputType="textPassword" app:layout_constraintStart_toStartOf="parent" android:maxLines="1" android:inputType="textPassword" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_username"/> 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 <ProgressBar
style="?android:attr/progressBarStyle" style="?android:attr/progressBarStyle"
android:layout_width="25dp" android:layout_width="25dp"
android:layout_height="25dp" android:layout_height="25dp"
android:id="@+id/add_dialog_login_progress" android:id="@+id/add_dialog_login_progress"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/add_dialog_login_error_image"
app:layout_constraintBottom_toTopOf="@+id/add_dialog_login_description" app:layout_constraintBottom_toTopOf="@+id/add_dialog_login_description"
android:indeterminate="true" android:layout_marginBottom="5dp"/> 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> </androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout> </LinearLayout>

View file

@ -91,7 +91,7 @@
<string name="add_dialog_instant_delivery">Instant delivery in doze mode</string> <string name="add_dialog_instant_delivery">Instant delivery in doze mode</string>
<string name="add_dialog_instant_delivery_description"> <string name="add_dialog_instant_delivery_description">
Ensures that messages are immediately delivered, even if the device is inactive or in doze mode. 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>
<string name="add_dialog_button_cancel">Cancel</string> <string name="add_dialog_button_cancel">Cancel</string>
<string name="add_dialog_button_subscribe">Subscribe</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. <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. If you send another one, it may look different.
</string> </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_copied_to_clipboard_message">Copied to clipboard</string>
<string name="detail_instant_delivery_enabled">Instant delivery enabled</string> <string name="detail_instant_delivery_enabled">Instant delivery enabled</string>
<string name="detail_instant_delivery_disabled">Instant delivery disabled</string> <string name="detail_instant_delivery_disabled">Instant delivery disabled</string>
@ -178,13 +180,7 @@
<!-- Detail settings --> <!-- Detail settings -->
<string name="detail_settings_title">Subscription settings</string> <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 --> <!-- Notification dialog -->
<string name="notification_dialog_title">Pause notifications</string> <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_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_key">RecordLogs</string>
<string name="settings_advanced_record_logs_title">Record logs</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_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_key">ExportLogs</string>
<string name="settings_advanced_export_logs_title">Copy/upload logs</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_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">Copy to clipboard</string> <string name="settings_advanced_export_logs_entry_copy_original">Copy to clipboard</string>
<string name="settings_advanced_export_logs_entry_upload">Upload to nopaste.net</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 &amp; copy link</string>
<string name="settings_advanced_export_logs_entry_upload_scrubbed">Upload &amp; copy link (censored)</string>
<string name="settings_advanced_export_logs_copied_logs">Logs copied to clipboard</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_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 &amp; URL copied</string>
<string name="settings_advanced_export_logs_error_uploading">Error uploading logs: %1$s</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_key">ClearLogs</string>
<string name="settings_advanced_clear_logs_title">Clear logs</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> <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_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_jsonhttp">JSON stream over HTTP</string>
<string name="settings_advanced_connection_protocol_entry_ws">WebSockets (experimental)</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_header">About</string>
<string name="settings_about_version_key">Version</string> <string name="settings_about_version_key">Version</string>
<string name="settings_about_version_title">Version</string> <string name="settings_about_version_title">Version</string>

View file

@ -62,12 +62,16 @@
<item>ws</item> <item>ws</item>
</string-array> </string-array>
<string-array name="settings_advanced_export_logs_entries"> <string-array name="settings_advanced_export_logs_entries">
<item>@string/settings_advanced_export_logs_entry_copy</item> <item>@string/settings_advanced_export_logs_entry_copy_original</item>
<item>@string/settings_advanced_export_logs_entry_upload</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>
<string-array name="settings_advanced_export_logs_values"> <string-array name="settings_advanced_export_logs_values">
<item>copy</item> <item>copy_original</item>
<item>upload</item> <item>copy_scrubbed</item>
<item>upload_original</item>
<item>upload_scrubbed</item>
</string-array> </string-array>
<string-array name="settings_appearance_dark_mode_entries"> <string-array name="settings_appearance_dark_mode_entries">
<item>@string/settings_appearance_dark_mode_entry_system</item> <item>@string/settings_appearance_dark_mode_entry_system</item>

View file

@ -1,12 +1,3 @@
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto" <PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
app:title="@string/detail_settings_title"> 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> </PreferenceScreen>

View file

@ -74,10 +74,6 @@
app:key="@string/settings_advanced_clear_logs_key" app:key="@string/settings_advanced_clear_logs_key"
app:title="@string/settings_advanced_clear_logs_title" app:title="@string/settings_advanced_clear_logs_title"
app:summary="@string/settings_advanced_clear_logs_summary"/> 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>
<PreferenceCategory app:title="@string/settings_experimental_header"> <PreferenceCategory app:title="@string/settings_experimental_header">
<ListPreference <ListPreference

View file

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

View file

@ -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: Bug fixes:
* Fix download issues on SDK 29 "Movement not allowed" (#116, thanks Jakob) * Fix download issues on SDK 29 "Movement not allowed" (#116, thanks Jakob)
* Fix for Android 12 crashes (#124, thanks @eskilop) * 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