lib: data store for wallet state

This commit is contained in:
Oscar Mira 2023-05-04 00:48:38 +02:00
parent 698ae32ef2
commit e61021d8bd
25 changed files with 379 additions and 231 deletions

View File

@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "8e728fa24771d06bb2988df1356e07ec",
"identityHash": "419c430462df613cab5f23d35923c2b7",
"entities": [
{
"tableName": "wallets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `public_address` TEXT NOT NULL, `name` TEXT NOT NULL)",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `public_address` TEXT NOT NULL, `filename` TEXT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
@ -20,6 +20,12 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
@ -173,7 +179,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8e728fa24771d06bb2988df1356e07ec')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '419c430462df613cab5f23d35923c2b7')"
]
}
}

View File

@ -24,12 +24,8 @@ object AppModule {
WalletDataSource(database.walletDao())
}
private val walletDataFileStorage: WalletDataFileStorage by lazy {
AppWalletDataFileStorage(application)
}
private val moneroSdkClient: MoneroSdkClient by lazy {
MoneroSdkClient(application, walletDataFileStorage)
MoneroSdkClient(application)
}
val settingsRepository: SettingsRepository by lazy {

View File

@ -1,61 +1,87 @@
package im.molly.monero.demo.data
import android.content.Context
import android.util.AtomicFile
import im.molly.monero.*
import im.molly.monero.loadbalancer.RoundRobinRule
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import okhttp3.OkHttpClient
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
class MoneroSdkClient(
private val context: Context,
private val walletDataFileStorage: WalletDataFileStorage,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val providerDeferred = CoroutineScope(ioDispatcher).async {
class MoneroSdkClient(private val context: Context) {
private val providerDeferred = CoroutineScope(Dispatchers.IO).async {
WalletProvider.connect(context)
}
suspend fun createWallet(moneroNetwork: MoneroNetwork): MoneroWallet {
suspend fun createWallet(network: MoneroNetwork, filename: String): MoneroWallet {
val provider = providerDeferred.await()
return withContext(ioDispatcher) {
val wallet = provider.createNewWallet(moneroNetwork)
saveToFile(wallet, provider, false)
wallet
}
}
suspend fun saveWallet(wallet: MoneroWallet) {
withContext(ioDispatcher) {
saveToFile(wallet, providerDeferred.await(), true)
}
}
private fun saveToFile(wallet: MoneroWallet, provider: WalletProvider, canOverwrite: Boolean) {
walletDataFileStorage.tryWriteData(wallet.publicAddress, canOverwrite) { output ->
provider.saveWallet(wallet, output)
return provider.createNewWallet(
network = network,
dataStore = WalletDataStoreFile(filename, newFile = true),
).also { wallet ->
wallet.commit()
}
}
suspend fun openWallet(
publicAddress: String,
network: MoneroNetwork,
filename: String,
remoteNodes: Flow<List<RemoteNode>>,
httpClient: OkHttpClient,
): MoneroWallet {
val dataStore = WalletDataStoreFile(filename)
val client = RemoteNodeClient.forNetwork(
network = network,
remoteNodes = remoteNodes,
loadBalancerRule = RoundRobinRule(),
httpClient = httpClient,
)
val provider = providerDeferred.await()
return withContext(ioDispatcher) {
val network = MoneroNetwork.of(publicAddress)
val client = RemoteNodeClient.forNetwork(
network = network,
remoteNodes = remoteNodes,
loadBalancerRule = RoundRobinRule(),
httpClient = httpClient,
)
val wallet = walletDataFileStorage.readData(publicAddress).use { input ->
provider.openWallet(network, client, input)
return provider.openWallet(network, dataStore, client)
}
private val filesDir = context.filesDir
private inner class WalletDataStoreFile(filename: String, newFile: Boolean = false) :
WalletDataStore {
private val file: AtomicFile = getBackingFile(filename)
init {
if (newFile && !file.baseFile.createNewFile()) {
throw IOException("Data file already exists: ${file.baseFile.path}")
}
check(publicAddress == wallet.primaryAccountAddress) { "primary address mismatch" }
wallet
}
private fun getBackingFile(filename: String): AtomicFile =
AtomicFile(File(getOrCreateWalletDataDir(), "$filename.wallet"))
private fun getOrCreateWalletDataDir(): File {
val walletDataDir = File(filesDir, "wallet_data")
if (walletDataDir.exists() || walletDataDir.mkdir()) {
return walletDataDir
}
throw IOException("Cannot create wallet data directory: ${walletDataDir.path}")
}
override suspend fun write(writer: (FileOutputStream) -> Unit) {
val output = file.startWrite()
try {
writer(output)
file.finishWrite(output)
} catch (ioe: IOException) {
file.failWrite(output)
throw ioe
}
}
override suspend fun read(): FileInputStream {
return file.openRead()
}
}
}

View File

@ -1,59 +0,0 @@
package im.molly.monero.demo.data
import android.content.Context
import android.util.AtomicFile
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
interface WalletDataFileStorage {
fun tryWriteData(
publicAddress: String,
canOverwrite: Boolean = true,
block: (FileOutputStream) -> Unit,
)
fun readData(publicAddress: String): FileInputStream
}
class AppWalletDataFileStorage(context: Context) : WalletDataFileStorage {
private val filesDir = context.filesDir
override fun tryWriteData(
publicAddress: String,
canOverwrite: Boolean,
block: (FileOutputStream) -> Unit,
) {
val file = getBackingFile(publicAddress)
if (!(canOverwrite || file.baseFile.createNewFile())) {
throw IOException("Data file already exists: ${file.baseFile.path}")
}
val output = file.startWrite()
try {
block(output)
file.finishWrite(output)
} catch (ioe: IOException) {
file.failWrite(output)
throw ioe
}
}
override fun readData(publicAddress: String): FileInputStream {
val file = getBackingFile(publicAddress)
return file.openRead()
}
private fun getBackingFile(publicAddress: String): AtomicFile {
val uniqueFilename = publicAddress.substring(0, 11) + ".wallet"
return AtomicFile(File(getOrCreateWalletDataDir(), uniqueFilename))
}
private fun getOrCreateWalletDataDir(): File {
val walletDataDir = File(filesDir, "wallet_data")
if (walletDataDir.exists() || walletDataDir.mkdir()) {
return walletDataDir
}
throw IOException("Cannot create wallet data directory: ${walletDataDir.path}")
}
}

View File

@ -19,12 +19,14 @@ class WalletDataSource(
suspend fun createWalletConfig(
publicAddress: String,
filename: String,
name: String,
remoteNodeIds: List<Long>,
): Long {
val walletId = walletDao.insert(
WalletEntity(
publicAddress = publicAddress,
filename = filename,
name = name,
)
)

View File

@ -5,6 +5,7 @@ import im.molly.monero.demo.data.model.WalletConfig
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import okhttp3.OkHttpClient
import java.util.*
import java.util.concurrent.ConcurrentHashMap
class WalletRepository(
@ -20,14 +21,16 @@ class WalletRepository(
suspend fun getWallet(walletId: Long): MoneroWallet {
return walletIdMap.computeIfAbsent(walletId) {
externalScope.async {
val config = getWalletConfig(walletId)
val configFlow = getWalletConfig(walletId)
val config = configFlow.first()
val userSettings = settingsRepository.getUserSettings().first()
val httpClient = sharedHttpClient.newBuilder()
.proxy(userSettings.activeProxy)
.build()
val wallet = moneroSdkClient.openWallet(
publicAddress = config.first().publicAddress,
remoteNodes = config.map {
network = MoneroNetwork.of(config.publicAddress),
filename = config.filename,
remoteNodes = configFlow.map {
it.remoteNodes.map { node ->
RemoteNode(
uri = node.uri,
@ -38,6 +41,7 @@ class WalletRepository(
},
httpClient = httpClient,
)
check(config.publicAddress == wallet.primaryAddress) { "primary address mismatch" }
wallet
}
}.await()
@ -59,18 +63,17 @@ class WalletRepository(
name: String,
remoteNodeIds: List<Long>,
): Pair<Long, MoneroWallet> {
val wallet = moneroSdkClient.createWallet(moneroNetwork)
val uniqueFilename = UUID.randomUUID().toString()
val wallet = moneroSdkClient.createWallet(moneroNetwork, uniqueFilename)
val walletId = walletDataSource.createWalletConfig(
publicAddress = wallet.publicAddress,
publicAddress = wallet.primaryAddress,
filename = uniqueFilename,
name = name,
remoteNodeIds = remoteNodeIds,
)
return walletId to wallet
}
suspend fun saveWallet(wallet: MoneroWallet) =
moneroSdkClient.saveWallet(wallet)
suspend fun updateWalletConfig(walletConfig: WalletConfig) =
walletDataSource.updateWalletConfig(walletConfig)
}

View File

@ -20,6 +20,9 @@ data class WalletEntity(
@ColumnInfo(name = "public_address")
val publicAddress: String,
@ColumnInfo(name = "filename")
val filename: String,
@ColumnInfo(name = "name")
val name: String = "",
)
@ -27,6 +30,7 @@ data class WalletEntity(
fun WalletEntity.asExternalModel() = WalletConfig(
id = id,
publicAddress = publicAddress,
filename = filename,
name = name,
remoteNodes = setOf(),
)
@ -34,5 +38,6 @@ fun WalletEntity.asExternalModel() = WalletConfig(
fun WalletConfig.asEntity() = WalletEntity(
id = id,
publicAddress = publicAddress,
filename = filename,
name = name
)

View File

@ -3,6 +3,7 @@ package im.molly.monero.demo.data.model
data class WalletConfig(
val id: Long,
val publicAddress: String,
val filename: String,
val name: String,
val remoteNodes: Set<RemoteNode>,
)

View File

@ -10,6 +10,8 @@ import androidx.lifecycle.lifecycleScope
import im.molly.monero.demo.AppModule
import im.molly.monero.demo.data.WalletRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Duration.Companion.seconds
const val TAG = "SyncService"
@ -21,6 +23,20 @@ class SyncService(
private suspend fun doSync() = coroutineScope {
val syncedWalletIds = mutableSetOf<Long>()
val mutex = Mutex()
launch {
while (isActive) {
mutex.withLock {
syncedWalletIds.map {
walletRepository.getWallet(it)
}.forEach { wallet ->
wallet.commit()
}
}
delay(60.seconds)
}
}
walletRepository.getWalletIdList().collect {
val idSet = it.toSet()
@ -33,12 +49,14 @@ class SyncService(
if (result.isError()) {
break
}
walletRepository.saveWallet(wallet)
wallet.commit()
delay(10.seconds)
}
}
}
syncedWalletIds.addAll(toSync)
mutex.withLock {
syncedWalletIds.addAll(toSync)
}
}
}

View File

@ -12,7 +12,7 @@ class WalletNativeTest {
assertThat(
WalletNative.fullNode(
networkId = MoneroNetwork.Mainnet.id,
secretSpendKey = SecretKey("d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706".parseHex())
secretSpendKey = SecretKey("d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706".parseHex()),
).primaryAccountAddress
).isEqualTo("4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T")

View File

@ -1,5 +0,0 @@
package im.molly.monero;
oneway interface IRefreshCallback {
void onResult(long blockHeight, int status);
}

View File

@ -0,0 +1,6 @@
package im.molly.monero;
interface IStorageAdapter {
boolean writeAsync(in ParcelFileDescriptor pfd);
void readAsync(in ParcelFileDescriptor pfd);
}

View File

@ -1,15 +1,15 @@
package im.molly.monero;
import im.molly.monero.IBalanceListener;
import im.molly.monero.IRefreshCallback;
import im.molly.monero.IWalletCallbacks;
interface IWallet {
String getPrimaryAccountAddress();
void addBalanceListener(in IBalanceListener listener);
void removeBalanceListener(in IBalanceListener listener);
oneway void save(in ParcelFileDescriptor destination);
oneway void resumeRefresh(boolean skipCoinbaseOutputs, in IRefreshCallback callback);
oneway void resumeRefresh(boolean skipCoinbaseOutputs, in IWalletCallbacks callback);
oneway void cancelRefresh();
oneway void setRefreshSince(long heightOrTimestamp);
oneway void commit(in IWalletCallbacks callback);
void close();
}

View File

@ -0,0 +1,6 @@
package im.molly.monero;
oneway interface IWalletCallbacks {
void onRefreshResult(long blockHeight, int status);
void onCommitResult(boolean success);
}

View File

@ -1,14 +1,13 @@
package im.molly.monero;
import im.molly.monero.IRemoteNodeClient;
import im.molly.monero.IWallet;
import im.molly.monero.IWalletServiceCallbacks;
import im.molly.monero.IWalletServiceListener;
import im.molly.monero.SecretKey;
import im.molly.monero.WalletConfig;
interface IWalletService {
IWallet createWallet(in WalletConfig config, in IRemoteNodeClient client);
IWallet restoreWallet(in WalletConfig config, in IRemoteNodeClient client, in SecretKey spendSecretKey, long accountCreationTimestamp);
IWallet openWallet(in WalletConfig config, in IRemoteNodeClient client, in ParcelFileDescriptor source);
oneway void createWallet(in WalletConfig config, in IWalletServiceCallbacks callback);
oneway void restoreWallet(in WalletConfig config, in IWalletServiceCallbacks callback, in SecretKey spendSecretKey, long accountCreationTimestamp);
oneway void openWallet(in WalletConfig config, in IWalletServiceCallbacks callback);
void setListener(in IWalletServiceListener listener);
}

View File

@ -0,0 +1,7 @@
package im.molly.monero;
import im.molly.monero.IWallet;
oneway interface IWalletServiceCallbacks {
void onWalletResult(in IWallet wallet);
}

View File

@ -186,6 +186,7 @@ Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) {
ret = Status::INTERRUPTED;
}
m_refresh_running.store(false);
m_blockchain_height = m_wallet.get_blockchain_current_height();
// Always notify the last block height.
callOnRefresh(false);
return ret;

View File

@ -3,19 +3,21 @@ package im.molly.monero
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
class MoneroWallet internal constructor(
private val wallet: IWallet,
private val storageAdapter: StorageAdapter,
val remoteNodeClient: RemoteNodeClient?,
) : IWallet by wallet, AutoCloseable {
) : AutoCloseable {
private val logger = loggerFor<MoneroWallet>()
val publicAddress: String = wallet.primaryAccountAddress
val primaryAddress: String = wallet.primaryAccountAddress
var dataStore by storageAdapter::dataStore
/**
* A [Flow] of ledger changes.
@ -25,7 +27,7 @@ class MoneroWallet internal constructor(
lateinit var lastKnownLedger: Ledger
override fun onBalanceChanged(txOuts: List<OwnedTxOut>?, checkedAtBlockHeight: Long) {
lastKnownLedger = Ledger(publicAddress, txOuts!!, checkedAtBlockHeight)
lastKnownLedger = Ledger(primaryAddress, txOuts!!, checkedAtBlockHeight)
sendLedger(lastKnownLedger)
}
@ -34,38 +36,49 @@ class MoneroWallet internal constructor(
}
private fun sendLedger(ledger: Ledger) {
trySend(ledger)
.onFailure {
logger.e("Too many ledger updates, channel capacity exceeded")
}
trySend(ledger).onFailure {
logger.e("Too many ledger updates, channel capacity exceeded")
}
}
}
addBalanceListener(listener)
wallet.addBalanceListener(listener)
awaitClose { removeBalanceListener(listener) }
awaitClose { wallet.removeBalanceListener(listener) }
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun awaitRefresh(
skipCoinbaseOutputs: Boolean = false,
): RefreshResult = suspendCancellableCoroutine { continuation ->
val callback = object : IRefreshCallback.Stub() {
override fun onResult(blockHeight: Long, status: Int) {
wallet.resumeRefresh(skipCoinbaseOutputs, object : BaseWalletCallbacks() {
override fun onRefreshResult(blockHeight: Long, status: Int) {
val result = RefreshResult(blockHeight, status)
continuation.resume(result) {}
}
}
})
resumeRefresh(skipCoinbaseOutputs, callback)
continuation.invokeOnCancellation { cancelRefresh() }
continuation.invokeOnCancellation { wallet.cancelRefresh() }
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun commit(): Boolean = suspendCancellableCoroutine { continuation ->
wallet.commit(object : BaseWalletCallbacks() {
override fun onCommitResult(success: Boolean) {
continuation.resume(success) {}
}
})
}
override fun close() = wallet.close()
}
class RefreshResult(
val blockHeight: Long,
private val status: Int
) {
private abstract class BaseWalletCallbacks : IWalletCallbacks.Stub() {
override fun onRefreshResult(blockHeight: Long, status: Int) = Unit
override fun onCommitResult(success: Boolean) = Unit
}
class RefreshResult(val blockHeight: Long, private val status: Int) {
fun isError() = status != WalletNative.Status.OK
}

View File

@ -16,7 +16,7 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
// TODO: Hide IRemoteNodeClient methods
// TODO: Hide IRemoteNodeClient methods and rename to HttpRpcClient
class RemoteNodeClient private constructor(
val network: MoneroNetwork,
private val loadBalancer: LoadBalancer,
@ -76,15 +76,13 @@ class RemoteNodeClient private constructor(
if (responseBody == null) {
callback?.onResponse(status, null, null)
} else {
val contentType = responseBody.contentType()?.toString()
val pipe = ParcelFileDescriptor.createPipe()
callback?.onResponse(status, contentType, pipe[0])
responseBody.use {
responseBody.use { body ->
val contentType = body.contentType()?.toString()
val pipe = ParcelFileDescriptor.createPipe()
pipe[1].use { writeSide ->
callback?.onResponse(status, contentType, pipe[0])
FileOutputStream(writeSide.fileDescriptor).use { out ->
runCatching { it.byteStream().copyTo(out) }
runCatching { body.byteStream().copyTo(out) }
}
}
}

View File

@ -0,0 +1,54 @@
package im.molly.monero
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
internal class StorageAdapter(var dataStore: WalletDataStore?) : IStorageAdapter.Stub() {
private val logger = loggerFor<StorageAdapter>()
private val storageScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mutex = Mutex()
override fun writeAsync(pfd: ParcelFileDescriptor?): Boolean {
requireNotNull(pfd)
val inputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
val localDataStore = dataStore
if (localDataStore == null) {
logger.i("Unable to save wallet data because WalletDataStore is unset")
inputStream.close()
return false
}
storageScope.launch {
mutex.withLock {
localDataStore.write { output ->
inputStream.copyTo(output)
}
}
}.invokeOnCompletion { inputStream.close() }
return true
}
override fun readAsync(pfd: ParcelFileDescriptor?) {
requireNotNull(pfd)
val outputStream = ParcelFileDescriptor.AutoCloseOutputStream(pfd)
val localDataStore = dataStore
if (localDataStore == null) {
outputStream.close()
throw IllegalArgumentException("WalletDataStore cannot be null")
}
storageScope.launch {
mutex.withLock {
localDataStore.read().use { input ->
input.copyTo(outputStream)
}
}
}.invokeOnCompletion { outputStream.close() }
}
}

View File

@ -6,4 +6,6 @@ import kotlinx.parcelize.Parcelize
@Parcelize
internal data class WalletConfig(
val networkId: Int,
val storageAdapter: IStorageAdapter.Stub?,
val remoteNodeClient: IRemoteNodeClient.Stub?,
) : Parcelable

View File

@ -0,0 +1,9 @@
package im.molly.monero
import java.io.FileInputStream
import java.io.FileOutputStream
interface WalletDataStore {
suspend fun write(writer: (FileOutputStream) -> Unit)
suspend fun read(): FileInputStream
}

View File

@ -11,39 +11,39 @@ import kotlin.coroutines.CoroutineContext
class WalletNative private constructor(
networkId: Int,
private val storageAdapter: IStorageAdapter?,
private val remoteNodeClient: IRemoteNodeClient?,
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
) : IWallet.Stub(), Closeable {
companion object {
// TODO: Full node wallet != local synchronization wallet
// TODO: Find better name because this is a local synchronization wallet, not a full node wallet
fun fullNode(
networkId: Int,
secretSpendKey: SecretKey? = null,
savedDataFd: Int? = null,
accountTimestamp: Long? = null,
storageAdapter: IStorageAdapter? = null,
remoteNodeClient: IRemoteNodeClient? = null,
secretSpendKey: SecretKey? = null,
accountTimestamp: Long? = null,
coroutineContext: CoroutineContext = Dispatchers.Default + SupervisorJob(),
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) = WalletNative(
networkId = networkId,
storageAdapter = storageAdapter,
remoteNodeClient = remoteNodeClient,
scope = CoroutineScope(coroutineContext),
ioDispatcher = ioDispatcher,
).apply {
secretSpendKey?.let { secretKey ->
require(savedDataFd == null)
require(accountTimestamp == null || accountTimestamp >= 0)
val timestampOrNow = accountTimestamp ?: (System.currentTimeMillis() / 1000)
nativeRestoreAccount(handle, secretKey.bytes, timestampOrNow)
}
savedDataFd?.let { fd ->
require(secretSpendKey == null)
require(accountTimestamp == null)
if (!nativeLoad(handle, fd)) {
throw IllegalArgumentException("Cannot load wallet data")
when {
secretSpendKey != null -> {
require(accountTimestamp == null || accountTimestamp >= 0)
val timestampOrNow = accountTimestamp ?: (System.currentTimeMillis() / 1000)
nativeRestoreAccount(handle, secretSpendKey.bytes, timestampOrNow)
tryWriteState()
}
else -> {
require(accountTimestamp == null)
readState()
}
}
}
@ -57,6 +57,31 @@ class WalletNative private constructor(
private val handle: Long = nativeCreate(networkId)
private fun tryWriteState(): Boolean {
requireNotNull(storageAdapter)
val pipe = ParcelFileDescriptor.createReliablePipe()
return pipe[1].use { writeSide ->
val storageIsReady = storageAdapter.writeAsync(pipe[0])
if (storageIsReady) {
val result = nativeSave(handle, writeSide.fd)
if (!result) {
logger.e("Wallet data serialization failed")
}
result
} else false
}
}
private fun readState() {
requireNotNull(storageAdapter)
val pipe = ParcelFileDescriptor.createReliablePipe()
return pipe[0].use { readSide ->
storageAdapter.readAsync(pipe[1])
val result = nativeLoad(handle, readSide.fd)
check(result) { "Wallet data deserialization failed" }
}
}
override fun getPrimaryAccountAddress() = nativeGetPrimaryAccountAddress(handle)
val currentBlockchainHeight: Long
@ -78,7 +103,7 @@ class WalletNative private constructor(
@OptIn(ExperimentalCoroutinesApi::class)
override fun resumeRefresh(
skipCoinbaseOutputs: Boolean,
callback: IRefreshCallback?,
callback: IWalletCallbacks?,
) {
scope.launch {
val status = suspendCancellableCoroutine { continuation ->
@ -89,7 +114,7 @@ class WalletNative private constructor(
nativeCancelRefresh(handle)
}
}
callback?.onResult(currentBlockchainHeight, status)
callback?.onRefreshResult(currentBlockchainHeight, status)
}
}
@ -105,6 +130,13 @@ class WalletNative private constructor(
}
}
override fun commit(callback: IWalletCallbacks?) {
scope.launch(ioDispatcher) {
val result = tryWriteState()
callback?.onCommitResult(result)
}
}
/**
* Also replays the last known balance whenever a new listener registers.
*/
@ -123,11 +155,6 @@ class WalletNative private constructor(
}
}
override fun save(destination: ParcelFileDescriptor?) {
requireNotNull(destination)
nativeSave(handle, destination.fd)
}
@CalledByNative("wallet.cc")
private fun onRefresh(blockchainHeight: Long, balanceChanged: Boolean) {
balanceListenersLock.withLock {

View File

@ -5,10 +5,7 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.*
import java.io.FileInputStream
import java.io.FileOutputStream
import java.time.Instant
// TODO: Rename to SandboxedWalletProvider and extract interface, add InProcessWalletProvider
@ -52,54 +49,83 @@ class WalletProvider private constructor(
private val logger = loggerFor<WalletProvider>()
fun createNewWallet(
suspend fun createNewWallet(
network: MoneroNetwork,
dataStore: WalletDataStore? = null,
client: RemoteNodeClient? = null,
): MoneroWallet {
require(client == null || client.network == network)
return MoneroWallet(
service.createWallet(buildConfig(network), client), client
)
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.createWallet(
buildConfig(network, StorageAdapter(dataStore), client),
WalletResultCallback(continuation),
)
}
return MoneroWallet(wallet, storageAdapter, client)
}
fun restoreWallet(
suspend fun restoreWallet(
network: MoneroNetwork,
dataStore: WalletDataStore? = null,
client: RemoteNodeClient? = null,
secretSpendKey: SecretKey,
accountCreationTime: Instant,
): MoneroWallet {
require(client == null || client.network == network)
return MoneroWallet(
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.restoreWallet(
buildConfig(network),
client,
buildConfig(network, StorageAdapter(dataStore), client),
WalletResultCallback(continuation),
secretSpendKey,
accountCreationTime.epochSecond,
),
client,
)
)
}
return MoneroWallet(wallet, storageAdapter, client)
}
fun openWallet(
suspend fun openWallet(
network: MoneroNetwork,
dataStore: WalletDataStore,
client: RemoteNodeClient? = null,
source: FileInputStream,
): MoneroWallet =
ParcelFileDescriptor.dup(source.fd).use { fd ->
MoneroWallet(service.openWallet(buildConfig(network), client, fd), client)
}
fun saveWallet(wallet: MoneroWallet, destination: FileOutputStream) {
ParcelFileDescriptor.dup(destination.fd).use {
wallet.save(it)
): MoneroWallet {
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.openWallet(
buildConfig(network, storageAdapter, client),
WalletResultCallback(continuation),
)
}
return MoneroWallet(wallet, storageAdapter, client)
}
private fun buildConfig(network: MoneroNetwork) = WalletConfig(network.id)
private fun buildConfig(
network: MoneroNetwork,
storageAdapter: StorageAdapter,
remoteNodeClient: RemoteNodeClient?,
): WalletConfig {
require(remoteNodeClient == null || remoteNodeClient.network == network)
return WalletConfig(network.id, storageAdapter, remoteNodeClient)
}
fun disconnect() {
context.unbindService(serviceConnection)
}
@OptIn(ExperimentalCoroutinesApi::class)
private class WalletResultCallback(
private val continuation: CancellableContinuation<IWallet>
) : IWalletServiceCallbacks.Stub() {
override fun onWalletResult(wallet: IWallet?) {
when {
wallet != null -> {
continuation.resume(wallet) {
wallet.close()
}
}
else -> TODO()
}
}
}
}
object WalletServiceListener : IWalletServiceListener.Stub() {

View File

@ -2,7 +2,6 @@ package im.molly.monero
import android.content.Intent
import android.os.IBinder
import android.os.ParcelFileDescriptor
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.*
@ -43,27 +42,49 @@ internal class WalletServiceImpl(
override fun createWallet(
config: WalletConfig?,
client: IRemoteNodeClient?,
): IWallet {
randomSecretKey().use { secretKey ->
return createOrRestoreWallet(config, client, secretKey)
callback: IWalletServiceCallbacks?,
) {
serviceScope.launch {
val secretSpendKey = randomSecretKey()
val wallet = secretSpendKey.use { secret ->
createOrRestoreWallet(config, secret)
}
callback?.onWalletResult(wallet)
}
}
override fun restoreWallet(
config: WalletConfig?,
client: IRemoteNodeClient?,
callback: IWalletServiceCallbacks?,
secretSpendKey: SecretKey?,
accountCreationTimestamp: Long,
): IWallet {
secretSpendKey.use { secretKey ->
return createOrRestoreWallet(config, client, secretKey, accountCreationTimestamp)
) {
serviceScope.launch {
val wallet = secretSpendKey.use { secret ->
createOrRestoreWallet(config, secret, accountCreationTimestamp)
}
callback?.onWalletResult(wallet)
}
}
override fun openWallet(
config: WalletConfig?,
callback: IWalletServiceCallbacks?,
) {
requireNotNull(config)
serviceScope.launch {
val wallet = WalletNative.fullNode(
networkId = config.networkId,
storageAdapter = config.storageAdapter,
remoteNodeClient = config.remoteNodeClient,
coroutineContext = serviceScope.coroutineContext,
)
callback?.onWalletResult(wallet)
}
}
private fun createOrRestoreWallet(
config: WalletConfig?,
client: IRemoteNodeClient?,
secretSpendKey: SecretKey?,
accountCreationTimestamp: Long? = null,
): IWallet {
@ -71,28 +92,14 @@ internal class WalletServiceImpl(
requireNotNull(secretSpendKey)
return WalletNative.fullNode(
networkId = config.networkId,
storageAdapter = config.storageAdapter,
remoteNodeClient = config.remoteNodeClient,
secretSpendKey = secretSpendKey,
remoteNodeClient = client,
accountTimestamp = accountCreationTimestamp,
coroutineContext = serviceScope.coroutineContext,
)
}
override fun openWallet(
config: WalletConfig?,
client: IRemoteNodeClient?,
source: ParcelFileDescriptor?,
): IWallet {
requireNotNull(config)
requireNotNull(source)
return WalletNative.fullNode(
networkId = config.networkId,
savedDataFd = source.fd,
remoteNodeClient = client,
coroutineContext = serviceScope.coroutineContext,
)
}
override fun print(priority: Int, tag: String, msg: String?, tr: Throwable?) {
listener?.onLogMessage(priority, tag, msg, tr?.toString())
}