From 8e1830d361dea3e7d434d23bd263d4fd345f6a54 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 14 Mar 2022 17:10:44 -0400 Subject: [PATCH] Backup/restore settings --- .../java/io/heckel/ntfy/backup/Backuper.kt | 314 ++++++++++++++++++ .../main/java/io/heckel/ntfy/db/Database.kt | 8 +- .../main/java/io/heckel/ntfy/db/Repository.kt | 12 +- .../java/io/heckel/ntfy/msg/DownloadWorker.kt | 20 +- .../ntfy/service/SubscriberServiceManager.kt | 35 +- .../java/io/heckel/ntfy/ui/MainActivity.kt | 19 ++ .../io/heckel/ntfy/ui/SettingsActivity.kt | 93 +++++- .../java/io/heckel/ntfy/ui/ShareActivity.kt | 2 + app/src/main/java/io/heckel/ntfy/util/Log.kt | 50 ++- app/src/main/java/io/heckel/ntfy/util/Util.kt | 27 ++ app/src/main/res/values/strings.xml | 16 +- app/src/main/res/values/values.xml | 10 + app/src/main/res/xml/main_preferences.xml | 13 + .../metadata/android/en-US/changelog/24.txt | 1 + 14 files changed, 561 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/backup/Backuper.kt diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt new file mode 100644 index 0000000..52720d3 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -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(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?) { + 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?) { + 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?) { + 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 { + 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 { + 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 { + 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?, + val notifications: List?, + val users: List? +) + +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?, +) + +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") diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 7b05ff3..272d597 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -228,7 +228,7 @@ interface SubscriptionDao { GROUP BY s.id ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC """) - fun list(): List + suspend fun list(): List @Query(""" SELECT @@ -281,6 +281,9 @@ interface SubscriptionDao { @Dao interface NotificationDao { + @Query("SELECT * FROM notification") + suspend fun list(): List + @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC") fun listFlow(subscriptionId: Long): Flow> @@ -326,6 +329,9 @@ interface UserDao { @Query("SELECT * FROM user ORDER BY username") suspend fun list(): List + @Query("SELECT * FROM user ORDER BY username") + fun listFlow(): Flow> + @Query("SELECT * FROM user WHERE baseUrl = :baseUrl") suspend fun get(baseUrl: String): User? diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index ffb837b..fada7d2 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -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 { + suspend fun getSubscriptions(): List { return toSubscriptionList(subscriptionDao.list()) } - fun getSubscriptionIdsWithInstantStatus(): Set> { + suspend fun getSubscriptionIdsWithInstantStatus(): Set> { 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 { + return notificationDao.list() + } + fun getNotificationsLiveData(subscriptionId: Long): LiveData> { return notificationDao.listFlow(subscriptionId).asLiveData() } @@ -144,6 +148,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas return userDao.list() } + fun getUsersLiveData(): LiveData> { + return userDao.listFlow().asLiveData() + } + suspend fun addUser(user: User) { userDao.insert(user) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt index d66af06..2d3028f 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt @@ -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" diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt index db59d19..ed4dfda 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt @@ -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() } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 02cc46f..6216d46 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -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) 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 63e510f..52d4ee6 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -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" diff --git a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt index b4b4d97..be7b20c 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt @@ -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) diff --git a/app/src/main/java/io/heckel/ntfy/util/Log.kt b/app/src/main/java/io/heckel/ntfy/util/Log.kt index 1875f35..7794a1d 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Log.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Log.kt @@ -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()) + private val scrubTerms = Collections.synchronizedMap(mutableMapOf()) 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): List { @@ -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 { - 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() { diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index bf60641..18e28c7 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -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 { return Pair(topicUrl.substringBeforeLast("/"), topicUrl.substringAfterLast("/")) } +fun maybeSplitTopicUrl(topicUrl: String): Pair? { + 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") +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d97c198..d502c73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -283,6 +283,20 @@ Use system default Light mode Dark mode + Backup & Restore + Backup + Backup to file + Export config, notifications and users + Everything + Everything, except users + Settings only + Backup successful + Backup failed: %1$s + Restore + Restore from file + Import config, notifications and users + Restore successful + Restore failed: %1$s Advanced BroadcastEnabled Broadcast messages @@ -303,7 +317,7 @@ Uploading log … Logs uploaded & URL copied Error uploading logs: %1$s - The following topics/hostnames were replaced with fruit names, so you can share the log without worry:\n\n%1$s + 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. No topics/hostnames were redacted, maybe you don\'t have any subscriptions? Got it ClearLogs diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 696b711..c4ce49e 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -89,6 +89,16 @@ upload_original upload_scrubbed + + @string/settings_backup_restore_backup_entry_everything + @string/settings_backup_restore_backup_entry_everything_no_users + @string/settings_backup_restore_backup_entry_settings_only + + + everything + everything_no_users + settings_only + @string/settings_general_dark_mode_entry_system @string/settings_general_dark_mode_entry_light diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index c2e98f6..3dd3826 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -44,6 +44,19 @@ app:entryValues="@array/settings_general_dark_mode_values" app:defaultValue="-1"/> + + + +