Copy logs to clipboard; scrub terms; add schema migration
This commit is contained in:
parent
5fb3ae0536
commit
7152469172
15 changed files with 562 additions and 100 deletions
|
@ -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'
|
||||
|
|
256
app/schemas/io.heckel.ntfy.data.Database/7.json
Normal file
256
app/schemas/io.heckel.ntfy.data.Database/7.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -19,8 +19,9 @@ 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)
|
||||
fun topicShortUrl(baseUrl: String, topic: String) = shortUrl(topicUrl(baseUrl, topic))
|
||||
|
||||
fun shortUrl(url: String) = url
|
||||
.replace("http://", "")
|
||||
.replace("https://", "")
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue