mirror of
https://github.com/mollyim/monero-wallet-sdk.git
synced 2024-10-01 03:45:36 -04:00
lib: data store for wallet state
This commit is contained in:
parent
698ae32ef2
commit
e61021d8bd
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}")
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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>,
|
||||
)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,7 @@ class WalletNativeTest {
|
||||
assertThat(
|
||||
WalletNative.fullNode(
|
||||
networkId = MoneroNetwork.Mainnet.id,
|
||||
secretSpendKey = SecretKey("d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706".parseHex())
|
||||
secretSpendKey = SecretKey("d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706".parseHex()),
|
||||
).primaryAccountAddress
|
||||
).isEqualTo("4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T")
|
||||
|
||||
|
@ -1,5 +0,0 @@
|
||||
package im.molly.monero;
|
||||
|
||||
oneway interface IRefreshCallback {
|
||||
void onResult(long blockHeight, int status);
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package im.molly.monero;
|
||||
|
||||
interface IStorageAdapter {
|
||||
boolean writeAsync(in ParcelFileDescriptor pfd);
|
||||
void readAsync(in ParcelFileDescriptor pfd);
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -0,0 +1,6 @@
|
||||
package im.molly.monero;
|
||||
|
||||
oneway interface IWalletCallbacks {
|
||||
void onRefreshResult(long blockHeight, int status);
|
||||
void onCommitResult(boolean success);
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
package im.molly.monero;
|
||||
|
||||
import im.molly.monero.IWallet;
|
||||
|
||||
oneway interface IWalletServiceCallbacks {
|
||||
void onWalletResult(in IWallet wallet);
|
||||
}
|
@ -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;
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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() }
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
}
|
@ -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 {
|
||||
|
@ -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() {
|
||||
|
@ -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())
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user