Backup/restore settings
This commit is contained in:
parent
2f0fa99d25
commit
8e1830d361
14 changed files with 561 additions and 59 deletions
314
app/src/main/java/io/heckel/ntfy/backup/Backuper.kt
Normal file
314
app/src/main/java/io/heckel/ntfy/backup/Backuper.kt
Normal 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")
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 & 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 & 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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue