Improvement: Refactor the initializer and move all DB creation code.

Move the code to create, migrate and populate data from the initializer over to the repository. Both classes are now much simpler.
This commit is contained in:
Kevin Gorham 2021-05-07 03:59:47 -04:00
parent fc7cead1f6
commit 44d1d55201
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
3 changed files with 200 additions and 147 deletions

View File

@ -3,7 +3,6 @@ package cash.z.ecc.android.sdk
import android.content.Context
import cash.z.ecc.android.sdk.exception.InitializerException
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.tryWarn
import cash.z.ecc.android.sdk.ext.twig
import cash.z.ecc.android.sdk.jni.RustBackend
import cash.z.ecc.android.sdk.tool.DerivationTool
@ -24,6 +23,7 @@ class Initializer constructor(appContext: Context, onCriticalErrorHandler: ((Thr
val host: String
val port: Int
val viewingKeys: List<UnifiedViewingKey>
val overwriteVks: Boolean
val birthday: WalletBirthday
/**
@ -51,13 +51,11 @@ class Initializer constructor(appContext: Context, onCriticalErrorHandler: ((Thr
val loadedBirthday = WalletBirthdayTool.loadNearest(context, network, heightToUse)
birthday = loadedBirthday
viewingKeys = config.viewingKeys
overwriteVks = config.overwriteVks
alias = config.alias
host = config.host
port = config.port
rustBackend = initRustBackend(network, birthday)
if (config.resetAccounts) resetAccounts()
// TODO: get rid of this by first answering the question: why is this necessary?
initMissingDatabases(birthday, *viewingKeys.toTypedArray())
} catch (t: Throwable) {
onCriticalError(t)
throw t
@ -79,68 +77,6 @@ class Initializer constructor(appContext: Context, onCriticalErrorHandler: ((Thr
)
}
private fun initMissingDatabases(
birthday: WalletBirthday,
vararg viewingKeys: UnifiedViewingKey
) {
maybeCreateDataDb()
maybeInitBlocksTable(birthday)
maybeInitAccountsTable(*viewingKeys)
}
private fun resetAccounts() {
// Short-term fix: drop and recreate accounts for key migration
tryWarn("Warning: did not drop the accounts table. It probably did not yet exist.") {
rustBackend.dropAccountsTable()
twig("Reset accounts table to allow for key migration")
}
}
/**
* Create the dataDb and its table, if it doesn't exist.
*/
private fun maybeCreateDataDb() {
tryWarn("Warning: did not create dataDb. It probably already exists.") {
rustBackend.initDataDb()
twig("Initialized wallet for first run")
}
}
/**
* Initialize the blocks table with the given birthday, if needed.
*/
private fun maybeInitBlocksTable(birthday: WalletBirthday) {
// TODO: consider converting these to typed exceptions in the welding layer
tryWarn(
"Warning: did not initialize the blocks table. It probably was already initialized.",
ifContains = "table is not empty"
) {
rustBackend.initBlocksTable(
birthday.height,
birthday.hash,
birthday.time,
birthday.tree
)
twig("seeded the database with sapling tree at height ${birthday.height}")
}
twig("database file: ${rustBackend.pathDataDb}")
}
/**
* Initialize the accounts table with the given viewing keys.
*/
private fun maybeInitAccountsTable(vararg viewingKeys: UnifiedViewingKey) {
// TODO: consider converting these to typed exceptions in the welding layer
tryWarn(
"Warning: did not initialize the accounts table. It probably was already initialized.",
ifContains = "table is not empty"
) {
rustBackend.initAccountsTable(*viewingKeys)
accountsCreated = true
twig("Initialized the accounts table with ${viewingKeys.size} viewingKey(s)")
}
}
private fun onCriticalError(error: Throwable) {
twig("********")
twig("******** INITIALIZER ERROR: $error")
@ -192,7 +128,7 @@ class Initializer constructor(appContext: Context, onCriticalErrorHandler: ((Thr
var defaultToOldestHeight: Boolean? = null
private set
var resetAccounts: Boolean = false
var overwriteVks: Boolean = false
private set
constructor(block: (Config) -> Unit) : this() {
@ -250,7 +186,7 @@ class Initializer constructor(appContext: Context, onCriticalErrorHandler: ((Thr
* probably has serious bugs.
*/
fun setViewingKeys(vararg unifiedViewingKeys: UnifiedViewingKey, overwrite: Boolean = false): Config = apply {
resetAccounts = overwrite
overwriteVks = overwrite
viewingKeys.apply {
clear()
addAll(unifiedViewingKeys)
@ -258,7 +194,7 @@ class Initializer constructor(appContext: Context, onCriticalErrorHandler: ((Thr
}
fun setOverwriteKeys(isOverwrite: Boolean) {
resetAccounts = isOverwrite
overwriteVks = isOverwrite
}
/**

View File

@ -38,7 +38,6 @@ import cash.z.ecc.android.sdk.ext.toHexReversed
import cash.z.ecc.android.sdk.ext.tryNull
import cash.z.ecc.android.sdk.ext.twig
import cash.z.ecc.android.sdk.ext.twigTask
import cash.z.ecc.android.sdk.jni.RustBackend
import cash.z.ecc.android.sdk.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.service.LightWalletService
import cash.z.ecc.android.sdk.tool.DerivationTool
@ -138,10 +137,10 @@ class SdkSynchronizer internal constructor(
//
override val balances: Flow<WalletBalance> = _balances.asFlow()
override val clearedTransactions = storage.allTransactions
override val clearedTransactions get() = storage.allTransactions
override val pendingTransactions = txManager.getAll()
override val sentTransactions = storage.sentTransactions
override val receivedTransactions = storage.receivedTransactions
override val sentTransactions get() = storage.sentTransactions
override val receivedTransactions get() = storage.receivedTransactions
//
// Status
@ -402,6 +401,7 @@ class SdkSynchronizer internal constructor(
private fun onCriticalError(unused: CoroutineContext?, error: Throwable) {
twig("********")
twig("******** ERROR: $error")
twig(error)
if (error.cause != null) twig("******** caused by ${error.cause}")
if (error.cause?.cause != null) twig("******** caused by ${error.cause?.cause}")
twig("********")
@ -759,7 +759,7 @@ class SdkSynchronizer internal constructor(
fun Synchronizer(
initializer: Initializer,
repository: TransactionRepository =
PagedTransactionRepository(initializer.context, 1000, initializer.rustBackend.pathDataDb), // TODO: fix this pagesize bug, small pages should not crash the app. It crashes with: Uncaught Exception: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. and is probably related to FlowPagedList
PagedTransactionRepository(initializer.context, 1000, initializer.rustBackend, initializer.birthday, initializer.viewingKeys, initializer.overwriteVks), // TODO: fix this pagesize bug, small pages should not crash the app. It crashes with: Uncaught Exception: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. and is probably related to FlowPagedList
blockStore: CompactBlockStore = CompactBlockDbStore(initializer.context, initializer.rustBackend.pathCacheDb),
service: LightWalletService = LightWalletGrpcService(initializer.context, initializer.host, initializer.port),
encoder: TransactionEncoder = WalletTransactionEncoder(initializer.rustBackend, repository),

View File

@ -9,12 +9,21 @@ import cash.z.ecc.android.sdk.db.BlockDao
import cash.z.ecc.android.sdk.db.DerivedDataDb
import cash.z.ecc.android.sdk.db.TransactionDao
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
import cash.z.ecc.android.sdk.exception.RepositoryException
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.android.RefreshableDataSourceFactory
import cash.z.ecc.android.sdk.ext.android.toFlowPagedList
import cash.z.ecc.android.sdk.ext.android.toRefreshable
import cash.z.ecc.android.sdk.ext.tryWarn
import cash.z.ecc.android.sdk.ext.twig
import cash.z.ecc.android.sdk.jni.RustBackend
import cash.z.ecc.android.sdk.type.UnifiedAddressAccount
import cash.z.ecc.android.sdk.type.UnifiedViewingKey
import cash.z.ecc.android.sdk.type.WalletBirthday
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
/**
* Example of a repository that leverages the Room paging library to return a [PagedList] of
@ -23,100 +32,208 @@ import kotlinx.coroutines.withContext
*
* @param pageSize transactions per page. This influences pre-fetch and memory configuration.
*/
open class PagedTransactionRepository(
open val derivedDataDb: DerivedDataDb,
open val pageSize: Int = 10
class PagedTransactionRepository(
val appContext: Context,
val pageSize: Int = 10,
val rustBackend: RustBackend,
val birthday: WalletBirthday,
val viewingKeys: List<UnifiedViewingKey>,
val overwriteVks: Boolean = false,
) : TransactionRepository {
private val lazy = LazyPropertyHolder()
override val receivedTransactions get() = lazy.receivedTransactions
override val sentTransactions get() = lazy.sentTransactions
override val allTransactions get() = lazy.allTransactions
//
// TransactionRepository API
//
override fun invalidate() = lazy.allTransactionsFactory.refresh()
override fun lastScannedHeight(): Int {
return lazy.blocks.lastScannedHeight()
}
override fun firstScannedHeight(): Int {
return lazy.blocks.firstScannedHeight()
}
override fun isInitialized(): Boolean {
return lazy.blocks.count() > 0
}
override suspend fun findEncodedTransactionById(txId: Long) = withContext(IO) {
lazy.transactions.findEncodedTransactionById(txId)
}
override suspend fun findNewTransactions(blockHeightRange: IntRange): List<ConfirmedTransaction> =
lazy.transactions.findAllTransactionsByRange(blockHeightRange.first, blockHeightRange.last)
override suspend fun findMinedHeight(rawTransactionId: ByteArray) = withContext(IO) {
lazy.transactions.findMinedHeight(rawTransactionId)
}
override suspend fun findMatchingTransactionId(rawTransactionId: ByteArray): Long? =
lazy.transactions.findMatchingTransactionId(rawTransactionId)
override suspend fun cleanupCancelledTx(rawTransactionId: ByteArray) = lazy.transactions.cleanupCancelledTx(rawTransactionId)
override suspend fun deleteExpired(lastScannedHeight: Int): Int {
// let expired transactions linger in the UI for a little while
return lazy.transactions.deleteExpired(lastScannedHeight - (ZcashSdk.EXPIRY_OFFSET / 2))
}
override suspend fun count(): Int = withContext(IO) {
lazy.transactions.count()
}
override suspend fun getAccount(accountId: Int): UnifiedAddressAccount? = lazy.accounts.findAccountById(accountId)
override suspend fun getAccountCount(): Int = lazy.accounts.count()
override fun prepare() {
twig("Preparing repository for use...")
initMissingDatabases()
// provide the database to all the lazy properties that are waiting for it to exist
lazy.db = buildDatabase()
applyKeyMigrations()
}
/**
* Constructor that creates the database.
* Create any databases that don't already exist via Rust. Originally, this was done on the Rust
* side because Rust was intended to own the "dataDb" and Kotlin just reads from it. Since then,
* it has been more clear that Kotlin should own the data and just let Rust use it.
*/
constructor(
context: Context,
pageSize: Int = 10,
dataDbName: String = ZcashSdk.DB_DATA_NAME
) : this(
Room.databaseBuilder(context, DerivedDataDb::class.java, dataDbName)
private fun initMissingDatabases() {
maybeCreateDataDb()
maybeInitBlocksTable(birthday)
maybeInitAccountsTable(viewingKeys)
}
/**
* Create the dataDb and its table, if it doesn't exist.
*/
private fun maybeCreateDataDb() {
tryWarn("Warning: did not create dataDb. It probably already exists.") {
rustBackend.initDataDb()
twig("Initialized wallet for first run file: ${rustBackend.pathDataDb}")
}
}
/**
* Initialize the blocks table with the given birthday, if needed.
*/
private fun maybeInitBlocksTable(birthday: WalletBirthday) {
// TODO: consider converting these to typed exceptions in the welding layer
tryWarn(
"Warning: did not initialize the blocks table. It probably was already initialized.",
ifContains = "table is not empty"
) {
rustBackend.initBlocksTable(
birthday.height,
birthday.hash,
birthday.time,
birthday.tree
)
twig("seeded the database with sapling tree at height ${birthday.height}")
}
twig("database file: ${rustBackend.pathDataDb}")
}
/**
* Initialize the accounts table with the given viewing keys.
*/
private fun maybeInitAccountsTable(viewingKeys: List<UnifiedViewingKey>) {
// TODO: consider converting these to typed exceptions in the welding layer
tryWarn(
"Warning: did not initialize the accounts table. It probably was already initialized.",
ifContains = "table is not empty"
) {
rustBackend.initAccountsTable(*viewingKeys.toTypedArray())
twig("Initialized the accounts table with ${viewingKeys.size} viewingKey(s)")
}
}
/**
* Build the database and apply migrations.
*/
private fun buildDatabase(): DerivedDataDb {
twig("Building dataDb and applying migrations")
return Room.databaseBuilder(appContext, DerivedDataDb::class.java, rustBackend.pathDataDb)
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
.addMigrations(DerivedDataDb.MIGRATION_3_4)
.addMigrations(DerivedDataDb.MIGRATION_4_3)
.addMigrations(DerivedDataDb.MIGRATION_4_5)
.addMigrations(DerivedDataDb.MIGRATION_5_6)
.addMigrations(DerivedDataDb.MIGRATION_6_7)
.build(),
pageSize
)
init {
derivedDataDb.openHelper.writableDatabase.beginTransaction()
derivedDataDb.openHelper.writableDatabase.endTransaction()
.build().also {
// TODO: document why we do this. My guess is to catch database issues early or to trigger migrations--I forget why it was added but there was a good reason?
it.openHelper.writableDatabase.beginTransaction()
it.openHelper.writableDatabase.endTransaction()
}
}
private val blocks: BlockDao = derivedDataDb.blockDao()
private val accounts: AccountDao = derivedDataDb.accountDao()
private val transactions: TransactionDao = derivedDataDb.transactionDao()
private val receivedTxDataSourceFactory = transactions.getReceivedTransactions().toRefreshable()
private val sentTxDataSourceFactory = transactions.getSentTransactions().toRefreshable()
private val allTxDataSourceFactory = transactions.getAllTransactions().toRefreshable()
//
// TransactionRepository API
//
override val receivedTransactions = receivedTxDataSourceFactory.toFlowPagedList(pageSize)
override val sentTransactions = sentTxDataSourceFactory.toFlowPagedList(pageSize)
override val allTransactions = allTxDataSourceFactory.toFlowPagedList(pageSize)
override fun invalidate() = allTxDataSourceFactory.refresh()
override fun lastScannedHeight(): Int {
return blocks.lastScannedHeight()
private fun applyKeyMigrations() {
if (overwriteVks) {
twig("applying key migrations . . .")
maybeInitAccountsTable(viewingKeys)
}
}
override fun firstScannedHeight(): Int {
return blocks.firstScannedHeight()
}
override fun isInitialized(): Boolean {
return blocks.count() > 0
}
override suspend fun findEncodedTransactionById(txId: Long) = withContext(IO) {
transactions.findEncodedTransactionById(txId)
}
override suspend fun findNewTransactions(blockHeightRange: IntRange): List<ConfirmedTransaction> =
transactions.findAllTransactionsByRange(blockHeightRange.first, blockHeightRange.last)
override suspend fun findMinedHeight(rawTransactionId: ByteArray) = withContext(IO) {
transactions.findMinedHeight(rawTransactionId)
}
override suspend fun findMatchingTransactionId(rawTransactionId: ByteArray): Long? =
transactions.findMatchingTransactionId(rawTransactionId)
override suspend fun cleanupCancelledTx(rawTransactionId: ByteArray) = transactions.cleanupCancelledTx(rawTransactionId)
override suspend fun deleteExpired(lastScannedHeight: Int): Int {
// let expired transactions linger in the UI for a little while
return transactions.deleteExpired(lastScannedHeight - (ZcashSdk.EXPIRY_OFFSET / 2))
}
override suspend fun count(): Int = withContext(IO) {
transactions.count()
}
override suspend fun getAccount(accountId: Int): UnifiedAddressAccount? = accounts.findAccountById(accountId)
override suspend fun getAccountCount(): Int = accounts.count()
/**
* Close the underlying database.
*/
fun close() {
derivedDataDb.close()
lazy.db?.close()
}
// TODO: begin converting these into Data Access API. For now, just collect the desired operations and iterate/refactor, later
fun findBlockHash(height: Int): ByteArray? = blocks.findHashByHeight(height)
fun getTransactionCount(): Int = transactions.count()
fun findBlockHash(height: Int): ByteArray? = lazy.blocks.findHashByHeight(height)
fun getTransactionCount(): Int = lazy.transactions.count()
// TODO: convert this into a wallet repository rather than "transaction repository"
/**
* Helper class that holds all the properties that depend on the database being prepared. If any
* properties are accessed before then, it results in an Unprepared Exception.
*/
inner class LazyPropertyHolder {
var isPrepared = AtomicBoolean(false)
var db: DerivedDataDb? = null
set(value) {
field = value
if (value != null) isPrepared.set(true)
}
// DAOs
val blocks: BlockDao by lazyDb { db!!.blockDao() }
val accounts: AccountDao by lazyDb { db!!.accountDao() }
val transactions: TransactionDao by lazyDb { db!!.transactionDao() }
// Transaction Flows
val allTransactionsFactory: RefreshableDataSourceFactory<Int, ConfirmedTransaction> by lazyDb {
transactions.getAllTransactions().toRefreshable()
}
val allTransactions: Flow<PagedList<ConfirmedTransaction>> by lazyDb {
allTransactionsFactory.toFlowPagedList(pageSize)
}
val receivedTransactions: Flow<PagedList<ConfirmedTransaction>> by lazyDb {
transactions.getReceivedTransactions().toRefreshable().toFlowPagedList(pageSize)
}
val sentTransactions: Flow<PagedList<ConfirmedTransaction>> by lazyDb {
transactions.getSentTransactions().toRefreshable().toFlowPagedList(pageSize)
}
/**
* If isPrepared is true, execute the given block and cache the value, always returning it
* to future requests. Otherwise, throw an Unprepared exception.
*/
inline fun <T> lazyDb(crossinline block: () -> T) = object : Lazy<T> {
val cached: T? = null
override val value: T
get() = cached ?: if (isPrepared.get()) block() else throw RepositoryException.Unprepared
override fun isInitialized(): Boolean = cached != null
}
}
}