From 3e8f435457a6bfa2b76aae0ec4bf113f7aeb70c4 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Wed, 11 Sep 2024 11:10:36 -0300 Subject: [PATCH] Check if is a valid relay before adding it --- .../nostrsigner/service/Nip11Retriever.kt | 102 +++ .../nostrsigner/ui/AccountScreen.kt | 156 +++-- .../nostrsigner/ui/EditPermission.kt | 1 + .../greenart7c3/nostrsigner/ui/MainScreen.kt | 1 + .../ui/actions/EditRelaysDialog.kt | 650 +++++++++++------- app/src/main/res/values/strings.xml | 13 + 6 files changed, 586 insertions(+), 337 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/service/Nip11Retriever.kt diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/Nip11Retriever.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/Nip11Retriever.kt new file mode 100644 index 00000000..830baa94 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/Nip11Retriever.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.greenart7c3.nostrsigner.service + +import android.util.Log +import com.greenart7c3.nostrsigner.checkNotInMainThread +import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.quartz.encoders.Nip11RelayInformation +import java.io.IOException +import kotlinx.coroutines.CancellationException +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Request +import okhttp3.Response + +class Nip11Retriever { + enum class ErrorCode { + FAIL_TO_ASSEMBLE_URL, + FAIL_TO_REACH_SERVER, + FAIL_TO_PARSE_RESULT, + FAIL_WITH_HTTP_STATUS, + } + + suspend fun loadRelayInfo( + url: String, + dirtyUrl: String, + onInfo: (Nip11RelayInformation) -> Unit, + onError: (String, ErrorCode, String?) -> Unit, + ) { + checkNotInMainThread() + try { + val request: Request = + Request + .Builder() + .header("Accept", "application/nostr+json") + .url(url) + .build() + + HttpClientManager + .getHttpClientForUrl(dirtyUrl) + .newCall(request) + .enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + checkNotInMainThread() + response.use { + val body = it.body.string() + try { + if (it.isSuccessful) { + onInfo(Nip11RelayInformation.fromJson(body)) + } else { + onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e( + "RelayInfoFail", + "Resulting Message from Relay $dirtyUrl in not parseable: $body", + e, + ) + onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) + } + } + } + + override fun onFailure( + call: Call, + e: IOException, + ) { + Log.e("RelayInfoFail", "$dirtyUrl unavailable", e) + onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message) + } + }, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) + onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) + } + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt index 97b96043..36d17cd2 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt @@ -76,93 +76,105 @@ fun AccountScreen( MainLoginPage(accountStateViewModel) } is AccountState.LoggedIn -> { + var isLoading by remember { mutableStateOf(true) } var isNotificationsConfigured by remember { mutableStateOf(false) } LaunchedEffect(Unit) { launch(Dispatchers.IO) { isNotificationsConfigured = LocalPreferences.isNotificationTypeConfigured() + isLoading = false } } - if (isNotificationsConfigured) { - var intentData by remember { - mutableStateOf(null) - } - LaunchedEffect(intent, intents) { - IntentUtils.getIntentData(context, intent, packageName, intent.getStringExtra("route"), state.account) { - intentData = it - } - val data = - intents.firstOrNull { - it.currentAccount.isNotBlank() - } - - data?.bunkerRequest?.let { - if (it.currentAccount.isNotBlank()) { - if (LocalPreferences.currentAccount(context) != it.currentAccount) { - accountStateViewModel.switchUser(it.currentAccount, null) - } - } - } - } - - if (intentData != null) { - if (intents.none { item -> item.id == intentData!!.id }) { - val oldIntents = intents.toMutableList() - oldIntents.add(intentData!!) - flow.value = oldIntents - } - } - - val newIntents = - intents.ifEmpty { - if (intentData == null) { - listOf() - } else { - listOf(intentData) - } - }.mapNotNull { it } - val database = NostrSigner.getInstance().getDatabase(state.account.keyPair.pubKey.toNpub()) - val localRoute = mutableStateOf(newIntents.firstNotNullOfOrNull { it.route } ?: state.route) - - SideEffect { - NostrSigner.getInstance().applicationIOScope.launch(Dispatchers.IO) { - PushNotificationUtils.accountState = accountStateViewModel - try { - NostrSigner.getInstance().applicationContext.startForegroundService( - Intent(NostrSigner.getInstance().applicationContext, ConnectivityService::class.java), - ) - } catch (e: Exception) { - Log.d("NostrSigner", "Failed to start ConnectivityService", e) - } - - @Suppress("KotlinConstantConditions") - if (NostrSigner.getInstance().settings.notificationType == NotificationType.DIRECT && BuildConfig.FLAVOR != "offline") { - NostrSigner.getInstance().checkForNewRelays() - NotificationDataSource.start() - delay(5000) - } - } - } - - AmberListenerSingleton.accountStateViewModel = accountStateViewModel - - DisplayErrorMessages(accountStateViewModel) - MainScreen(state.account, accountStateViewModel, newIntents, packageName, appName, localRoute, database) - } else { - AmberListenerSingleton.accountStateViewModel = accountStateViewModel - DisplayErrorMessages(accountStateViewModel) + if (isLoading) { Scaffold { innerPadding -> - NotificationTypeScreen( + CenterCircularProgressIndicator( Modifier .fillMaxSize() .padding(innerPadding), - onDone = { - isNotificationsConfigured = true - }, ) } + } else { + if (isNotificationsConfigured) { + var intentData by remember { + mutableStateOf(null) + } + LaunchedEffect(intent, intents) { + IntentUtils.getIntentData(context, intent, packageName, intent.getStringExtra("route"), state.account) { + intentData = it + } + val data = + intents.firstOrNull { + it.currentAccount.isNotBlank() + } + + data?.bunkerRequest?.let { + if (it.currentAccount.isNotBlank()) { + if (LocalPreferences.currentAccount(context) != it.currentAccount) { + accountStateViewModel.switchUser(it.currentAccount, null) + } + } + } + } + + if (intentData != null) { + if (intents.none { item -> item.id == intentData!!.id }) { + val oldIntents = intents.toMutableList() + oldIntents.add(intentData!!) + flow.value = oldIntents + } + } + + val newIntents = + intents.ifEmpty { + if (intentData == null) { + listOf() + } else { + listOf(intentData) + } + }.mapNotNull { it } + val database = NostrSigner.getInstance().getDatabase(state.account.keyPair.pubKey.toNpub()) + val localRoute = mutableStateOf(newIntents.firstNotNullOfOrNull { it.route } ?: state.route) + + SideEffect { + NostrSigner.getInstance().applicationIOScope.launch(Dispatchers.IO) { + PushNotificationUtils.accountState = accountStateViewModel + try { + NostrSigner.getInstance().applicationContext.startForegroundService( + Intent(NostrSigner.getInstance().applicationContext, ConnectivityService::class.java), + ) + } catch (e: Exception) { + Log.d("NostrSigner", "Failed to start ConnectivityService", e) + } + + @Suppress("KotlinConstantConditions") + if (NostrSigner.getInstance().settings.notificationType == NotificationType.DIRECT && BuildConfig.FLAVOR != "offline") { + NostrSigner.getInstance().checkForNewRelays() + NotificationDataSource.start() + delay(5000) + } + } + } + + AmberListenerSingleton.accountStateViewModel = accountStateViewModel + + DisplayErrorMessages(accountStateViewModel) + MainScreen(state.account, accountStateViewModel, newIntents, packageName, appName, localRoute, database) + } else { + AmberListenerSingleton.accountStateViewModel = accountStateViewModel + DisplayErrorMessages(accountStateViewModel) + Scaffold { innerPadding -> + NotificationTypeScreen( + Modifier + .fillMaxSize() + .padding(innerPadding), + onDone = { + isNotificationsConfigured = true + }, + ) + } + } } } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/EditPermission.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/EditPermission.kt index 6397d5e0..cc02db5c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/EditPermission.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/EditPermission.kt @@ -122,6 +122,7 @@ fun EditPermission( if (editRelaysDialog) { EditRelaysDialog( applicationData = applicationData, + accountStateViewModel = accountStateViewModel, onClose = { editRelaysDialog = false }, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt index 5bac29bc..f1704c3a 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt @@ -846,6 +846,7 @@ fun MainScreen( .fillMaxSize() .padding(padding), navController = navController, + accountStateViewModel = accountStateViewModel, ) } }, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/EditRelaysDialog.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/EditRelaysDialog.kt index 4454693f..05e7a2fa 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/EditRelaysDialog.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/EditRelaysDialog.kt @@ -1,5 +1,6 @@ package com.greenart7c3.nostrsigner.ui.actions +import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -26,6 +27,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -56,12 +58,24 @@ import com.greenart7c3.nostrsigner.NostrSigner import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.database.ApplicationEntity import com.greenart7c3.nostrsigner.models.TimeUtils +import com.greenart7c3.nostrsigner.service.Nip11Retriever +import com.greenart7c3.nostrsigner.ui.AccountStateViewModel +import com.greenart7c3.nostrsigner.ui.CenterCircularProgressIndicator import com.greenart7c3.nostrsigner.ui.components.CloseButton import com.greenart7c3.nostrsigner.ui.components.PostButton import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES +import com.vitorpamplona.ammolite.relays.Client +import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.ammolite.relays.RelayPool import com.vitorpamplona.ammolite.relays.RelaySetupInfo +import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.RelayUrlFormatter +import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.signers.NostrSignerInternal +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -69,12 +83,18 @@ import kotlinx.coroutines.withContext @Composable fun EditRelaysDialog( applicationData: ApplicationEntity, + accountStateViewModel: AccountStateViewModel, onClose: () -> Unit, onPost: (SnapshotStateList) -> Unit, ) { - var textFieldRelay by remember { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val textFieldRelay = remember { mutableStateOf(TextFieldValue("")) } + val isLoading = remember { + mutableStateOf(false) + } val relays2 = remember { val localRelays = mutableStateListOf() @@ -93,31 +113,244 @@ fun EditRelaysDialog( modifier = Modifier .fillMaxSize(), ) { + if (isLoading.value) { + CenterCircularProgressIndicator(Modifier) + } else { + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton { + onClose() + } + PostButton( + isActive = !applicationData.isConnected, + onPost = { + onPost(relays2) + }, + ) + } + LazyColumn( + Modifier + .fillMaxWidth(), + ) { + items(relays2.size) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + relays2[it].url, + Modifier + .weight(0.9f) + .padding(8.dp), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + IconButton( + onClick = { + relays2.removeAt(it) + }, + ) { + Icon( + Icons.Default.Delete, + stringResource(R.string.delete), + ) + } + } + } + } + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + enabled = !applicationData.isConnected, + modifier = Modifier + .fillMaxWidth(0.9f) + .padding(horizontal = 16.dp), + value = textFieldRelay.value.text, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + onValueChange = { + textFieldRelay.value = TextFieldValue(it) + }, + keyboardActions = KeyboardActions( + onDone = { + if (applicationData.isConnected) return@KeyboardActions + + scope.launch(Dispatchers.IO) { + onAddRelay( + textFieldRelay, + isLoading, + relays2, + scope, + accountStateViewModel, + context, + ) + } + }, + ), + label = { + Text("Relay") + }, + ) + IconButton( + onClick = { + if (applicationData.isConnected) return@IconButton + scope.launch(Dispatchers.IO) { + onAddRelay( + textFieldRelay, + isLoading, + relays2, + scope, + accountStateViewModel, + context, + ) + } + }, + ) { + Icon( + Icons.Default.Add, + null, + ) + } + } + } + } + } + } +} + +@Composable +fun DefaultRelaysScreen( + modifier: Modifier, + accountStateViewModel: AccountStateViewModel, + navController: NavController, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val textFieldRelay = remember { + mutableStateOf(TextFieldValue("")) + } + val isLoading = remember { + mutableStateOf(false) + } + val relays2 = + remember { + val localRelays = mutableStateListOf() + NostrSigner.getInstance().settings.defaultRelays.forEach { + localRelays.add( + it.copy(), + ) + } + localRelays + } + + Surface( + modifier = modifier + .fillMaxSize(), + ) { + if (isLoading.value) { + CenterCircularProgressIndicator(Modifier) + } else { Column( modifier = Modifier .background(MaterialTheme.colorScheme.background) .fillMaxSize(), ) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth(0.9f) + .padding(horizontal = 16.dp), + value = textFieldRelay.value.text, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + onValueChange = { + textFieldRelay.value = TextFieldValue(it) + }, + keyboardActions = KeyboardActions( + onDone = { + scope.launch(Dispatchers.IO) { + onAddRelay( + textFieldRelay, + isLoading, + relays2, + scope, + accountStateViewModel, + context, + ) + } + }, + ), + label = { + Text("Relay") + }, + ) + IconButton( + onClick = { + scope.launch(Dispatchers.IO) { + onAddRelay( + textFieldRelay, + isLoading, + relays2, + scope, + accountStateViewModel, + context, + ) + } + }, + ) { + Icon( + Icons.Default.Add, + null, + ) + } + } + Row( modifier = Modifier .fillMaxWidth() .padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - CloseButton { - onClose() - } PostButton( - isActive = !applicationData.isConnected, + isActive = relays2.isNotEmpty(), onPost = { - onPost(relays2) + scope.launch(Dispatchers.IO) { + if (relays2.isNotEmpty()) { + NostrSigner.getInstance().settings = NostrSigner.getInstance().settings.copy( + defaultRelays = relays2, + ) + LocalPreferences.saveSettingsToEncryptedStorage(NostrSigner.getInstance().settings) + } + scope.launch(Dispatchers.Main) { + navController.navigateUp() + } + } }, ) } + LazyColumn( Modifier - .fillMaxWidth(), + .weight(1f), ) { items(relays2.size) { Row( @@ -146,42 +379,103 @@ fun EditRelaysDialog( } } } - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - enabled = !applicationData.isConnected, - modifier = Modifier - .fillMaxWidth(0.9f) - .padding(horizontal = 16.dp), - value = textFieldRelay.text, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - ), - onValueChange = { - textFieldRelay = TextFieldValue(it) - }, - keyboardActions = KeyboardActions( - onDone = { - if (applicationData.isConnected) return@KeyboardActions - val url = textFieldRelay.text - if (url.isNotBlank() && url != "/") { - var addedWSS = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) { - if (url.endsWith(".onion") || url.endsWith(".onion/")) { - "ws://$url" - } else { - "wss://$url" - } - } else { - url - } - if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) - if (relays2.any { it.url == addedWSS }) { - textFieldRelay = TextFieldValue("") - return@KeyboardActions - } + } + } + } +} + +private suspend fun onAddRelay( + textFieldRelay: MutableState, + isLoading: MutableState, + relays2: SnapshotStateList, + scope: CoroutineScope, + accountStateViewModel: AccountStateViewModel, + context: Context, +) { + val url = textFieldRelay.value.text + if (url.isNotBlank() && url != "/") { + isLoading.value = true + var addedWSS = + if (!url.startsWith("wss://") && !url.startsWith("ws://")) { + // TODO: How to identify relays on the local network? + val isLocalHost = url.contains("127.0.0.1") || url.contains("localhost") + if (url.endsWith(".onion") || url.endsWith(".onion/") || isLocalHost) { + "ws://$url" + } else { + "wss://$url" + } + } else { + url + } + if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) + if (relays2.any { it.url == addedWSS }) { + textFieldRelay.value = TextFieldValue("") + isLoading.value = false + return + } + val httpsUrl = RelayUrlFormatter.getHttpsUrl(addedWSS) + val retriever = Nip11Retriever() + scope.launch(Dispatchers.IO) { + retriever.loadRelayInfo( + httpsUrl, + addedWSS, + onInfo = { info -> + scope.launch(Dispatchers.IO) secondLaunch@{ + if (info.limitation?.payment_required == true) { + accountStateViewModel.toast( + context.getString(R.string.relay), + context.getString(R.string.paid_relays_are_not_supported), + ) + textFieldRelay.value = TextFieldValue("") + isLoading.value = false + return@secondLaunch + } + + if (info.limitation?.auth_required == true) { + accountStateViewModel.toast( + context.getString(R.string.relay), + context.getString(R.string.auth_required_message), + ) + textFieldRelay.value = TextFieldValue("") + isLoading.value = false + return@secondLaunch + } + + val signer = NostrSignerInternal(KeyPair()) + val encryptedContent = signer.signerSync.nip04Encrypt( + "Test bunker event", + signer.keyPair.pubKey.toHexKey(), + ) + encryptedContent?.let { + val event = signer.signerSync.sign( + TimeUtils.now(), + 24133, + arrayOf(arrayOf("p", signer.keyPair.pubKey.toHexKey())), + it, + ) + + event?.let { signedEvent -> + val relay = Relay( + addedWSS, + read = true, + write = true, + activeTypes = setOf(), + ) + RelayPool.addRelay( + relay, + ) + relay.connect() + delay(2000) + val result = Client.sendAndWaitForResponse( + signedEvent = signedEvent, + relayList = listOf(RelaySetupInfo(addedWSS, read = true, write = true, setOf())), + ) + RelayPool.getRelay(addedWSS)?.disconnect() + RelayPool.removeRelay( + relay, + ) + + if (result) { relays2.add( RelaySetupInfo( addedWSS, @@ -190,232 +484,58 @@ fun EditRelaysDialog( feedTypes = COMMON_FEED_TYPES, ), ) - textFieldRelay = TextFieldValue("") - } - }, - ), - label = { - Text("Relay") - }, - ) - IconButton( - onClick = { - if (applicationData.isConnected) return@IconButton - val url = textFieldRelay.text - if (url.isNotBlank() && url != "/") { - var addedWSS = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) { - if (url.endsWith(".onion") || url.endsWith(".onion/")) { - "ws://$url" - } else { - "wss://$url" - } - } else { - url - } - if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) - if (relays2.any { it.url == addedWSS }) { - textFieldRelay = TextFieldValue("") - return@IconButton - } - relays2.add( - RelaySetupInfo( - addedWSS, - read = true, - write = true, - feedTypes = COMMON_FEED_TYPES, - ), - ) - textFieldRelay = TextFieldValue("") - } - }, - ) { - Icon( - Icons.Default.Add, - null, - ) - } - } - } - } - } -} - -@Composable -fun DefaultRelaysScreen( - modifier: Modifier, - navController: NavController, -) { - val scope = rememberCoroutineScope() - var textFieldRelay by remember { - mutableStateOf(TextFieldValue("")) - } - val relays2 = - remember { - val localRelays = mutableStateListOf() - NostrSigner.getInstance().settings.defaultRelays.forEach { - localRelays.add( - it.copy(), - ) - } - localRelays - } - - Surface( - modifier = modifier - .fillMaxSize(), - ) { - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.background) - .fillMaxSize(), - ) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - modifier = Modifier - .fillMaxWidth(0.9f) - .padding(horizontal = 16.dp), - value = textFieldRelay.text, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - ), - onValueChange = { - textFieldRelay = TextFieldValue(it) - }, - keyboardActions = KeyboardActions( - onDone = { - val url = textFieldRelay.text - if (url.isNotBlank() && url != "/") { - var addedWSS = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) { - if (url.endsWith(".onion") || url.endsWith(".onion/")) { - "ws://$url" - } else { - "wss://$url" - } - } else { - url - } - if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) - if (relays2.any { it.url == addedWSS }) { - textFieldRelay = TextFieldValue("") - return@KeyboardActions - } - relays2.add( - RelaySetupInfo( - addedWSS, - read = true, - write = true, - feedTypes = COMMON_FEED_TYPES, - ), - ) - textFieldRelay = TextFieldValue("") - } - }, - ), - label = { - Text("Relay") - }, - ) - IconButton( - onClick = { - val url = textFieldRelay.text - if (url.isNotBlank() && url != "/") { - var addedWSS = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) { - // TODO: How to identify relays on the local network? - val isLocalHost = url.contains("127.0.0.1") || url.contains("localhost") - if (url.endsWith(".onion") || url.endsWith(".onion/") || isLocalHost) { - "ws://$url" - } else { - "wss://$url" - } } else { - url + accountStateViewModel.toast( + context.getString(R.string.relay), + context.getString(R.string.failed_to_send_event), + ) } - if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) - if (relays2.any { it.url == addedWSS }) { - textFieldRelay = TextFieldValue("") - return@IconButton + textFieldRelay.value = TextFieldValue("") } - relays2.add( - RelaySetupInfo( - addedWSS, - read = true, - write = true, - feedTypes = COMMON_FEED_TYPES, - ), - ) - textFieldRelay = TextFieldValue("") - } - }, - ) { - Icon( - Icons.Default.Add, - null, - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - PostButton( - isActive = relays2.isNotEmpty(), - onPost = { - scope.launch(Dispatchers.IO) { - if (relays2.isNotEmpty()) { - NostrSigner.getInstance().settings = NostrSigner.getInstance().settings.copy( - defaultRelays = relays2, - ) - LocalPreferences.saveSettingsToEncryptedStorage(NostrSigner.getInstance().settings) - } - scope.launch(Dispatchers.Main) { - navController.navigateUp() - } - } - }, - ) - } - - LazyColumn( - Modifier - .weight(1f), - ) { - items(relays2.size) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - relays2[it].url, - Modifier - .weight(0.9f) - .padding(8.dp), - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - IconButton( - onClick = { - relays2.removeAt(it) - }, - ) { - Icon( - Icons.Default.Delete, - stringResource(R.string.delete), - ) + isLoading.value = false } } - } - } + }, + onError = { dirtyUrl, errorCode, exceptionMessage -> + isLoading.value = false + val msg = + when (errorCode) { + Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> + context.getString( + R.string.relay_information_document_error_assemble_url, + dirtyUrl, + exceptionMessage, + ) + + Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> + context.getString( + R.string.relay_information_document_error_assemble_url, + dirtyUrl, + exceptionMessage, + ) + + Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> + context.getString( + R.string.relay_information_document_error_assemble_url, + dirtyUrl, + exceptionMessage, + ) + + Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> + context.getString( + R.string.relay_information_document_error_assemble_url, + dirtyUrl, + exceptionMessage, + ) + } + + accountStateViewModel.toast( + context.getString(R.string.unable_to_download_relay_document), + msg, + ) + textFieldRelay.value = TextFieldValue("") + }, + ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee48ad4a..ec454fd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -368,4 +368,17 @@ Keep the relays connections open all the time. Using this option is faster and will result in a better experience Notification Settings Please configure your notification settings before continuing. + Relay + Paid relays are not supported + Relays that require auth are not supported + Failed to reach %1$s: %2$s + Failed to assemble NIP-11 url for %1$s: %2$s + Failed to reach %1$s: %2$s + Failed to parse response from %1$s: %2$s + Relay declined request %1$s: %2$s + Failed to reach %1$s: %2$s + Failed to parse result from %1$s: %2$s + %1$s failed with code %2$s + Download relay document unavailable + Failed to send event to relay