diff --git a/app/src/main/java/io/heckel/ntfy/log/Log.kt b/app/src/main/java/io/heckel/ntfy/log/Log.kt index ce6bfd3..69c265d 100644 --- a/app/src/main/java/io/heckel/ntfy/log/Log.kt +++ b/app/src/main/java/io/heckel/ntfy/log/Log.kt @@ -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 { - 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): List { + 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): 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 { - 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) { diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index d248798..d9975aa 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -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, 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 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ba4ef9..7680393 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -240,13 +240,18 @@ Record logs Logs are currently being recorded to your device. Up to 5,000 log lines are stored. Enable log recording, so you can share the logs later. This is useful for diagnosing issues. - Would you like to delete the existing logs? - Keep logs - Delete logs - CopyLogs - Copy logs - Copy logs to the clipboard. Hostnames and topics are scrubbed, notifications are not. - Copied to clipboard + ExportLogs + Copy/upload logs + Copy logs to the clipboard, or upload to nopaste.net (owned by ntfy author). Hostnames and topics are scrubbed, notifications are not. + Copy to clipboard + Upload to nopaste.net + Logs copied to clipboard + URL copied to clipboard + Error uploading logs: %1$s + ClearLogs + Clear logs + Delete previously recorded logs, and start over + Logs successfully deleted Experimental ConnectionProtocol Connection protocol diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 1ce4bc6..5987d50 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -42,4 +42,12 @@ jsonhttp ws + + @string/settings_advanced_export_logs_entry_copy + @string/settings_advanced_export_logs_entry_upload + + + copy + upload + diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index feb810e..519d17c 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -42,10 +42,17 @@ app:key="@string/settings_advanced_record_logs_key" app:title="@string/settings_advanced_record_logs_title" app:enabled="true"/> + + app:key="@string/settings_advanced_clear_logs_key" + app:title="@string/settings_advanced_clear_logs_title" + app:summary="@string/settings_advanced_clear_logs_summary"/>