Copy logs to clipboard; scrub terms; add schema migration

This commit is contained in:
Philipp Heckel 2022-01-17 18:05:59 -05:00
parent 5fb3ae0536
commit 7152469172
15 changed files with 562 additions and 100 deletions

View file

@ -12,8 +12,8 @@ android {
minSdkVersion 21
targetSdkVersion 30
versionCode 16
versionName "1.6.0"
versionCode 17
versionName "1.7.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -64,7 +64,7 @@ dependencies {
implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
implementation "androidx.fragment:fragment-ktx:$rootProject.fragmentVersion"
implementation 'com.google.code.gson:gson:2.8.8'
implementation 'com.google.code.gson:gson:2.8.9'
// WorkManager
implementation "androidx.work:work-runtime-ktx:2.6.0"
@ -76,7 +76,7 @@ dependencies {
kapt "androidx.room:room-compiler:$roomVersion"
// OkHttp (HTTP library)
implementation "com.squareup.okhttp3:okhttp:4.9.2"
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
// Firebase, sigh ... (only Google Play)
playImplementation 'com.google.firebase:firebase-messaging:22.0.0'

View file

@ -0,0 +1,256 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "ecb1b85b2ae822dc62b2843620368477",
"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"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
},
{
"name": "index_Subscription_upConnectorToken",
"unique": true,
"columnNames": [
"upConnectorToken"
],
"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": "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, 'ecb1b85b2ae822dc62b2843620368477')"
]
}
}

View file

@ -13,6 +13,10 @@ class Application : Application() {
}
val repository by lazy {
val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
val repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
if (repository.getRecordLogs()) {
Log.setRecord(true)
}
repository
}
}

View file

@ -77,8 +77,8 @@ const val PROGRESS_FAILED = -3
const val PROGRESS_DELETED = -4
const val PROGRESS_DONE = 100
@Entity
data class Logs(
@Entity(tableName = "Log")
data class LogEntry(
@PrimaryKey(autoGenerate = true) val id: Long, // Internal ID, only used in Repository and activities
@ColumnInfo(name = "timestamp") val timestamp: Long,
@ColumnInfo(name = "tag") val tag: String,
@ -90,11 +90,11 @@ data class Logs(
this(0, timestamp, tag, level, message, exception)
}
@androidx.room.Database(entities = [Subscription::class, Notification::class, Logs::class], version = 6)
@androidx.room.Database(entities = [Subscription::class, Notification::class, LogEntry::class], version = 7)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
abstract fun logsDao(): LogsDao
abstract fun logDao(): LogDao
companion object {
@Volatile
@ -109,6 +109,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_5)
.addMigrations(MIGRATION_5_6)
.addMigrations(MIGRATION_6_7)
.fallbackToDestructiveMigration()
.build()
this.instance = instance
@ -166,6 +167,12 @@ abstract class Database : RoomDatabase() {
db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_progress INT") // Room limitation: Has to be nullable for @Embedded
}
}
private val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
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)")
}
}
}
}
@ -276,12 +283,17 @@ interface NotificationDao {
fun removeAll(subscriptionId: Long)
}
@Dao
interface LogsDao {
interface LogDao {
@Insert
suspend fun insert(entry: Logs)
suspend fun insert(entry: LogEntry)
@Query("DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT :keepCount)")
@Query("DELETE FROM log WHERE id NOT IN (SELECT id FROM log ORDER BY id DESC LIMIT :keepCount)")
suspend fun prune(keepCount: Int)
@Query("SELECT * FROM log ORDER BY timestamp ASC, id ASC")
fun getAll(): List<LogEntry>
@Query("DELETE FROM log")
fun deleteAll()
}

View file

@ -215,6 +215,16 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
.apply()
}
fun getRecordLogs(): Boolean {
return sharedPrefs.getBoolean(SHARED_PREFS_RECORD_LOGS_ENABLED, false) // Disabled by default
}
fun setRecordLogsEnabled(enabled: Boolean) {
sharedPrefs.edit()
.putBoolean(SHARED_PREFS_RECORD_LOGS_ENABLED, enabled)
.apply()
}
fun getUnifiedPushEnabled(): Boolean {
return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, true) // Enabled by default
}
@ -339,6 +349,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
const val SHARED_PREFS_WAKELOCK_ENABLED = "WakelockEnabled"
const val SHARED_PREFS_CONNECTION_PROTOCOL = "ConnectionProtocol"
const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled"
const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs"
const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled"
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL"

View file

@ -2,22 +2,25 @@ package io.heckel.ntfy.log
import android.content.Context
import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Logs
import io.heckel.ntfy.data.LogsDao
import io.heckel.ntfy.data.LogDao
import io.heckel.ntfy.data.LogEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class Log(private val logsDao: LogsDao) {
private var record: AtomicBoolean = AtomicBoolean(false)
private var count: AtomicInteger = AtomicInteger(0)
class Log(private val logsDao: LogDao) {
private val record: AtomicBoolean = AtomicBoolean(false)
private val count: AtomicInteger = AtomicInteger(0)
private val scrubNum: AtomicInteger = AtomicInteger(-1)
private val scrubTerms = Collections.synchronizedMap(mutableMapOf<String, String>())
private fun log(level: Int, tag: String, message: String, exception: Throwable?) {
if (!record.get()) return
GlobalScope.launch(Dispatchers.IO) {
logsDao.insert(Logs(System.currentTimeMillis(), tag, level, message, exception?.stackTraceToString()))
GlobalScope.launch(Dispatchers.IO) { // FIXME This does not guarantee the log order
logsDao.insert(LogEntry(System.currentTimeMillis(), tag, level, message, exception?.stackTraceToString()))
val current = count.incrementAndGet()
if (current >= PRUNE_EVERY) {
logsDao.prune(ENTRIES_MAX)
@ -26,7 +29,55 @@ class Log(private val logsDao: LogsDao) {
}
}
fun getAll(): Collection<LogEntry> {
return logsDao
.getAll()
.map { e ->
e.copy(
message = scrub(e.message)!!,
exception = scrub(e.exception)
)
}
}
private fun deleteAll() {
return logsDao.deleteAll()
}
fun addScrubTerm(term: String, type: TermType = TermType.Term) {
if (scrubTerms[term] != null || IGNORE_TERMS.contains(term)) {
return
}
val replaceTermIndex = scrubNum.incrementAndGet()
val replaceTerm = REPLACE_TERMS.getOrNull(replaceTermIndex) ?: "scrubbed${replaceTermIndex}"
scrubTerms[term] = when (type) {
TermType.Domain -> "$replaceTerm.example.com"
else -> replaceTerm
}
}
private fun scrub(line: String?): String? {
var newLine = line ?: return null
scrubTerms.forEach { (scrubTerm, replaceTerm) ->
newLine = newLine.replace(scrubTerm, replaceTerm)
}
return newLine
}
enum class TermType {
Domain, Term
}
companion object {
private const val TAG = "NtfyLog"
private const val PRUNE_EVERY = 100
private const val ENTRIES_MAX = 5000
private val IGNORE_TERMS = listOf("ntfy.sh")
private val REPLACE_TERMS = listOf(
"potato", "banana", "coconut", "kiwi", "avocado", "orange", "apple", "lemon", "olive", "peach"
)
private var instance: Log? = null
fun d(tag: String, message: String, exception: Throwable? = null) {
if (exception == null) android.util.Log.d(tag, message) else android.util.Log.d(tag, message, exception)
getInstance()?.log(android.util.Log.DEBUG, tag, message, exception)
@ -57,20 +108,27 @@ class Log(private val logsDao: LogsDao) {
return getInstance()?.record?.get() ?: false
}
fun getAll(): Collection<LogEntry> {
return getInstance()?.getAll().orEmpty()
}
fun deleteAll() {
getInstance()?.deleteAll()
}
fun addScrubTerm(term: String, type: TermType = TermType.Term) {
getInstance()?.addScrubTerm(term, type)
}
fun init(context: Context) {
return synchronized(Log::class) {
if (instance == null) {
val database = Database.getInstance(context.applicationContext)
instance = Log(database.logsDao())
instance = Log(database.logDao())
}
}
}
private const val TAG = "NtfyLog"
private const val PRUNE_EVERY = 100
private const val ENTRIES_MAX = 10000
private var instance: Log? = null
private fun getInstance(): Log? {
return synchronized(Log::class) {
instance

View file

@ -8,7 +8,6 @@ import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Log
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.content.FileProvider
@ -18,6 +17,7 @@ import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.*
import io.heckel.ntfy.log.Log
import io.heckel.ntfy.util.queryFilename
import okhttp3.OkHttpClient
import okhttp3.Request

View file

@ -1,10 +1,10 @@
package io.heckel.ntfy.msg
import android.content.Context
import android.util.Log
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.log.Log
import io.heckel.ntfy.up.Distributor
import io.heckel.ntfy.util.safeLet

View file

@ -1,10 +1,10 @@
package io.heckel.ntfy.service
import android.util.Log
import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.log.Log
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.*
@ -47,7 +47,7 @@ class JsonConnection(
notificationListener(subscription, notificationWithSubscriptionId)
}
val failed = AtomicBoolean(false)
val fail = { e: Exception ->
val fail = { _: Exception ->
failed.set(true)
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)

View file

@ -82,6 +82,8 @@ class SubscriberService : Service() {
override fun onCreate() {
super.onCreate()
Log.init(this) // Init logs in all entry points
Log.d(TAG, "Subscriber service has been created")
val title = getString(R.string.channel_subscriber_notification_title)

View file

@ -29,6 +29,7 @@ import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.fadeStatusBarColor
import io.heckel.ntfy.util.formatDateShort
import io.heckel.ntfy.util.shortUrl
import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.Dispatchers
@ -63,9 +64,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Log.init(this)
Log.setRecord(true)
Log.init(this) // Init logs in all entry points
Log.d(TAG, "Create $this")
// Dependencies that depend on Context
@ -98,6 +97,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
viewModel.list().observe(this) {
it?.let { subscriptions ->
// Update main list
adapter.submitList(subscriptions as MutableList<Subscription>)
if (it.isEmpty()) {
mainListContainer.visibility = View.GONE
@ -106,6 +106,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
mainListContainer.visibility = View.VISIBLE
noEntries.visibility = View.GONE
}
// Add scrub terms to log (in case it gets exported)
subscriptions.forEach { s ->
Log.addScrubTerm(shortUrl(s.baseUrl), Log.TermType.Domain)
Log.addScrubTerm(s.topic)
}
}
}

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.ui
import android.Manifest
import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@ -14,6 +15,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.lifecycleScope
import androidx.preference.*
import androidx.preference.Preference.OnPreferenceClickListener
import io.heckel.ntfy.BuildConfig
@ -24,6 +26,10 @@ import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.formatBytes
import io.heckel.ntfy.util.formatDateShort
import io.heckel.ntfy.util.toPriorityString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
class SettingsActivity : AppCompatActivity() {
private lateinit var fragment: SettingsFragment
@ -154,68 +160,6 @@ class SettingsActivity : AppCompatActivity() {
}
}
// Connection protocol
val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return
val connectionProtocol: ListPreference? = findPreference(connectionProtocolPrefId)
connectionProtocol?.value = repository.getConnectionProtocol()
connectionProtocol?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val proto = value ?: repository.getConnectionProtocol()
repository.setConnectionProtocol(proto)
restartService()
}
override fun getString(key: String?, defValue: String?): String {
return repository.getConnectionProtocol()
}
}
connectionProtocol?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
when (pref.value) {
Repository.CONNECTION_PROTOCOL_WS -> getString(R.string.settings_advanced_connection_protocol_summary_ws)
else -> getString(R.string.settings_advanced_connection_protocol_summary_jsonhttp)
}
}
// 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)
}
}
// Broadcast enabled
val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return
val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId)
broadcastEnabled?.isChecked = repository.getBroadcastEnabled()
broadcastEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setBroadcastEnabled(value)
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return repository.getBroadcastEnabled()
}
}
broadcastEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
getString(R.string.settings_advanced_broadcast_summary_enabled)
} else {
getString(R.string.settings_advanced_broadcast_summary_disabled)
}
}
// UnifiedPush enabled
val upEnabledPrefId = context?.getString(R.string.settings_unified_push_enabled_key) ?: return
val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId)
@ -258,6 +202,123 @@ class SettingsActivity : AppCompatActivity() {
}
}
// Broadcast enabled
val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return
val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId)
broadcastEnabled?.isChecked = repository.getBroadcastEnabled()
broadcastEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setBroadcastEnabled(value)
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return repository.getBroadcastEnabled()
}
}
broadcastEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
getString(R.string.settings_advanced_broadcast_summary_enabled)
} else {
getString(R.string.settings_advanced_broadcast_summary_disabled)
}
}
// Copy logs
val copyLogsPrefId = context?.getString(R.string.settings_advanced_copy_logs_key) ?: return
val copyLogs: Preference? = findPreference(copyLogsPrefId)
copyLogs?.isVisible = Log.getRecord()
copyLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
copyLogs?.onPreferenceClickListener = OnPreferenceClickListener {
copyLogsToClipboard()
true
}
// Record logs
val recordLogsPrefId = context?.getString(R.string.settings_advanced_record_logs_key) ?: return
val recordLogsEnabled: SwitchPreference? = findPreference(recordLogsPrefId)
recordLogsEnabled?.isChecked = Log.getRecord()
recordLogsEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setRecordLogsEnabled(value)
Log.setRecord(value)
copyLogs?.isVisible = value
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return Log.getRecord()
}
}
recordLogsEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
getString(R.string.settings_advanced_record_logs_summary_enabled)
} else {
getString(R.string.settings_advanced_record_logs_summary_disabled)
}
}
recordLogsEnabled?.setOnPreferenceChangeListener { _, v ->
val newValue = v as Boolean
if (!newValue) {
val dialog = AlertDialog.Builder(activity)
.setMessage(R.string.settings_advanced_record_logs_delete_dialog_message)
.setPositiveButton(R.string.settings_advanced_record_logs_delete_dialog_button_delete) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
Log.deleteAll()
} }
.setNegativeButton(R.string.settings_advanced_record_logs_delete_dialog_button_keep) { _, _ ->
// Do nothing
}
.create()
dialog
.setOnShowListener {
dialog
.getButton(AlertDialog.BUTTON_POSITIVE)
.setTextColor(ContextCompat.getColor(requireContext(), R.color.primaryDangerButtonColor))
}
dialog.show()
}
true
}
// Connection protocol
val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return
val connectionProtocol: ListPreference? = findPreference(connectionProtocolPrefId)
connectionProtocol?.value = repository.getConnectionProtocol()
connectionProtocol?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val proto = value ?: repository.getConnectionProtocol()
repository.setConnectionProtocol(proto)
restartService()
}
override fun getString(key: String?, defValue: String?): String {
return repository.getConnectionProtocol()
}
}
connectionProtocol?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
when (pref.value) {
Repository.CONNECTION_PROTOCOL_WS -> getString(R.string.settings_advanced_connection_protocol_summary_ws)
else -> getString(R.string.settings_advanced_connection_protocol_summary_jsonhttp)
}
}
// 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)
@ -266,7 +327,7 @@ class SettingsActivity : AppCompatActivity() {
versionPref?.onPreferenceClickListener = OnPreferenceClickListener {
val context = context ?: return@OnPreferenceClickListener false
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("app version", version)
val clip = ClipData.newPlainText("ntfy version", version)
clipboard.setPrimaryClip(clip)
Toast
.makeText(context, getString(R.string.settings_about_version_copied_to_clipboard_message), Toast.LENGTH_LONG)
@ -290,6 +351,38 @@ class SettingsActivity : AppCompatActivity() {
context?.stopService(intent) // Service will auto-restart
}
}
private fun copyLogsToClipboard() {
lifecycleScope.launch(Dispatchers.IO) {
val log = Log.getAll().joinToString(separator = "\n") { e ->
val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(Date(e.timestamp))
val level = when (e.level) {
android.util.Log.DEBUG -> "D"
android.util.Log.INFO -> "I"
android.util.Log.WARN -> "W"
android.util.Log.ERROR -> "E"
else -> "?"
}
val tag = e.tag.format("%-23s")
val prefix = "${e.timestamp} $date $level $tag"
val message = if (e.exception != null) {
"${e.message}\nException:\n${e.exception}"
} else {
e.message
}
"$prefix $message"
}
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_copy_logs_copied), Toast.LENGTH_LONG)
.show()
}
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {

View file

@ -19,10 +19,11 @@ fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // U
fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since"
fun topicUrlWs(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/ws?since=$since"
fun topicUrlJsonPoll(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?poll=1&since=$since"
fun topicShortUrl(baseUrl: String, topic: String) =
topicUrl(baseUrl, topic)
.replace("http://", "")
.replace("https://", "")
fun topicShortUrl(baseUrl: String, topic: String) = shortUrl(topicUrl(baseUrl, topic))
fun shortUrl(url: String) = url
.replace("http://", "")
.replace("https://", "")
fun formatDateShort(timestampSecs: Long): String {
val date = Date(timestampSecs*1000)

View file

@ -230,6 +230,17 @@
<string name="settings_advanced_broadcast_title">Broadcast messages</string>
<string name="settings_advanced_broadcast_summary_enabled">Apps can receive incoming 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_title">Record logs</string>
<string name="settings_advanced_record_logs_summary_enabled">Logs are currently being recorded to your device. Up to 5,000 log lines 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_delete_dialog_message">Would you like to delete the existing logs?</string>
<string name="settings_advanced_record_logs_delete_dialog_button_keep">Keep logs</string>
<string name="settings_advanced_record_logs_delete_dialog_button_delete">Delete logs</string>
<string name="settings_advanced_copy_logs_key">CopyLogs</string>
<string name="settings_advanced_copy_logs_title">Copy logs</string>
<string name="settings_advanced_copy_logs_summary">Copy logs to the clipboard. Hostnames and topics are scrubbed, notifications are not.</string>
<string name="settings_advanced_copy_logs_copied">Copied to clipboard</string>
<string name="settings_experimental_header">Experimental</string>
<string name="settings_advanced_connection_protocol_key">ConnectionProtocol</string>
<string name="settings_advanced_connection_protocol_title">Connection protocol</string>

View file

@ -38,6 +38,14 @@
app:key="@string/settings_advanced_broadcast_key"
app:title="@string/settings_advanced_broadcast_title"
app:enabled="true"/>
<SwitchPreference
app:key="@string/settings_advanced_record_logs_key"
app:title="@string/settings_advanced_record_logs_title"
app:enabled="true"/>
<Preference
app:key="@string/settings_advanced_copy_logs_key"
app:title="@string/settings_advanced_copy_logs_title"
android:summary="@string/settings_advanced_copy_logs_summary"/>
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_experimental_header">
<ListPreference