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 GROUP BY s.id
ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
""") """)
fun list(): List<SubscriptionWithMetadata> suspend fun list(): List<SubscriptionWithMetadata>
@Query(""" @Query("""
SELECT SELECT
@ -281,6 +281,9 @@ interface SubscriptionDao {
@Dao @Dao
interface NotificationDao { 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") @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC")
fun listFlow(subscriptionId: Long): Flow<List<Notification>> fun listFlow(subscriptionId: Long): Flow<List<Notification>>
@ -326,6 +329,9 @@ interface UserDao {
@Query("SELECT * FROM user ORDER BY username") @Query("SELECT * FROM user ORDER BY username")
suspend fun list(): List<User> suspend fun list(): List<User>
@Query("SELECT * FROM user ORDER BY username")
fun listFlow(): Flow<List<User>>
@Query("SELECT * FROM user WHERE baseUrl = :baseUrl") @Query("SELECT * FROM user WHERE baseUrl = :baseUrl")
suspend fun get(baseUrl: String): User? 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() } .map { list -> list.map { Pair(it.id, it.instant) }.toSet() }
} }
fun getSubscriptions(): List<Subscription> { suspend fun getSubscriptions(): List<Subscription> {
return toSubscriptionList(subscriptionDao.list()) return toSubscriptionList(subscriptionDao.list())
} }
fun getSubscriptionIdsWithInstantStatus(): Set<Pair<Long, Boolean>> { suspend fun getSubscriptionIdsWithInstantStatus(): Set<Pair<Long, Boolean>> {
return subscriptionDao return subscriptionDao
.list() .list()
.map { Pair(it.id, it.instant) }.toSet() .map { Pair(it.id, it.instant) }.toSet()
@ -84,6 +84,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
subscriptionDao.remove(subscriptionId) subscriptionDao.remove(subscriptionId)
} }
suspend fun getNotifications(): List<Notification> {
return notificationDao.list()
}
fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> { fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> {
return notificationDao.listFlow(subscriptionId).asLiveData() return notificationDao.listFlow(subscriptionId).asLiveData()
} }
@ -144,6 +148,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
return userDao.list() return userDao.list()
} }
fun getUsersLiveData(): LiveData<List<User>> {
return userDao.listFlow().asLiveData()
}
suspend fun addUser(user: User) { suspend fun addUser(user: User) {
userDao.insert(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.app.Application
import io.heckel.ntfy.db.* import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.ensureSafeNewFile
import io.heckel.ntfy.util.fileName import io.heckel.ntfy.util.fileName
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request 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 { companion object {
const val INPUT_DATA_ID = "id" const val INPUT_DATA_ID = "id"
const val INPUT_DATA_USER_ACTION = "userAction" const val INPUT_DATA_USER_ACTION = "userAction"

View file

@ -6,6 +6,8 @@ import androidx.core.content.ContextCompat
import androidx.work.* import androidx.work.*
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.util.Log 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. * 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 * 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. * exist. If there's > 0, then we need a foreground service.
*/ */
class ServiceStartWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { class ServiceStartWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override fun doWork(): Result { override suspend fun doWork(): Result {
val id = this.id
if (context.applicationContext !is Application) { 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() return Result.failure()
} }
val app = context.applicationContext as Application withContext(Dispatchers.IO) {
val subscriptionIdsWithInstantStatus = app.repository.getSubscriptionIdsWithInstantStatus() val app = context.applicationContext as Application
val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size val subscriptionIdsWithInstantStatus = app.repository.getSubscriptionIdsWithInstantStatus()
val action = if (instantSubscriptions > 0) SubscriberService.Action.START else SubscriberService.Action.STOP val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size
val serviceState = SubscriberService.readServiceState(context) val action = if (instantSubscriptions > 0) SubscriberService.Action.START else SubscriberService.Action.STOP
if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Action.STOP) { val serviceState = SubscriberService.readServiceState(context)
return Result.success() 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: ${this.id})") }
Intent(context, SubscriberService::class.java).also { Log.d(TAG, "ServiceStartWorker: Starting foreground service with action $action (work ID: ${id})")
it.action = action.name Intent(context, SubscriberService::class.java).also {
ContextCompat.startForegroundService(context, it) it.action = action.name
ContextCompat.startForegroundService(context, it)
}
} }
return Result.success() 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 // React to changes in instant delivery setting
viewModel.listIdsWithInstantStatus().observe(this) { viewModel.listIdsWithInstantStatus().observe(this) {
SubscriberServiceManager.refresh(this) SubscriberServiceManager.refresh(this)

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.ui package io.heckel.ntfy.ui
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.app.AlertDialog import android.app.AlertDialog
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
@ -10,6 +11,7 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
@ -22,19 +24,17 @@ import androidx.preference.Preference.OnPreferenceClickListener
import com.google.gson.Gson import com.google.gson.Gson
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.backup.Backuper
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User import io.heckel.ntfy.db.User
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.formatBytes import io.heckel.ntfy.util.*
import io.heckel.ntfy.util.formatDateShort
import io.heckel.ntfy.util.shortUrl
import io.heckel.ntfy.util.toPriorityString
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -340,6 +340,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
false false
} }
// Clear logs
val clearLogsPrefId = context?.getString(R.string.settings_advanced_clear_logs_key) ?: return val clearLogsPrefId = context?.getString(R.string.settings_advanced_clear_logs_key) ?: return
val clearLogs: Preference? = findPreference(clearLogsPrefId) val clearLogs: Preference? = findPreference(clearLogsPrefId)
clearLogs?.isVisible = Log.getRecord() clearLogs?.isVisible = Log.getRecord()
@ -377,10 +378,84 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
Log.addScrubTerm(shortUrl(s.baseUrl), Log.TermType.Domain) Log.addScrubTerm(shortUrl(s.baseUrl), Log.TermType.Domain)
Log.addScrubTerm(s.topic) 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 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 // Connection protocol
val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return
val connectionProtocol: ListPreference? = findPreference(connectionProtocolPrefId) val connectionProtocol: ListPreference? = findPreference(connectionProtocolPrefId)
@ -434,8 +509,8 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
private fun copyLogsToClipboard(scrub: Boolean) { private fun copyLogsToClipboard(scrub: Boolean) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val log = Log.getFormatted(scrub = scrub)
val context = context ?: return@launch val context = context ?: return@launch
val log = Log.getFormatted(context, scrub = scrub)
requireActivity().runOnUiThread { requireActivity().runOnUiThread {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("ntfy logs", log) val clip = ClipData.newPlainText("ntfy logs", log)
@ -454,7 +529,8 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
private fun uploadLogsToNopaste(scrub: Boolean) { private fun uploadLogsToNopaste(scrub: Boolean) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
Log.d(TAG, "Uploading log to $EXPORT_LOGS_UPLOAD_URL ...") 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) { if (log.length > EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD) {
requireActivity().runOnUiThread { requireActivity().runOnUiThread {
Toast Toast
@ -672,6 +748,9 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
private const val TITLE_TAG = "title" private const val TITLE_TAG = "title"
private const val REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD = 2586 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 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_ORIGINAL = "copy_original"
private const val EXPORT_LOGS_COPY_SCRUBBED = "copy_scrubbed" private const val EXPORT_LOGS_COPY_SCRUBBED = "copy_scrubbed"
private const val EXPORT_LOGS_UPLOAD_ORIGINAL = "upload_original" private const val EXPORT_LOGS_UPLOAD_ORIGINAL = "upload_original"

View file

@ -290,6 +290,8 @@ class ShareActivity : AppCompatActivity() {
) )
runOnUiThread { runOnUiThread {
repository.addLastShareTopic(topicUrl(baseUrl, topic)) repository.addLastShareTopic(topicUrl(baseUrl, topic))
Log.addScrubTerm(shortUrl(baseUrl), Log.TermType.Domain)
Log.addScrubTerm(topic, Log.TermType.Term)
finish() finish()
Toast Toast
.makeText(this@ShareActivity, getString(R.string.share_successful), Toast.LENGTH_LONG) .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.content.Context
import android.os.Build import android.os.Build
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.backup.Backuper
import io.heckel.ntfy.db.Database import io.heckel.ntfy.db.Database
import io.heckel.ntfy.db.LogDao import io.heckel.ntfy.db.LogDao
import io.heckel.ntfy.db.LogEntry import io.heckel.ntfy.db.LogEntry
@ -18,7 +19,7 @@ class Log(private val logsDao: LogDao) {
private val record: AtomicBoolean = AtomicBoolean(false) private val record: AtomicBoolean = AtomicBoolean(false)
private val count: AtomicInteger = AtomicInteger(0) private val count: AtomicInteger = AtomicInteger(0)
private val scrubNum: AtomicInteger = AtomicInteger(-1) 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?) { private fun log(level: Int, tag: String, message: String, exception: Throwable?) {
if (!record.get()) return 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) { 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 { } 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 "" val maybeScrubLine = if (scrubLine) "Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑.\n" else ""
return """ return """
This is a log of the ntfy Android app. The log shows up to 1,000 entries. 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}) Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})
Model: ${Build.DEVICE} Model: ${Build.DEVICE}
Product: ${Build.PRODUCT} 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) { fun addScrubTerm(term: String, type: TermType = TermType.Term) {
if (scrubTerms[term] != null || IGNORE_TERMS.contains(term)) { if (scrubTerms[term] != null || IGNORE_TERMS.contains(term)) {
return return
} }
if (type == TermType.Password) {
scrubTerms[term] = ReplaceTerm(type, "********")
return
}
val replaceTermIndex = scrubNum.incrementAndGet() val replaceTermIndex = scrubNum.incrementAndGet()
val replaceTerm = REPLACE_TERMS.getOrNull(replaceTermIndex) ?: "fruit${replaceTermIndex}" val replaceTerm = REPLACE_TERMS.getOrNull(replaceTermIndex) ?: "fruit${replaceTermIndex}"
scrubTerms[term] = when (type) { scrubTerms[term] = ReplaceTerm(type, when (type) {
TermType.Domain -> "$replaceTerm.example.com" TermType.Domain -> "$replaceTerm.example.com"
TermType.Username -> "${replaceTerm}user"
else -> replaceTerm else -> replaceTerm
} })
} }
private fun scrubEntries(entries: List<LogEntry>): List<LogEntry> { private fun scrubEntries(entries: List<LogEntry>): List<LogEntry> {
@ -81,7 +94,7 @@ class Log(private val logsDao: LogDao) {
private fun scrub(line: String?): String? { private fun scrub(line: String?): String? {
var newLine = line ?: return null var newLine = line ?: return null
scrubTerms.forEach { (scrubTerm, replaceTerm) -> scrubTerms.forEach { (scrubTerm, replaceTerm) ->
newLine = newLine.replace(scrubTerm, replaceTerm) newLine = newLine.replace(scrubTerm, replaceTerm.replaceTerm)
} }
return newLine return newLine
} }
@ -112,16 +125,22 @@ class Log(private val logsDao: LogDao) {
} }
enum class TermType { enum class TermType {
Domain, Term Domain, Username, Password, Term
} }
data class ReplaceTerm(
val termType: TermType,
val replaceTerm: String
)
companion object { companion object {
private const val TAG = "NtfyLog" private const val TAG = "NtfyLog"
private const val PRUNE_EVERY = 100 private const val PRUNE_EVERY = 100
private const val ENTRIES_MAX = 1000 private const val ENTRIES_MAX = 1000
private val IGNORE_TERMS = listOf("ntfy.sh") private val IGNORE_TERMS = listOf("ntfy.sh")
private val REPLACE_TERMS = listOf( 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 private var instance: Log? = null
@ -155,12 +174,15 @@ class Log(private val logsDao: LogDao) {
return getInstance()?.record?.get() ?: false return getInstance()?.record?.get() ?: false
} }
fun getFormatted(scrub: Boolean): String { fun getFormatted(context: Context, scrub: Boolean): String {
return getInstance()?.getFormatted(scrub) ?: "(no logs)" return getInstance()?.getFormatted(context, scrub) ?: "(no logs)"
} }
fun getScrubTerms(): Map<String, String> { 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() { fun deleteAll() {

View file

@ -29,6 +29,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody import okhttp3.RequestBody
import okio.BufferedSink import okio.BufferedSink
import okio.source import okio.source
import java.io.File
import java.io.IOException import java.io.IOException
import java.security.SecureRandom import java.security.SecureRandom
import java.text.DateFormat import java.text.DateFormat
@ -53,6 +54,14 @@ fun splitTopicUrl(topicUrl: String): Pair<String, String> {
return Pair(topicUrl.substringBeforeLast("/"), topicUrl.substringAfterLast("/")) 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 { fun validTopic(topic: String): Boolean {
return "[-_A-Za-z0-9]{1,64}".toRegex().matches(topic) // Must match server side! 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_system">Use system default</string>
<string name="settings_general_dark_mode_entry_light">Light mode</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_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_header">Advanced</string>
<string name="settings_advanced_broadcast_key">BroadcastEnabled</string> <string name="settings_advanced_broadcast_key">BroadcastEnabled</string>
<string name="settings_advanced_broadcast_title">Broadcast messages</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_uploading">Uploading log …</string>
<string name="settings_advanced_export_logs_copied_url">Logs uploaded &amp; URL copied</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_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_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_export_logs_scrub_dialog_button_ok">Got it</string>
<string name="settings_advanced_clear_logs_key">ClearLogs</string> <string name="settings_advanced_clear_logs_key">ClearLogs</string>

View file

@ -89,6 +89,16 @@
<item>upload_original</item> <item>upload_original</item>
<item>upload_scrubbed</item> <item>upload_scrubbed</item>
</string-array> </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"> <string-array name="settings_general_dark_mode_entries">
<item>@string/settings_general_dark_mode_entry_system</item> <item>@string/settings_general_dark_mode_entry_system</item>
<item>@string/settings_general_dark_mode_entry_light</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:entryValues="@array/settings_general_dark_mode_values"
app:defaultValue="-1"/> app:defaultValue="-1"/>
</PreferenceCategory> </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"> <PreferenceCategory app:title="@string/settings_advanced_header">
<SwitchPreference <SwitchPreference
app:key="@string/settings_advanced_broadcast_key" app:key="@string/settings_advanced_broadcast_key"

View file

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