Upload log feature

This commit is contained in:
Philipp Heckel 2022-01-18 22:07:49 -05:00
parent e402bbf381
commit 4855ba7d32
5 changed files with 191 additions and 86 deletions

View file

@ -1,12 +1,16 @@
package io.heckel.ntfy.log package io.heckel.ntfy.log
import android.content.Context import android.content.Context
import android.os.Build
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Database import io.heckel.ntfy.db.Database
import io.heckel.ntfy.db.LogDao import io.heckel.ntfy.db.LogDao
import io.heckel.ntfy.db.LogEntry import io.heckel.ntfy.db.LogEntry
import io.heckel.ntfy.util.isIgnoringBatteryOptimizations
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.text.SimpleDateFormat
import java.util.* 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
@ -29,19 +33,24 @@ class Log(private val logsDao: LogDao) {
} }
} }
fun getAll(): Collection<LogEntry> { fun getFormatted(): String {
return logsDao return prependDeviceInfo(formatEntries(scrubEntries(logsDao.getAll())))
.getAll()
.map { e ->
e.copy(
message = scrub(e.message)!!,
exception = scrub(e.exception)
)
}
} }
private fun deleteAll() { private fun prependDeviceInfo(s: String): String {
return logsDao.deleteAll() return """
This is a log of the ntfy Android app. The log shows up to 5,000 lines.
Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑.
Device info:
--
ntfy: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR})
OS: ${System.getProperty("os.version")}
Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})
Model: ${Build.DEVICE}
Product: ${Build.PRODUCT}
---
""".trimIndent() + "\n\n$s"
} }
fun addScrubTerm(term: String, type: TermType = TermType.Term) { fun addScrubTerm(term: String, type: TermType = TermType.Term) {
@ -49,13 +58,23 @@ class Log(private val logsDao: LogDao) {
return return
} }
val replaceTermIndex = scrubNum.incrementAndGet() val replaceTermIndex = scrubNum.incrementAndGet()
val replaceTerm = REPLACE_TERMS.getOrNull(replaceTermIndex) ?: "scrubbed${replaceTermIndex}" val replaceTerm = REPLACE_TERMS.getOrNull(replaceTermIndex) ?: "fruit${replaceTermIndex}"
scrubTerms[term] = when (type) { scrubTerms[term] = when (type) {
TermType.Domain -> "$replaceTerm.example.com" TermType.Domain -> "$replaceTerm.example.com"
else -> replaceTerm else -> replaceTerm
} }
} }
private fun scrubEntries(entries: List<LogEntry>): List<LogEntry> {
return entries
.map { e ->
e.copy(
message = scrub(e.message)!!,
exception = scrub(e.exception)
)
}
}
private fun scrub(line: String?): String? { private fun scrub(line: String?): String? {
var newLine = line ?: return null var newLine = line ?: return null
scrubTerms.forEach { (scrubTerm, replaceTerm) -> scrubTerms.forEach { (scrubTerm, replaceTerm) ->
@ -64,6 +83,31 @@ class Log(private val logsDao: LogDao) {
return newLine return newLine
} }
private fun formatEntries(entries: List<LogEntry>): String {
return entries.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"
}
}
private fun deleteAll() {
return logsDao.deleteAll()
}
enum class TermType { enum class TermType {
Domain, Term Domain, Term
} }
@ -74,7 +118,7 @@ class Log(private val logsDao: LogDao) {
private const val ENTRIES_MAX = 5000 private const val ENTRIES_MAX = 5000
private val IGNORE_TERMS = listOf("ntfy.sh") private val IGNORE_TERMS = listOf("ntfy.sh")
private val REPLACE_TERMS = listOf( private val REPLACE_TERMS = listOf(
"potato", "banana", "coconut", "kiwi", "avocado", "orange", "apple", "lemon", "olive", "peach" "banana", "kiwi", "lemon", "coconut", "avocado", "orange", "apple", "peach"
) )
private var instance: Log? = null private var instance: Log? = null
@ -108,12 +152,13 @@ class Log(private val logsDao: LogDao) {
return getInstance()?.record?.get() ?: false return getInstance()?.record?.get() ?: false
} }
fun getAll(): Collection<LogEntry> { fun getFormatted(): String {
return getInstance()?.getAll().orEmpty() return getInstance()?.getFormatted() ?: "(no logs)"
} }
fun deleteAll() { fun deleteAll() {
getInstance()?.deleteAll() getInstance()?.deleteAll()
d(TAG, "Log was truncated")
} }
fun addScrubTerm(term: String, type: TermType = TermType.Term) { fun addScrubTerm(term: String, type: TermType = TermType.Term) {

View file

@ -1,7 +1,6 @@
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
@ -11,6 +10,7 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.widget.Toast import android.widget.Toast
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatActivity 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
@ -18,6 +18,7 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.* import androidx.preference.*
import androidx.preference.Preference.OnPreferenceClickListener import androidx.preference.Preference.OnPreferenceClickListener
import com.google.gson.Gson
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
@ -28,8 +29,10 @@ 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import okhttp3.OkHttpClient
import java.util.* import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
private lateinit var fragment: SettingsFragment private lateinit var fragment: SettingsFragment
@ -222,14 +225,26 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
// Copy logs // Export logs
val copyLogsPrefId = context?.getString(R.string.settings_advanced_copy_logs_key) ?: return val exportLogsPrefId = context?.getString(R.string.settings_advanced_export_logs_key) ?: return
val copyLogs: Preference? = findPreference(copyLogsPrefId) val exportLogs: ListPreference? = findPreference(exportLogsPrefId)
copyLogs?.isVisible = Log.getRecord() exportLogs?.isVisible = Log.getRecord()
copyLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting exportLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
copyLogs?.onPreferenceClickListener = OnPreferenceClickListener { exportLogs?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v ->
copyLogsToClipboard() when (v) {
true EXPORT_LOGS_COPY -> copyLogsToClipboard()
EXPORT_LOGS_UPLOAD -> uploadLogsToNopaste()
}
false
}
val clearLogsPrefId = context?.getString(R.string.settings_advanced_clear_logs_key) ?: return
val clearLogs: Preference? = findPreference(clearLogsPrefId)
clearLogs?.isVisible = Log.getRecord()
clearLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
clearLogs?.onPreferenceClickListener = OnPreferenceClickListener {
deleteLogs()
false
} }
// Record logs // Record logs
@ -240,7 +255,8 @@ class SettingsActivity : AppCompatActivity() {
override fun putBoolean(key: String?, value: Boolean) { override fun putBoolean(key: String?, value: Boolean) {
repository.setRecordLogsEnabled(value) repository.setRecordLogsEnabled(value)
Log.setRecord(value) Log.setRecord(value)
copyLogs?.isVisible = value exportLogs?.isVisible = value
clearLogs?.isVisible = value
} }
override fun getBoolean(key: String?, defValue: Boolean): Boolean { override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return Log.getRecord() return Log.getRecord()
@ -253,29 +269,6 @@ class SettingsActivity : AppCompatActivity() {
getString(R.string.settings_advanced_record_logs_summary_disabled) 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 // Connection protocol
val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return
@ -354,35 +347,79 @@ class SettingsActivity : AppCompatActivity() {
private fun copyLogsToClipboard() { private fun copyLogsToClipboard() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val log = Log.getAll().joinToString(separator = "\n") { e -> val log = Log.getFormatted()
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 val context = context ?: return@launch
requireActivity().runOnUiThread { requireActivity().runOnUiThread {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("ntfy logs", log) val clip = ClipData.newPlainText("ntfy logs", log)
clipboard.setPrimaryClip(clip) clipboard.setPrimaryClip(clip)
Toast Toast
.makeText(context, getString(R.string.settings_advanced_copy_logs_copied), Toast.LENGTH_LONG) .makeText(context, getString(R.string.settings_advanced_export_logs_copied_logs), Toast.LENGTH_LONG)
.show() .show()
} }
} }
} }
private fun uploadLogsToNopaste() {
lifecycleScope.launch(Dispatchers.IO) {
Log.d(TAG, "Uploading log to $EXPORT_LOGS_UPLOAD_URL ...")
val log = Log.getFormatted()
val gson = Gson()
val request = Request.Builder()
.url(EXPORT_LOGS_UPLOAD_URL)
.put(log.toRequestBody())
.build()
val client = OkHttpClient.Builder()
.callTimeout(1, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw Exception("Unexpected response ${response.code}")
}
val body = response.body?.string()?.trim()
if (body == null || body.isEmpty()) throw Exception("Return body is empty")
Log.d(TAG, "Logs uploaded successfully: $body")
val resp = gson.fromJson(body.toString(), NopasteResponse::class.java)
val context = context ?: return@launch
requireActivity().runOnUiThread {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("logs URL", resp.url)
clipboard.setPrimaryClip(clip)
Toast
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_url), Toast.LENGTH_LONG)
.show()
}
}
} catch (e: Exception) {
Log.w(TAG, "Error uploading logs", e)
val context = context ?: return@launch
requireActivity().runOnUiThread {
Toast
.makeText(context, getString(R.string.settings_advanced_export_logs_error_uploading, e.message), Toast.LENGTH_LONG)
.show()
}
}
}
}
private fun deleteLogs() {
lifecycleScope.launch(Dispatchers.IO) {
Log.deleteAll()
val context = context ?: return@launch
requireActivity().runOnUiThread {
Toast
.makeText(context, getString(R.string.settings_advanced_clear_logs_deleted_toast), Toast.LENGTH_LONG)
.show()
}
}
}
@Keep
data class NopasteResponse(val url: String)
} }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
@ -403,5 +440,8 @@ class SettingsActivity : AppCompatActivity() {
private const val TAG = "NtfySettingsActivity" private const val TAG = "NtfySettingsActivity"
private const val REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD = 2586 private const val REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD = 2586
private const val AUTO_DOWNLOAD_SELECTION_NOT_SET = -99L private const val AUTO_DOWNLOAD_SELECTION_NOT_SET = -99L
private const val EXPORT_LOGS_COPY = "copy"
private const val EXPORT_LOGS_UPLOAD = "upload"
private const val EXPORT_LOGS_UPLOAD_URL = "https://nopaste.net/?f=json" // Run by binwiederhier; see https://github.com/binwiederhier/pcopy
} }
} }

View file

@ -240,13 +240,18 @@
<string name="settings_advanced_record_logs_title">Record logs</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_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_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_export_logs_key">ExportLogs</string>
<string name="settings_advanced_record_logs_delete_dialog_button_keep">Keep logs</string> <string name="settings_advanced_export_logs_title">Copy/upload logs</string>
<string name="settings_advanced_record_logs_delete_dialog_button_delete">Delete logs</string> <string name="settings_advanced_export_logs_summary">Copy logs to the clipboard, or upload to nopaste.net (owned by ntfy author). Hostnames and topics are scrubbed, notifications are not.</string>
<string name="settings_advanced_copy_logs_key">CopyLogs</string> <string name="settings_advanced_export_logs_entry_copy">Copy to clipboard</string>
<string name="settings_advanced_copy_logs_title">Copy logs</string> <string name="settings_advanced_export_logs_entry_upload">Upload to nopaste.net</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_export_logs_copied_logs">Logs copied to clipboard</string>
<string name="settings_advanced_copy_logs_copied">Copied to clipboard</string> <string name="settings_advanced_export_logs_copied_url">URL copied to clipboard</string>
<string name="settings_advanced_export_logs_error_uploading">Error uploading logs: %1$s</string>
<string name="settings_advanced_clear_logs_key">ClearLogs</string>
<string name="settings_advanced_clear_logs_title">Clear logs</string>
<string name="settings_advanced_clear_logs_summary">Delete previously recorded logs, and start over</string>
<string name="settings_advanced_clear_logs_deleted_toast">Logs successfully deleted</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

@ -42,4 +42,12 @@
<item>jsonhttp</item> <item>jsonhttp</item>
<item>ws</item> <item>ws</item>
</string-array> </string-array>
<string-array name="settings_advanced_export_logs_entries">
<item>@string/settings_advanced_export_logs_entry_copy</item>
<item>@string/settings_advanced_export_logs_entry_upload</item>
</string-array>
<string-array name="settings_advanced_export_logs_values">
<item>copy</item>
<item>upload</item>
</string-array>
</resources> </resources>

View file

@ -42,10 +42,17 @@
app:key="@string/settings_advanced_record_logs_key" app:key="@string/settings_advanced_record_logs_key"
app:title="@string/settings_advanced_record_logs_title" app:title="@string/settings_advanced_record_logs_title"
app:enabled="true"/> app:enabled="true"/>
<ListPreference
app:key="@string/settings_advanced_export_logs_key"
app:title="@string/settings_advanced_export_logs_title"
app:summary="@string/settings_advanced_export_logs_summary"
app:entries="@array/settings_advanced_export_logs_entries"
app:entryValues="@array/settings_advanced_export_logs_values"
app:defaultValue="copy"/>
<Preference <Preference
app:key="@string/settings_advanced_copy_logs_key" app:key="@string/settings_advanced_clear_logs_key"
app:title="@string/settings_advanced_copy_logs_title" app:title="@string/settings_advanced_clear_logs_title"
android:summary="@string/settings_advanced_copy_logs_summary"/> app:summary="@string/settings_advanced_clear_logs_summary"/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory app:title="@string/settings_experimental_header"> <PreferenceCategory app:title="@string/settings_experimental_header">
<ListPreference <ListPreference