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 04bde8c44..c1f243586 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() @@ -619,6 +621,8 @@ 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) } + waitForNodeToStop().onFailure { return@withContext Result.failure(it) } stop().onFailure { Logger.error("Failed to stop node during RGS server change", it, context = TAG) @@ -640,6 +644,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/ui/settings/advanced/RgsServerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt index 28effc038..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 @@ -1,9 +1,12 @@ 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 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 @@ -15,7 +18,9 @@ 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 +import kotlin.time.Duration.Companion.seconds @HiltViewModel class RgsServerViewModel @Inject constructor( @@ -24,9 +29,20 @@ class RgsServerViewModel @Inject constructor( private val lightningRepo: LightningRepo, ) : ViewModel() { + companion object { + 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 VALIDATION_DEBOUNCE = 1.seconds + } + private val _uiState = MutableStateFlow(RgsServerUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var validationJob: Job? = null + init { observeState() } @@ -47,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) } } } @@ -110,23 +129,27 @@ class RgsServerViewModel @Inject constructor( } private fun isValidURL(data: String): Boolean { - val 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 + val normalized = if (!data.startsWith("http://") && !data.startsWith("https://")) { + "https://$data" + } else { + data } - return pattern.matches(data) + return runCatching { + 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) + }.getOrDefault(false) } } +@Stable data class RgsServerUiState( val connectedRgsUrl: String? = null, val rgsUrl: String = "", 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/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 diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 6666d0be8..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, ) } @@ -498,6 +501,78 @@ 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 `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() 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..49e085104 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/RgsServerViewModelTest.kt @@ -0,0 +1,301 @@ +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 kotlinx.coroutines.withTimeout +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 +import kotlin.time.Duration.Companion.seconds + +@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") + advanceUntilIdle() + + 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() + advanceUntilIdle() + + 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) + advanceUntilIdle() + + sut.uiState.test { + skipItems(1) + sut.onClickConnect() + val loadingState = awaitItem() + assertTrue(loadingState.isLoading) + 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) + "!") + advanceUntilIdle() + } + + 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") + advanceUntilIdle() + } + + 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") + advanceUntilIdle() + + assertTrue(sut.uiState.value.canConnect) + } +}