mirror of
https://github.com/mollyim/monero-wallet-sdk.git
synced 2024-10-01 03:45:36 -04:00
demo: include SOCKS proxy support
This commit is contained in:
parent
7d39842402
commit
08de472199
@ -69,6 +69,7 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion"
|
||||
implementation "androidx.activity:activity-compose:1.6.1"
|
||||
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
||||
|
||||
implementation "androidx.room:room-runtime:$roomVersion"
|
||||
implementation "androidx.room:room-ktx:$roomVersion"
|
||||
|
@ -3,7 +3,6 @@ package im.molly.monero.demo
|
||||
import android.app.Application
|
||||
import androidx.room.Room
|
||||
import im.molly.monero.demo.data.*
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Naive container of global instances.
|
||||
@ -11,7 +10,7 @@ import okhttp3.OkHttpClient
|
||||
* A complex app should use Koin or Hilt for dependencies.
|
||||
*/
|
||||
object AppModule {
|
||||
lateinit var application: Application
|
||||
private lateinit var application: Application
|
||||
|
||||
private val applicationScope = kotlinx.coroutines.MainScope()
|
||||
|
||||
@ -21,10 +20,6 @@ object AppModule {
|
||||
).build()
|
||||
}
|
||||
|
||||
private val walletHttpClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder().build()
|
||||
}
|
||||
|
||||
private val walletDataSource: WalletDataSource by lazy {
|
||||
WalletDataSource(database.walletDao())
|
||||
}
|
||||
@ -34,7 +29,11 @@ object AppModule {
|
||||
}
|
||||
|
||||
private val moneroSdkClient: MoneroSdkClient by lazy {
|
||||
MoneroSdkClient(application, walletDataFileStorage, walletHttpClient)
|
||||
MoneroSdkClient(application, walletDataFileStorage)
|
||||
}
|
||||
|
||||
val settingsRepository: SettingsRepository by lazy {
|
||||
SettingsRepository(application.preferencesDataStore)
|
||||
}
|
||||
|
||||
val remoteNodeRepository: RemoteNodeRepository by lazy {
|
||||
@ -42,7 +41,7 @@ object AppModule {
|
||||
}
|
||||
|
||||
val walletRepository: WalletRepository by lazy {
|
||||
WalletRepository(moneroSdkClient, walletDataSource, applicationScope)
|
||||
WalletRepository(moneroSdkClient, walletDataSource, settingsRepository, applicationScope)
|
||||
}
|
||||
|
||||
fun provide(application: Application) {
|
||||
|
@ -6,8 +6,6 @@ import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.VmPolicy
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
|
@ -1,10 +1,16 @@
|
||||
package im.molly.monero.demo
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.StrictMode
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import im.molly.monero.demo.service.SyncService
|
||||
import im.molly.monero.isIsolatedProcess
|
||||
|
||||
val Context.preferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
class MainApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
|
@ -10,7 +10,6 @@ import okhttp3.OkHttpClient
|
||||
class MoneroSdkClient(
|
||||
private val context: Context,
|
||||
private val walletDataFileStorage: WalletDataFileStorage,
|
||||
private val httpClient: OkHttpClient,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
private val providerDeferred = CoroutineScope(ioDispatcher).async {
|
||||
@ -31,6 +30,7 @@ class MoneroSdkClient(
|
||||
suspend fun openWallet(
|
||||
publicAddress: String,
|
||||
remoteNodes: Flow<List<RemoteNode>>,
|
||||
httpClient: OkHttpClient,
|
||||
): MoneroWallet {
|
||||
val provider = providerDeferred.await()
|
||||
return withContext(ioDispatcher) {
|
||||
|
@ -4,19 +4,27 @@ import im.molly.monero.*
|
||||
import im.molly.monero.demo.data.model.WalletConfig
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class WalletRepository(
|
||||
private val moneroSdkClient: MoneroSdkClient,
|
||||
private val walletDataSource: WalletDataSource,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val externalScope: CoroutineScope,
|
||||
) {
|
||||
private val walletIdMap = ConcurrentHashMap<Long, Deferred<MoneroWallet>>()
|
||||
|
||||
private val sharedHttpClient = OkHttpClient.Builder().build()
|
||||
|
||||
suspend fun getWallet(walletId: Long): MoneroWallet {
|
||||
return walletIdMap.computeIfAbsent(walletId) {
|
||||
externalScope.async {
|
||||
val config = getWalletConfig(walletId)
|
||||
val userSettings = settingsRepository.getUserSettings().first()
|
||||
val httpClient = sharedHttpClient.newBuilder()
|
||||
.proxy(userSettings.activeProxy)
|
||||
.build()
|
||||
val wallet = moneroSdkClient.openWallet(
|
||||
publicAddress = config.first().publicAddress,
|
||||
remoteNodes = config.map {
|
||||
@ -27,7 +35,8 @@ class WalletRepository(
|
||||
password = node.password,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
httpClient = httpClient,
|
||||
)
|
||||
wallet
|
||||
}
|
||||
@ -36,6 +45,9 @@ class WalletRepository(
|
||||
|
||||
fun getWalletIdList() = walletDataSource.readWalletIdList()
|
||||
|
||||
fun getRemoteClients(): Flow<List<RemoteNodeClient>> =
|
||||
getWalletIdList().map { it.mapNotNull { walletId -> getWallet(walletId).remoteNodeClient } }
|
||||
|
||||
fun getWalletConfig(walletId: Long) = walletDataSource.readWalletConfig(walletId)
|
||||
|
||||
fun getLedger(walletId: Long): Flow<Ledger> = flow {
|
||||
|
@ -1,13 +1,12 @@
|
||||
package im.molly.monero.demo.ui
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -15,8 +14,9 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import im.molly.monero.demo.R
|
||||
import im.molly.monero.demo.data.model.RemoteNode
|
||||
import im.molly.monero.demo.data.model.UserSettings
|
||||
import im.molly.monero.demo.data.model.toSocketAddress
|
||||
import im.molly.monero.demo.ui.theme.AppIcons
|
||||
import im.molly.monero.demo.ui.theme.AppTheme
|
||||
|
||||
@ -26,46 +26,72 @@ fun SettingsRoute(
|
||||
viewModel: SettingsViewModel = viewModel(),
|
||||
navigateToEditRemoteNode: (Long?) -> Unit,
|
||||
) {
|
||||
val remoteNodes by viewModel.remoteNodes.collectAsStateWithLifecycle()
|
||||
val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()
|
||||
|
||||
SettingsScreen(
|
||||
remoteNodes = remoteNodes,
|
||||
settingsUiState = settingsUiState,
|
||||
modifier = modifier,
|
||||
onAddRemoteNode = { navigateToEditRemoteNode(RemoteNode.EMPTY.id) },
|
||||
onEditRemoteNode = { navigateToEditRemoteNode(it.id) },
|
||||
onDeleteRemoteNode = { viewModel.forgetRemoteNodeDetails(it) },
|
||||
onChangeSocksProxy = { viewModel.setSocksProxyAddress(it) },
|
||||
onValidateSocksProxy = { viewModel.isSocksProxyAddressCorrect(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(
|
||||
remoteNodes: List<RemoteNode>,
|
||||
settingsUiState: SettingsUiState,
|
||||
modifier: Modifier = Modifier,
|
||||
onAddRemoteNode: () -> Unit = {},
|
||||
onEditRemoteNode: (RemoteNode) -> Unit = {},
|
||||
onDeleteRemoteNode: (RemoteNode) -> Unit = {},
|
||||
onChangeSocksProxy: (String) -> Unit = {},
|
||||
onValidateSocksProxy: (String) -> Boolean = { true },
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SettingsSection(
|
||||
header = {
|
||||
SettingsSectionTitle(R.string.remote_nodes)
|
||||
IconButton(onClick = onAddRemoteNode) {
|
||||
Icon(
|
||||
imageVector = AppIcons.AddRemoteWallet,
|
||||
contentDescription = stringResource(R.string.add_remote_node),
|
||||
when (settingsUiState) {
|
||||
SettingsUiState.Loading -> {
|
||||
// TODO: Add loading wheel
|
||||
}
|
||||
is SettingsUiState.Success -> {
|
||||
SettingsSection(
|
||||
header = {
|
||||
SettingsSectionTitle("Remote nodes")
|
||||
IconButton(onClick = onAddRemoteNode) {
|
||||
Icon(
|
||||
imageVector = AppIcons.AddRemoteWallet,
|
||||
contentDescription = "Add remote node",
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
RemoteNodeEditableList(
|
||||
remoteNodes = settingsUiState.remoteNodes,
|
||||
onEditRemoteNode = onEditRemoteNode,
|
||||
onDeleteRemoteNode = onDeleteRemoteNode,
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
SettingsSection(
|
||||
header = {
|
||||
SettingsSectionTitle("Network")
|
||||
}
|
||||
) {
|
||||
EditTextSettingsItem(
|
||||
title = "SOCKS proxy server",
|
||||
summary = "Connect via proxy.",
|
||||
value = settingsUiState.socksProxyAddress,
|
||||
onValueChange = onChangeSocksProxy,
|
||||
inputHeading = "Provide proxy address or leave it blank for no proxy.",
|
||||
inputLabel = "host:port",
|
||||
inputChecker = onValidateSocksProxy,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
RemoteNodeEditableList(
|
||||
remoteNodes = remoteNodes,
|
||||
onEditRemoteNode = onEditRemoteNode,
|
||||
onDeleteRemoteNode = onDeleteRemoteNode,
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,7 +104,7 @@ private fun SettingsSection(
|
||||
Column(
|
||||
modifier = modifier.padding(horizontal = 24.dp),
|
||||
) {
|
||||
Divider(Modifier.padding(top = 24.dp))
|
||||
Divider(Modifier.padding(bottom = 24.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@ -92,20 +118,86 @@ private fun SettingsSection(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSectionTitle(@StringRes titleRes: Int) {
|
||||
private fun SettingsSectionTitle(title: String) {
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EditTextSettingsItem(
|
||||
title: String,
|
||||
summary: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
inputLabel: String,
|
||||
inputHeading: String = title,
|
||||
inputChecker: (String) -> Boolean = { true },
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
ListItem(
|
||||
headlineText = { Text(title) },
|
||||
supportingText = { Text(summary) },
|
||||
trailingContent = { Text(value) },
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { showDialog = true }),
|
||||
)
|
||||
|
||||
if (showDialog) {
|
||||
var input by remember { mutableStateOf(value) }
|
||||
val valid = inputChecker(input)
|
||||
|
||||
AlertDialog(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = inputHeading,
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
label = { Text(inputLabel) },
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
singleLine = true,
|
||||
isError = !valid,
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismissRequest = { showDialog = false },
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDialog = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = valid,
|
||||
onClick = {
|
||||
onValueChange(input)
|
||||
showDialog = false
|
||||
},
|
||||
) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsScreenPreview() {
|
||||
private fun SettingsScreenPopulatedPreview() {
|
||||
AppTheme {
|
||||
val aNode = RemoteNode.EMPTY.copy(uri = Uri.parse("http://node.monero"))
|
||||
SettingsScreen(
|
||||
remoteNodes = listOf(aNode),
|
||||
SettingsUiState.Success(
|
||||
socksProxyAddress = "localhost:9050",
|
||||
remoteNodes = listOf(aNode),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -3,29 +3,44 @@ package im.molly.monero.demo.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import im.molly.monero.demo.AppModule
|
||||
|
||||
import im.molly.monero.demo.data.RemoteNodeRepository
|
||||
import im.molly.monero.demo.data.SettingsRepository
|
||||
import im.molly.monero.demo.data.WalletRepository
|
||||
import im.molly.monero.demo.data.model.RemoteNode
|
||||
import im.molly.monero.demo.data.model.SocksProxy
|
||||
import im.molly.monero.demo.data.model.toSocketAddress
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.Proxy
|
||||
|
||||
class SettingsViewModel(
|
||||
private val settingsRepository: SettingsRepository = AppModule.settingsRepository,
|
||||
private val remoteNodeRepository: RemoteNodeRepository = AppModule.remoteNodeRepository,
|
||||
private val walletRepository: WalletRepository = AppModule.walletRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val remoteNodes: StateFlow<List<RemoteNode>> =
|
||||
remoteNodeRepository.getAllRemoteNodes()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = listOf(RemoteNode.EMPTY),
|
||||
val settingsUiState: StateFlow<SettingsUiState> =
|
||||
combine(
|
||||
settingsRepository.getUserSettings(),
|
||||
remoteNodeRepository.getAllRemoteNodes(),
|
||||
::Pair,
|
||||
).map {
|
||||
SettingsUiState.Success(
|
||||
socksProxyAddress = it.first.socksProxy?.address()?.toString().orEmpty(),
|
||||
remoteNodes = it.second,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = SettingsUiState.Loading,
|
||||
)
|
||||
|
||||
fun remoteNode(remoteNodeId: Long): Flow<RemoteNode> =
|
||||
remoteNodeRepository.getRemoteNode(remoteNodeId)
|
||||
|
||||
fun isRemoteNodeCorrect(remoteNode: RemoteNode): Boolean {
|
||||
return remoteNode.uri.host?.isEmpty() == false
|
||||
}
|
||||
fun isRemoteNodeCorrect(remoteNode: RemoteNode): Boolean =
|
||||
remoteNode.uri.host?.isEmpty() == false
|
||||
|
||||
fun saveRemoteNodeDetails(remoteNode: RemoteNode) {
|
||||
viewModelScope.launch {
|
||||
@ -38,4 +53,50 @@ class SettingsViewModel(
|
||||
remoteNodeRepository.deleteRemoteNode(remoteNode)
|
||||
}
|
||||
}
|
||||
|
||||
fun isSocksProxyAddressCorrect(address: String) =
|
||||
try {
|
||||
if (address.isNotEmpty()) address.toSocketAddress()
|
||||
true
|
||||
} catch (_: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
|
||||
fun setSocksProxyAddress(address: String) {
|
||||
viewModelScope.launch {
|
||||
val socksProxy =
|
||||
if (address.isNotEmpty()) SocksProxy(address.toSocketAddress()) else null
|
||||
settingsRepository.setSocksProxy(socksProxy)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Consider using a ProxySelector in OkHttpClient instead once this bug is resolved:
|
||||
// https://github.com/square/okhttp/issues/7698
|
||||
viewModelScope.launch {
|
||||
settingsRepository.getUserSettings()
|
||||
.distinctUntilChangedBy { it.activeProxy }
|
||||
.map { it.activeProxy }
|
||||
.collect { onProxyChanged(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onProxyChanged(newProxy: Proxy) {
|
||||
walletRepository.getRemoteClients().first().forEach {
|
||||
val currentProxy = it.httpClient.proxy()
|
||||
if (currentProxy != newProxy) {
|
||||
val builder = it.httpClient.newBuilder()
|
||||
it.httpClient = builder.proxy(newProxy).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SettingsUiState {
|
||||
data class Success(
|
||||
val socksProxyAddress: String,
|
||||
val remoteNodes: List<RemoteNode>,
|
||||
) : SettingsUiState
|
||||
|
||||
object Loading : SettingsUiState
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
class MoneroWallet internal constructor(
|
||||
private val wallet: IWallet,
|
||||
client: RemoteNodeClient?,
|
||||
val remoteNodeClient: RemoteNodeClient?,
|
||||
) : IWallet by wallet, AutoCloseable {
|
||||
|
||||
val publicAddress: String = wallet.primaryAccountAddress
|
||||
|
@ -19,7 +19,7 @@ class RemoteNodeClient private constructor(
|
||||
val network: MoneroNetwork,
|
||||
private val loadBalancer: LoadBalancer,
|
||||
private val loadBalancerRule: Rule,
|
||||
private val httpClient: OkHttpClient,
|
||||
var httpClient: OkHttpClient,
|
||||
private val retryBackoff: BackoffPolicy,
|
||||
private val requestsScope: CoroutineScope,
|
||||
) : IRemoteNodeClient.Stub(), AutoCloseable {
|
||||
|
Loading…
Reference in New Issue
Block a user