Pith - shim
shim/app/src/main/kotlin/com/vgmlr/shim/ShimBackUp.kt [8.0 kb]
Modified: 22:39:16 117 026 (14 Jul 026)
11 Days Ago
package com.vgmlr.shim

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import java.io.File
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
import androidx.core.content.edit

data class BackupSettings(
    val intervalMinutes: Int,
    val location: String,
    val encrypt: Boolean,
    val phrase: String,
    val stamp: Long
)

class ShimBackUp(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
        try {
            val ctx = applicationContext
            val dbFile = ctx.getDatabasePath(DB_NAME)
            if (!dbFile.exists()) return@withContext Result.success()

            val s = loadSettings(ctx)
            if (s.encrypt && s.phrase.isBlank()) return@withContext Result.failure()

            val raw = dbFile.readBytes()
            val payload = if (s.encrypt) encryptBytes(raw, s.phrase) else raw
            val name = if (s.encrypt) ENC_NAME else PLAIN_NAME

            if (s.location.isEmpty()) {
                val dir = File(ctx.getExternalFilesDir(null) ?: return@withContext Result.failure(), "backups")
                if (!dir.exists()) dir.mkdirs()
                File(dir, if (s.encrypt) PLAIN_NAME else ENC_NAME).delete()
                File(dir, name).writeBytes(payload)
            } else {
                val tree = DocumentFile.fromTreeUri(ctx, s.location.toUri()) ?: return@withContext Result.failure()
                tree.findFile(if (s.encrypt) PLAIN_NAME else ENC_NAME)?.delete()
                val target = tree.findFile(name) ?: tree.createFile(MIME, name) ?: return@withContext Result.failure()
                ctx.contentResolver.openOutputStream(target.uri, "wt")?.use { it.write(payload) }
                    ?: return@withContext Result.failure()
            }

            prefs(ctx).edit { putLong(KEY_STAMP, System.currentTimeMillis()) }
            Result.success()
        } catch (_: Exception) {
            Result.failure()
        }
    }

    companion object {
        private const val DB_NAME = "shim.db"
        private const val PLAIN_NAME = "shim_backup.db"
        private const val ENC_NAME = "shim_backup.db.enc"
        private const val MIME = "application/octet-stream"
        private const val WORK_NAME = "ShimDailyBackup"

        private const val KEY_INTERVAL = "backup_interval"
        private const val KEY_LOCATION = "backup_location"
        private const val KEY_ENCRYPT = "backup_encrypt"
        private const val KEY_PHRASE = "backup_phrase"
        private const val KEY_STAMP = "backup_stamp"

        const val INTERVAL_DEFAULT = 360
        val INTERVALS = listOf(60, 360, 720, 1440)

        fun intervalLabel(minutes: Int): String = when {
            minutes % 60 == 0 -> {
                val hours = minutes / 60
                if (hours == 1) "1 hour" else "$hours hours"
            }
            minutes == 1 -> "1 minute"
            else -> "$minutes minutes"
        }

        private fun prefs(context: Context) =
            context.getSharedPreferences("shim_prefs", Context.MODE_PRIVATE)

        fun loadSettings(context: Context): BackupSettings {
            val p = prefs(context)
            return BackupSettings(
                p.getInt(KEY_INTERVAL, INTERVAL_DEFAULT),
                p.getString(KEY_LOCATION, "") ?: "",
                p.getBoolean(KEY_ENCRYPT, false),
                p.getString(KEY_PHRASE, "") ?: "",
                p.getLong(KEY_STAMP, 0L)
            )
        }

        fun saveSettings(context: Context, interval: Int, location: String, encrypt: Boolean, phrase: String) {
            prefs(context).edit {
                putInt(KEY_INTERVAL, interval)
                putString(KEY_LOCATION, location)
                putBoolean(KEY_ENCRYPT, encrypt)
                putString(KEY_PHRASE, phrase)
            }
        }

        fun lastStamp(context: Context): Long = prefs(context).getLong(KEY_STAMP, 0L)

        fun schedule(context: Context, minutes: Int = loadSettings(context).intervalMinutes) {
            val request = PeriodicWorkRequestBuilder<ShimBackUp>(
                minutes.coerceAtLeast(15).toLong(), TimeUnit.MINUTES
            ).build()
            WorkManager.getInstance(context)
                .enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
        }

        suspend fun restore(context: Context): Boolean = withContext(Dispatchers.IO) {
            try {
                val s = loadSettings(context)
                val data = readBackup(context, s) ?: return@withContext false
                val plain = if (isEncrypted(data)) {
                    if (s.phrase.isBlank()) return@withContext false
                    decryptBytes(data, s.phrase)
                } else data

                val temp = File(context.cacheDir, "restore_temp.db")
                temp.writeBytes(plain)
                val ok = ShimDatabase(context).restoreFrom(temp)
                temp.delete()
                ok
            } catch (_: Exception) {
                false
            }
        }

        private fun readBackup(context: Context, s: BackupSettings): ByteArray? {
            if (s.location.isEmpty()) {
                val dir = File(context.getExternalFilesDir(null) ?: return null, "backups")
                val file = listOf(ENC_NAME, PLAIN_NAME).map { File(dir, it) }
                    .firstOrNull { it.exists() } ?: return null
                return file.readBytes()
            }
            val tree = DocumentFile.fromTreeUri(context, s.location.toUri()) ?: return null
            val doc = listOf(ENC_NAME, PLAIN_NAME).firstNotNullOfOrNull { tree.findFile(it) } ?: return null
            return context.contentResolver.openInputStream(doc.uri)?.use { it.readBytes() }
        }

        private val MAGIC = "SHIMENC1".toByteArray()
        private const val SALT_LEN = 16
        private const val IV_LEN = 12
        private const val TAG_BITS = 128
        private const val ITERATIONS = 120_000
        private const val KEY_BITS = 256

        fun isEncrypted(data: ByteArray): Boolean =
            data.size > MAGIC.size + SALT_LEN + IV_LEN &&
                    data.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)

        private fun deriveKey(phrase: String, salt: ByteArray): SecretKeySpec {
            val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
            val key = factory.generateSecret(
                PBEKeySpec(phrase.toCharArray(), salt, ITERATIONS, KEY_BITS)
            ).encoded
            return SecretKeySpec(key, "AES")
        }

        fun encryptBytes(plain: ByteArray, phrase: String): ByteArray {
            val rnd = SecureRandom()
            val salt = ByteArray(SALT_LEN).also { rnd.nextBytes(it) }
            val iv = ByteArray(IV_LEN).also { rnd.nextBytes(it) }
            val cipher = Cipher.getInstance("AES/GCM/NoPadding")
            cipher.init(Cipher.ENCRYPT_MODE, deriveKey(phrase, salt), GCMParameterSpec(TAG_BITS, iv))
            return MAGIC + salt + iv + cipher.doFinal(plain)
        }

        fun decryptBytes(data: ByteArray, phrase: String): ByteArray {
            val head = MAGIC.size
            val salt = data.copyOfRange(head, head + SALT_LEN)
            val iv = data.copyOfRange(head + SALT_LEN, head + SALT_LEN + IV_LEN)
            val body = data.copyOfRange(head + SALT_LEN + IV_LEN, data.size)
            val cipher = Cipher.getInstance("AES/GCM/NoPadding")
            cipher.init(Cipher.DECRYPT_MODE, deriveKey(phrase, salt), GCMParameterSpec(TAG_BITS, iv))
            return cipher.doFinal(body)
        }
    }
}
Updates
Wedge - Android 126.026
Wedge - Linux 124.026
Shim - Android 117.026
Miter - 114.026
Kerf - Android 112.026
Dev
TVShow (227) 'CSA'
TVShow (228) 'APT'
TVProgram (83) 'BXT'
Miter Update(s)
Peen (Messaging)

Menu
Calendar
Project Tin (024/029)
Miter
RSS Feed
User Avatar
@vgmlr
=SUM(parts)