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 minSdkVersion 21
targetSdkVersion 30 targetSdkVersion 30
versionCode 16 versionCode 17
versionName "1.6.0" versionName "1.7.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -64,7 +64,7 @@ dependencies {
implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion" implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion" implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
implementation "androidx.fragment:fragment-ktx:$rootProject.fragmentVersion" 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 // WorkManager
implementation "androidx.work:work-runtime-ktx:2.6.0" implementation "androidx.work:work-runtime-ktx:2.6.0"
@ -76,7 +76,7 @@ dependencies {
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
// OkHttp (HTTP library) // OkHttp (HTTP library)
implementation "com.squareup.okhttp3:okhttp:4.9.2" implementation 'com.squareup.okhttp3:okhttp:4.9.3'
// Firebase, sigh ... (only Google Play) // Firebase, sigh ... (only Google Play)
playImplementation 'com.google.firebase:firebase-messaging:22.0.0' 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 repository by lazy {
val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE) 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_DELETED = -4
const val PROGRESS_DONE = 100 const val PROGRESS_DONE = 100
@Entity @Entity(tableName = "Log")
data class Logs( data class LogEntry(
@PrimaryKey(autoGenerate = true) val id: Long, // Internal ID, only used in Repository and activities @PrimaryKey(autoGenerate = true) val id: Long, // Internal ID, only used in Repository and activities
@ColumnInfo(name = "timestamp") val timestamp: Long, @ColumnInfo(name = "timestamp") val timestamp: Long,
@ColumnInfo(name = "tag") val tag: String, @ColumnInfo(name = "tag") val tag: String,
@ -90,11 +90,11 @@ data class Logs(
this(0, timestamp, tag, level, message, exception) 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 class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao abstract fun notificationDao(): NotificationDao
abstract fun logsDao(): LogsDao abstract fun logDao(): LogDao
companion object { companion object {
@Volatile @Volatile
@ -109,6 +109,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_3_4) .addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_5) .addMigrations(MIGRATION_4_5)
.addMigrations(MIGRATION_5_6) .addMigrations(MIGRATION_5_6)
.addMigrations(MIGRATION_6_7)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
this.instance = instance 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 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) fun removeAll(subscriptionId: Long)
} }
@Dao @Dao
interface LogsDao { interface LogDao {
@Insert @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) 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() .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 { fun getUnifiedPushEnabled(): Boolean {
return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, true) // Enabled by default 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_WAKELOCK_ENABLED = "WakelockEnabled"
const val SHARED_PREFS_CONNECTION_PROTOCOL = "ConnectionProtocol" const val SHARED_PREFS_CONNECTION_PROTOCOL = "ConnectionProtocol"
const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled" 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_ENABLED = "UnifiedPushEnabled"
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" 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 android.content.Context
import io.heckel.ntfy.data.Database import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Logs import io.heckel.ntfy.data.LogDao
import io.heckel.ntfy.data.LogsDao import io.heckel.ntfy.data.LogEntry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
class Log(private val logsDao: LogsDao) { class Log(private val logsDao: LogDao) {
private var record: AtomicBoolean = AtomicBoolean(false) private val record: AtomicBoolean = AtomicBoolean(false)
private var count: AtomicInteger = AtomicInteger(0) 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?) { private fun log(level: Int, tag: String, message: String, exception: Throwable?) {
if (!record.get()) return if (!record.get()) return
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) { // FIXME This does not guarantee the log order
logsDao.insert(Logs(System.currentTimeMillis(), tag, level, message, exception?.stackTraceToString())) logsDao.insert(LogEntry(System.currentTimeMillis(), tag, level, message, exception?.stackTraceToString()))
val current = count.incrementAndGet() val current = count.incrementAndGet()
if (current >= PRUNE_EVERY) { if (current >= PRUNE_EVERY) {
logsDao.prune(ENTRIES_MAX) 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 { 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) { 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) 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) getInstance()?.log(android.util.Log.DEBUG, tag, message, exception)
@ -57,20 +108,27 @@ class Log(private val logsDao: LogsDao) {
return getInstance()?.record?.get() ?: false 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) { fun init(context: Context) {
return synchronized(Log::class) { return synchronized(Log::class) {
if (instance == null) { if (instance == null) {
val database = Database.getInstance(context.applicationContext) 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? { private fun getInstance(): Log? {
return synchronized(Log::class) { return synchronized(Log::class) {
instance instance

View file

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

View file

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

View file

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

View file

@ -82,6 +82,8 @@ class SubscriberService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.init(this) // Init logs in all entry points
Log.d(TAG, "Subscriber service has been created") Log.d(TAG, "Subscriber service has been created")
val title = getString(R.string.channel_subscriber_notification_title) 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.service.SubscriberServiceManager
import io.heckel.ntfy.util.fadeStatusBarColor import io.heckel.ntfy.util.fadeStatusBarColor
import io.heckel.ntfy.util.formatDateShort import io.heckel.ntfy.util.formatDateShort
import io.heckel.ntfy.util.shortUrl
import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.work.PollWorker import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -63,9 +64,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
Log.init(this) Log.init(this) // Init logs in all entry points
Log.setRecord(true)
Log.d(TAG, "Create $this") Log.d(TAG, "Create $this")
// Dependencies that depend on Context // Dependencies that depend on Context
@ -98,6 +97,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
viewModel.list().observe(this) { viewModel.list().observe(this) {
it?.let { subscriptions -> it?.let { subscriptions ->
// Update main list
adapter.submitList(subscriptions as MutableList<Subscription>) adapter.submitList(subscriptions as MutableList<Subscription>)
if (it.isEmpty()) { if (it.isEmpty()) {
mainListContainer.visibility = View.GONE mainListContainer.visibility = View.GONE
@ -106,6 +106,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
mainListContainer.visibility = View.VISIBLE mainListContainer.visibility = View.VISIBLE
noEntries.visibility = View.GONE 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 package io.heckel.ntfy.ui
import android.Manifest import android.Manifest
import android.app.AlertDialog
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
@ -14,6 +15,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.lifecycleScope
import androidx.preference.* import androidx.preference.*
import androidx.preference.Preference.OnPreferenceClickListener import androidx.preference.Preference.OnPreferenceClickListener
import io.heckel.ntfy.BuildConfig 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.formatBytes
import io.heckel.ntfy.util.formatDateShort import io.heckel.ntfy.util.formatDateShort
import io.heckel.ntfy.util.toPriorityString import io.heckel.ntfy.util.toPriorityString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
private lateinit var fragment: SettingsFragment 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 // UnifiedPush enabled
val upEnabledPrefId = context?.getString(R.string.settings_unified_push_enabled_key) ?: return val upEnabledPrefId = context?.getString(R.string.settings_unified_push_enabled_key) ?: return
val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId) 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 // Version
val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return
val versionPref: Preference? = findPreference(versionPrefId) val versionPref: Preference? = findPreference(versionPrefId)
@ -266,7 +327,7 @@ class SettingsActivity : AppCompatActivity() {
versionPref?.onPreferenceClickListener = OnPreferenceClickListener { versionPref?.onPreferenceClickListener = OnPreferenceClickListener {
val context = context ?: return@OnPreferenceClickListener false val context = context ?: return@OnPreferenceClickListener false
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 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) clipboard.setPrimaryClip(clip)
Toast Toast
.makeText(context, getString(R.string.settings_about_version_copied_to_clipboard_message), Toast.LENGTH_LONG) .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 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) { 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 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 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 topicUrlJsonPoll(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?poll=1&since=$since"
fun topicShortUrl(baseUrl: String, topic: String) = fun topicShortUrl(baseUrl: String, topic: String) = shortUrl(topicUrl(baseUrl, topic))
topicUrl(baseUrl, topic)
.replace("http://", "") fun shortUrl(url: String) = url
.replace("https://", "") .replace("http://", "")
.replace("https://", "")
fun formatDateShort(timestampSecs: Long): String { fun formatDateShort(timestampSecs: Long): String {
val date = Date(timestampSecs*1000) 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_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_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_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_experimental_header">Experimental</string>
<string name="settings_advanced_connection_protocol_key">ConnectionProtocol</string> <string name="settings_advanced_connection_protocol_key">ConnectionProtocol</string>
<string name="settings_advanced_connection_protocol_title">Connection protocol</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:key="@string/settings_advanced_broadcast_key"
app:title="@string/settings_advanced_broadcast_title" app:title="@string/settings_advanced_broadcast_title"
app:enabled="true"/> 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>
<PreferenceCategory app:title="@string/settings_experimental_header"> <PreferenceCategory app:title="@string/settings_experimental_header">
<ListPreference <ListPreference