lib: add in-process wallet provider

This commit is contained in:
Oscar Mira 2025-02-28 16:36:54 +01:00
parent 5f6f67c7fc
commit 1069160fef
No known key found for this signature in database
GPG Key ID: B371B98C5DC32237
12 changed files with 268 additions and 149 deletions

View File

@ -15,7 +15,7 @@ import java.io.OutputStream
class MoneroSdkClient(private val context: Context) {
private val providerDeferred = CoroutineScope(Dispatchers.IO).async {
WalletProvider.connect(context)
SandboxedWalletProvider.connect(context)
}
suspend fun createWallet(network: MoneroNetwork, filename: String): MoneroWallet {

View File

@ -5,10 +5,13 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application android:usesCleartextTraffic="true">
<service android:name=".service.InProcessWalletService" />
<service
android:name=".WalletService"
android:name=".service.SandboxedWalletService"
android:isolatedProcess="true"
android:process=":wallet_service" />
</application>
</manifest>

View File

@ -1,11 +1,11 @@
package im.molly.monero;
package im.molly.monero.internal;
import im.molly.monero.IStorageAdapter;
import im.molly.monero.IWalletServiceCallbacks;
import im.molly.monero.IWalletServiceListener;
import im.molly.monero.SecretKey;
import im.molly.monero.WalletConfig;
import im.molly.monero.internal.IHttpRpcClient;
import im.molly.monero.internal.IWalletServiceCallbacks;
import im.molly.monero.internal.IWalletServiceListener;
interface IWalletService {
oneway void createWallet(in WalletConfig config, in IStorageAdapter storage, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback);

View File

@ -1,4 +1,4 @@
package im.molly.monero;
package im.molly.monero.internal;
import im.molly.monero.IWallet;

View File

@ -1,4 +1,4 @@
package im.molly.monero;
package im.molly.monero.internal;
oneway interface IWalletServiceListener {
void onLogMessage(int priority, String tag, String msg, String cause);

View File

@ -1,68 +1,17 @@
package im.molly.monero
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import kotlinx.coroutines.*
// TODO: Rename to SandboxedWalletProvider and extract interface, add InProcessWalletProvider
class WalletProvider private constructor(
private val context: Context,
private val service: IWalletService,
private val serviceConnection: ServiceConnection,
// TODO: Remove DataStore dependencies if unused
// private val dataStore: DataStore<WalletProto.State>,
) {
companion object {
suspend fun connect(context: Context): WalletProvider {
val (serviceConnection, service) = bindService(context)
return WalletProvider(context, service, serviceConnection)
}
private suspend fun bindService(context: Context): Pair<ServiceConnection, IWalletService> {
val deferredService = CompletableDeferred<IWalletService>()
val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
val service = IWalletService.Stub.asInterface(binder)
service.setListener(WalletServiceListener)
deferredService.complete(service)
}
override fun onServiceDisconnected(name: ComponentName?) {
deferredService.completeExceptionally(ServiceNotBoundException())
}
}
Intent(context, WalletService::class.java).also { intent ->
if (!context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)) {
throw ServiceNotBoundException()
}
}
return serviceConnection to deferredService.await()
}
}
/** Exception thrown by [WalletProvider] if the remote service can't be bound. */
class ServiceNotBoundException : Exception()
private val logger = loggerFor<WalletProvider>()
import im.molly.monero.internal.WalletServiceClient
import im.molly.monero.service.InProcessWalletService
import im.molly.monero.service.SandboxedWalletService
import java.io.Closeable
interface WalletProvider : Closeable {
suspend fun createNewWallet(
network: MoneroNetwork,
dataStore: WalletDataStore? = null,
client: MoneroNodeClient? = null,
): MoneroWallet {
require(client == null || client.network == network)
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.createWallet(
buildConfig(network), storageAdapter, client?.httpRpcClient,
WalletResultCallback(continuation),
)
}
return MoneroWallet(wallet, storageAdapter, client)
}
): MoneroWallet
suspend fun restoreWallet(
network: MoneroNetwork,
@ -70,70 +19,44 @@ class WalletProvider private constructor(
client: MoneroNodeClient? = null,
secretSpendKey: SecretKey,
restorePoint: RestorePoint,
): MoneroWallet {
require(client == null || client.network == network)
if (restorePoint is BlockchainTime) {
require(restorePoint.network == network)
}
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.restoreWallet(
buildConfig(network), storageAdapter, client?.httpRpcClient,
WalletResultCallback(continuation),
secretSpendKey,
restorePoint.toLong(),
)
}
return MoneroWallet(wallet, storageAdapter, client)
}
): MoneroWallet
suspend fun openWallet(
network: MoneroNetwork,
dataStore: WalletDataStore,
client: MoneroNodeClient? = null,
): MoneroWallet {
require(client == null || client.network == network)
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.openWallet(
buildConfig(network), storageAdapter, client?.httpRpcClient,
WalletResultCallback(continuation),
)
}
return MoneroWallet(wallet, storageAdapter, client)
): MoneroWallet
fun disconnect()
override fun close() {
disconnect()
}
private fun buildConfig(network: MoneroNetwork): WalletConfig {
return WalletConfig(network.id)
}
/** Exception thrown if the wallet service cannot be bound. */
class ServiceNotBoundException : Exception()
}
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()
}
}
/**
* Provides wallet services using a sandboxed process.
*/
object SandboxedWalletProvider {
/**
* Connects to the sandboxed wallet service and returns a connected [WalletProvider].
*/
suspend fun connect(context: Context): WalletProvider {
return WalletServiceClient.connect(context, SandboxedWalletService::class.java)
}
}
object WalletServiceListener : IWalletServiceListener.Stub() {
override fun onLogMessage(priority: Int, tag: String, msg: String, cause: String?) {
if (Logger.adapter.isLoggable(priority, tag)) {
val tr = if (cause != null) Throwable(cause) else null
Logger.adapter.print(priority, tag, msg, tr)
}
/**
* Provides wallet services using an in-process bound service.
*/
object InProcessWalletProvider {
/**
* Connects to the in-process wallet service and returns a connected [WalletProvider].
*/
suspend fun connect(context: Context): WalletProvider {
return WalletServiceClient.connect(context, InProcessWalletService::class.java)
}
}

View File

@ -1,46 +1,42 @@
package im.molly.monero
package im.molly.monero.internal
import android.content.Intent
import android.os.IBinder
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import im.molly.monero.internal.IHttpRpcClient
import kotlinx.coroutines.*
import im.molly.monero.IStorageAdapter
import im.molly.monero.IWallet
import im.molly.monero.LogAdapter
import im.molly.monero.NativeLoader
import im.molly.monero.SecretKey
import im.molly.monero.WalletConfig
import im.molly.monero.WalletNative
import im.molly.monero.loggerFor
import im.molly.monero.randomSecretKey
import im.molly.monero.setLoggingAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class WalletService : LifecycleService() {
private lateinit var service: IWalletService
override fun onCreate() {
super.onCreate()
service = WalletServiceImpl(application.isIsolatedProcess(), lifecycleScope)
}
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return service.asBinder()
}
}
internal class WalletServiceImpl(
isIsolated: Boolean,
internal class NativeWalletService(
private val serviceScope: CoroutineScope,
) : IWalletService.Stub(), LogAdapter {
private val logger = loggerFor<WalletService>()
private val logger = loggerFor<NativeWalletService>()
init {
if (isIsolated) {
setLoggingAdapter(this)
}
NativeLoader.loadWalletLibrary(logger = logger)
}
fun configureLoggingAdapter() {
setLoggingAdapter(this)
}
private var listener: IWalletServiceListener? = null
override fun setListener(l: IWalletServiceListener) {
listener = l
}
override fun print(priority: Int, tag: String, msg: String?, tr: Throwable?) {
listener?.onLogMessage(priority, tag, msg, tr?.toString())
}
override fun createWallet(
config: WalletConfig,
storage: IStorageAdapter,
@ -105,8 +101,4 @@ internal class WalletServiceImpl(
coroutineContext = serviceScope.coroutineContext,
)
}
override fun print(priority: Int, tag: String, msg: String?, tr: Throwable?) {
listener?.onLogMessage(priority, tag, msg, tr?.toString())
}
}

View File

@ -0,0 +1,160 @@
package im.molly.monero.internal
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import im.molly.monero.BlockchainTime
import im.molly.monero.IWallet
import im.molly.monero.MoneroNetwork
import im.molly.monero.MoneroNodeClient
import im.molly.monero.MoneroWallet
import im.molly.monero.RestorePoint
import im.molly.monero.SecretKey
import im.molly.monero.StorageAdapter
import im.molly.monero.WalletConfig
import im.molly.monero.WalletDataStore
import im.molly.monero.WalletProvider
import im.molly.monero.loggerFor
import im.molly.monero.service.BaseWalletService
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
internal class WalletServiceClient(
private val context: Context,
private val service: IWalletService,
private val serviceConnection: ServiceConnection,
) : WalletProvider {
companion object {
suspend fun connect(
context: Context,
serviceClass: Class<out BaseWalletService>
): WalletServiceClient {
val (serviceConnection, service) = bindService(context, serviceClass)
return WalletServiceClient(context, service, serviceConnection)
}
private suspend fun bindService(
context: Context,
serviceClass: Class<out BaseWalletService>,
): Pair<ServiceConnection, IWalletService> {
val deferredService = CompletableDeferred<IWalletService>()
val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
val service = IWalletService.Stub.asInterface(binder).apply {
setListener(WalletServiceLogListener)
}
deferredService.complete(service)
}
override fun onServiceDisconnected(name: ComponentName?) {
deferredService.completeExceptionally(WalletProvider.ServiceNotBoundException())
}
}
val intent = Intent(context, serviceClass)
if (!context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)) {
throw WalletProvider.ServiceNotBoundException()
}
return serviceConnection to deferredService.await()
}
}
private val logger = loggerFor<WalletServiceClient>()
override suspend fun createNewWallet(
network: MoneroNetwork,
dataStore: WalletDataStore?,
client: MoneroNodeClient?,
): MoneroWallet {
validateClientNetwork(client, network)
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.createWallet(
buildConfig(network), storageAdapter, client?.httpRpcClient,
WalletResultCallback(continuation),
)
}
return MoneroWallet(wallet, storageAdapter, client)
}
override suspend fun restoreWallet(
network: MoneroNetwork,
dataStore: WalletDataStore?,
client: MoneroNodeClient?,
secretSpendKey: SecretKey,
restorePoint: RestorePoint,
): MoneroWallet {
validateClientNetwork(client, network)
validateRestorePoint(restorePoint, network)
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.restoreWallet(
buildConfig(network), storageAdapter, client?.httpRpcClient,
WalletResultCallback(continuation),
secretSpendKey,
restorePoint.toLong(),
)
}
return MoneroWallet(wallet, storageAdapter, client)
}
override suspend fun openWallet(
network: MoneroNetwork,
dataStore: WalletDataStore,
client: MoneroNodeClient?,
): MoneroWallet {
validateClientNetwork(client, network)
val storageAdapter = StorageAdapter(dataStore)
val wallet = suspendCancellableCoroutine { continuation ->
service.openWallet(
buildConfig(network), storageAdapter, client?.httpRpcClient,
WalletResultCallback(continuation),
)
}
return MoneroWallet(wallet, storageAdapter, client)
}
private fun buildConfig(network: MoneroNetwork): WalletConfig {
return WalletConfig(network.id)
}
private fun validateClientNetwork(client: MoneroNodeClient?, network: MoneroNetwork) {
require(client == null || client.network == network) {
"Client network mismatch: expected $network, got ${client?.network}"
}
}
private fun validateRestorePoint(restorePoint: RestorePoint, network: MoneroNetwork) {
if (restorePoint is BlockchainTime) {
require(restorePoint.network == network) {
"Restore point network mismatch: expected $network, got ${restorePoint.network}"
}
}
}
override fun disconnect() {
context.unbindService(serviceConnection)
}
@OptIn(ExperimentalCoroutinesApi::class)
private class WalletResultCallback(
private val continuation: CancellableContinuation<IWallet>
) : IWalletServiceCallbacks.Stub() {
override fun onWalletResult(wallet: IWallet?) {
if (wallet != null) {
continuation.resume(wallet) { wallet.close() }
} else {
TODO()
}
}
}
}

View File

@ -0,0 +1,12 @@
package im.molly.monero.internal
import im.molly.monero.Logger
internal object WalletServiceLogListener : IWalletServiceListener.Stub() {
override fun onLogMessage(priority: Int, tag: String, msg: String, cause: String?) {
if (Logger.adapter.isLoggable(priority, tag)) {
val tr = if (cause != null) Throwable(cause) else null
Logger.adapter.print(priority, tag, msg, tr)
}
}
}

View File

@ -0,0 +1,23 @@
package im.molly.monero.service
import android.content.Intent
import android.os.IBinder
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import im.molly.monero.internal.IWalletService
import im.molly.monero.internal.NativeWalletService
abstract class BaseWalletService(private val isolated: Boolean) : LifecycleService() {
private val service: IWalletService by lazy {
NativeWalletService(lifecycleScope).apply {
if (isolated) {
configureLoggingAdapter()
}
}
}
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return service.asBinder()
}
}

View File

@ -0,0 +1,3 @@
package im.molly.monero.service
class InProcessWalletService : BaseWalletService(isolated = false)

View File

@ -0,0 +1,3 @@
package im.molly.monero.service
class SandboxedWalletService : BaseWalletService(isolated = true)