demo: include SOCKS proxy support

This commit is contained in:
Oscar Mira 2023-04-26 12:40:54 +02:00
parent 7d39842402
commit 08de472199
10 changed files with 218 additions and 49 deletions

View File

@ -69,6 +69,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion"
implementation "androidx.activity:activity-compose:1.6.1" 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-runtime:$roomVersion"
implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-ktx:$roomVersion"

View File

@ -3,7 +3,6 @@ package im.molly.monero.demo
import android.app.Application import android.app.Application
import androidx.room.Room import androidx.room.Room
import im.molly.monero.demo.data.* import im.molly.monero.demo.data.*
import okhttp3.OkHttpClient
/** /**
* Naive container of global instances. * Naive container of global instances.
@ -11,7 +10,7 @@ import okhttp3.OkHttpClient
* A complex app should use Koin or Hilt for dependencies. * A complex app should use Koin or Hilt for dependencies.
*/ */
object AppModule { object AppModule {
lateinit var application: Application private lateinit var application: Application
private val applicationScope = kotlinx.coroutines.MainScope() private val applicationScope = kotlinx.coroutines.MainScope()
@ -21,10 +20,6 @@ object AppModule {
).build() ).build()
} }
private val walletHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder().build()
}
private val walletDataSource: WalletDataSource by lazy { private val walletDataSource: WalletDataSource by lazy {
WalletDataSource(database.walletDao()) WalletDataSource(database.walletDao())
} }
@ -34,7 +29,11 @@ object AppModule {
} }
private val moneroSdkClient: MoneroSdkClient by lazy { 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 { val remoteNodeRepository: RemoteNodeRepository by lazy {
@ -42,7 +41,7 @@ object AppModule {
} }
val walletRepository: WalletRepository by lazy { val walletRepository: WalletRepository by lazy {
WalletRepository(moneroSdkClient, walletDataSource, applicationScope) WalletRepository(moneroSdkClient, walletDataSource, settingsRepository, applicationScope)
} }
fun provide(application: Application) { fun provide(application: Application) {

View File

@ -6,8 +6,6 @@ import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.os.StrictMode
import android.os.StrictMode.VmPolicy
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme

View File

@ -1,10 +1,16 @@
package im.molly.monero.demo package im.molly.monero.demo
import android.app.Application import android.app.Application
import android.content.Context
import android.os.StrictMode 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.demo.service.SyncService
import im.molly.monero.isIsolatedProcess import im.molly.monero.isIsolatedProcess
val Context.preferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
class MainApplication : Application() { class MainApplication : Application() {
override fun onCreate() { override fun onCreate() {

View File

@ -10,7 +10,6 @@ import okhttp3.OkHttpClient
class MoneroSdkClient( class MoneroSdkClient(
private val context: Context, private val context: Context,
private val walletDataFileStorage: WalletDataFileStorage, private val walletDataFileStorage: WalletDataFileStorage,
private val httpClient: OkHttpClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) { ) {
private val providerDeferred = CoroutineScope(ioDispatcher).async { private val providerDeferred = CoroutineScope(ioDispatcher).async {
@ -31,6 +30,7 @@ class MoneroSdkClient(
suspend fun openWallet( suspend fun openWallet(
publicAddress: String, publicAddress: String,
remoteNodes: Flow<List<RemoteNode>>, remoteNodes: Flow<List<RemoteNode>>,
httpClient: OkHttpClient,
): MoneroWallet { ): MoneroWallet {
val provider = providerDeferred.await() val provider = providerDeferred.await()
return withContext(ioDispatcher) { return withContext(ioDispatcher) {

View File

@ -4,19 +4,27 @@ import im.molly.monero.*
import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.data.model.WalletConfig
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import okhttp3.OkHttpClient
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
class WalletRepository( class WalletRepository(
private val moneroSdkClient: MoneroSdkClient, private val moneroSdkClient: MoneroSdkClient,
private val walletDataSource: WalletDataSource, private val walletDataSource: WalletDataSource,
private val settingsRepository: SettingsRepository,
private val externalScope: CoroutineScope, private val externalScope: CoroutineScope,
) { ) {
private val walletIdMap = ConcurrentHashMap<Long, Deferred<MoneroWallet>>() private val walletIdMap = ConcurrentHashMap<Long, Deferred<MoneroWallet>>()
private val sharedHttpClient = OkHttpClient.Builder().build()
suspend fun getWallet(walletId: Long): MoneroWallet { suspend fun getWallet(walletId: Long): MoneroWallet {
return walletIdMap.computeIfAbsent(walletId) { return walletIdMap.computeIfAbsent(walletId) {
externalScope.async { externalScope.async {
val config = getWalletConfig(walletId) val config = getWalletConfig(walletId)
val userSettings = settingsRepository.getUserSettings().first()
val httpClient = sharedHttpClient.newBuilder()
.proxy(userSettings.activeProxy)
.build()
val wallet = moneroSdkClient.openWallet( val wallet = moneroSdkClient.openWallet(
publicAddress = config.first().publicAddress, publicAddress = config.first().publicAddress,
remoteNodes = config.map { remoteNodes = config.map {
@ -27,7 +35,8 @@ class WalletRepository(
password = node.password, password = node.password,
) )
} }
} },
httpClient = httpClient,
) )
wallet wallet
} }
@ -36,6 +45,9 @@ class WalletRepository(
fun getWalletIdList() = walletDataSource.readWalletIdList() 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 getWalletConfig(walletId: Long) = walletDataSource.readWalletConfig(walletId)
fun getLedger(walletId: Long): Flow<Ledger> = flow { fun getLedger(walletId: Long): Flow<Ledger> = flow {

View File

@ -1,13 +1,12 @@
package im.molly.monero.demo.ui package im.molly.monero.demo.ui
import android.net.Uri import android.net.Uri
import androidx.annotation.StringRes import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.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.AppIcons
import im.molly.monero.demo.ui.theme.AppTheme import im.molly.monero.demo.ui.theme.AppTheme
@ -26,46 +26,72 @@ fun SettingsRoute(
viewModel: SettingsViewModel = viewModel(), viewModel: SettingsViewModel = viewModel(),
navigateToEditRemoteNode: (Long?) -> Unit, navigateToEditRemoteNode: (Long?) -> Unit,
) { ) {
val remoteNodes by viewModel.remoteNodes.collectAsStateWithLifecycle() val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()
SettingsScreen( SettingsScreen(
remoteNodes = remoteNodes, settingsUiState = settingsUiState,
modifier = modifier, modifier = modifier,
onAddRemoteNode = { navigateToEditRemoteNode(RemoteNode.EMPTY.id) }, onAddRemoteNode = { navigateToEditRemoteNode(RemoteNode.EMPTY.id) },
onEditRemoteNode = { navigateToEditRemoteNode(it.id) }, onEditRemoteNode = { navigateToEditRemoteNode(it.id) },
onDeleteRemoteNode = { viewModel.forgetRemoteNodeDetails(it) }, onDeleteRemoteNode = { viewModel.forgetRemoteNodeDetails(it) },
onChangeSocksProxy = { viewModel.setSocksProxyAddress(it) },
onValidateSocksProxy = { viewModel.isSocksProxyAddressCorrect(it) },
) )
} }
@Composable @Composable
private fun SettingsScreen( private fun SettingsScreen(
remoteNodes: List<RemoteNode>, settingsUiState: SettingsUiState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onAddRemoteNode: () -> Unit = {}, onAddRemoteNode: () -> Unit = {},
onEditRemoteNode: (RemoteNode) -> Unit = {}, onEditRemoteNode: (RemoteNode) -> Unit = {},
onDeleteRemoteNode: (RemoteNode) -> Unit = {}, onDeleteRemoteNode: (RemoteNode) -> Unit = {},
onChangeSocksProxy: (String) -> Unit = {},
onValidateSocksProxy: (String) -> Boolean = { true },
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
SettingsSection( when (settingsUiState) {
header = { SettingsUiState.Loading -> {
SettingsSectionTitle(R.string.remote_nodes) // TODO: Add loading wheel
IconButton(onClick = onAddRemoteNode) { }
Icon( is SettingsUiState.Success -> {
imageVector = AppIcons.AddRemoteWallet, SettingsSection(
contentDescription = stringResource(R.string.add_remote_node), 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( Column(
modifier = modifier.padding(horizontal = 24.dp), modifier = modifier.padding(horizontal = 24.dp),
) { ) {
Divider(Modifier.padding(top = 24.dp)) Divider(Modifier.padding(bottom = 24.dp))
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -92,20 +118,86 @@ private fun SettingsSection(
} }
@Composable @Composable
private fun SettingsSectionTitle(@StringRes titleRes: Int) { private fun SettingsSectionTitle(title: String) {
Text( Text(
text = stringResource(titleRes), text = title,
style = MaterialTheme.typography.titleMedium, 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 @Preview
@Composable @Composable
private fun SettingsScreenPreview() { private fun SettingsScreenPopulatedPreview() {
AppTheme { AppTheme {
val aNode = RemoteNode.EMPTY.copy(uri = Uri.parse("http://node.monero")) val aNode = RemoteNode.EMPTY.copy(uri = Uri.parse("http://node.monero"))
SettingsScreen( SettingsScreen(
remoteNodes = listOf(aNode), SettingsUiState.Success(
socksProxyAddress = "localhost:9050",
remoteNodes = listOf(aNode),
)
) )
} }
} }

View File

@ -3,29 +3,44 @@ package im.molly.monero.demo.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import im.molly.monero.demo.AppModule import im.molly.monero.demo.AppModule
import im.molly.monero.demo.data.RemoteNodeRepository 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.RemoteNode
import im.molly.monero.demo.data.model.SocksProxy
import im.molly.monero.demo.data.model.toSocketAddress
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.Proxy
class SettingsViewModel( class SettingsViewModel(
private val settingsRepository: SettingsRepository = AppModule.settingsRepository,
private val remoteNodeRepository: RemoteNodeRepository = AppModule.remoteNodeRepository, private val remoteNodeRepository: RemoteNodeRepository = AppModule.remoteNodeRepository,
private val walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() { ) : ViewModel() {
val remoteNodes: StateFlow<List<RemoteNode>> = val settingsUiState: StateFlow<SettingsUiState> =
remoteNodeRepository.getAllRemoteNodes() combine(
.stateIn( settingsRepository.getUserSettings(),
scope = viewModelScope, remoteNodeRepository.getAllRemoteNodes(),
started = SharingStarted.WhileSubscribed(5000), ::Pair,
initialValue = listOf(RemoteNode.EMPTY), ).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> = fun remoteNode(remoteNodeId: Long): Flow<RemoteNode> =
remoteNodeRepository.getRemoteNode(remoteNodeId) remoteNodeRepository.getRemoteNode(remoteNodeId)
fun isRemoteNodeCorrect(remoteNode: RemoteNode): Boolean { fun isRemoteNodeCorrect(remoteNode: RemoteNode): Boolean =
return remoteNode.uri.host?.isEmpty() == false remoteNode.uri.host?.isEmpty() == false
}
fun saveRemoteNodeDetails(remoteNode: RemoteNode) { fun saveRemoteNodeDetails(remoteNode: RemoteNode) {
viewModelScope.launch { viewModelScope.launch {
@ -38,4 +53,50 @@ class SettingsViewModel(
remoteNodeRepository.deleteRemoteNode(remoteNode) 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
} }

View File

@ -9,7 +9,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
class MoneroWallet internal constructor( class MoneroWallet internal constructor(
private val wallet: IWallet, private val wallet: IWallet,
client: RemoteNodeClient?, val remoteNodeClient: RemoteNodeClient?,
) : IWallet by wallet, AutoCloseable { ) : IWallet by wallet, AutoCloseable {
val publicAddress: String = wallet.primaryAccountAddress val publicAddress: String = wallet.primaryAccountAddress

View File

@ -19,7 +19,7 @@ class RemoteNodeClient private constructor(
val network: MoneroNetwork, val network: MoneroNetwork,
private val loadBalancer: LoadBalancer, private val loadBalancer: LoadBalancer,
private val loadBalancerRule: Rule, private val loadBalancerRule: Rule,
private val httpClient: OkHttpClient, var httpClient: OkHttpClient,
private val retryBackoff: BackoffPolicy, private val retryBackoff: BackoffPolicy,
private val requestsScope: CoroutineScope, private val requestsScope: CoroutineScope,
) : IRemoteNodeClient.Stub(), AutoCloseable { ) : IRemoteNodeClient.Stub(), AutoCloseable {