From 715246917263633a0102281e150073c055d9035b Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 17 Jan 2022 18:05:59 -0500 Subject: [PATCH] Copy logs to clipboard; scrub terms; add schema migration --- app/build.gradle | 8 +- .../io.heckel.ntfy.data.Database/7.json | 256 ++++++++++++++++++ .../java/io/heckel/ntfy/app/Application.kt | 6 +- .../main/java/io/heckel/ntfy/data/Database.kt | 28 +- .../java/io/heckel/ntfy/data/Repository.kt | 11 + app/src/main/java/io/heckel/ntfy/log/Log.kt | 84 +++++- .../java/io/heckel/ntfy/msg/DownloadWorker.kt | 2 +- .../heckel/ntfy/msg/NotificationDispatcher.kt | 2 +- .../io/heckel/ntfy/service/JsonConnection.kt | 4 +- .../heckel/ntfy/service/SubscriberService.kt | 2 + .../java/io/heckel/ntfy/ui/MainActivity.kt | 12 +- .../io/heckel/ntfy/ui/SettingsActivity.kt | 219 ++++++++++----- app/src/main/java/io/heckel/ntfy/util/Util.kt | 9 +- app/src/main/res/values/strings.xml | 11 + app/src/main/res/xml/main_preferences.xml | 8 + 15 files changed, 562 insertions(+), 100 deletions(-) create mode 100644 app/schemas/io.heckel.ntfy.data.Database/7.json diff --git a/app/build.gradle b/app/build.gradle index af62db1..58f6175 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/schemas/io.heckel.ntfy.data.Database/7.json b/app/schemas/io.heckel.ntfy.data.Database/7.json new file mode 100644 index 0000000..096ca25 --- /dev/null +++ b/app/schemas/io.heckel.ntfy.data.Database/7.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/app/Application.kt b/app/src/main/java/io/heckel/ntfy/app/Application.kt index 8d1294d..7d24470 100644 --- a/app/src/main/java/io/heckel/ntfy/app/Application.kt +++ b/app/src/main/java/io/heckel/ntfy/app/Application.kt @@ -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 } } diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index ef72343..10a81f6 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -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 + + @Query("DELETE FROM log") + fun deleteAll() } diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index 953aa04..144f690 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -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" diff --git a/app/src/main/java/io/heckel/ntfy/log/Log.kt b/app/src/main/java/io/heckel/ntfy/log/Log.kt index b79c92f..f36d037 100644 --- a/app/src/main/java/io/heckel/ntfy/log/Log.kt +++ b/app/src/main/java/io/heckel/ntfy/log/Log.kt @@ -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()) 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 { + 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 { + 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 diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt index 9135398..eeb2b8f 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 13dc96c..51b3b04 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt index d599819..29666fa 100644 --- a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt @@ -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) diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 19f3caf..3e41e14 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -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) diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index c848aa4..1264a0b 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -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) 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) + } } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index a7d1f17..47221ce 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, grantResults: IntArray) { diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 583e9ec..8b6e7e2 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0978ab4..c5d6e8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -230,6 +230,17 @@ Broadcast messages Apps can receive incoming notifications as broadcasts Apps cannot receive notifications as broadcasts + RecordLogs + Record logs + Logs are currently being recorded to your device. Up to 5,000 log lines are stored. + Enable log recording, so you can share the logs later. This is useful for diagnosing issues. + Would you like to delete the existing logs? + Keep logs + Delete logs + CopyLogs + Copy logs + Copy logs to the clipboard. Hostnames and topics are scrubbed, notifications are not. + Copied to clipboard Experimental ConnectionProtocol Connection protocol diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index 015422e..feb810e 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -38,6 +38,14 @@ app:key="@string/settings_advanced_broadcast_key" app:title="@string/settings_advanced_broadcast_title" app:enabled="true"/> + +