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)
}
}
}