diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/LockedActivity.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/LockedActivity.kt index d0edf827..c4abb303 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/LockedActivity.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/LockedActivity.kt @@ -3,6 +3,7 @@ package com.philkes.notallyx.presentation.activity import android.app.Activity import android.app.KeyguardManager import android.content.Intent +import android.database.sqlite.SQLiteBlobTooBigException import android.hardware.biometrics.BiometricPrompt.BIOMETRIC_ERROR_HW_NOT_PRESENT import android.hardware.biometrics.BiometricPrompt.BIOMETRIC_ERROR_NO_BIOMETRICS import android.os.Build @@ -15,18 +16,28 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope import androidx.viewbinding.ViewBinding import com.google.android.material.color.DynamicColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.philkes.notallyx.NotallyXApplication import com.philkes.notallyx.R +import com.philkes.notallyx.presentation.setupProgressDialog import com.philkes.notallyx.presentation.showToast import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.Theme +import com.philkes.notallyx.presentation.viewmodel.progress.MigrationProgress +import com.philkes.notallyx.utils.log +import com.philkes.notallyx.utils.secondsBetween import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt +import com.philkes.notallyx.utils.splitOversizedNotes +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext abstract class LockedActivity : AppCompatActivity() { @@ -40,6 +51,8 @@ abstract class LockedActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setupGlobalExceptionHandler() + initViewModel() notallyXApplication = (application as NotallyXApplication) preferences = NotallyXPreferences.getInstance(notallyXApplication) if (preferences.useDynamicColors.value) { @@ -62,6 +75,53 @@ abstract class LockedActivity : AppCompatActivity() { } } + open fun initViewModel() { + baseModel.startObserving() + } + + private fun setupGlobalExceptionHandler() { + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + if ( + throwable is SQLiteBlobTooBigException || + throwable.cause is SQLiteBlobTooBigException + ) { + lifecycleScope.launch { + EXCEPTION_HANDLER_MUTEX.withLock { + val time = System.currentTimeMillis() + if (!isExceptionAlreadyBeingHandled(time)) { + EXCEPTION_HANDLER_MUTEX_LAST_TIMESTAMP = time + val migrationProgress = + MutableLiveData().apply { + setupProgressDialog(this@LockedActivity) + postValue( + MigrationProgress( + R.string.migration_splitting_notes, + indeterminate = true, + ) + ) + } + log( + TAG, + msg = + "SQLiteBlobTooBigException occurred, trying to fix broken notes...", + ) + withContext(Dispatchers.IO) { application.splitOversizedNotes() } + migrationProgress.postValue( + MigrationProgress(R.string.migrating_data, inProgress = false) + ) + } + } + } + } else { + previousHandler?.uncaughtException(thread, throwable) + } + } + } + + private fun isExceptionAlreadyBeingHandled(time: Long): Boolean = + EXCEPTION_HANDLER_MUTEX_LAST_TIMESTAMP?.let { it.secondsBetween(time) < 20 } ?: false + override fun onResume() { if (preferences.isLockEnabled) { if (hasToAuthenticateWithBiometric()) { @@ -153,4 +213,10 @@ abstract class LockedActivity : AppCompatActivity() { } } ?: false } + + companion object { + private const val TAG = "LockedActivity" + private val EXCEPTION_HANDLER_MUTEX = Mutex() + private var EXCEPTION_HANDLER_MUTEX_LAST_TIMESTAMP: Long? = null + } } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/main/MainActivity.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/main/MainActivity.kt index e1285baf..480cccff 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/main/MainActivity.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/main/MainActivity.kt @@ -131,9 +131,12 @@ class MainActivity : LockedActivity() { baseModel.progress.setupProgressDialog(this) } + override fun initViewModel() {} + private fun checkForMigrations(savedInstanceState: Bundle?) { // Run migrations first (blocking dialog), then proceed with initial navigation val proceed: () -> Unit = { + baseModel.startObserving() val fragmentIdToLoad = intent.getIntExtra(EXTRA_FRAGMENT_TO_OPEN, -1) if (fragmentIdToLoad != -1) { navController.navigate(fragmentIdToLoad, intent.extras) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt index 7bf37913..2db8cd09 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt @@ -110,13 +110,13 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) { lateinit var selectedExportMimeType: ExportMimeType - lateinit var labels: LiveData> - lateinit var reminders: LiveData> - private var allNotes: LiveData>? = null + var labels: LiveData> = NotNullLiveData(mutableListOf()) + var reminders: LiveData> = NotNullLiveData(mutableListOf()) + private var allNotes: LiveData>? = NotNullLiveData(mutableListOf()) private var allNotesObserver: Observer>? = null - var baseNotes: Content? = null - var deletedNotes: Content? = null - var archivedNotes: Content? = null + var baseNotes: Content? = Content(MutableLiveData(), ::transform) + var deletedNotes: Content? = Content(MutableLiveData(), ::transform) + var archivedNotes: Content? = Content(MutableLiveData(), ::transform) val folder = NotNullLiveData(Folder.NOTES) @@ -149,7 +149,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) { internal var showRefreshBackupsFolderAfterThemeChange = false private var labelsHiddenObserver: Observer>? = null - init { + fun startObserving() { NotallyDatabase.getDatabase(app).observeForever(::init) folder.observeForever { newFolder -> searchResults!!.fetch(keyword, newFolder, currentLabel) @@ -166,7 +166,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) { // colors = baseNoteDao.getAllColorsAsync() reminders = baseNoteDao.getAllRemindersAsync() - allNotes?.removeObserver(allNotesObserver!!) + allNotesObserver?.let { allNotes?.removeObserver(it) } allNotesObserver = Observer { list -> Cache.list = list } allNotes = baseNoteDao.getAllAsync() allNotes!!.observeForever(allNotesObserver!!) @@ -897,7 +897,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) { askForUriPermissions(backupFolderUri) } .show() - } catch (e: Exception) { + } catch (_: Exception) { showRefreshBackupsFolderAfterThemeChange = false disableBackups() } diff --git a/app/src/main/java/com/philkes/notallyx/utils/DataSchemaMigrations.kt b/app/src/main/java/com/philkes/notallyx/utils/DataSchemaMigrations.kt index 69d791fe..0ea7da0d 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/DataSchemaMigrations.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/DataSchemaMigrations.kt @@ -61,7 +61,7 @@ private fun Application.moveAttachments(preferences: NotallyXPreferences) { * end of each truncated note that points to the next note. The link text is included in the body * and must also fit within the size limit. */ -private suspend fun Application.splitOversizedNotes() { +suspend fun Application.splitOversizedNotes() { log( TAG, "Running migration 2: Splitting notes exceeding the body size limit (limit: $MAX_BODY_CHAR_LENGTH characters)", @@ -87,8 +87,14 @@ private suspend fun Application.splitOversizedNotes() { e, ) repaired += 1 - truncateBodyAndFixSpans(dao, id) - dao.get(id) + try { + truncateBodyAndFixSpans(dao, id) + dao.get(id) + } catch (e: SQLiteBlobTooBigException) { + log(TAG, "Note (id: $id) could not be repaired. Deleting...", e) + dao.delete(id) + null + } } if (original == null) return@forEach if (original.type != Type.NOTE) return@forEach diff --git a/app/src/main/java/com/philkes/notallyx/utils/MiscExtensions.kt b/app/src/main/java/com/philkes/notallyx/utils/MiscExtensions.kt index fbbc689f..24ec1924 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/MiscExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/MiscExtensions.kt @@ -3,6 +3,7 @@ package com.philkes.notallyx.utils import android.util.Patterns import java.util.Calendar import java.util.Locale +import kotlin.math.abs fun CharSequence.truncate(limit: Int): CharSequence { return if (length > limit) { @@ -110,3 +111,7 @@ fun now(): Calendar = set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) } + +typealias TimeMillis = Long + +fun TimeMillis.secondsBetween(other: TimeMillis): Long = abs(this - other) / 1000 diff --git a/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt b/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt index 5597806e..2e4ca6b7 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt @@ -15,6 +15,7 @@ import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.MutableLiveData import com.philkes.notallyx.R import com.philkes.notallyx.data.NotallyDatabase +import com.philkes.notallyx.data.dao.BaseNoteDao.Companion.MAX_BODY_CHAR_LENGTH import com.philkes.notallyx.data.imports.ImportProgress import com.philkes.notallyx.data.imports.ImportStage import com.philkes.notallyx.data.model.Audio @@ -121,8 +122,28 @@ suspend fun ContextWrapper.importZip( SQLiteDatabase.openDatabase(dbFile.path, null, SQLiteDatabase.OPEN_READONLY) val labelCursor = database.query("Label", null, null, null, null, null, null) - val baseNoteCursor = database.query("BaseNote", null, null, null, null, null, null) - + val columns = + arrayOf( + "id", + "type", + "folder", + "color", + "title", + "pinned", + "timestamp", + "modifiedTimestamp", + "labels", + "SUBSTR(body, 1, ${MAX_BODY_CHAR_LENGTH}) AS body", + "spans", + "items", + "images", + "files", + "audios", + "reminders", + "viewMode", + ) + val baseNoteCursor = + database.query("BaseNote", columns, null, null, null, null, null) val labels = labelCursor.toList { cursor -> cursor.toLabel() } var total = baseNoteCursor.count @@ -137,7 +158,6 @@ suspend fun ContextWrapper.importZip( importingBackup?.postValue(ImportProgress(counter++, total)) baseNote } - delay(1000) total =