Upload log feature
This commit is contained in:
parent
e402bbf381
commit
4855ba7d32
5 changed files with 191 additions and 86 deletions
|
@ -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,19 +33,24 @@ class Log(private val logsDao: LogDao) {
|
|||
}
|
||||
}
|
||||
|
||||
fun getAll(): Collection<LogEntry> {
|
||||
return logsDao
|
||||
.getAll()
|
||||
.map { e ->
|
||||
e.copy(
|
||||
message = scrub(e.message)!!,
|
||||
exception = scrub(e.exception)
|
||||
)
|
||||
}
|
||||
fun getFormatted(): String {
|
||||
return prependDeviceInfo(formatEntries(scrubEntries(logsDao.getAll())))
|
||||
}
|
||||
|
||||
private fun deleteAll() {
|
||||
return logsDao.deleteAll()
|
||||
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) {
|
||||
|
@ -49,13 +58,23 @@ class Log(private val logsDao: LogDao) {
|
|||
return
|
||||
}
|
||||
val replaceTermIndex = scrubNum.incrementAndGet()
|
||||
val replaceTerm = REPLACE_TERMS.getOrNull(replaceTermIndex) ?: "scrubbed${replaceTermIndex}"
|
||||
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)!!,
|
||||
exception = scrub(e.exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue