442 lines
16 KiB
Kotlin
442 lines
16 KiB
Kotlin
package cash.z.ecc.android.sdk.internal.db
|
|
|
|
import android.content.Context
|
|
import androidx.annotation.VisibleForTesting
|
|
import cash.z.ecc.android.sdk.exception.InitializeException
|
|
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
|
import cash.z.ecc.android.sdk.internal.Files
|
|
import cash.z.ecc.android.sdk.internal.LazyWithArgument
|
|
import cash.z.ecc.android.sdk.internal.Twig
|
|
import cash.z.ecc.android.sdk.internal.ext.deleteRecursivelySuspend
|
|
import cash.z.ecc.android.sdk.internal.ext.deleteSuspend
|
|
import cash.z.ecc.android.sdk.internal.ext.existsSuspend
|
|
import cash.z.ecc.android.sdk.internal.ext.getDatabasePathSuspend
|
|
import cash.z.ecc.android.sdk.internal.ext.renameToSuspend
|
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
|
import kotlinx.coroutines.sync.Mutex
|
|
import kotlinx.coroutines.sync.withLock
|
|
import java.io.File
|
|
|
|
/**
|
|
* Wrapper class for various SDK databases operations. It always guaranties exclusive access to
|
|
* provided operations.
|
|
*
|
|
* @param context the application context
|
|
*/
|
|
@Suppress("TooManyFunctions")
|
|
internal class DatabaseCoordinator private constructor(context: Context) {
|
|
/*
|
|
* This implementation is thread-safe but is not multi-process safe.
|
|
*
|
|
* The mutex helps to ensure that two instances of the SDK being initialized in the same
|
|
* process do not have conflicts with regard to moving the databases around. However if an
|
|
* application decides to use multiple processes this could cause a problem during the one-time
|
|
* the database path migration.
|
|
*/
|
|
|
|
private val applicationContext = context.applicationContext
|
|
private val createFileMutex = Mutex()
|
|
private val deleteFileMutex = Mutex()
|
|
|
|
companion object {
|
|
@VisibleForTesting
|
|
internal const val DB_DATA_NAME_LEGACY = "Data.db" // $NON-NLS
|
|
const val DB_DATA_NAME = "data.sqlite3" // $NON-NLS
|
|
|
|
@VisibleForTesting
|
|
internal const val DB_CACHE_OLDER_NAME_LEGACY = "Cache.db" // $NON-NLS
|
|
internal const val DB_CACHE_NEWER_NAME_LEGACY = "cache.sqlite3" // $NON-NLS
|
|
const val DB_FS_BLOCK_DB_ROOT_NAME = "fs_cache" // $NON-NLS
|
|
|
|
@VisibleForTesting
|
|
internal const val DB_PENDING_TRANSACTIONS_NAME_LEGACY = "PendingTransactions.db" // $NON-NLS
|
|
const val DB_PENDING_TRANSACTIONS_NAME = "pending_transactions.sqlite3" // $NON-NLS
|
|
|
|
const val DATABASE_FILE_JOURNAL_SUFFIX = "journal" // $NON-NLS
|
|
const val DATABASE_FILE_WAL_SUFFIX = "wal" // $NON-NLS
|
|
|
|
@VisibleForTesting
|
|
internal const val ALIAS_LEGACY = "ZcashSdk" // $NON-NLS
|
|
|
|
private val lazy =
|
|
LazyWithArgument<Context, DatabaseCoordinator> { DatabaseCoordinator(it) }
|
|
|
|
fun getInstance(context: Context) = lazy.getInstance(context)
|
|
}
|
|
|
|
/**
|
|
* Returns the root folder of the cache database files that would correspond to the given
|
|
* alias and network attributes.
|
|
*
|
|
* @param network the network associated with the data in the cache
|
|
* @param alias the alias to convert into a cache path
|
|
*
|
|
* @return the cache database folder
|
|
*/
|
|
internal suspend fun fsBlockDbRoot(
|
|
network: ZcashNetwork,
|
|
alias: String
|
|
): File {
|
|
// First we deal with the legacy Cache database files (rollback included) on both older and newer path. In
|
|
// case of deletion failure caused by any reason, we try it on the next time again.
|
|
val legacyDbFilesDeleted = deleteLegacyCacheDbFiles(network, alias)
|
|
val result =
|
|
if (legacyDbFilesDeleted) {
|
|
"are successfully deleted"
|
|
} else {
|
|
"failed to be deleted. Will be retried it on the next time"
|
|
}
|
|
Twig.debug { "Legacy Cache database files $result." }
|
|
|
|
return newDatabaseFilePointer(
|
|
network,
|
|
alias,
|
|
DB_FS_BLOCK_DB_ROOT_NAME,
|
|
Files.getZcashNoBackupSubdirectory(applicationContext)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Returns the file of the Data database that would correspond to the given alias
|
|
* and network attributes.
|
|
*
|
|
* @param network the network associated with the data in the database
|
|
* @param alias the alias to convert into a database path
|
|
*
|
|
* @return the Data database file
|
|
*/
|
|
internal suspend fun dataDbFile(
|
|
network: ZcashNetwork,
|
|
alias: String
|
|
): File {
|
|
val dbLocationsPair =
|
|
prepareDbFiles(
|
|
applicationContext,
|
|
network,
|
|
alias,
|
|
DB_DATA_NAME_LEGACY,
|
|
DB_DATA_NAME
|
|
)
|
|
|
|
createFileMutex.withLock {
|
|
return checkAndMoveDatabaseFiles(
|
|
dbLocationsPair.first,
|
|
dbLocationsPair.second
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the file of the PendingTransaction database that would correspond to the given
|
|
* alias and network attributes. As the originally created file was called just
|
|
* PendingTransactions.db, we choose slightly different approach, but it also leads to
|
|
* original database files migration with additional renaming too.
|
|
*
|
|
* @param network the network associated with the data in the database
|
|
* @param alias the alias to convert into a database path
|
|
*
|
|
* @return the PendingTransaction database file
|
|
*/
|
|
internal suspend fun pendingTransactionsDbFile(
|
|
network: ZcashNetwork,
|
|
alias: String
|
|
): File {
|
|
val legacyLocationDbFile =
|
|
newDatabaseFilePointer(
|
|
null,
|
|
null,
|
|
DB_PENDING_TRANSACTIONS_NAME_LEGACY,
|
|
getDatabaseParentDir(applicationContext)
|
|
)
|
|
val preferredLocationDbFile =
|
|
newDatabaseFilePointer(
|
|
network,
|
|
alias,
|
|
DB_PENDING_TRANSACTIONS_NAME,
|
|
Files.getZcashNoBackupSubdirectory(applicationContext)
|
|
)
|
|
|
|
createFileMutex.withLock {
|
|
return checkAndMoveDatabaseFiles(
|
|
legacyLocationDbFile,
|
|
preferredLocationDbFile
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Function for common deletion of Data and Cache database files. It also checks and deletes
|
|
* additional journal and wal files, if they exist.
|
|
*
|
|
* @param network the network associated with the data in the database
|
|
* @param alias the alias to convert into a database path
|
|
*
|
|
* @return true only if any database deleted, false otherwise
|
|
*/
|
|
internal suspend fun deleteDatabases(
|
|
network: ZcashNetwork,
|
|
alias: String
|
|
): Boolean {
|
|
deleteFileMutex.withLock {
|
|
val dataDeleted = deleteDatabase(dataDbFile(network, alias))
|
|
|
|
val cacheDeleted = fsBlockDbRoot(network, alias).deleteRecursivelySuspend()
|
|
|
|
return dataDeleted || cacheDeleted
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Function for common deletion of pending transaction database files. It also checks and deletes
|
|
* additional journal and wal files, if they exist.
|
|
*
|
|
* @param network the network associated with the data in the database
|
|
* @param alias the alias to convert into a database path
|
|
*
|
|
* @return true only if any database deleted, false otherwise
|
|
*/
|
|
internal suspend fun deletePendingTransactionDatabase(
|
|
network: ZcashNetwork,
|
|
alias: String
|
|
): Boolean {
|
|
deleteFileMutex.withLock {
|
|
return deleteDatabase(pendingTransactionsDbFile(network, alias))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This checks and potentially deletes all the legacy Cache database files, which correspond to the given alias and
|
|
* network attributes, as we recently switched to the store blocks on disk mechanism instead of putting them into
|
|
* the Cache database.
|
|
*
|
|
* This function deals with database rollback files too.
|
|
*
|
|
* @param network the network associated with the data in the Cache database
|
|
* @param alias the alias to convert into a database path
|
|
*
|
|
* @return true in case of successful deletion of all the files, false otherwise
|
|
*/
|
|
private suspend fun deleteLegacyCacheDbFiles(
|
|
network: ZcashNetwork,
|
|
alias: String
|
|
): Boolean {
|
|
val legacyDatabaseLocationPair =
|
|
prepareDbFiles(
|
|
applicationContext,
|
|
network,
|
|
alias,
|
|
DB_CACHE_OLDER_NAME_LEGACY,
|
|
DB_CACHE_NEWER_NAME_LEGACY
|
|
)
|
|
|
|
var olderLegacyCacheDbDeleted = true
|
|
var newerLegacyCacheDbDeleted = true
|
|
|
|
if (legacyDatabaseLocationPair.first.existsSuspend()) {
|
|
olderLegacyCacheDbDeleted = deleteDatabase(legacyDatabaseLocationPair.first)
|
|
}
|
|
if (legacyDatabaseLocationPair.second.existsSuspend()) {
|
|
newerLegacyCacheDbDeleted = deleteDatabase(legacyDatabaseLocationPair.second)
|
|
}
|
|
|
|
return olderLegacyCacheDbDeleted && newerLegacyCacheDbDeleted
|
|
}
|
|
|
|
/**
|
|
* This helper function prepares a legacy (i.e. previously created) database file, as well
|
|
* as the preferred (i.e. newly created) file for subsequent use (and eventually move).
|
|
*
|
|
* @param appContext the application context
|
|
* @param network the network associated with the data in the database
|
|
* @param alias the alias to convert into a database path
|
|
* @param databaseName the name of the new database file
|
|
*/
|
|
private suspend fun prepareDbFiles(
|
|
appContext: Context,
|
|
network: ZcashNetwork,
|
|
alias: String,
|
|
databaseNameLegacy: String,
|
|
databaseName: String
|
|
): Pair<File, File> {
|
|
// Here we change the alias to be lowercase and underscored only if we work with the default
|
|
// Zcash alias, otherwise we need to keep an SDK caller alias the same to avoid the database
|
|
// files move breakage.
|
|
val aliasLegacy =
|
|
if (ZcashSdk.DEFAULT_ALIAS == alias) {
|
|
ALIAS_LEGACY
|
|
} else {
|
|
alias
|
|
}
|
|
|
|
val legacyLocationDbFile =
|
|
newDatabaseFilePointer(
|
|
network,
|
|
aliasLegacy,
|
|
databaseNameLegacy,
|
|
getDatabaseParentDir(appContext)
|
|
)
|
|
val preferredLocationDbFile =
|
|
newDatabaseFilePointer(
|
|
network,
|
|
alias,
|
|
databaseName,
|
|
Files.getZcashNoBackupSubdirectory(appContext)
|
|
)
|
|
|
|
return Pair(
|
|
legacyLocationDbFile,
|
|
preferredLocationDbFile
|
|
)
|
|
}
|
|
|
|
/**
|
|
* This function do actual database file move or simply validate the file and return it.
|
|
* From the Android SDK level 21 it places database files into no_backup folder, as it does
|
|
* not allow automatic backup. On older APIs it places database files into databases folder,
|
|
* which allows automatic backup. It also copies database files between these two folders,
|
|
* if older folder usage is detected.
|
|
*
|
|
* @param legacyLocationDbFile the previously used file location
|
|
* @param preferredLocationDbFile the newly used file location
|
|
*/
|
|
private suspend fun checkAndMoveDatabaseFiles(
|
|
legacyLocationDbFile: File,
|
|
preferredLocationDbFile: File
|
|
): File {
|
|
var resultDbFile = preferredLocationDbFile
|
|
|
|
// check if the move wasn't already performed and if it's needed
|
|
if (!preferredLocationDbFile.existsSuspend() && legacyLocationDbFile.existsSuspend()) {
|
|
// We check the move operation result and fallback to the legacy file, if
|
|
// anything went wrong.
|
|
if (!moveDatabaseFile(legacyLocationDbFile, preferredLocationDbFile)) {
|
|
resultDbFile = legacyLocationDbFile
|
|
}
|
|
}
|
|
|
|
return resultDbFile
|
|
}
|
|
|
|
/**
|
|
* The purpose of this function is to move database files between the old location (given by the {@code
|
|
* legacyLocationDbFile} parameter) and the new location (given by {@code preferredLocationDbFile}). The actual
|
|
* move operation is performed with the renameTo function, which simply renames a file path and persists the
|
|
* metadata information. The mechanism deals with the additional database files -journal and -wal too, if they
|
|
* exist.
|
|
*
|
|
* @param legacyLocationDbFile the previously used file location (rename from)
|
|
* @param preferredLocationDbFile the newly used file location (rename to)
|
|
*/
|
|
private suspend fun moveDatabaseFile(
|
|
legacyLocationDbFile: File,
|
|
preferredLocationDbFile: File
|
|
): Boolean {
|
|
val filesToBeRenamed =
|
|
mutableListOf<Pair<File, File>>().apply {
|
|
add(Pair(legacyLocationDbFile, preferredLocationDbFile))
|
|
}
|
|
|
|
// add journal database file, if exists
|
|
val journalSuffixedDbFile =
|
|
File(
|
|
"${legacyLocationDbFile.absolutePath}-$DATABASE_FILE_JOURNAL_SUFFIX"
|
|
)
|
|
if (journalSuffixedDbFile.existsSuspend()) {
|
|
filesToBeRenamed.add(
|
|
Pair(
|
|
journalSuffixedDbFile,
|
|
File(
|
|
"${preferredLocationDbFile.absolutePath}-$DATABASE_FILE_JOURNAL_SUFFIX"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
// add wal database file, if exists
|
|
val walSuffixedDbFile =
|
|
File(
|
|
"${legacyLocationDbFile.absolutePath}-$DATABASE_FILE_WAL_SUFFIX"
|
|
)
|
|
if (walSuffixedDbFile.existsSuspend()) {
|
|
filesToBeRenamed.add(
|
|
Pair(
|
|
walSuffixedDbFile,
|
|
File(
|
|
"${preferredLocationDbFile.absolutePath}-$DATABASE_FILE_WAL_SUFFIX"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
return runCatching {
|
|
return@runCatching filesToBeRenamed.all {
|
|
it.first.renameToSuspend(it.second)
|
|
}
|
|
}.onFailure {
|
|
Twig.warn(it) { "Failed while renaming database files" }
|
|
}.getOrDefault(false)
|
|
}
|
|
|
|
/**
|
|
* This function returns previously used database folder path (i.e. databases). The databases
|
|
* folder is deprecated now, as it allows automatic data backup, which is not permitted for
|
|
* our database files.
|
|
*
|
|
* @param appContext the application context
|
|
*/
|
|
private suspend fun getDatabaseParentDir(appContext: Context): File {
|
|
return appContext.getDatabasePathSuspend("unused.db").parentFile
|
|
?: throw InitializeException.DatabasePathException
|
|
}
|
|
|
|
/**
|
|
* Simple helper function, which prepares a database file object by input parameters. It does
|
|
* not create the file, just determines the file path.
|
|
*
|
|
* @param network the network associated with the data in the database.
|
|
* @param alias the alias to convert into a database path
|
|
* @param dbFileName the name of the new database file
|
|
* @param parentDir the name of the parent directory, in which the file should be placed
|
|
*/
|
|
private fun newDatabaseFilePointer(
|
|
network: ZcashNetwork?,
|
|
alias: String?,
|
|
dbFileName: String,
|
|
parentDir: File
|
|
): File {
|
|
val aliasPrefix =
|
|
if (alias == null) {
|
|
""
|
|
} else if (alias.endsWith('_')) {
|
|
alias
|
|
} else {
|
|
"${alias}_"
|
|
}
|
|
|
|
val networkPrefix = network?.networkName ?: ""
|
|
|
|
return if (aliasPrefix.isNotEmpty()) {
|
|
File(parentDir, "$aliasPrefix${networkPrefix}_$dbFileName")
|
|
} else {
|
|
File(parentDir, dbFileName)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a database and its potential journal and wal file at the given path.
|
|
*
|
|
* The rollback journal (or newer wal) file is a temporary file used to implement atomic commit
|
|
* and rollback capabilities in SQLite.
|
|
*
|
|
* @param file the path of the db to erase.
|
|
* @return true when a file exists at the given path and was deleted.
|
|
*/
|
|
private suspend fun deleteDatabase(file: File): Boolean {
|
|
// Just try the journal and wal files too. Doesn't matter if they're not there.
|
|
File("${file.absolutePath}-$DATABASE_FILE_JOURNAL_SUFFIX").deleteSuspend()
|
|
File("${file.absolutePath}-$DATABASE_FILE_WAL_SUFFIX").deleteSuspend()
|
|
|
|
return file.deleteSuspend()
|
|
}
|
|
}
|