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-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"

View File

@ -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) {

View File

@ -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

View File

@ -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() {

View File

@ -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) {

View File

@ -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 {

View File

@ -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())
) {
when (settingsUiState) {
SettingsUiState.Loading -> {
// TODO: Add loading wheel
}
is SettingsUiState.Success -> {
SettingsSection(
header = {
SettingsSectionTitle(R.string.remote_nodes)
SettingsSectionTitle("Remote nodes")
IconButton(onClick = onAddRemoteNode) {
Icon(
imageVector = AppIcons.AddRemoteWallet,
contentDescription = stringResource(R.string.add_remote_node),
contentDescription = "Add remote node",
)
}
}
)
RemoteNodeEditableList(
remoteNodes = remoteNodes,
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,
)
}
}
}
}
}
@ -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(
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.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(
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(5000),
initialValue = listOf(RemoteNode.EMPTY),
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
}

View File

@ -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

View File

@ -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 {