From 4ba02512e8d95e56203cd210c34c668097273695 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 10:05:24 -0300 Subject: [PATCH 01/17] fix: handle restartWithRgsServer exceptions --- .../settings/advanced/RgsServerViewModel.kt | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt index 28effc038..6754a79b2 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.settings.advanced +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -72,24 +73,26 @@ class RgsServerViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } viewModelScope.launch(bgDispatcher) { - lightningRepo.restartWithRgsServer(url) - .onSuccess { - _uiState.update { - val newState = it.copy( - isLoading = false, - connectionResult = Result.success(Unit), - ) - computeState(newState) - } - } - .onFailure { error -> - _uiState.update { - it.copy( - isLoading = false, - connectionResult = Result.failure(error), - ) + runCatching { + lightningRepo.restartWithRgsServer(url) + .onSuccess { + _uiState.update { + val newState = it.copy( + isLoading = false, + connectionResult = Result.success(Unit), + ) + computeState(newState) + } } + .onFailure { error -> throw error } + }.onFailure { e -> + _uiState.update { + it.copy( + isLoading = false, + connectionResult = Result.failure(e), + ) } + } } } @@ -127,6 +130,7 @@ class RgsServerViewModel @Inject constructor( } } +@Stable data class RgsServerUiState( val connectedRgsUrl: String? = null, val rgsUrl: String = "", From 5337b50c98644a428ea1b451da04848ccb7b62e8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 10:14:45 -0300 Subject: [PATCH 02/17] chore: remove unused method --- .../to/bitkit/viewmodels/WalletViewModel.kt | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 15b8c7dea..57fbb5ecb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -328,53 +328,6 @@ class WalletViewModel @Inject constructor( } } - private suspend fun checkForOrphanedChannelMonitorRecovery() { - if (migrationService.isChannelRecoveryChecked()) return - - Logger.info("Running one-time channel monitor recovery check", context = TAG) - - val allMonitorsRetrieved = runCatching { - val allRetrieved = migrationService.fetchRNRemoteLdkData() - // don't overwrite channel manager, we only need the monitors for the sweep - val channelMigration = buildChannelMigrationIfAvailable()?.let { - ChannelDataMigration(channelManager = null, channelMonitors = it.channelMonitors) - } - - if (channelMigration == null) { - Logger.info("No channel monitors found on RN backup", context = TAG) - return@runCatching allRetrieved - } - - Logger.info( - "Found ${channelMigration.channelMonitors.size} monitors on RN backup, attempting recovery", - context = TAG, - ) - - lightningRepo.stop().onFailure { - Logger.error("Failed to stop node for channel recovery", it, context = TAG) - } - delay(CHANNEL_RECOVERY_RESTART_DELAY_MS) - lightningRepo.start(channelMigration = channelMigration, shouldRetry = false) - .onSuccess { - migrationService.consumePendingChannelMigration() - walletRepo.syncNodeAndWallet() - walletRepo.syncBalances() - Logger.info("Channel monitor recovery complete", context = TAG) - } - .onFailure { - Logger.error("Failed to restart node after channel recovery", it, context = TAG) - } - - allRetrieved - }.getOrDefault(false) - - if (allMonitorsRetrieved) { - migrationService.markChannelRecoveryChecked() - } else { - Logger.warn("Some monitors failed to download, will retry on next startup", context = TAG) - } - } - fun stop() { if (!walletExists) return From f9ccad38af390e05b1675da2cd8d5a366d468941 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 10:25:04 -0300 Subject: [PATCH 03/17] refactor: better handling error cases --- .../settings/advanced/RgsServerViewModel.kt | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt index 6754a79b2..bdfc01a41 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt @@ -73,26 +73,24 @@ class RgsServerViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } viewModelScope.launch(bgDispatcher) { - runCatching { - lightningRepo.restartWithRgsServer(url) - .onSuccess { - _uiState.update { - val newState = it.copy( - isLoading = false, - connectionResult = Result.success(Unit), - ) - computeState(newState) - } + lightningRepo.restartWithRgsServer(url) + .onSuccess { + _uiState.update { + val newState = it.copy( + isLoading = false, + connectionResult = Result.success(Unit), + ) + computeState(newState) + } + } + .onFailure { e -> + _uiState.update { + it.copy( + isLoading = false, + connectionResult = Result.failure(e), + ) } - .onFailure { error -> throw error } - }.onFailure { e -> - _uiState.update { - it.copy( - isLoading = false, - connectionResult = Result.failure(e), - ) } - } } } From 8a31c0f86eca4d8f4d39630ea32e4e62c72e8bce Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 10:38:26 -0300 Subject: [PATCH 04/17] test: viewmodel tests --- .../advanced/RgsServerViewModelTest.kt | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt new file mode 100644 index 000000000..8756e1a91 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt @@ -0,0 +1,249 @@ +package to.bitkit.ui.settings.advanced + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.repositories.LightningRepo +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class RgsServerViewModelTest : BaseUnitTest() { + private val settingsStore: SettingsStore = mock() + private val lightningRepo: LightningRepo = mock() + + private lateinit var sut: RgsServerViewModel + + private val defaultRgsUrl = "https://rgs.blocktank.to/snapshot" + + @Before + fun setUp() { + whenever(settingsStore.data).thenReturn( + flowOf(SettingsData(rgsServerUrl = defaultRgsUrl)) + ) + } + + private fun createSut(): RgsServerViewModel = RgsServerViewModel( + bgDispatcher = testDispatcher, + settingsStore = settingsStore, + lightningRepo = lightningRepo, + ) + + @Test + fun `initial state loads rgsServerUrl from settings`() = test { + sut = createSut() + + sut.uiState.test { + val state = awaitItem() + assertEquals(defaultRgsUrl, state.connectedRgsUrl) + assertEquals(defaultRgsUrl, state.rgsUrl) + assertFalse(state.hasEdited) + assertFalse(state.canConnect) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `setRgsUrl updates url and computes canConnect`() = test { + sut = createSut() + advanceUntilIdle() + + sut.setRgsUrl("https://other.server.com/snapshot") + + val state = sut.uiState.value + assertEquals("https://other.server.com/snapshot", state.rgsUrl) + assertTrue(state.hasEdited) + assertTrue(state.canConnect) + } + + @Test + fun `setRgsUrl trims whitespace`() = test { + sut = createSut() + advanceUntilIdle() + + sut.setRgsUrl(" https://other.server.com/snapshot ") + + assertEquals("https://other.server.com/snapshot", sut.uiState.value.rgsUrl) + } + + @Test + fun `canConnect is false when url matches connected url`() = test { + sut = createSut() + advanceUntilIdle() + + sut.setRgsUrl(defaultRgsUrl) + + assertFalse(sut.uiState.value.canConnect) + } + + @Test + fun `canConnect is false when url is blank`() = test { + sut = createSut() + advanceUntilIdle() + + sut.setRgsUrl(" ") + + assertFalse(sut.uiState.value.canConnect) + } + + @Test + fun `canConnect is false when url is invalid`() = test { + sut = createSut() + advanceUntilIdle() + + sut.setRgsUrl("not-a-url") + + assertFalse(sut.uiState.value.canConnect) + } + + @Test + fun `onClickConnect does nothing for blank url`() = test { + sut = createSut() + advanceUntilIdle() + sut.setRgsUrl("") + + sut.onClickConnect() + advanceUntilIdle() + + verify(lightningRepo, never()).restartWithRgsServer("") + assertFalse(sut.uiState.value.isLoading) + } + + @Test + fun `onClickConnect does nothing for invalid url`() = test { + sut = createSut() + advanceUntilIdle() + sut.setRgsUrl("invalid") + + sut.onClickConnect() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isLoading) + } + + @Test + fun `onClickConnect success sets connectionResult success`() = test { + val newUrl = "https://other.server.com/snapshot" + whenever(lightningRepo.restartWithRgsServer(newUrl)) + .thenReturn(Result.success(Unit)) + sut = createSut() + advanceUntilIdle() + sut.setRgsUrl(newUrl) + + sut.onClickConnect() + advanceUntilIdle() + + val state = sut.uiState.value + assertFalse(state.isLoading) + val result = assertNotNull(state.connectionResult) + assertTrue(result.isSuccess) + } + + @Test + fun `onClickConnect failure sets connectionResult failure`() = test { + val newUrl = "https://other.server.com/snapshot" + val error = Exception("Connection failed") + whenever(lightningRepo.restartWithRgsServer(newUrl)) + .thenReturn(Result.failure(error)) + sut = createSut() + advanceUntilIdle() + sut.setRgsUrl(newUrl) + + sut.onClickConnect() + advanceUntilIdle() + + val state = sut.uiState.value + assertFalse(state.isLoading) + val result = assertNotNull(state.connectionResult) + assertTrue(result.isFailure) + assertEquals("Connection failed", result.exceptionOrNull()?.message) + } + + @Test + fun `onClickConnect with invalid host sets connectionResult failure`() = test { + val newUrl = "https://rapidsync.lightningdevkit/snapshot" + whenever(lightningRepo.restartWithRgsServer(newUrl)) + .thenReturn(Result.failure(Exception("Failed to start node"))) + sut = createSut() + advanceUntilIdle() + sut.setRgsUrl(newUrl) + + sut.onClickConnect() + advanceUntilIdle() + + val state = sut.uiState.value + assertFalse(state.isLoading) + val result = assertNotNull(state.connectionResult) + assertTrue(result.isFailure) + assertEquals("Failed to start node", result.exceptionOrNull()?.message) + } + + @Test + fun `clearConnectionResult resets connectionResult to null`() = test { + val newUrl = "https://other.server.com/snapshot" + whenever(lightningRepo.restartWithRgsServer(newUrl)) + .thenReturn(Result.success(Unit)) + sut = createSut() + advanceUntilIdle() + sut.setRgsUrl(newUrl) + sut.onClickConnect() + advanceUntilIdle() + + sut.clearConnectionResult() + + assertNull(sut.uiState.value.connectionResult) + } + + @Test + fun `onScan delegates to setRgsUrl`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onScan("https://scanned.server.com/snapshot") + + assertEquals("https://scanned.server.com/snapshot", sut.uiState.value.rgsUrl) + } + + @Test + fun `resetToDefault sets url to env default`() = test { + sut = createSut() + advanceUntilIdle() + sut.setRgsUrl("https://custom.server.com/snapshot") + + sut.resetToDefault() + + val state = sut.uiState.value + assertFalse(state.canReset) + } + + @Test + fun `isLoading is true while connecting`() = test { + val newUrl = "https://other.server.com/snapshot" + whenever(lightningRepo.restartWithRgsServer(newUrl)) + .thenReturn(Result.success(Unit)) + sut = createSut() + advanceUntilIdle() + sut.setRgsUrl(newUrl) + + sut.uiState.test { + skipItems(1) + sut.onClickConnect() + val loadingState = awaitItem() + assertTrue(loadingState.isLoading) + cancelAndIgnoreRemainingEvents() + } + } +} From 4d654bcf4b7b4a8d5cbfcba0fbe7be36cc1a3593 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 10:56:50 -0300 Subject: [PATCH 05/17] fix: wrap restartWithRgsServer with runCatching --- .../to/bitkit/repositories/LightningRepo.kt | 39 ++++++++++-------- .../bitkit/repositories/LightningRepoTest.kt | 40 +++++++++++++++++++ 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 04bde8c44..9d43ff67e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -617,26 +617,31 @@ class LightningRepo @Inject constructor( } suspend fun restartWithRgsServer(newRgsUrl: String): Result = withContext(bgDispatcher) { - Logger.info("Changing ldk-node RGS server to: '$newRgsUrl'", context = TAG) + runCatching { + Logger.info("Changing ldk-node RGS server to: '$newRgsUrl'", context = TAG) - waitForNodeToStop().onFailure { return@withContext Result.failure(it) } - stop().onFailure { - Logger.error("Failed to stop node during RGS server change", it, context = TAG) - return@withContext Result.failure(it) - } + waitForNodeToStop().onFailure { return@runCatching Result.failure(it) } + stop().onFailure { + Logger.error("Failed to stop node during RGS server change", it, context = TAG) + return@runCatching Result.failure(it) + } - Logger.debug("Starting node with new RGS server: '$newRgsUrl'", context = TAG) + Logger.debug("Starting node with new RGS server: '$newRgsUrl'", context = TAG) - start( - shouldRetry = false, - customRgsServerUrl = newRgsUrl, - ).onFailure { - Logger.warn("Failed ldk-node config change, attempting recovery…", context = TAG) - restartWithPreviousConfig() - }.onSuccess { - settingsStore.update { it.copy(rgsServerUrl = newRgsUrl) } - - Logger.info("Successfully changed RGS server", context = TAG) + start( + shouldRetry = false, + customRgsServerUrl = newRgsUrl, + ).onFailure { + Logger.warn("Failed ldk-node config change, attempting recovery…", context = TAG) + restartWithPreviousConfig() + }.onSuccess { + settingsStore.update { it.copy(rgsServerUrl = newRgsUrl) } + + Logger.info("Successfully changed RGS server", context = TAG) + } + }.getOrElse { + Logger.error("Unexpected error during RGS server change", it, context = TAG) + Result.failure(it) } } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 6666d0be8..07b25d325 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -498,6 +498,46 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } + @Test + fun `restartWithRgsServer should setup with new rgs server`() = test { + startNodeForTesting() + val customRgsUrl = "https://rgs.example.com/snapshot" + whenever(lightningService.node).thenReturn(null) + whenever(lightningService.stop()).thenReturn(Unit) + + val result = sut.restartWithRgsServer(customRgsUrl) + + assertTrue(result.isSuccess) + val inOrder = inOrder(lightningService) + inOrder.verify(lightningService).stop() + inOrder.verify(lightningService).setup(any(), isNull(), eq(customRgsUrl), anyOrNull(), anyOrNull()) + inOrder.verify(lightningService).start(anyOrNull(), any()) + assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) + } + + @Test + fun `restartWithRgsServer should handle stop failure`() = test { + startNodeForTesting() + whenever(lightningService.stop()).thenThrow(RuntimeException("Stop failed")) + + val result = sut.restartWithRgsServer("https://rgs.example.com/snapshot") + + assertTrue(result.isFailure) + } + + @Test + fun `restartWithRgsServer should handle start failure and recover`() = test { + startNodeForTesting() + whenever(lightningService.node).thenReturn(null) + whenever(lightningService.stop()).thenReturn(Unit) + whenever(lightningService.setup(any(), isNull(), eq("https://bad.rgs/snapshot"), anyOrNull(), anyOrNull())) + .thenThrow(RuntimeException("Failed to start node")) + + val result = sut.restartWithRgsServer("https://bad.rgs/snapshot") + + assertTrue(result.isFailure) + } + @Test fun `getFeeRateForSpeed should use provided feeRates`() = test { val mockFeeRates = mock() From 4017c5c784c246ac7ae0a49863e7e1b1f6bb3fd9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 10:57:35 -0300 Subject: [PATCH 06/17] chore: restore code --- .../java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt index bdfc01a41..25539c3c9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt @@ -83,11 +83,11 @@ class RgsServerViewModel @Inject constructor( computeState(newState) } } - .onFailure { e -> + .onFailure { error -> _uiState.update { it.copy( isLoading = false, - connectionResult = Result.failure(e), + connectionResult = Result.failure(error), ) } } From 3b75e1e44f843321881b0cc1c3e9a291d274051b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 11:02:21 -0300 Subject: [PATCH 07/17] refactor: moved the Regex to a companion object constant so it's compiled once instead of on every keystroke --- .../ui/settings/advanced/RgsServerViewModel.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt index 25539c3c9..f25cad452 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt @@ -111,20 +111,22 @@ class RgsServerViewModel @Inject constructor( } private fun isValidURL(data: String): Boolean { - val pattern = Regex( + // Allow localhost in development mode + if (Env.isDebug && data.contains("localhost")) { + return true + } + + return URL_PATTERN.matches(data) + } + + companion object { + private val URL_PATTERN = Regex( "^(https?://)?" + // protocol "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name "((\\d{1,3}\\.){3}\\d{1,3}))" + // IP (v4) address "(:\\d+)?(/[-a-z\\d%_.~+]*)*", // port and path RegexOption.IGNORE_CASE ) - - // Allow localhost in development mode - if (Env.isDebug && data.contains("localhost")) { - return true - } - - return pattern.matches(data) } } From a6f0f409b7e06595840b624929a955707a551f43 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 11:56:31 -0300 Subject: [PATCH 08/17] fix: check if RGS url is reachable --- app/src/main/java/to/bitkit/di/HttpModule.kt | 15 ++++++++ .../to/bitkit/repositories/LightningRepo.kt | 12 +++++++ .../main/java/to/bitkit/utils/UrlValidator.kt | 5 +++ .../bitkit/repositories/LightningRepoTest.kt | 35 +++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 app/src/main/java/to/bitkit/utils/UrlValidator.kt diff --git a/app/src/main/java/to/bitkit/di/HttpModule.kt b/app/src/main/java/to/bitkit/di/HttpModule.kt index 0d89ae3f8..f652939b3 100644 --- a/app/src/main/java/to/bitkit/di/HttpModule.kt +++ b/app/src/main/java/to/bitkit/di/HttpModule.kt @@ -12,10 +12,14 @@ import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.LoggingConfig +import io.ktor.client.request.head import io.ktor.http.ContentType import io.ktor.http.contentType +import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json +import to.bitkit.utils.UrlValidator +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Singleton import io.ktor.client.plugins.logging.Logger as KtorLogger @@ -43,6 +47,17 @@ object HttpModule { } } + @Provides + @Singleton + fun provideUrlValidator(httpClient: HttpClient) = UrlValidator { url -> + runCatching { + val response = httpClient.head(url) + if (!response.status.isSuccess()) { + throw AppError("Server returned '${response.status}'") + } + } + } + @Suppress("MagicNumber") private fun HttpTimeoutConfig.defaultTimeoutConfig() { requestTimeoutMillis = 60_000 diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 9d43ff67e..716d31dc3 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -79,6 +79,7 @@ import to.bitkit.services.NodeEventHandler import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError +import to.bitkit.utils.UrlValidator import java.io.File import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean @@ -105,6 +106,7 @@ class LightningRepo @Inject constructor( private val preActivityMetadataRepo: PreActivityMetadataRepo, private val connectivityRepo: ConnectivityRepo, private val vssBackupClientLdk: VssBackupClientLdk, + private val urlValidator: UrlValidator, ) { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() @@ -620,6 +622,8 @@ class LightningRepo @Inject constructor( runCatching { Logger.info("Changing ldk-node RGS server to: '$newRgsUrl'", context = TAG) + validateRgsUrl(newRgsUrl).onFailure { return@runCatching Result.failure(it) } + waitForNodeToStop().onFailure { return@runCatching Result.failure(it) } stop().onFailure { Logger.error("Failed to stop node during RGS server change", it, context = TAG) @@ -645,6 +649,14 @@ class LightningRepo @Inject constructor( } } + private suspend fun validateRgsUrl(url: String): Result = withContext(bgDispatcher) { + val initialTimestamp = 0 + val testUrl = "${url.trimEnd('/')}/$initialTimestamp" + urlValidator.validate(testUrl).onFailure { + Logger.warn("RGS server unreachable at '$testUrl'", it, context = TAG) + } + } + suspend fun getBalanceForAddressType(addressType: AddressType): Result = withContext(bgDispatcher) { executeWhenNodeRunning("getBalanceForAddressType") { runCatching { diff --git a/app/src/main/java/to/bitkit/utils/UrlValidator.kt b/app/src/main/java/to/bitkit/utils/UrlValidator.kt new file mode 100644 index 000000000..be2ac0024 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/UrlValidator.kt @@ -0,0 +1,5 @@ +package to.bitkit.utils + +fun interface UrlValidator { + suspend fun validate(url: String): Result +} diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 07b25d325..9ee56808e 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -51,6 +51,7 @@ import to.bitkit.services.LightningService import to.bitkit.services.LnurlService import to.bitkit.services.LspNotificationsService import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.UrlValidator import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -72,6 +73,7 @@ class LightningRepoTest : BaseUnitTest() { private val lnurlService = mock() private val connectivityRepo = mock() private val vssBackupClientLdk = mock() + private val urlValidator = UrlValidator { Result.success(Unit) } @Before fun setUp() = runBlocking { @@ -94,6 +96,7 @@ class LightningRepoTest : BaseUnitTest() { preActivityMetadataRepo = preActivityMetadataRepo, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, + urlValidator = urlValidator, ) } @@ -538,6 +541,38 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } + @Test + fun `restartWithRgsServer should fail when url is unreachable`() = test { + val failingValidator = UrlValidator { Result.failure(Exception("DNS resolution failed")) } + val sutWithFailingValidator = LightningRepo( + bgDispatcher = testDispatcher, + lightningService = lightningService, + settingsStore = settingsStore, + coreService = coreService, + lspNotificationsService = lspNotificationsService, + firebaseMessaging = firebaseMessaging, + keychain = keychain, + lnurlService = lnurlService, + cacheStore = cacheStore, + preActivityMetadataRepo = preActivityMetadataRepo, + connectivityRepo = connectivityRepo, + vssBackupClientLdk = vssBackupClientLdk, + urlValidator = failingValidator, + ) + sutWithFailingValidator.setInitNodeLifecycleState() + whenever(lightningService.node).thenReturn(mock()) + whenever(lightningService.sync()).thenReturn(Unit) + val blocktank = mock() + whenever(coreService.blocktank).thenReturn(blocktank) + whenever(blocktank.info(any())).thenReturn(null) + sutWithFailingValidator.start() + + val result = sutWithFailingValidator.restartWithRgsServer("https://rapidsync.lightningdevkit/snapshot") + + assertTrue(result.isFailure) + assertEquals("DNS resolution failed", result.exceptionOrNull()?.message) + } + @Test fun `getFeeRateForSpeed should use provided feeRates`() = test { val mockFeeRates = mock() From 365dbe40bf5036c720312bb8a74e0bbc74206d56 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 12:01:09 -0300 Subject: [PATCH 09/17] chore: lint --- .../settings/advanced/RgsServerViewModel.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt index f25cad452..a15e9f333 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt @@ -25,6 +25,16 @@ class RgsServerViewModel @Inject constructor( private val lightningRepo: LightningRepo, ) : ViewModel() { + companion object { + private val URL_PATTERN = Regex( + "^(https?://)?" + // protocol + "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name + "((\\d{1,3}\\.){3}\\d{1,3}))" + // IP (v4) address + "(:\\d+)?(/[-a-z\\d%_.~+]*)*", // port and path + RegexOption.IGNORE_CASE + ) + } + private val _uiState = MutableStateFlow(RgsServerUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -118,16 +128,6 @@ class RgsServerViewModel @Inject constructor( return URL_PATTERN.matches(data) } - - companion object { - private val URL_PATTERN = Regex( - "^(https?://)?" + // protocol - "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name - "((\\d{1,3}\\.){3}\\d{1,3}))" + // IP (v4) address - "(:\\d+)?(/[-a-z\\d%_.~+]*)*", // port and path - RegexOption.IGNORE_CASE - ) - } } @Stable From f99e57a8b0ff45acecc7ff5e84ca34e7ae0194cf Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 12:12:24 -0300 Subject: [PATCH 10/17] chore: lint --- .../to/bitkit/repositories/LightningRepo.kt | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 716d31dc3..c1f243586 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -619,33 +619,28 @@ class LightningRepo @Inject constructor( } suspend fun restartWithRgsServer(newRgsUrl: String): Result = withContext(bgDispatcher) { - runCatching { - Logger.info("Changing ldk-node RGS server to: '$newRgsUrl'", context = TAG) + Logger.info("Changing ldk-node RGS server to: '$newRgsUrl'", context = TAG) - validateRgsUrl(newRgsUrl).onFailure { return@runCatching Result.failure(it) } + validateRgsUrl(newRgsUrl).onFailure { return@withContext Result.failure(it) } - waitForNodeToStop().onFailure { return@runCatching Result.failure(it) } - stop().onFailure { - Logger.error("Failed to stop node during RGS server change", it, context = TAG) - return@runCatching Result.failure(it) - } + waitForNodeToStop().onFailure { return@withContext Result.failure(it) } + stop().onFailure { + Logger.error("Failed to stop node during RGS server change", it, context = TAG) + return@withContext Result.failure(it) + } - Logger.debug("Starting node with new RGS server: '$newRgsUrl'", context = TAG) + Logger.debug("Starting node with new RGS server: '$newRgsUrl'", context = TAG) - start( - shouldRetry = false, - customRgsServerUrl = newRgsUrl, - ).onFailure { - Logger.warn("Failed ldk-node config change, attempting recovery…", context = TAG) - restartWithPreviousConfig() - }.onSuccess { - settingsStore.update { it.copy(rgsServerUrl = newRgsUrl) } - - Logger.info("Successfully changed RGS server", context = TAG) - } - }.getOrElse { - Logger.error("Unexpected error during RGS server change", it, context = TAG) - Result.failure(it) + start( + shouldRetry = false, + customRgsServerUrl = newRgsUrl, + ).onFailure { + Logger.warn("Failed ldk-node config change, attempting recovery…", context = TAG) + restartWithPreviousConfig() + }.onSuccess { + settingsStore.update { it.copy(rgsServerUrl = newRgsUrl) } + + Logger.info("Successfully changed RGS server", context = TAG) } } From 6d2c9ff853aaf46e92f3e9148be4ab6e42fa6485 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 13:37:00 -0300 Subject: [PATCH 11/17] fix: the URL_PATTERN regex had a nested quantifier (/[-a-z\d%_.~+]*)* that caused catastrophic backtracking in the ICU regex engine on Android, running on every keystroke on the main thread --- .../settings/advanced/RgsServerViewModel.kt | 32 +++++++++---- .../advanced/RgsServerViewModelTest.kt | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt index a15e9f333..617f6913f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt @@ -16,6 +16,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.repositories.LightningRepo +import java.net.URI import javax.inject.Inject @HiltViewModel @@ -26,13 +27,11 @@ class RgsServerViewModel @Inject constructor( ) : ViewModel() { companion object { - private val URL_PATTERN = Regex( - "^(https?://)?" + // protocol - "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name - "((\\d{1,3}\\.){3}\\d{1,3}))" + // IP (v4) address - "(:\\d+)?(/[-a-z\\d%_.~+]*)*", // port and path - RegexOption.IGNORE_CASE + private val HOSTNAME_PATTERN = Regex( + "^([a-z\\d]([a-z\\d-]*[a-z\\d])*\\.)+[a-z]{2,}|(\\d{1,3}\\.){3}\\d{1,3}$", + RegexOption.IGNORE_CASE, ) + private val PATH_PATTERN = Regex("^(/[a-zA-Z\\d_.~%+-]*)*$") } private val _uiState = MutableStateFlow(RgsServerUiState()) @@ -121,12 +120,25 @@ class RgsServerViewModel @Inject constructor( } private fun isValidURL(data: String): Boolean { - // Allow localhost in development mode - if (Env.isDebug && data.contains("localhost")) { - return true + val normalized = if (!data.startsWith("http://") && !data.startsWith("https://")) { + "https://$data" + } else { + data } - return URL_PATTERN.matches(data) + return try { + val uri = URI(normalized) + val hostname = uri.host ?: return false + + if (Env.isDebug && hostname == "localhost") return true + + if (!HOSTNAME_PATTERN.matches(hostname)) return false + + val path = uri.path.orEmpty() + path.isEmpty() || PATH_PATTERN.matches(path) + } catch (_: Throwable) { + false + } } } diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt index 8756e1a91..7e1d56571 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt @@ -14,11 +14,13 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.repositories.LightningRepo import to.bitkit.test.BaseUnitTest +import kotlinx.coroutines.withTimeout import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) class RgsServerViewModelTest : BaseUnitTest() { @@ -246,4 +248,48 @@ class RgsServerViewModelTest : BaseUnitTest() { cancelAndIgnoreRemainingEvents() } } + + @Test + fun `setRgsUrl does not hang on long urls with special characters`() = test { + sut = createSut() + advanceUntilIdle() + + withTimeout(2.seconds) { + sut.setRgsUrl("https://rapidsync.lightningdevkit/snapshot/" + "a".repeat(100) + "!") + } + + assertFalse(sut.uiState.value.canConnect) + } + + @Test + fun `setRgsUrl does not hang on url that caused ANR`() = test { + sut = createSut() + advanceUntilIdle() + + withTimeout(2.seconds) { + sut.setRgsUrl("https://rapidsync.lightningdevkit/snapshot") + } + + assertTrue(sut.uiState.value.canConnect) + } + + @Test + fun `setRgsUrl accepts valid rgs url with path`() = test { + sut = createSut() + advanceUntilIdle() + + sut.setRgsUrl("https://rgs.blocktank.to/snapshot") + + assertFalse(sut.uiState.value.canConnect) + } + + @Test + fun `setRgsUrl accepts ip address url`() = test { + sut = createSut() + advanceUntilIdle() + + sut.setRgsUrl("https://192.168.1.1:8080/snapshot") + + assertTrue(sut.uiState.value.canConnect) + } } From c05b471dc65587ab2f23ab98f91e5917aebe07dc Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 14:01:23 -0300 Subject: [PATCH 12/17] fix: debounce --- .../settings/advanced/RgsServerViewModel.kt | 25 +++++++++++++------ .../advanced/RgsServerViewModelTest.kt | 8 +++++- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt index 617f6913f..745c7c7a7 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -18,6 +20,7 @@ import to.bitkit.env.Env import to.bitkit.repositories.LightningRepo import java.net.URI import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class RgsServerViewModel @Inject constructor( @@ -32,11 +35,14 @@ class RgsServerViewModel @Inject constructor( RegexOption.IGNORE_CASE, ) private val PATH_PATTERN = Regex("^(/[a-zA-Z\\d_.~%+-]*)*$") + private val VALIDATION_DEBOUNCE = 1.seconds } private val _uiState = MutableStateFlow(RgsServerUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var validationJob: Job? = null + init { observeState() } @@ -57,17 +63,20 @@ class RgsServerViewModel @Inject constructor( } fun setRgsUrl(url: String) { - _uiState.update { - val newState = it.copy(rgsUrl = url.trim()) - computeState(newState) - } + _uiState.update { it.copy(rgsUrl = url.trim()) } + debounceValidation() } fun resetToDefault() { - val defaultUrl = Env.ldkRgsServerUrl ?: "" - _uiState.update { - val newState = it.copy(rgsUrl = defaultUrl) - computeState(newState) + _uiState.update { it.copy(rgsUrl = Env.ldkRgsServerUrl ?: "") } + debounceValidation() + } + + private fun debounceValidation() { + validationJob?.cancel() + validationJob = viewModelScope.launch(bgDispatcher) { + delay(VALIDATION_DEBOUNCE) + _uiState.update { computeState(it) } } } diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt index 7e1d56571..49e085104 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock @@ -14,7 +15,6 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.repositories.LightningRepo import to.bitkit.test.BaseUnitTest -import kotlinx.coroutines.withTimeout import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -64,6 +64,7 @@ class RgsServerViewModelTest : BaseUnitTest() { advanceUntilIdle() sut.setRgsUrl("https://other.server.com/snapshot") + advanceUntilIdle() val state = sut.uiState.value assertEquals("https://other.server.com/snapshot", state.rgsUrl) @@ -226,6 +227,7 @@ class RgsServerViewModelTest : BaseUnitTest() { sut.setRgsUrl("https://custom.server.com/snapshot") sut.resetToDefault() + advanceUntilIdle() val state = sut.uiState.value assertFalse(state.canReset) @@ -239,6 +241,7 @@ class RgsServerViewModelTest : BaseUnitTest() { sut = createSut() advanceUntilIdle() sut.setRgsUrl(newUrl) + advanceUntilIdle() sut.uiState.test { skipItems(1) @@ -256,6 +259,7 @@ class RgsServerViewModelTest : BaseUnitTest() { withTimeout(2.seconds) { sut.setRgsUrl("https://rapidsync.lightningdevkit/snapshot/" + "a".repeat(100) + "!") + advanceUntilIdle() } assertFalse(sut.uiState.value.canConnect) @@ -268,6 +272,7 @@ class RgsServerViewModelTest : BaseUnitTest() { withTimeout(2.seconds) { sut.setRgsUrl("https://rapidsync.lightningdevkit/snapshot") + advanceUntilIdle() } assertTrue(sut.uiState.value.canConnect) @@ -289,6 +294,7 @@ class RgsServerViewModelTest : BaseUnitTest() { advanceUntilIdle() sut.setRgsUrl("https://192.168.1.1:8080/snapshot") + advanceUntilIdle() assertTrue(sut.uiState.value.canConnect) } From 26d7b2ac42fde2154dd60366672fc10ff0499669 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 1 Apr 2026 14:12:09 -0300 Subject: [PATCH 13/17] refactor: replace try/catch with runCatching --- .../to/bitkit/ui/settings/advanced/RgsServerViewModel.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt index 745c7c7a7..a02d678b9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt @@ -135,7 +135,7 @@ class RgsServerViewModel @Inject constructor( data } - return try { + return runCatching { val uri = URI(normalized) val hostname = uri.host ?: return false @@ -145,9 +145,7 @@ class RgsServerViewModel @Inject constructor( val path = uri.path.orEmpty() path.isEmpty() || PATH_PATTERN.matches(path) - } catch (_: Throwable) { - false - } + }.getOrDefault(false) } } From a9d85487c1a4d3dcb2bb01ce18779c13183a697b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 06:47:00 -0300 Subject: [PATCH 14/17] doc: changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 358ebc984..349d35720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880 + [Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.1.2...HEAD From bd83ffbb81071dbb5741b485868220e9a45e5993 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 06:49:07 -0300 Subject: [PATCH 15/17] doc: changelog entry --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 349d35720..333a54e01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,5 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880 +- Fix crash when returning app to foreground on Receive screen #875 +- Show loading state on Spending tab when node is not running #875 + +### Added +- Lightning Connections empty state with onboarding screen #857 +- Unified PIN management screen (enable/disable/change in one place) #857 +- Support entry in drawer menu #857 +- Brand endorsement row (Synonym + Tether logos) in Support screen #857 +- Reset Widgets and Reset Suggestions Cards options in Widgets settings #857 +- Diagonal orange footer background in Support screen #857 +- Mnemonic warning text transitions on reveal #857 + +### Changed +- Settings redesigned with tabbed navigation (General/Security/Advanced) with swipe support #857 +- Icons added to all settings rows for faster scanning #857 +- Selected values displayed on right side of settings rows #857 +- Support screen redesigned with About content merged in #857 +- Backup and Reset moved into Security tab #857 +- PIN flow reworked into sheet-based enable/disable/change #857 +- Social links simplified with Brand tint #857 +- Mnemonic warning updated with new copy and red styling #857 +- Security title changed from "Security and Privacy" to "Security" #857 +- Language model updated to use string resources for "System Settings" #857 + +### Removed +- About screen (content merged into Support) #857 +- Standalone General, Security, and Advanced settings screens (merged into tabs) #857 [Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.1.2...HEAD From b57e9fa235188830a1ca1dbaffbc897ca3859f98 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 08:45:04 -0300 Subject: [PATCH 16/17] chore: use log at call site --- .../main/java/to/bitkit/repositories/LightningRepo.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c1f243586..f5d2182b5 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -621,7 +621,10 @@ class LightningRepo @Inject constructor( suspend fun restartWithRgsServer(newRgsUrl: String): Result = withContext(bgDispatcher) { Logger.info("Changing ldk-node RGS server to: '$newRgsUrl'", context = TAG) - validateRgsUrl(newRgsUrl).onFailure { return@withContext Result.failure(it) } + validateRgsUrl(newRgsUrl).onFailure { + Logger.warn("RGS server unreachable at '$newRgsUrl'", it, context = TAG) + return@withContext Result.failure(it) + } waitForNodeToStop().onFailure { return@withContext Result.failure(it) } stop().onFailure { @@ -647,9 +650,7 @@ class LightningRepo @Inject constructor( private suspend fun validateRgsUrl(url: String): Result = withContext(bgDispatcher) { val initialTimestamp = 0 val testUrl = "${url.trimEnd('/')}/$initialTimestamp" - urlValidator.validate(testUrl).onFailure { - Logger.warn("RGS server unreachable at '$testUrl'", it, context = TAG) - } + urlValidator.validate(testUrl) } suspend fun getBalanceForAddressType(addressType: AddressType): Result = withContext(bgDispatcher) { From 06c28357f0e399fe15ddac1834fdb6ccf14febbe Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 2 Apr 2026 08:50:50 -0300 Subject: [PATCH 17/17] fix: url normalization --- .../ui/settings/advanced/RgsServerViewModel.kt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt index a02d678b9..d58b78194 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt @@ -91,7 +91,7 @@ class RgsServerViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } viewModelScope.launch(bgDispatcher) { - lightningRepo.restartWithRgsServer(url) + lightningRepo.restartWithRgsServer(normalizeUrl(url)) .onSuccess { _uiState.update { val newState = it.copy( @@ -128,15 +128,12 @@ class RgsServerViewModel @Inject constructor( ) } - private fun isValidURL(data: String): Boolean { - val normalized = if (!data.startsWith("http://") && !data.startsWith("https://")) { - "https://$data" - } else { - data - } + private fun normalizeUrl(url: String): String = + if (!url.startsWith("http://") && !url.startsWith("https://")) "https://$url" else url + private fun isValidURL(data: String): Boolean { return runCatching { - val uri = URI(normalized) + val uri = URI(normalizeUrl(data)) val hostname = uri.host ?: return false if (Env.isDebug && hostname == "localhost") return true