Skip to content

Commit 3d31aa8

Browse files
committed
Verify MD5 hash and CRC when creating backups
1 parent fadc024 commit 3d31aa8

File tree

3 files changed

+142
-97
lines changed

3 files changed

+142
-97
lines changed

app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,11 +377,13 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
377377
viewModelScope.launch {
378378
val exportedNotes =
379379
withContext(Dispatchers.IO) {
380+
app.log(TAG, msg = "Exporting backup to '$uri'...")
380381
return@withContext app.exportAsZip(
381-
uri,
382-
password = preferences.backupPassword.value,
383-
backupProgress = progress,
384-
)
382+
uri,
383+
password = preferences.backupPassword.value,
384+
backupProgress = progress,
385+
)
386+
.also { app.log(TAG, msg = "Finished exporting backup to '$uri'") }
385387
}
386388
val message = app.getQuantityString(R.plurals.exported_notes, exportedNotes)
387389
app.showToast(message)

app/src/main/java/com/philkes/notallyx/utils/backup/AutoBackupWorker.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ package com.philkes.notallyx.utils.backup
22

33
import android.content.Context
44
import android.content.ContextWrapper
5-
import androidx.work.Worker
5+
import androidx.work.CoroutineWorker
66
import androidx.work.WorkerParameters
77

88
class AutoBackupWorker(private val context: Context, params: WorkerParameters) :
9-
Worker(context, params) {
9+
CoroutineWorker(context, params) {
1010

11-
override fun doWork(): Result {
11+
override suspend fun doWork(): Result {
1212
return (context.applicationContext as ContextWrapper).createBackup()
1313
}
1414
}

app/src/main/java/com/philkes/notallyx/utils/backup/ExportExtensions.kt

Lines changed: 133 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import com.philkes.notallyx.utils.MIME_TYPE_ZIP
5050
import com.philkes.notallyx.utils.SUBFOLDER_AUDIOS
5151
import com.philkes.notallyx.utils.SUBFOLDER_FILES
5252
import com.philkes.notallyx.utils.SUBFOLDER_IMAGES
53+
import com.philkes.notallyx.utils.copyToLarge
5354
import com.philkes.notallyx.utils.createChannelIfNotExists
5455
import com.philkes.notallyx.utils.createFileSafe
5556
import com.philkes.notallyx.utils.createReportBugIntent
@@ -60,15 +61,17 @@ import com.philkes.notallyx.utils.getExternalImagesDirectory
6061
import com.philkes.notallyx.utils.getLogFileUri
6162
import com.philkes.notallyx.utils.listZipFiles
6263
import com.philkes.notallyx.utils.log
64+
import com.philkes.notallyx.utils.md5Hash
6365
import com.philkes.notallyx.utils.recreateDir
6466
import com.philkes.notallyx.utils.security.decryptDatabase
6567
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
68+
import com.philkes.notallyx.utils.verifyCrc
6669
import com.philkes.notallyx.utils.wrapWithChooser
6770
import java.io.File
71+
import java.io.File.createTempFile
6872
import java.io.FileInputStream
6973
import java.io.IOException
7074
import java.io.InputStream
71-
import java.io.OutputStream
7275
import java.io.OutputStreamWriter
7376
import java.text.SimpleDateFormat
7477
import java.util.Date
@@ -99,56 +102,61 @@ val BACKUP_TIMESTAMP_FORMATTER = SimpleDateFormat("yyyyMMdd-HHmmssSSS", Locale.E
99102
private const val ON_SAVE_BACKUP_FILE = "NotallyX_AutoBackup"
100103
private const val PERIODIC_BACKUP_FILE_PREFIX = "NotallyX_Backup_"
101104

102-
fun ContextWrapper.createBackup(): Result {
103-
val app = applicationContext as Application
104-
val preferences = NotallyXPreferences.getInstance(app)
105-
val (_, maxBackups) = preferences.periodicBackups.value
106-
val path = preferences.backupsFolder.value
107-
108-
if (path != EMPTY_PATH) {
109-
val uri = path.toUri()
110-
val folder =
111-
requireBackupFolder(
112-
path,
113-
"Periodic Backup failed, because auto-backup path '$path' is invalid",
114-
) ?: return Result.success()
115-
try {
116-
val backupFilePrefix = PERIODIC_BACKUP_FILE_PREFIX
117-
val name =
118-
"$backupFilePrefix${BACKUP_TIMESTAMP_FORMATTER.format(System.currentTimeMillis())}"
119-
log(TAG, msg = "Creating '$uri/$name.zip'...")
120-
val zipUri = folder.createFileSafe(MIME_TYPE_ZIP, name, ".zip").uri
121-
val exportedNotes = app.exportAsZip(zipUri, password = preferences.backupPassword.value)
122-
log(TAG, msg = "Exported $exportedNotes notes")
123-
val backupFiles = folder.listZipFiles(backupFilePrefix)
124-
log(TAG, msg = "Found ${backupFiles.size} backups")
125-
val backupsToBeDeleted = backupFiles.drop(maxBackups)
126-
if (backupsToBeDeleted.isNotEmpty()) {
127-
log(
128-
TAG,
129-
msg =
130-
"Deleting ${backupsToBeDeleted.size} oldest backups (maxBackups: $maxBackups): ${backupsToBeDeleted.joinToString { "'${it.name.toString()}'" }}",
131-
)
132-
}
133-
backupsToBeDeleted.forEach {
134-
if (it.exists()) {
135-
it.delete()
105+
private val periodicBackupMutex = Mutex()
106+
107+
suspend fun ContextWrapper.createBackup(): Result {
108+
return periodicBackupMutex.withLock {
109+
val app = applicationContext as Application
110+
val preferences = NotallyXPreferences.getInstance(app)
111+
val (_, maxBackups) = preferences.periodicBackups.value
112+
val path = preferences.backupsFolder.value
113+
114+
if (path != EMPTY_PATH) {
115+
val uri = path.toUri()
116+
val folder =
117+
requireBackupFolder(
118+
path,
119+
"Periodic Backup failed, because auto-backup path '$path' is invalid",
120+
) ?: return@withLock Result.success()
121+
try {
122+
val backupFilePrefix = PERIODIC_BACKUP_FILE_PREFIX
123+
val name =
124+
"$backupFilePrefix${BACKUP_TIMESTAMP_FORMATTER.format(System.currentTimeMillis())}"
125+
log(TAG, msg = "Creating '$uri/$name.zip'...")
126+
val zipUri = folder.createFileSafe(MIME_TYPE_ZIP, name, ".zip").uri
127+
val exportedNotes =
128+
app.exportAsZip(zipUri, password = preferences.backupPassword.value)
129+
log(TAG, msg = "Exported $exportedNotes notes")
130+
val backupFiles = folder.listZipFiles(backupFilePrefix)
131+
log(TAG, msg = "Found ${backupFiles.size} backups")
132+
val backupsToBeDeleted = backupFiles.drop(maxBackups)
133+
if (backupsToBeDeleted.isNotEmpty()) {
134+
log(
135+
TAG,
136+
msg =
137+
"Deleting ${backupsToBeDeleted.size} oldest backups (maxBackups: $maxBackups): ${backupsToBeDeleted.joinToString { "'${it.name.toString()}'" }}",
138+
)
139+
}
140+
backupsToBeDeleted.forEach {
141+
if (it.exists()) {
142+
it.delete()
143+
}
136144
}
145+
log(TAG, msg = "Finished backup to '$zipUri'")
146+
preferences.periodicBackupLastExecution.save(Date().time)
147+
return@withLock Result.success(
148+
Data.Builder().putString(OUTPUT_DATA_BACKUP_URI, zipUri.path!!).build()
149+
)
150+
} catch (e: Exception) {
151+
log(TAG, msg = "Failed creating backup to '$path'", throwable = e)
152+
tryPostErrorNotification(e)
153+
return Result.success(
154+
Data.Builder().putString(OUTPUT_DATA_EXCEPTION, e.message).build()
155+
)
137156
}
138-
log(TAG, msg = "Finished backup to '$zipUri'")
139-
preferences.periodicBackupLastExecution.save(Date().time)
140-
return Result.success(
141-
Data.Builder().putString(OUTPUT_DATA_BACKUP_URI, zipUri.path!!).build()
142-
)
143-
} catch (e: Exception) {
144-
log(TAG, msg = "Failed creating backup to '$path'", throwable = e)
145-
tryPostErrorNotification(e)
146-
return Result.success(
147-
Data.Builder().putString(OUTPUT_DATA_EXCEPTION, e.message).build()
148-
)
149157
}
158+
return@withLock Result.success()
150159
}
151-
return Result.success()
152160
}
153161

154162
fun ContextWrapper.autoBackupOnSaveFileExists(backupPath: String): Boolean {
@@ -166,7 +174,7 @@ suspend fun ContextWrapper.autoBackupOnSave(
166174
password: String,
167175
savedNote: BaseNote?,
168176
) {
169-
autoBackupOnSaveMutex.withLock {
177+
return autoBackupOnSaveMutex.withLock {
170178
Log.d(
171179
TAG,
172180
"Starting Auto Backup${savedNote?.let { " for Note id: ${it.id} title: ${it.title}" } ?: ""}...",
@@ -175,7 +183,7 @@ suspend fun ContextWrapper.autoBackupOnSave(
175183
requireBackupFolder(
176184
backupPath,
177185
"Auto backup on note save (${savedNote?.let { "id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed, because auto-backup path '$backupPath' is invalid",
178-
) ?: return
186+
) ?: return@withLock
179187
try {
180188
var changedNote = savedNote
181189
var backupFile = folder.findFile("$ON_SAVE_BACKUP_FILE.zip")
@@ -197,7 +205,7 @@ suspend fun ContextWrapper.autoBackupOnSave(
197205
} else {
198206
Log.d(TAG, "Creating partial backup for Note ${changedNote.id}")
199207
// Only add changed note to existing backup ZIP
200-
val (_, file) = copyDatabase()
208+
val (_, databaseFile) = copyDatabase()
201209
val files =
202210
with(changedNote) {
203211
images.map {
@@ -218,7 +226,7 @@ suspend fun ContextWrapper.autoBackupOnSave(
218226
File(getExternalAudioDirectory(), it.name),
219227
)
220228
} +
221-
BackupFile(null, file)
229+
BackupFile(null, databaseFile)
222230
}
223231
try {
224232
exportToZip(backupFile.uri, files, password)
@@ -242,7 +250,7 @@ suspend fun ContextWrapper.autoBackupOnSave(
242250
)
243251
} catch (logException: Exception) {
244252
tryPostErrorNotification(logException)
245-
return
253+
return@withLock
246254
}
247255
tryPostErrorNotification(e)
248256
}
@@ -303,9 +311,10 @@ fun ContextWrapper.exportAsZip(
303311
compress: Boolean = false,
304312
password: String = PASSWORD_EMPTY,
305313
backupProgress: MutableLiveData<Progress>? = null,
314+
retryOnFail: Boolean = true,
306315
): Int {
307316
backupProgress?.postValue(BackupProgress(indeterminate = true))
308-
val tempFile = File.createTempFile("export", "tmp", cacheDir)
317+
val tempFile = createTempFile("export", "tmp", cacheDir)
309318
try {
310319
val zipFile =
311320
ZipFile(tempFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
@@ -367,16 +376,45 @@ fun ContextWrapper.exportAsZip(
367376
)
368377
}
369378
}
370-
371-
zipFile.close()
379+
if (!zipFile.isValidZipFile || !zipFile.verifyCrc(databaseCopy)) {
380+
log(TAG, stackTrace = "ZipFile '${zipFile.file}' is not a valid ZIP!")
381+
if (retryOnFail) {
382+
zipFile.file.delete()
383+
log(TAG, stackTrace = "Retrying to export ZIP to $fileUri...")
384+
return exportAsZip(fileUri, compress, password, backupProgress, false)
385+
} else {
386+
throw IOException(
387+
"exportAsZip failed because created '${zipFile.file}' is not a valid ZIP!"
388+
)
389+
}
390+
}
372391
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
373392
FileInputStream(zipFile.file).use { inputStream ->
374-
inputStream.copyTo(outputStream)
393+
inputStream.copyToLarge(outputStream)
375394
outputStream.flush()
376395
}
377-
zipFile.file.delete()
378-
databaseCopy.delete()
379396
}
397+
if (!md5Hash(fileUri).contentEquals(zipFile.file.md5Hash())) {
398+
log(TAG, stackTrace = "Exported zipFile '$fileUri' has wrong MD5 hash!")
399+
if (retryOnFail) {
400+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
401+
try {
402+
contentResolver.delete(fileUri, null)
403+
} catch (e: Exception) {
404+
log(TAG, msg = "Deleting $fileUri failed", throwable = e)
405+
}
406+
}
407+
zipFile.file.delete()
408+
log(TAG, stackTrace = "Retrying to export ZIP to $fileUri...")
409+
return exportAsZip(fileUri, compress, password, backupProgress, false)
410+
} else {
411+
throw IOException(
412+
"exportAsZip failed because created '$fileUri' has wrong MD5 hash!"
413+
)
414+
}
415+
}
416+
zipFile.file.delete()
417+
databaseCopy.delete()
380418
backupProgress?.postValue(BackupProgress(inProgress = false))
381419
return totalNotes
382420
} finally {
@@ -395,20 +433,52 @@ fun Context.exportToZip(
395433
extractZipToDirectory(zipInputStream, tempDir, password)
396434
files.forEach { file ->
397435
val targetFile = File(tempDir, "${file.first?.let { "$it/" } ?: ""}${file.second.name}")
398-
file.second.copyTo(targetFile, overwrite = true)
436+
file.second.copyToLarge(targetFile, overwrite = true)
399437
}
400438
val zipOutputStream = contentResolver.openOutputStream(zipUri, "w") ?: return false
401-
createZipFromDirectory(tempDir, zipOutputStream, password)
439+
val tempZipFile = createTempFile("tempZip", ".zip")
440+
try {
441+
tempZipFile.deleteOnExit()
442+
val zipFile =
443+
ZipFile(
444+
tempZipFile,
445+
if (password != PASSWORD_EMPTY) password.toCharArray() else null,
446+
)
447+
val zipParameters =
448+
ZipParameters().apply {
449+
this.isEncryptFiles = password != PASSWORD_EMPTY
450+
this.compressionLevel = CompressionLevel.NO_COMPRESSION
451+
this.encryptionMethod = EncryptionMethod.AES
452+
this.isIncludeRootFolder = false
453+
}
454+
zipFile.addFolder(tempDir, zipParameters)
455+
if (!zipFile.isValidZipFile) {
456+
throw IOException("ZipFile '${zipFile.file}' is not a valid ZIP!")
457+
}
458+
val databaseFile = files.find { it.second.name == DATABASE_NAME }
459+
databaseFile?.let {
460+
if (!zipFile.verifyCrc(it.second)) {
461+
throw IOException("ZipFile '${zipFile.file}' CRC verification failed!")
462+
}
463+
}
464+
tempZipFile.inputStream().use { inputStream ->
465+
inputStream.copyToLarge(zipOutputStream)
466+
}
467+
} finally {
468+
tempZipFile.delete()
469+
}
402470
} finally {
403471
tempDir.deleteRecursively()
404472
}
405473
return true
406474
}
407475

408476
private fun extractZipToDirectory(zipInputStream: InputStream, outputDir: File, password: String) {
409-
val tempZipFile = File.createTempFile("extractedZip", null, outputDir)
477+
val tempZipFile = createTempFile("extractedZip", null, outputDir)
410478
try {
411-
tempZipFile.outputStream().use { zipOutputStream -> zipInputStream.copyTo(zipOutputStream) }
479+
tempZipFile.outputStream().use { zipOutputStream ->
480+
zipInputStream.copyToLarge(zipOutputStream)
481+
}
412482
val zipFile =
413483
ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
414484
zipFile.extractAll(outputDir.absolutePath)
@@ -417,33 +487,6 @@ private fun extractZipToDirectory(zipInputStream: InputStream, outputDir: File,
417487
}
418488
}
419489

420-
private fun createZipFromDirectory(
421-
sourceDir: File,
422-
zipOutputStream: OutputStream,
423-
password: String = PASSWORD_EMPTY,
424-
compress: Boolean = false,
425-
) {
426-
val tempZipFile = File.createTempFile("tempZip", ".zip")
427-
try {
428-
tempZipFile.deleteOnExit()
429-
val zipFile =
430-
ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
431-
val zipParameters =
432-
ZipParameters().apply {
433-
isEncryptFiles = password != PASSWORD_EMPTY
434-
if (!compress) {
435-
compressionLevel = CompressionLevel.NO_COMPRESSION
436-
}
437-
encryptionMethod = EncryptionMethod.AES
438-
isIncludeRootFolder = false
439-
}
440-
zipFile.addFolder(sourceDir, zipParameters)
441-
tempZipFile.inputStream().use { inputStream -> inputStream.copyTo(zipOutputStream) }
442-
} finally {
443-
tempZipFile.delete()
444-
}
445-
}
446-
447490
fun ContextWrapper.copyDatabase(
448491
decrypt: Boolean = true,
449492
suffix: String = "",
@@ -462,7 +505,7 @@ fun ContextWrapper.copyDatabase(
462505
Pair(database, decryptedFile)
463506
} else {
464507
val dbFile = File(cacheDir, DATABASE_NAME + suffix)
465-
databaseFile.copyTo(dbFile, overwrite = true)
508+
databaseFile.copyToLarge(dbFile, overwrite = true)
466509
Pair(database, dbFile)
467510
}
468511
}

0 commit comments

Comments
 (0)