Backup/restore settings

This commit is contained in:
Philipp Heckel 2022-03-14 17:10:44 -04:00
parent 2f0fa99d25
commit 8e1830d361
14 changed files with 561 additions and 59 deletions

View file

@ -0,0 +1,314 @@
package io.heckel.ntfy.backup
import android.content.Context
import android.net.Uri
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.stream.JsonReader
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.topicUrl
import java.io.InputStreamReader
class Backuper(val context: Context) {
private val gson = Gson()
private val resolver = context.applicationContext.contentResolver
private val repository = (context.applicationContext as Application).repository
suspend fun backup(uri: Uri, withSettings: Boolean = true, withSubscriptions: Boolean = true, withUsers: Boolean = true) {
Log.d(TAG, "Backing up settings to file $uri")
val json = gson.toJson(createBackupFile(withSettings, withSubscriptions, withUsers))
val outputStream = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
outputStream.use { it.write(json.toByteArray()) }
Log.d(TAG, "Backup done")
}
suspend fun restore(uri: Uri) {
Log.d(TAG, "Restoring settings from file $uri")
val reader = JsonReader(InputStreamReader(resolver.openInputStream(uri)))
val backupFile = gson.fromJson<BackupFile>(reader, BackupFile::class.java)
applyBackupFile(backupFile)
Log.d(TAG, "Restoring done")
}
fun settingsAsString(): String {
val gson = GsonBuilder().setPrettyPrinting().create()
return gson.toJson(createSettings())
}
private suspend fun applyBackupFile(backupFile: BackupFile) {
if (backupFile.magic != FILE_MAGIC) {
throw InvalidBackupFileException()
}
applySettings(backupFile.settings)
applySubscriptions(backupFile.subscriptions)
applyNotifications(backupFile.notifications)
applyUsers(backupFile.users)
}
private fun applySettings(settings: Settings?) {
if (settings == null) {
return
}
if (settings.minPriority != null) {
repository.setMinPriority(settings.minPriority)
}
if (settings.autoDownloadMaxSize != null) {
repository.setAutoDownloadMaxSize(settings.autoDownloadMaxSize)
}
if (settings.autoDeleteSeconds != null) {
repository.setAutoDeleteSeconds(settings.autoDeleteSeconds)
}
if (settings.darkMode != null) {
repository.setDarkMode(settings.darkMode)
}
if (settings.connectionProtocol != null) {
repository.setConnectionProtocol(settings.connectionProtocol)
}
if (settings.broadcastEnabled != null) {
repository.setBroadcastEnabled(settings.broadcastEnabled)
}
if (settings.recordLogs != null) {
repository.setRecordLogsEnabled(settings.recordLogs)
}
if (settings.defaultBaseUrl != null) {
repository.setDefaultBaseUrl(settings.defaultBaseUrl)
}
if (settings.mutedUntil != null) {
repository.setGlobalMutedUntil(settings.mutedUntil)
}
if (settings.lastSharedTopics != null) {
settings.lastSharedTopics.forEach { repository.addLastShareTopic(it) }
}
}
private suspend fun applySubscriptions(subscriptions: List<Subscription>?) {
if (subscriptions == null) {
return;
}
subscriptions.forEach { s ->
try {
repository.addSubscription(io.heckel.ntfy.db.Subscription(
id = s.id,
baseUrl = s.baseUrl,
topic = s.topic,
instant = s.instant,
mutedUntil = s.mutedUntil,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken
))
} catch (e: Exception) {
Log.w(TAG, "Unable to restore subscription ${s.id} (${topicUrl(s.baseUrl, s.topic)}): ${e.message}. Ignoring.", e)
}
}
}
private suspend fun applyNotifications(notifications: List<Notification>?) {
if (notifications == null) {
return;
}
notifications.forEach { n ->
try {
val attachment = if (n.attachment != null) {
io.heckel.ntfy.db.Attachment(
name = n.attachment.name,
type = n.attachment.type,
size = n.attachment.size,
expires = n.attachment.expires,
url = n.attachment.url,
contentUri = n.attachment.contentUri,
progress = n.attachment.progress,
)
} else {
null
}
repository.addNotification(io.heckel.ntfy.db.Notification(
id = n.id,
subscriptionId = n.subscriptionId,
timestamp = n.timestamp,
title = n.title,
message = n.message,
encoding = n.encoding,
notificationId = 0,
priority = n.priority,
tags = n.tags,
click = n.click,
attachment = attachment,
deleted = n.deleted
))
} catch (e: Exception) {
Log.w(TAG, "Unable to restore notification ${n.id}: ${e.message}. Ignoring.", e)
}
}
}
private suspend fun applyUsers(users: List<User>?) {
if (users == null) {
return;
}
users.forEach { u ->
try {
repository.addUser(io.heckel.ntfy.db.User(
baseUrl = u.baseUrl,
username = u.username,
password = u.password
))
} catch (e: Exception) {
Log.w(TAG, "Unable to restore user ${u.baseUrl} / ${u.username}: ${e.message}. Ignoring.", e)
}
}
}
private suspend fun createBackupFile(withSettings: Boolean, withSubscriptions: Boolean, withUsers: Boolean): BackupFile {
return BackupFile(
magic = FILE_MAGIC,
version = FILE_VERSION,
settings = if (withSettings) createSettings() else null,
subscriptions = if (withSubscriptions) createSubscriptionList() else null,
notifications = if (withSubscriptions) createNotificationList() else null,
users = if (withUsers) createUserList() else null
)
}
private fun createSettings(): Settings {
return Settings(
minPriority = repository.getMinPriority(),
autoDownloadMaxSize = repository.getAutoDownloadMaxSize(),
autoDeleteSeconds = repository.getAutoDeleteSeconds(),
darkMode = repository.getDarkMode(),
connectionProtocol = repository.getConnectionProtocol(),
broadcastEnabled = repository.getBroadcastEnabled(),
recordLogs = repository.getRecordLogs(),
defaultBaseUrl = repository.getDefaultBaseUrl() ?: "",
mutedUntil = repository.getGlobalMutedUntil(),
lastSharedTopics = repository.getLastShareTopics()
)
}
private suspend fun createSubscriptionList(): List<Subscription> {
return repository.getSubscriptions().map { s ->
Subscription(
id = s.id,
baseUrl = s.baseUrl,
topic = s.topic,
instant = s.instant,
mutedUntil = s.mutedUntil,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken
)
}
}
private suspend fun createNotificationList(): List<Notification> {
return repository.getNotifications().map { n ->
val attachment = if (n.attachment != null) {
Attachment(
name = n.attachment.name,
type = n.attachment.type,
size = n.attachment.size,
expires = n.attachment.expires,
url = n.attachment.url,
contentUri = n.attachment.contentUri,
progress = n.attachment.progress,
)
} else {
null
}
Notification(
id = n.id,
subscriptionId = n.subscriptionId,
timestamp = n.timestamp,
title = n.title,
message = n.message,
encoding = n.encoding,
priority = n.priority,
tags = n.tags,
click = n.click,
attachment = attachment,
deleted = n.deleted
)
}
}
private suspend fun createUserList(): List<User> {
return repository.getUsers().map { u ->
User(
baseUrl = u.baseUrl,
username = u.username,
password = u.password
)
}
}
companion object {
const val MIME_TYPE = "application/json"
private const val FILE_MAGIC = "ntfy2586"
private const val FILE_VERSION = 1
private const val TAG = "NtfyExporter"
}
}
data class BackupFile(
val magic: String,
val version: Int,
val settings: Settings?,
val subscriptions: List<Subscription>?,
val notifications: List<Notification>?,
val users: List<User>?
)
data class Settings(
val minPriority: Int?,
val autoDownloadMaxSize: Long?,
val autoDeleteSeconds: Long?,
val darkMode: Int?,
val connectionProtocol: String?,
val broadcastEnabled: Boolean?,
val recordLogs: Boolean?,
val defaultBaseUrl: String?,
val mutedUntil: Long?,
val lastSharedTopics: List<String>?,
)
data class Subscription(
val id: Long,
val baseUrl: String,
val topic: String,
val instant: Boolean,
val mutedUntil: Long,
val upAppId: String?,
val upConnectorToken: String?
)
data class Notification(
val id: String,
val subscriptionId: Long,
val timestamp: Long,
val title: String,
val message: String,
val encoding: String, // "base64" or ""
val priority: Int, // 1=min, 3=default, 5=max
val tags: String,
val click: String, // URL/intent to open on notification click
val attachment: Attachment?,
val deleted: Boolean
)
data class Attachment(
val name: String, // Filename
val type: String?, // MIME type
val size: Long?, // Size in bytes
val expires: Long?, // Unix timestamp
val url: String, // URL (mandatory, see ntfy server)
val contentUri: String?, // After it's downloaded, the content:// location
val progress: Int, // Progress during download, -1 if not downloaded
)
data class User(
val baseUrl: String,
val username: String,
val password: String
)
class InvalidBackupFileException : Exception("Invalid backup file format")

View file

@ -228,7 +228,7 @@ interface SubscriptionDao {
GROUP BY s.id
ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
""")
fun list(): List<SubscriptionWithMetadata>
suspend fun list(): List<SubscriptionWithMetadata>
@Query("""
SELECT
@ -281,6 +281,9 @@ interface SubscriptionDao {
@Dao
interface NotificationDao {
@Query("SELECT * FROM notification")
suspend fun list(): List<Notification>
@Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC")
fun listFlow(subscriptionId: Long): Flow<List<Notification>>
@ -326,6 +329,9 @@ interface UserDao {
@Query("SELECT * FROM user ORDER BY username")
suspend fun list(): List<User>
@Query("SELECT * FROM user ORDER BY username")
fun listFlow(): Flow<List<User>>
@Query("SELECT * FROM user WHERE baseUrl = :baseUrl")
suspend fun get(baseUrl: String): User?

View file

@ -40,11 +40,11 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
.map { list -> list.map { Pair(it.id, it.instant) }.toSet() }
}
fun getSubscriptions(): List<Subscription> {
suspend fun getSubscriptions(): List<Subscription> {
return toSubscriptionList(subscriptionDao.list())
}
fun getSubscriptionIdsWithInstantStatus(): Set<Pair<Long, Boolean>> {
suspend fun getSubscriptionIdsWithInstantStatus(): Set<Pair<Long, Boolean>> {
return subscriptionDao
.list()
.map { Pair(it.id, it.instant) }.toSet()
@ -84,6 +84,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
subscriptionDao.remove(subscriptionId)
}
suspend fun getNotifications(): List<Notification> {
return notificationDao.list()
}
fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> {
return notificationDao.listFlow(subscriptionId).asLiveData()
}
@ -144,6 +148,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
return userDao.list()
}
fun getUsersLiveData(): LiveData<List<User>> {
return userDao.listFlow().asLiveData()
}
suspend fun addUser(user: User) {
userDao.insert(user)
}

View file

@ -18,6 +18,7 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.ensureSafeNewFile
import io.heckel.ntfy.util.fileName
import okhttp3.OkHttpClient
import okhttp3.Request
@ -216,25 +217,6 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
}
}
private fun ensureSafeNewFile(dir: File, name: String): File {
val safeName = name.replace("[^-_.()\\w]+".toRegex(), "_");
val file = File(dir, safeName)
if (!file.exists()) {
return file
}
(1..1000).forEach { i ->
val newFile = File(dir, if (file.extension == "") {
"${file.nameWithoutExtension} ($i)"
} else {
"${file.nameWithoutExtension} ($i).${file.extension}"
})
if (!newFile.exists()) {
return newFile
}
}
throw Exception("Cannot find safe file")
}
companion object {
const val INPUT_DATA_ID = "id"
const val INPUT_DATA_USER_ACTION = "userAction"

View file

@ -6,6 +6,8 @@ import androidx.core.content.ContextCompat
import androidx.work.*
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* This class only manages the SubscriberService, i.e. it starts or stops it.
@ -34,24 +36,27 @@ class SubscriberServiceManager(private val context: Context) {
* Starts or stops the foreground service by figuring out how many instant delivery subscriptions
* exist. If there's > 0, then we need a foreground service.
*/
class ServiceStartWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
class ServiceStartWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val id = this.id
if (context.applicationContext !is Application) {
Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: ${this.id})")
Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: ${id})")
return Result.failure()
}
val app = context.applicationContext as Application
val subscriptionIdsWithInstantStatus = app.repository.getSubscriptionIdsWithInstantStatus()
val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size
val action = if (instantSubscriptions > 0) SubscriberService.Action.START else SubscriberService.Action.STOP
val serviceState = SubscriberService.readServiceState(context)
if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Action.STOP) {
return Result.success()
}
Log.d(TAG, "ServiceStartWorker: Starting foreground service with action $action (work ID: ${this.id})")
Intent(context, SubscriberService::class.java).also {
it.action = action.name
ContextCompat.startForegroundService(context, it)
withContext(Dispatchers.IO) {
val app = context.applicationContext as Application
val subscriptionIdsWithInstantStatus = app.repository.getSubscriptionIdsWithInstantStatus()
val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size
val action = if (instantSubscriptions > 0) SubscriberService.Action.START else SubscriberService.Action.STOP
val serviceState = SubscriberService.readServiceState(context)
if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Action.STOP) {
return@withContext Result.success()
}
Log.d(TAG, "ServiceStartWorker: Starting foreground service with action $action (work ID: ${id})")
Intent(context, SubscriberService::class.java).also {
it.action = action.name
ContextCompat.startForegroundService(context, it)
}
}
return Result.success()
}

View file

@ -124,6 +124,25 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
}
// Add scrub terms to log (in case it gets exported) // FIXME this should be in Log.getFormatted
repository.getUsersLiveData().observe(this) {
it?.let { users ->
users.forEach { u ->
Log.addScrubTerm(shortUrl(u.baseUrl), Log.TermType.Domain)
Log.addScrubTerm(u.username, Log.TermType.Username)
Log.addScrubTerm(u.password, Log.TermType.Password)
}
}
}
// Scrub terms for last topics // FIXME this should be in Log.getFormatted
repository.getLastShareTopics().forEach { topicUrl ->
maybeSplitTopicUrl(topicUrl)?.let {
Log.addScrubTerm(shortUrl(it.first), Log.TermType.Domain)
Log.addScrubTerm(shortUrl(it.second), Log.TermType.Term)
}
}
// React to changes in instant delivery setting
viewModel.listIdsWithInstantStatus().observe(this) {
SubscriberServiceManager.refresh(this)

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.ui
import android.Manifest
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
@ -10,6 +11,7 @@ import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
@ -22,19 +24,17 @@ import androidx.preference.Preference.OnPreferenceClickListener
import com.google.gson.Gson
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.backup.Backuper
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.formatBytes
import io.heckel.ntfy.util.formatDateShort
import io.heckel.ntfy.util.shortUrl
import io.heckel.ntfy.util.toPriorityString
import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
@ -340,6 +340,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
false
}
// Clear logs
val clearLogsPrefId = context?.getString(R.string.settings_advanced_clear_logs_key) ?: return
val clearLogs: Preference? = findPreference(clearLogsPrefId)
clearLogs?.isVisible = Log.getRecord()
@ -377,10 +378,84 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
Log.addScrubTerm(shortUrl(s.baseUrl), Log.TermType.Domain)
Log.addScrubTerm(s.topic)
}
repository.getUsers().forEach { u ->
Log.addScrubTerm(shortUrl(u.baseUrl), Log.TermType.Domain)
Log.addScrubTerm(u.username, Log.TermType.Username)
Log.addScrubTerm(u.password, Log.TermType.Password)
}
}
true
}
// Backup
val backuper = Backuper(requireContext())
val backupPrefId = context?.getString(R.string.settings_backup_restore_backup_key) ?: return
val backup: ListPreference? = findPreference(backupPrefId)
var backupSelection = BACKUP_EVERYTHING
val backupResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri ->
if (uri == null) {
return@registerForActivityResult
}
lifecycleScope.launch(Dispatchers.IO) {
try {
when (backupSelection) {
BACKUP_EVERYTHING -> backuper.backup(uri)
BACKUP_EVERYTHING_NO_USERS -> backuper.backup(uri, withUsers = false)
BACKUP_SETTINGS_ONLY -> backuper.backup(uri, withUsers = false, withSubscriptions = false)
}
requireActivity().runOnUiThread {
Toast.makeText(context, getString(R.string.settings_backup_restore_backup_successful), Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
Log.w(TAG, "Backup failed", e)
requireActivity().runOnUiThread {
Toast.makeText(context, getString(R.string.settings_backup_restore_backup_failed, e.message), Toast.LENGTH_LONG).show()
}
}
}
}
backup?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
backup?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v ->
backupSelection = v.toString()
val timestamp = SimpleDateFormat("yyMMdd-HHmm").format(Date());
val suggestedFilename = when (backupSelection) {
BACKUP_EVERYTHING_NO_USERS -> "ntfy-backup-no-users-$timestamp.json"
BACKUP_SETTINGS_ONLY -> "ntfy-settings-$timestamp.json"
else -> "ntfy-backup-$timestamp.json"
}
backupResultLauncher.launch(suggestedFilename)
false
}
backup?.onPreferenceClickListener = OnPreferenceClickListener {
true
}
// Restore
val restorePrefId = context?.getString(R.string.settings_backup_restore_restore_key) ?: return
val restore: Preference? = findPreference(restorePrefId)
val restoreResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
return@registerForActivityResult
}
lifecycleScope.launch(Dispatchers.IO) {
try {
backuper.restore(uri)
requireActivity().runOnUiThread {
Toast.makeText(context, getString(R.string.settings_backup_restore_restore_successful), Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
Log.w(TAG, "Restore failed", e)
requireActivity().runOnUiThread {
Toast.makeText(context, getString(R.string.settings_backup_restore_restore_failed, e.message), Toast.LENGTH_LONG).show()
}
}
}
}
restore?.onPreferenceClickListener = OnPreferenceClickListener {
restoreResultLauncher.launch(Backuper.MIME_TYPE)
true
}
// Connection protocol
val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return
val connectionProtocol: ListPreference? = findPreference(connectionProtocolPrefId)
@ -434,8 +509,8 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
private fun copyLogsToClipboard(scrub: Boolean) {
lifecycleScope.launch(Dispatchers.IO) {
val log = Log.getFormatted(scrub = scrub)
val context = context ?: return@launch
val log = Log.getFormatted(context, scrub = scrub)
requireActivity().runOnUiThread {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("ntfy logs", log)
@ -454,7 +529,8 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
private fun uploadLogsToNopaste(scrub: Boolean) {
lifecycleScope.launch(Dispatchers.IO) {
Log.d(TAG, "Uploading log to $EXPORT_LOGS_UPLOAD_URL ...")
val log = Log.getFormatted(scrub = scrub)
val context = context ?: return@launch
val log = Log.getFormatted(context, scrub = scrub)
if (log.length > EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD) {
requireActivity().runOnUiThread {
Toast
@ -672,6 +748,9 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
private const val TITLE_TAG = "title"
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 BACKUP_EVERYTHING = "everything"
private const val BACKUP_EVERYTHING_NO_USERS = "everything_no_users"
private const val BACKUP_SETTINGS_ONLY = "settings_only"
private const val EXPORT_LOGS_COPY_ORIGINAL = "copy_original"
private const val EXPORT_LOGS_COPY_SCRUBBED = "copy_scrubbed"
private const val EXPORT_LOGS_UPLOAD_ORIGINAL = "upload_original"

View file

@ -290,6 +290,8 @@ class ShareActivity : AppCompatActivity() {
)
runOnUiThread {
repository.addLastShareTopic(topicUrl(baseUrl, topic))
Log.addScrubTerm(shortUrl(baseUrl), Log.TermType.Domain)
Log.addScrubTerm(topic, Log.TermType.Term)
finish()
Toast
.makeText(this@ShareActivity, getString(R.string.share_successful), Toast.LENGTH_LONG)

View file

@ -3,6 +3,7 @@ package io.heckel.ntfy.util
import android.content.Context
import android.os.Build
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.backup.Backuper
import io.heckel.ntfy.db.Database
import io.heckel.ntfy.db.LogDao
import io.heckel.ntfy.db.LogEntry
@ -18,7 +19,7 @@ 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 val scrubTerms = Collections.synchronizedMap(mutableMapOf<String, ReplaceTerm>())
private fun log(level: Int, tag: String, message: String, exception: Throwable?) {
if (!record.get()) return
@ -32,15 +33,20 @@ class Log(private val logsDao: LogDao) {
}
}
fun getFormatted(scrub: Boolean): String {
fun getFormatted(context: Context, scrub: Boolean): String {
val backuper = Backuper(context)
return if (scrub) {
prependDeviceInfo(formatEntries(scrubEntries(logsDao.getAll())), scrubLine = true)
val logs = formatEntries(scrubEntries(logsDao.getAll()))
val settings = scrub(backuper.settingsAsString()) ?: ""
prependDeviceInfo(logs, settings, scrubLine = true)
} else {
prependDeviceInfo(formatEntries(logsDao.getAll()), scrubLine = false)
val logs = formatEntries(logsDao.getAll())
val settings = backuper.settingsAsString()
prependDeviceInfo(logs, settings, scrubLine = false)
}
}
private fun prependDeviceInfo(s: String, scrubLine: Boolean): String {
private fun prependDeviceInfo(logs: String, settings: String, scrubLine: Boolean): String {
val maybeScrubLine = if (scrubLine) "Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑.\n" else ""
return """
This is a log of the ntfy Android app. The log shows up to 1,000 entries.
@ -52,20 +58,27 @@ class Log(private val logsDao: LogDao) {
Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})
Model: ${Build.DEVICE}
Product: ${Build.PRODUCT}
--
""".trimIndent() + "\n\n$s"
Settings:
""".trimIndent() + "\n$settings\n\nLogs\n--\n\n$logs"
}
fun addScrubTerm(term: String, type: TermType = TermType.Term) {
if (scrubTerms[term] != null || IGNORE_TERMS.contains(term)) {
return
}
if (type == TermType.Password) {
scrubTerms[term] = ReplaceTerm(type, "********")
return
}
val replaceTermIndex = scrubNum.incrementAndGet()
val replaceTerm = REPLACE_TERMS.getOrNull(replaceTermIndex) ?: "fruit${replaceTermIndex}"
scrubTerms[term] = when (type) {
scrubTerms[term] = ReplaceTerm(type, when (type) {
TermType.Domain -> "$replaceTerm.example.com"
TermType.Username -> "${replaceTerm}user"
else -> replaceTerm
}
})
}
private fun scrubEntries(entries: List<LogEntry>): List<LogEntry> {
@ -81,7 +94,7 @@ class Log(private val logsDao: LogDao) {
private fun scrub(line: String?): String? {
var newLine = line ?: return null
scrubTerms.forEach { (scrubTerm, replaceTerm) ->
newLine = newLine.replace(scrubTerm, replaceTerm)
newLine = newLine.replace(scrubTerm, replaceTerm.replaceTerm)
}
return newLine
}
@ -112,16 +125,22 @@ class Log(private val logsDao: LogDao) {
}
enum class TermType {
Domain, Term
Domain, Username, Password, Term
}
data class ReplaceTerm(
val termType: TermType,
val replaceTerm: String
)
companion object {
private const val TAG = "NtfyLog"
private const val PRUNE_EVERY = 100
private const val ENTRIES_MAX = 1000
private val IGNORE_TERMS = listOf("ntfy.sh")
private val REPLACE_TERMS = listOf(
"banana", "kiwi", "lemon", "coconut", "avocado", "orange", "apple", "peach"
"banana", "kiwi", "lemon", "coconut", "avocado", "orange", "apple", "peach",
"pineapple", "dragonfruit", "durian", "starfruit"
)
private var instance: Log? = null
@ -155,12 +174,15 @@ class Log(private val logsDao: LogDao) {
return getInstance()?.record?.get() ?: false
}
fun getFormatted(scrub: Boolean): String {
return getInstance()?.getFormatted(scrub) ?: "(no logs)"
fun getFormatted(context: Context, scrub: Boolean): String {
return getInstance()?.getFormatted(context, scrub) ?: "(no logs)"
}
fun getScrubTerms(): Map<String, String> {
return getInstance()?.scrubTerms!!.toMap()
return getInstance()?.scrubTerms!!
.filter { e -> e.value.termType != TermType.Password } // We do not want to display passwords
.map { e -> e.key to e.value.replaceTerm }
.toMap()
}
fun deleteAll() {

View file

@ -29,6 +29,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.File
import java.io.IOException
import java.security.SecureRandom
import java.text.DateFormat
@ -53,6 +54,14 @@ fun splitTopicUrl(topicUrl: String): Pair<String, String> {
return Pair(topicUrl.substringBeforeLast("/"), topicUrl.substringAfterLast("/"))
}
fun maybeSplitTopicUrl(topicUrl: String): Pair<String, String>? {
return try {
splitTopicUrl(topicUrl)
} catch (_: Exception) {
null
}
}
fun validTopic(topic: String): Boolean {
return "[-_A-Za-z0-9]{1,64}".toRegex().matches(topic) // Must match server side!
}
@ -339,3 +348,21 @@ class AfterChangedTextWatcher(val afterTextChangedFn: (s: Editable?) -> Unit) :
}
}
fun ensureSafeNewFile(dir: File, name: String): File {
val safeName = name.replace("[^-_.()\\w]+".toRegex(), "_");
val file = File(dir, safeName)
if (!file.exists()) {
return file
}
(1..1000).forEach { i ->
val newFile = File(dir, if (file.extension == "") {
"${file.nameWithoutExtension} ($i)"
} else {
"${file.nameWithoutExtension} ($i).${file.extension}"
})
if (!newFile.exists()) {
return newFile
}
}
throw Exception("Cannot find safe file")
}

View file

@ -283,6 +283,20 @@
<string name="settings_general_dark_mode_entry_system">Use system default</string>
<string name="settings_general_dark_mode_entry_light">Light mode</string>
<string name="settings_general_dark_mode_entry_dark">Dark mode</string>
<string name="settings_backup_restore_header">Backup &amp; Restore</string>
<string name="settings_backup_restore_backup_key">Backup</string>
<string name="settings_backup_restore_backup_title">Backup to file</string>
<string name="settings_backup_restore_backup_summary">Export config, notifications and users</string>
<string name="settings_backup_restore_backup_entry_everything">Everything</string>
<string name="settings_backup_restore_backup_entry_everything_no_users">Everything, except users</string>
<string name="settings_backup_restore_backup_entry_settings_only">Settings only</string>
<string name="settings_backup_restore_backup_successful">Backup successful</string>
<string name="settings_backup_restore_backup_failed">Backup failed: %1$s</string>
<string name="settings_backup_restore_restore_key">Restore</string>
<string name="settings_backup_restore_restore_title">Restore from file</string>
<string name="settings_backup_restore_restore_summary">Import config, notifications and users</string>
<string name="settings_backup_restore_restore_successful">Restore successful</string>
<string name="settings_backup_restore_restore_failed">Restore failed: %1$s</string>
<string name="settings_advanced_header">Advanced</string>
<string name="settings_advanced_broadcast_key">BroadcastEnabled</string>
<string name="settings_advanced_broadcast_title">Broadcast messages</string>
@ -303,7 +317,7 @@
<string name="settings_advanced_export_logs_uploading">Uploading log …</string>
<string name="settings_advanced_export_logs_copied_url">Logs uploaded &amp; URL copied</string>
<string name="settings_advanced_export_logs_error_uploading">Error uploading logs: %1$s</string>
<string name="settings_advanced_export_logs_scrub_dialog_text">The following topics/hostnames were replaced with fruit names, so you can share the log without worry:\n\n%1$s</string>
<string name="settings_advanced_export_logs_scrub_dialog_text">The following topics/hostnames were replaced with fruit names, so you can share the log without worry:\n\n%1$s\n\nPasswords are always scrubbed but not listed here.</string>
<string name="settings_advanced_export_logs_scrub_dialog_empty">No topics/hostnames were redacted, maybe you don\'t have any subscriptions?</string>
<string name="settings_advanced_export_logs_scrub_dialog_button_ok">Got it</string>
<string name="settings_advanced_clear_logs_key">ClearLogs</string>

View file

@ -89,6 +89,16 @@
<item>upload_original</item>
<item>upload_scrubbed</item>
</string-array>
<string-array name="settings_backup_restore_backup_entries">
<item>@string/settings_backup_restore_backup_entry_everything</item>
<item>@string/settings_backup_restore_backup_entry_everything_no_users</item>
<item>@string/settings_backup_restore_backup_entry_settings_only</item>
</string-array>
<string-array name="settings_backup_restore_backup_values">
<item>everything</item>
<item>everything_no_users</item>
<item>settings_only</item>
</string-array>
<string-array name="settings_general_dark_mode_entries">
<item>@string/settings_general_dark_mode_entry_system</item>
<item>@string/settings_general_dark_mode_entry_light</item>

View file

@ -44,6 +44,19 @@
app:entryValues="@array/settings_general_dark_mode_values"
app:defaultValue="-1"/>
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_backup_restore_header">
<ListPreference
app:key="@string/settings_backup_restore_backup_key"
app:title="@string/settings_backup_restore_backup_title"
app:summary="@string/settings_backup_restore_backup_summary"
app:entries="@array/settings_backup_restore_backup_entries"
app:entryValues="@array/settings_backup_restore_backup_values"
app:defaultValue="everything"/>
<Preference
app:key="@string/settings_backup_restore_restore_key"
app:title="@string/settings_backup_restore_restore_title"
app:summary="@string/settings_backup_restore_restore_summary"/>
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_advanced_header">
<SwitchPreference
app:key="@string/settings_advanced_broadcast_key"

View file

@ -1,5 +1,6 @@
Features:
* Support for UnifiedPush 2.0 specification (bytes messages, #130)
* Export/import settings and subscriptions (#115, thanks @cmeis for reporting)
Bugs:
* Display locale-specific times, with AM/PM or 24h format (#140, thanks @hl2guide for reporting)