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
|
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?
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
@ -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 & 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 & URL copied</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_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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue