@@ -50,6 +50,7 @@ import com.philkes.notallyx.utils.MIME_TYPE_ZIP
5050import com.philkes.notallyx.utils.SUBFOLDER_AUDIOS
5151import com.philkes.notallyx.utils.SUBFOLDER_FILES
5252import com.philkes.notallyx.utils.SUBFOLDER_IMAGES
53+ import com.philkes.notallyx.utils.copyToLarge
5354import com.philkes.notallyx.utils.createChannelIfNotExists
5455import com.philkes.notallyx.utils.createFileSafe
5556import com.philkes.notallyx.utils.createReportBugIntent
@@ -60,15 +61,17 @@ import com.philkes.notallyx.utils.getExternalImagesDirectory
6061import com.philkes.notallyx.utils.getLogFileUri
6162import com.philkes.notallyx.utils.listZipFiles
6263import com.philkes.notallyx.utils.log
64+ import com.philkes.notallyx.utils.md5Hash
6365import com.philkes.notallyx.utils.recreateDir
6466import com.philkes.notallyx.utils.security.decryptDatabase
6567import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
68+ import com.philkes.notallyx.utils.verifyCrc
6669import com.philkes.notallyx.utils.wrapWithChooser
6770import java.io.File
71+ import java.io.File.createTempFile
6872import java.io.FileInputStream
6973import java.io.IOException
7074import java.io.InputStream
71- import java.io.OutputStream
7275import java.io.OutputStreamWriter
7376import java.text.SimpleDateFormat
7477import java.util.Date
@@ -99,56 +102,61 @@ val BACKUP_TIMESTAMP_FORMATTER = SimpleDateFormat("yyyyMMdd-HHmmssSSS", Locale.E
99102private const val ON_SAVE_BACKUP_FILE = " NotallyX_AutoBackup"
100103private 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
154162fun 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
408476private 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-
447490fun 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