Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 8e1830d3 authored by Philipp Heckel's avatar Philipp Heckel
Browse files

Backup/restore settings

parent 2f0fa99d
Loading
Loading
Loading
Loading
+314 −0
Original line number Diff line number Diff line
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")
+7 −1
Original line number Diff line number Diff line
@@ -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?

+10 −2
Original line number Diff line number Diff line
@@ -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)
    }
+1 −19
Original line number Diff line number Diff line
@@ -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"
+20 −15
Original line number Diff line number Diff line
@@ -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,25 +36,28 @@ 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()
            }
            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 Result.success()
                    return@withContext Result.success()
                }
            Log.d(TAG, "ServiceStartWorker: Starting foreground service with action $action (work ID: ${this.id})")
                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()
        }
    }
Loading