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
import android.content.Context
import android.os.Build
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Database
import io.heckel.ntfy.db.LogDao
import io.heckel.ntfy.db.LogEntry
import io.heckel.ntfy.util.isIgnoringBatteryOptimizations
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
@ -29,9 +33,40 @@ class Log(private val logsDao: LogDao) {
}
}
fun getAll(): Collection<LogEntry> {
return logsDao
.getAll()
fun getFormatted(): String {
return prependDeviceInfo(formatEntries(scrubEntries(logsDao.getAll())))
}
private fun prependDeviceInfo(s: String): String {
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) {
if (scrubTerms[term] != null || IGNORE_TERMS.contains(term)) {
return
}
val replaceTermIndex = scrubNum.incrementAndGet()
val replaceTerm = REPLACE_TERMS.getOrNull(replaceTermIndex) ?: "fruit${replaceTermIndex}"
scrubTerms[term] = when (type) {
TermType.Domain -> "$replaceTerm.example.com"
else -> replaceTerm
}
}
private fun scrubEntries(entries: List<LogEntry>): List<LogEntry> {
return entries
.map { e ->
e.copy(
message = scrub(e.message)!!,
@ -40,22 +75,6 @@ class Log(private val logsDao: LogDao) {
}
}
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) ->
@ -64,6 +83,31 @@ class Log(private val logsDao: LogDao) {
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 {
Domain, Term
}
@ -74,7 +118,7 @@ class Log(private val logsDao: LogDao) {
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"
"banana", "kiwi", "lemon", "coconut", "avocado", "orange", "apple", "peach"
)
private var instance: Log? = null
@ -108,12 +152,13 @@ class Log(private val logsDao: LogDao) {
return getInstance()?.record?.get() ?: false
}
fun getAll(): Collection<LogEntry> {
return getInstance()?.getAll().orEmpty()
fun getFormatted(): String {
return getInstance()?.getFormatted() ?: "(no logs)"
}
fun deleteAll() {
getInstance()?.deleteAll()
d(TAG, "Log was truncated")
}
fun addScrubTerm(term: String, type: TermType = TermType.Term) {

View file

@ -1,7 +1,6 @@
package io.heckel.ntfy.ui
import android.Manifest
import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@ -11,6 +10,7 @@ import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.widget.Toast
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
@ -18,6 +18,7 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.lifecycleScope
import androidx.preference.*
import androidx.preference.Preference.OnPreferenceClickListener
import com.google.gson.Gson
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository
@ -28,8 +29,10 @@ 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.*
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
class SettingsActivity : AppCompatActivity() {
private lateinit var fragment: SettingsFragment
@ -222,14 +225,26 @@ class SettingsActivity : AppCompatActivity() {
}
}
// 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
// Export logs
val exportLogsPrefId = context?.getString(R.string.settings_advanced_export_logs_key) ?: return
val exportLogs: ListPreference? = findPreference(exportLogsPrefId)
exportLogs?.isVisible = Log.getRecord()
exportLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
exportLogs?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v ->
when (v) {
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
@ -240,7 +255,8 @@ class SettingsActivity : AppCompatActivity() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setRecordLogsEnabled(value)
Log.setRecord(value)
copyLogs?.isVisible = value
exportLogs?.isVisible = value
clearLogs?.isVisible = value
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return Log.getRecord()
@ -253,29 +269,6 @@ class SettingsActivity : AppCompatActivity() {
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
@ -354,35 +347,79 @@ class SettingsActivity : AppCompatActivity() {
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 log = Log.getFormatted()
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)
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_logs), Toast.LENGTH_LONG)
.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) {
@ -403,5 +440,8 @@ class SettingsActivity : AppCompatActivity() {
private const val TAG = "NtfySettingsActivity"
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 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_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_advanced_export_logs_key">ExportLogs</string>
<string name="settings_advanced_export_logs_title">Copy/upload 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_export_logs_entry_copy">Copy to clipboard</string>
<string name="settings_advanced_export_logs_entry_upload">Upload to nopaste.net</string>
<string name="settings_advanced_export_logs_copied_logs">Logs 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_advanced_connection_protocol_key">ConnectionProtocol</string>
<string name="settings_advanced_connection_protocol_title">Connection protocol</string>

View file

@ -42,4 +42,12 @@
<item>jsonhttp</item>
<item>ws</item>
</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>

View file

@ -42,10 +42,17 @@
app:key="@string/settings_advanced_record_logs_key"
app:title="@string/settings_advanced_record_logs_title"
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
app:key="@string/settings_advanced_copy_logs_key"
app:title="@string/settings_advanced_copy_logs_title"
android:summary="@string/settings_advanced_copy_logs_summary"/>
app:key="@string/settings_advanced_clear_logs_key"
app:title="@string/settings_advanced_clear_logs_title"
app:summary="@string/settings_advanced_clear_logs_summary"/>
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_experimental_header">
<ListPreference