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

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)
}
@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))")
}
}
}
}
@ -243,7 +250,7 @@ interface SubscriptionDao {
@Query("""
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(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
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)
}
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()

View file

@ -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() {

View file

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

View file

@ -68,16 +68,21 @@ class BroadcastService(private val ctx: Context) {
GlobalScope.launch(Dispatchers.IO) {
val repository = Repository.getInstance(ctx)
val user = repository.getUser(baseUrl) // May be null
api.publish(
baseUrl = baseUrl,
topic = topic,
user = user,
message = message,
title = title,
priority = priority,
tags = splitTags(tags),
delay = delay
)
try {
Log.d(TAG, "Publishing message $intent")
api.publish(
baseUrl = baseUrl,
topic = topic,
user = user,
message = message,
title = title,
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.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,95 +147,115 @@ class SubscriberService : Service() {
saveServiceState(this, ServiceState.STOPPED)
}
private fun refreshConnections() =
private fun refreshConnections() {
GlobalScope.launch(Dispatchers.IO) {
// 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
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.")
if (!refreshMutex.tryLock()) {
Log.d(TAG, "Refreshing subscriptions already in progress. Skipping.")
return@launch
}
// Open new connections
newConnectionIds.forEach { connectionId ->
// FIXME since !!!
// 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)
}
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 }
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) {
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()) {
// Wakelocks are reference counted by default so that should work neatly here
wakeLock?.acquire(10*60*1000L /*10 minutes*/)
}
// Wakelock while notifications are being dispatched
// Wakelocks are reference counted by default so that should work neatly here
wakeLock?.acquire(NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS)
val url = topicUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "[$url] Received notification: $notification")
@ -245,12 +264,9 @@ class SubscriberService : Service() {
Log.d(TAG, "[$url] Dispatching notification $notification")
dispatcher.dispatch(subscription, notification)
}
if (!repository.getWakelockEnabled()) {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
}
@ -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"

View file

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

View file

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

View file

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

View file

@ -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()
}
}

View file

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

View file

@ -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)
Toast
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_logs), Toast.LENGTH_LONG)
.show()
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,9 +482,13 @@ 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)
Toast
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_url), Toast.LENGTH_LONG)
.show()
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) {
@ -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
}

View file

@ -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()
}

View file

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

View file

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

View file

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

View file

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

View file

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

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:
* 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