diff --git a/.github/img/detekt.png b/.github/img/detekt.png new file mode 100644 index 000000000..792abe76e Binary files /dev/null and b/.github/img/detekt.png differ diff --git a/AGENTS.md b/AGENTS.md index e70f3c16a..a8a9f9ffe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,7 +195,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS be mindful of thread safety when working with mutable lists & state - ALWAYS split screen composables into parent accepting viewmodel + inner private child accepting state and callbacks `Content()` - ALWAYS name lambda parameters in a composable function using present tense, NEVER use past tense -- ALWAYS list 3 suggested commit messages after implementation work for the entire set of uncommitted changes +- ALWAYS list 3 suggested commit messages after implementation work for ALL uncommitted changes - NEVER use `wheneverBlocking` in unit test expression body functions wrapped in a `= test {}` lambda - ALWAYS wrap unit tests `setUp` methods mocking suspending calls with `runBlocking`, e.g `setUp() = runBlocking { }` - ALWAYS add business logic to Repository layer via methods returning `Result` and use it in ViewModels diff --git a/README.md b/README.md index e33fad3a6..089b1e651 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ This repository contains a **new native Android app** which is **not ready for p #### 1. Firebase Configuration -Download `google-services.json` from the Firebase Console for each build flavor: -- **Dev/Testnet**: Place in `app/` (default location) -- **Mainnet**: Place in `app/src/mainnet/google-services.json` +Download `google-services.json` from the Firebase Console for each of the following build flavor groups,: +- dev/tnet/mainnetDebug: Place in `app/google-services.json` +- mainnetRelease: Place in `app/src/mainnetRelease/google-services.json` > **Note**: Each flavor requires its own Firebase project configuration. The mainnet flavor will fail to build without its dedicated `google-services.json` file. @@ -57,9 +57,16 @@ See also: This project uses detekt with default ktlint and compose-rules for android code linting. -Recommended Android Studio plugins: -- EditorConfig -- Detekt +### IDE Plugins +The following IDE plugins are recommended for development with Android Studio or IntelliJ IDEA: +- [Compose Color Preview](https://plugins.jetbrains.com/plugin/21298-compose-color-preview) +- [Compose Stability Analyzer](https://plugins.jetbrains.com/plugin/28767-compose-stability-analyzer) +- [detekt](https://plugins.jetbrains.com/plugin/10761-detekt) +
+ See screenshot on how to setup the Detekt plugin after installation. + + ![Detekt plugin setup][img_detekt] +
**Commands** ```sh @@ -112,16 +119,32 @@ The build config supports building 3 different apps for the 3 bitcoin networks ( - `mainnet` flavour = mainnet - `tnet` flavour = testnet -### Build for Mainnet +### Build for Internal Testing -To build the mainnet flavor: +**Prerequisites** +Setup the signing config: +- Add the keystore file to root dir (i.e. `internal.keystore`) +- Setup `keystore.properties` file in root dir (`cp keystore.properties.template keystore.properties`) + +**Routine** +Increment `versionCode` and `versionName` in `app/build.gradle.kts`, then run: ```sh -./gradlew assembleMainnetDebug # debug build -./gradlew assembleMainnetRelease # release build (requires signing config) +./gradlew assembleDevRelease +#./gradlew assembleMainnetDebug # mainnet debug build +# ./gradlew assembleRelease # for all flavors ``` -> **Important**: Ensure `app/src/mainnet/google-services.json` exists before building. See [Firebase Configuration](#1-firebase-configuration). +APK is generated in `app/build/outputs/apk/_flavor_/release`. (`_flavor_` can be any of 'dev', 'mainnet', 'tnet'). +Example for dev: `app/build/outputs/apk/dev/release` + +### Build for Release + +To build the mainnet flavor for release run: + +```sh +./gradlew assembleMainnetRelease +``` ### Build for E2E Testing @@ -152,24 +175,6 @@ By default, geoblocking checks via API are enabled. To disable at build time, us GEO=false E2E=true ./gradlew assembleDevRelease ``` -### Build for Release - -**Prerequisites** -Setup the signing config: -- Add the keystore file to root dir (i.e. `release.keystore`) -- Setup `keystore.properties` file in root dir (`cp keystore.properties.template keystore.properties`) - -**Routine** - -Increment `versionCode` and `versionName` in `app/build.gradle.kts`, then run: -```sh -./gradlew assembleDevRelease -# ./gradlew assembleRelease # for all flavors -``` - -APK is generated in `app/build/outputs/apk/_flavor_/release`. (`_flavor_` can be any of 'dev', 'mainnet', 'tnet'). -Example for dev: `app/build/outputs/apk/dev/release` - ## Contributing ### AI Code Review with Claude @@ -223,3 +228,5 @@ Destructive operations like `rm -rf`, `git commit`, and `git push` still require This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for more details. + +[img_detekt]: .github/img/detekt.png diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4450eab5b..19caafb39 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose.stability.analyzer) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) @@ -219,6 +220,7 @@ dependencies { androidTestImplementation(platform(libs.compose.bom)) implementation(libs.compose.material3) implementation(libs.compose.material.icons.extended) + implementation(libs.compose.runtime.tracing) implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.tooling.preview) diff --git a/app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt b/app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt deleted file mode 100644 index 51dfbab12..000000000 --- a/app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package to.bitkit.services - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.runBlocking -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import to.bitkit.data.keychain.Keychain -import to.bitkit.env.Env -import to.bitkit.ext.readAsset -import javax.inject.Inject -import kotlin.test.assertTrue - -@HiltAndroidTest -class LdkMigrationTest { - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @Inject - lateinit var keychain: Keychain - - @Inject - lateinit var lightningService: LightningService - - private val mnemonic = "pool curve feature leader elite dilemma exile toast smile couch crane public" - - private val testContext by lazy { InstrumentationRegistry.getInstrumentation().context } - private val appContext = ApplicationProvider.getApplicationContext() - - @Before - fun init() { - hiltRule.inject() - Env.initAppStoragePath(appContext.filesDir.absolutePath) - runBlocking { keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) } - } - - @Test - fun nodeShouldStartFromBackupAfterMigration() = runBlocking { -// TODO Fix or remove check on channel size -// val seed = testContext.readAsset("ldk-backup/seed.bin") -// val manager = testContext.readAsset("ldk-backup/manager.bin") -// val monitor = testContext.readAsset("ldk-backup/monitor.bin") -// -// MigrationService(appContext).migrate(seed, manager, listOf(monitor)) -// -// with(lightningService) { -// setup(walletIndex = 0) -// runBlocking { start() } -// -// assertTrue { nodeId == "02cd08b7b375e4263849121f9f0ffb2732a0b88d0fb74487575ac539b374f45a55" } -// assertTrue { channels?.isNotEmpty() == true } -// -// runBlocking { stop() } -// } - } -} diff --git a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt index d698f9a1a..e46347f45 100644 --- a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt @@ -138,9 +138,10 @@ class TxBumpingTests { assertEquals(depositAmount, totalBalance, "Balance should equal deposit amount") // Send a transaction with a low fee rate + @Suppress("SpellCheckingInspection") val destinationAddress = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" val sendAmount = 10_000uL // Send 10,000 sats - val lowFeeRate = 1u // 1 sat/vbyte (very low) + val lowFeeRate = 1uL // 1 sat/vbyte (very low) println("Sending $sendAmount sats to $destinationAddress with low fee rate of $lowFeeRate sat/vbyte") val originalTxId = lightningService.send( @@ -160,7 +161,7 @@ class TxBumpingTests { println("Wait completed") // Bump the fee using RBF with a higher fee rate - val highFeeRate = 10u // 10 sat/vbyte (much higher) + val highFeeRate = 10uL // 10 sat/vbyte (much higher) println("Bumping fee for transaction $originalTxId to $highFeeRate sat/vbyte using RBF") val replacementTxId = lightningService.bumpFeeByRbf( @@ -261,7 +262,7 @@ class TxBumpingTests { // Now use CPFP to spend from the incoming transaction with high fees // This demonstrates using CPFP to quickly move received funds - val highFeeRate = 20u // 20 sat/vbyte (very high for fast confirmation) + val highFeeRate = 20uL // 20 sat/vbyte (very high for fast confirmation) println("Using CPFP to quickly spend from incoming transaction $stuckIncomingTxId with $highFeeRate sat/vbyte") // Generate a destination address for the CPFP transaction (where we'll send the funds) @@ -272,7 +273,7 @@ class TxBumpingTests { val childTxId = lightningService.accelerateByCpfp( txid = stuckIncomingTxId, satsPerVByte = highFeeRate, - destinationAddress = cpfpDestinationAddress, + toAddress = cpfpDestinationAddress, ) assertFalse(childTxId.isEmpty(), "CPFP child transaction ID should not be empty") diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt index cf0cb569f..2bde503cf 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt @@ -7,9 +7,6 @@ import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.PrimaryDisplay -import to.bitkit.repositories.CurrencyState -import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState import to.bitkit.viewmodels.previewAmountInputViewModel @@ -19,22 +16,20 @@ class SendAmountContentTest { @get:Rule val composeTestRule = createComposeRule() - private val testUiState = SendUiState( + private val uiState = SendUiState( payMethod = SendMethod.LIGHTNING, amount = 100u, isUnified = true ) - private val testWalletState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Running - ) + private val nodeLifecycleState = NodeLifecycleState.Running @Test fun whenScreenLoaded_shouldShowAllComponents() { composeTestRule.setContent { SendAmountContent( - walletUiState = testWalletState, - uiState = testUiState, + nodeLifecycleState = nodeLifecycleState, + uiState = uiState, amountInputViewModel = previewAmountInputViewModel(), ) } @@ -51,10 +46,8 @@ class SendAmountContentTest { fun whenNodeNotRunning_shouldShowSyncView() { composeTestRule.setContent { SendAmountContent( - walletUiState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Initializing - ), - uiState = testUiState, + nodeLifecycleState = nodeLifecycleState, + uiState = uiState, amountInputViewModel = previewAmountInputViewModel(), ) } @@ -68,15 +61,14 @@ class SendAmountContentTest { var eventTriggered = false composeTestRule.setContent { SendAmountContent( - walletUiState = testWalletState, - uiState = testUiState, + nodeLifecycleState = nodeLifecycleState, + uiState = uiState, amountInputViewModel = previewAmountInputViewModel(), onClickPayMethod = { eventTriggered = true } ) } - composeTestRule.onNodeWithTag("AssetButton-switch") - .performClick() + composeTestRule.onNodeWithTag("AssetButton-switch").performClick() assert(eventTriggered) } @@ -86,8 +78,8 @@ class SendAmountContentTest { var eventTriggered = false composeTestRule.setContent { SendAmountContent( - walletUiState = testWalletState, - uiState = testUiState, + nodeLifecycleState = nodeLifecycleState, + uiState = uiState, amountInputViewModel = previewAmountInputViewModel(), onContinue = { eventTriggered = true } ) @@ -103,8 +95,8 @@ class SendAmountContentTest { fun whenAmountInvalid_continueButtonShouldBeDisabled() { composeTestRule.setContent { SendAmountContent( - walletUiState = testWalletState, - uiState = testUiState.copy(amount = 0u), + nodeLifecycleState = nodeLifecycleState, + uiState = uiState.copy(amount = 0u), amountInputViewModel = previewAmountInputViewModel(), ) } diff --git a/app/src/main/java/to/bitkit/data/AppDb.kt b/app/src/main/java/to/bitkit/data/AppDb.kt index 1763a3818..d67f22094 100644 --- a/app/src/main/java/to/bitkit/data/AppDb.kt +++ b/app/src/main/java/to/bitkit/data/AppDb.kt @@ -23,6 +23,7 @@ import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.ConfigEntity import to.bitkit.data.entities.TransferEntity import to.bitkit.data.typeConverters.StringListConverter +import to.bitkit.env.Env @Database( entities = [ @@ -53,7 +54,6 @@ abstract class AppDb : RoomDatabase() { private fun buildDatabase(context: Context): AppDb { return Room.databaseBuilder(context, AppDb::class.java, DB_NAME) .setJournalMode(JournalMode.TRUNCATE) - .fallbackToDestructiveMigration() // TODO remove in prod .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) @@ -65,6 +65,9 @@ abstract class AppDb : RoomDatabase() { } } }) + .apply { + if (Env.isDebug) fallbackToDestructiveMigration(dropAllTables = true) + } .build() } } diff --git a/app/src/main/java/to/bitkit/data/ChatwootHttpClient.kt b/app/src/main/java/to/bitkit/data/ChatwootHttpClient.kt index e4d505a27..eed4eed8d 100644 --- a/app/src/main/java/to/bitkit/data/ChatwootHttpClient.kt +++ b/app/src/main/java/to/bitkit/data/ChatwootHttpClient.kt @@ -60,5 +60,5 @@ class ChatwootHttpClient @Inject constructor( } sealed class ChatwootHttpError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : ChatwootHttpError(message) + class InvalidResponse(override val message: String) : ChatwootHttpError(message) } diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index f5a6e0a0b..b0b95c95b 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -40,7 +40,7 @@ class VssBackupClient @Inject constructor( Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'") if (lnurlAuthServerUrl.isNotEmpty()) { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw ServiceError.MnemonicNotFound + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) vssNewClientWithLnurlAuth( diff --git a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt index a682ed9bc..5f88f13a9 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt @@ -19,7 +19,8 @@ class VssStoreIdProvider @Inject constructor( synchronized(this) { cacheMap[walletIndex]?.let { return it } - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val storeId = vssDeriveStoreId( diff --git a/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt b/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt index d0695acc8..f2ba871a2 100644 --- a/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/BlocksService.kt @@ -101,5 +101,5 @@ class BlocksService @Inject constructor( * Block-specific error types */ sealed class BlockError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : BlockError(message) + class InvalidResponse(override val message: String) : BlockError(message) } diff --git a/app/src/main/java/to/bitkit/data/widgets/NewsService.kt b/app/src/main/java/to/bitkit/data/widgets/NewsService.kt index a04d8f125..108381eb7 100644 --- a/app/src/main/java/to/bitkit/data/widgets/NewsService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/NewsService.kt @@ -52,5 +52,5 @@ class NewsService @Inject constructor( * News-specific error types */ sealed class NewsError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : NewsError(message) + class InvalidResponse(override val message: String) : NewsError(message) } diff --git a/app/src/main/java/to/bitkit/data/widgets/PriceService.kt b/app/src/main/java/to/bitkit/data/widgets/PriceService.kt index 6d11929b4..ced46186c 100644 --- a/app/src/main/java/to/bitkit/data/widgets/PriceService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/PriceService.kt @@ -180,6 +180,6 @@ class PriceService @Inject constructor( * Price-specific error types */ sealed class PriceError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : PriceError(message) - data class NetworkError(override val message: String) : PriceError(message) + class InvalidResponse(override val message: String) : PriceError(message) + class NetworkError(override val message: String) : PriceError(message) } diff --git a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt index 4c5f8b3e1..d3e516b51 100644 --- a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt @@ -10,6 +10,7 @@ import to.bitkit.data.dto.FeeCondition import to.bitkit.data.dto.FeeEstimates import to.bitkit.data.dto.WeatherDTO import to.bitkit.env.Env +import to.bitkit.ext.nowMs import to.bitkit.models.WidgetType import to.bitkit.repositories.CurrencyRepo import to.bitkit.utils.AppError @@ -18,26 +19,42 @@ import java.math.BigDecimal import javax.inject.Inject import javax.inject.Singleton import kotlin.math.floor +import kotlin.time.Clock +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) @Singleton class WeatherService @Inject constructor( private val client: HttpClient, private val currencyRepo: CurrencyRepo, + private val clock: Clock, ) : WidgetService { override val widgetType = WidgetType.WEATHER override val refreshInterval = 8.minutes - private companion object { + private var cachedFeeEstimates: FeeEstimates? = null + private var feeEstimatesTimestamp: Long = 0L + private var cachedHistoricalData: List? = null + private var historicalDataTimestamp: Long = 0L + + companion object { private const val TAG = "WeatherService" + + @Suppress("SpellCheckingInspection") private const val AVERAGE_SEGWIT_VBYTES_SIZE = 140 private const val USD_GOOD_THRESHOLD = 1.0 // $1 USD threshold for good condition private const val PERCENTILE_LOW = 0.33 private const val PERCENTILE_HIGH = 0.66 private const val USD_CURRENCY = "USD" + private val TTL_FEE_ESTIMATES = 2.minutes + private val TTL_HISTORICAL_DATA = 30.minutes } + private fun isCacheValid(timestamp: Long, ttl: Duration) = clock.nowMs() - timestamp < ttl.inWholeMilliseconds + override suspend fun fetchData(): Result = runCatching { // Fetch fee estimates and historical data in parallel val feeEstimates = getFeeEstimates() @@ -56,34 +73,42 @@ class WeatherService @Inject constructor( nextBlockFee = feeEstimates.fast ) }.onFailure { - Logger.warn(e = it, msg = "Failed to fetch weather data", context = TAG) + Logger.warn("Failed to fetch weather data", e = it, context = TAG) } - private suspend fun getFeeEstimates(): FeeEstimates { // TODO CACHE + private suspend fun getFeeEstimates(): FeeEstimates { + cachedFeeEstimates?.takeIf { isCacheValid(feeEstimatesTimestamp, TTL_FEE_ESTIMATES) }?.let { return it } val response: HttpResponse = client.get("${Env.mempoolBaseUrl}/v1/fees/recommended") return when (response.status.isSuccess()) { - true -> response.body() + true -> response.body().also { + cachedFeeEstimates = it + feeEstimatesTimestamp = clock.nowMs() + } + else -> throw WeatherError.InvalidResponse("Failed to fetch fee estimates: ${response.status.description}") } } - private suspend fun getHistoricalFeeData(): List { // TODO CACHE + private suspend fun getHistoricalFeeData(): List { + cachedHistoricalData?.takeIf { isCacheValid(historicalDataTimestamp, TTL_HISTORICAL_DATA) }?.let { return it } val response: HttpResponse = client.get("${Env.mempoolBaseUrl}/v1/mining/blocks/fee-rates/3m") return when (response.status.isSuccess()) { - true -> response.body>() + true -> response.body>().also { + cachedHistoricalData = it + historicalDataTimestamp = clock.nowMs() + } + else -> throw WeatherError.InvalidResponse( "Failed to fetch historical fee data: ${response.status.description}" ) } } - private suspend fun calculateCondition( + private fun calculateCondition( currentFeeRate: Double, - history: List + history: List, ): FeeCondition { - if (history.isEmpty()) { - return FeeCondition.AVERAGE - } + if (history.isEmpty()) return FeeCondition.AVERAGE // Extract median fees from historical data and sort val historicalFees = history.map { it.avgFee50 }.sorted() @@ -94,11 +119,10 @@ class WeatherService @Inject constructor( // Check USD threshold first val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE - val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE + val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() + ?: return FeeCondition.AVERAGE - if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) { - return FeeCondition.GOOD - } + if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) return FeeCondition.GOOD // Determine condition based on percentiles return when { @@ -108,16 +132,12 @@ class WeatherService @Inject constructor( } } - private suspend fun formatFeeForDisplay(satoshis: Int): String { + private fun formatFeeForDisplay(satoshis: Int): String { val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull() return usdValue?.formatted.orEmpty() } } -/** - * Weather-specific error types - */ sealed class WeatherError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : WeatherError(message) - data class ConversionError(override val message: String) : WeatherError(message) + class InvalidResponse(override val message: String) : WeatherError(message) } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 30e815741..31f1574cd 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -6,21 +6,21 @@ import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.BuildConfig import to.bitkit.ext.ensureDir -import to.bitkit.ext.parse +import to.bitkit.ext.of import to.bitkit.models.BlocktankNotificationType import to.bitkit.utils.Logger import java.io.File import kotlin.io.path.Path -@Suppress("ConstPropertyName", "KotlinConstantConditions") +@Suppress("ConstPropertyName", "KotlinConstantConditions", "SimplifyBooleanWithConstants") internal object Env { val isDebug = BuildConfig.DEBUG const val isE2eTest = BuildConfig.E2E const val isGeoblockingEnabled = BuildConfig.GEO - private val e2eBackend = BuildConfig.E2E_BACKEND.lowercase() + val e2eBackend = BuildConfig.E2E_BACKEND.lowercase() val network = Network.valueOf(BuildConfig.NETWORK) val locales = BuildConfig.LOCALES.split(",") - val walletSyncIntervalSecs = 10_uL // TODO review + val walletSyncIntervalSecs = 10_uL val platform = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" const val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" @@ -28,25 +28,18 @@ internal object Env { val trustedLnPeers get() = when (network) { - Network.BITCOIN -> listOf(Peers.mainnetLnd1, Peers.mainnetLnd3, Peers.mainnetLnd4) - Network.REGTEST -> listOf(Peers.staging) - Network.TESTNET -> listOf(Peers.staging) - else -> emptyList() + Network.BITCOIN -> listOf(Peers.lnd1, Peers.lnd3, Peers.lnd4) + Network.REGTEST -> listOf(Peers.stag) + Network.TESTNET -> listOf(Peers.stag) + else -> listOf() } - const val fxRateRefreshInterval: Long = 2 * 60 * 1000 // 2 minutes in milliseconds - const val fxRateStaleThreshold: Long = 10 * 60 * 1000 // 10 minutes in milliseconds + const val fxRateRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis + const val fxRateStaleThreshold = 10 * 60 * 1000L // 10 minutes in millis + const val lspOrdersRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis - const val blocktankOrderRefreshInterval: Long = 2 * 60 * 1000 // 2 minutes in milliseconds - - val pushNotificationFeatures = listOf( - BlocktankNotificationType.incomingHtlc, - BlocktankNotificationType.mutualClose, - BlocktankNotificationType.orderPaymentConfirmed, - BlocktankNotificationType.cjitPaymentArrived, - BlocktankNotificationType.wakeToTimeout, - ) - const val DERIVATION_NAME = "bitkit-notifications" + val pushNotificationFeatures = BlocktankNotificationType.entries + const val derivationName = "bitkit-notifications" const val FILE_PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.fileprovider" const val SUPPORT_EMAIL = "support@synonym.to" @@ -54,57 +47,16 @@ internal object Env { const val PIN_LENGTH = 4 const val PIN_ATTEMPTS = 8 - // region File Paths - - private lateinit var appStoragePath: String - - fun initAppStoragePath(path: String) { - require(path.isNotBlank()) { "App storage path cannot be empty." } - appStoragePath = path - Logger.info("App storage path: $path") - } - - val logDir: File - get() { - require(::appStoragePath.isInitialized) - return File(appStoragePath).resolve("logs").ensureDir() - } - - fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk") - - fun bitkitCoreStoragePath(walletIndex: Int): String { - return storagePathOf(walletIndex, network.name.lowercase(), "core") - } - - /** - * Generates the storage path for a specified wallet index, network, and directory. - * - * Output format: - * - * `appStoragePath/network/walletN/dir` - */ - private fun storagePathOf(walletIndex: Int, network: String, dir: String): String { - require(::appStoragePath.isInitialized) { "App storage path should be 'context.filesDir.absolutePath'." } - val path = Path(appStoragePath, network, "wallet$walletIndex", dir) - .toFile() - .ensureDir() - .path - Logger.debug("Using ${dir.uppercase()} storage path: $path") - return path - } - - // endregion - - // region Server URLs + // region urls val electrumServerUrl: String get() { - if (isE2eTest && e2eBackend == "local") return ElectrumServers.E2E + val isE2eLocal = isE2eTest && e2eBackend == "local" return when (network) { - Network.REGTEST -> ElectrumServers.REGTEST + Network.BITCOIN -> ElectrumServers.MAINNET.FULCRUM + Network.REGTEST -> if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG Network.TESTNET -> ElectrumServers.TESTNET - Network.BITCOIN -> ElectrumServers.BITCOIN - else -> TODO("${network.name} network not implemented") + else -> throw Error("${network.name} network not implemented") } } @@ -201,11 +153,52 @@ internal object Env { } // endregion + + // region paths + + private lateinit var appStoragePath: String + + fun initAppStoragePath(path: String) { + require(path.isNotBlank()) { "App storage path cannot be empty." } + appStoragePath = path + Logger.info("App storage path: $path") + } + + val logDir: File + get() { + require(::appStoragePath.isInitialized) + return File(appStoragePath).resolve("logs").ensureDir() + } + + fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk") + + fun bitkitCoreStoragePath(walletIndex: Int): String { + return storagePathOf(walletIndex, network.name.lowercase(), "core") + } + + /** + * Generates the storage path for a specified wallet index, network, and directory. + * + * Output format: + * + * `appStoragePath/network/walletN/dir` + */ + private fun storagePathOf(walletIndex: Int, network: String, dir: String): String { + require(::appStoragePath.isInitialized) { "App storage path should be 'context.filesDir.absolutePath'." } + val path = Path(appStoragePath, network, "wallet$walletIndex", dir) + .toFile() + .ensureDir() + .path + Logger.debug("Using ${dir.uppercase()} storage path: $path") + return path + } + + // endregion } @Suppress("ConstPropertyName") -object TransactionDefaults { - /** Total recommended tx base fee in sats */ +object Defaults { + /** Recommended transaction base fee in sats */ const val recommendedBaseFee = 256u /** @@ -216,19 +209,21 @@ object TransactionDefaults { } object Peers { - val staging = - PeerDetails.parse("028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc@34.65.86.104:9400") - val mainnetLnd1 = - PeerDetails.parse("039b8b4dd1d88c2c5db374290cda397a8f5d79f312d6ea5d5bfdfc7c6ff363eae3@34.65.111.104:9735") - val mainnetLnd3 = - PeerDetails.parse("03816141f1dce7782ec32b66a300783b1d436b19777e7c686ed00115bd4b88ff4b@34.65.191.64:9735") - val mainnetLnd4 = - PeerDetails.parse("02a371038863605300d0b3fc9de0cf5ccb57728b7f8906535709a831b16e311187@34.65.186.40:9735") + val stag = PeerDetails.of("028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc@34.65.86.104:9400") + val lnd1 = PeerDetails.of("039b8b4dd1d88c2c5db374290cda397a8f5d79f312d6ea5d5bfdfc7c6ff363eae3@34.65.111.104:9735") + val lnd3 = PeerDetails.of("03816141f1dce7782ec32b66a300783b1d436b19777e7c686ed00115bd4b88ff4b@34.65.191.64:9735") + val lnd4 = PeerDetails.of("02a371038863605300d0b3fc9de0cf5ccb57728b7f8906535709a831b16e311187@34.65.186.40:9735") } private object ElectrumServers { - const val BITCOIN = "ssl://fulcrum.bitkit.blocktank.to:8900" + object MAINNET { + const val FULCRUM = "ssl://fulcrum.bitkit.blocktank.to:8900" + } + + object REGTEST { + const val STAG = "tcp://34.65.252.32:18483" + const val LOCAL = "tcp://127.0.0.1:60001" + } + const val TESTNET = "ssl://electrum.blockstream.info:60002" - const val REGTEST = "tcp://34.65.252.32:18483" - const val E2E = "tcp://127.0.0.1:60001" } diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index bea254c2b..757024341 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -36,6 +36,9 @@ import kotlin.time.Instant as KInstant @OptIn(ExperimentalTime::class) fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMilliseconds() +@OptIn(ExperimentalTime::class) +fun Clock.nowMs(): Long = now().toEpochMilliseconds() + fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) fun Instant.formatted(pattern: String = DatePattern.DATE_TIME): String { @@ -108,7 +111,6 @@ fun Long.toRelativeTimeString( fun getDaysInMonth(month: LocalDate): List { val firstDayOfMonth = LocalDate(month.year, month.month, Constants.FIRST_DAY_OF_MONTH) - // FIXME fix month.number val daysInMonth = month.month.toJavaMonth().length(isLeapYear(month.year)) // Get the day of week for the first day (1 = Monday, 7 = Sunday) diff --git a/app/src/main/java/to/bitkit/ext/PeerDetails.kt b/app/src/main/java/to/bitkit/ext/PeerDetails.kt index cb419a395..6ed4e5543 100644 --- a/app/src/main/java/to/bitkit/ext/PeerDetails.kt +++ b/app/src/main/java/to/bitkit/ext/PeerDetails.kt @@ -9,7 +9,8 @@ val PeerDetails.port get() = address.substringAfter(":") val PeerDetails.uri get() = "$nodeId@$address" -fun PeerDetails.Companion.parse(uri: String): PeerDetails { +/*** Creates a [PeerDetails] object from a URI string.*/ +fun PeerDetails.Companion.of(uri: String): PeerDetails { val parts = uri.split("@") require(parts.size == 2) { "Invalid uri format, expected: '@:', got: '$uri'" } @@ -30,7 +31,8 @@ fun PeerDetails.Companion.parse(uri: String): PeerDetails { ) } -fun PeerDetails.Companion.from(nodeId: String, host: String, port: String) = PeerDetails( +/*** Creates a [PeerDetails] object from a node ID, host, and port.*/ +fun PeerDetails.Companion.of(nodeId: String, host: String, port: String) = PeerDetails( nodeId = nodeId, address = "$host:$port", isConnected = false, diff --git a/app/src/main/java/to/bitkit/ext/WebView.kt b/app/src/main/java/to/bitkit/ext/WebView.kt index 806c637dc..5dae2f839 100644 --- a/app/src/main/java/to/bitkit/ext/WebView.kt +++ b/app/src/main/java/to/bitkit/ext/WebView.kt @@ -4,17 +4,16 @@ import android.annotation.SuppressLint import android.webkit.WebSettings import android.webkit.WebView -/** - * Configures WebView settings for basic web content display - */ -@SuppressLint("SetJavaScriptEnabled") fun WebView.configureForBasicWebContent() { settings.apply { + @SuppressLint("SetJavaScriptEnabled") javaScriptEnabled = true domStorageEnabled = true allowContentAccess = true allowFileAccess = false + @Suppress("DEPRECATION") allowUniversalAccessFromFileURLs = false + @Suppress("DEPRECATION") allowFileAccessFromFileURLs = false // Disable mixed content for security mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index a9ab62d45..b37a7e655 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -14,7 +14,7 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonObject import to.bitkit.data.keychain.Keychain import to.bitkit.di.json -import to.bitkit.env.Env.DERIVATION_NAME +import to.bitkit.env.Env.derivationName import to.bitkit.ext.fromBase64 import to.bitkit.ext.fromHex import to.bitkit.models.BlocktankNotificationType @@ -105,7 +105,7 @@ class FcmService : FirebaseMessagingService() { return } val password = - runCatching { crypto.generateSharedSecret(privateKey, response.publicKey, DERIVATION_NAME) }.getOrElse { + runCatching { crypto.generateSharedSecret(privateKey, response.publicKey, derivationName) }.getOrElse { Logger.error("Failed to generate shared secret", it, context = TAG) return } diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 77aedd670..93957ea27 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -96,7 +96,7 @@ class WakeNodeWorker @AssistedInject constructor( Logger.error("Failed to open channel", e, context = TAG) bestAttemptContent = NotificationDetails( title = appContext.getString(R.string.notification_channel_open_failed_title), - body = e.message ?: appContext.getString(R.string.notification_unknown_error), + body = e.message ?: appContext.getString(R.string.common__error_desc), ) deliver() } @@ -106,7 +106,7 @@ class WakeNodeWorker @AssistedInject constructor( withTimeout(timeout) { deliverSignal.await() } // Stops node on timeout & avoids notification replay by OS return Result.success() } catch (e: Exception) { - val reason = e.message ?: appContext.getString(R.string.notification_unknown_error) + val reason = e.message ?: appContext.getString(R.string.common__error_desc) bestAttemptContent = NotificationDetails( title = appContext.getString(R.string.notification_lightning_error_title), diff --git a/app/src/main/java/to/bitkit/models/FeeRate.kt b/app/src/main/java/to/bitkit/models/FeeRate.kt index 4c0b6328d..d4a8785de 100644 --- a/app/src/main/java/to/bitkit/models/FeeRate.kt +++ b/app/src/main/java/to/bitkit/models/FeeRate.kt @@ -1,10 +1,9 @@ package to.bitkit.models +import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import com.synonym.bitkitcore.FeeRates import to.bitkit.R import to.bitkit.ui.theme.Colors @@ -81,16 +80,12 @@ enum class FeeRate( } } - @Composable - fun getFeeDescription( + fun Context.getFeeShortDescription( feeRate: ULong, - feeEstimates: FeeRates?, + feeRates: FeeRates?, ): String { - val feeRateEnum = feeEstimates?.let { - fromSatsPerVByte(feeRate, it) - } ?: NORMAL - - return stringResource(feeRateEnum.shortDescription) + val feeRateEnum = feeRates?.let { fromSatsPerVByte(feeRate, it) } ?: NORMAL + return getString(feeRateEnum.shortDescription) } } } diff --git a/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt b/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt index ab8cf7694..1c5e92a16 100644 --- a/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt +++ b/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt @@ -1,5 +1,8 @@ package to.bitkit.models +import android.content.Context +import to.bitkit.R + sealed class NodeLifecycleState { data object Stopped : NodeLifecycleState() data object Starting : NodeLifecycleState() @@ -14,16 +17,14 @@ sealed class NodeLifecycleState { fun isRunning() = this is Running fun canRun() = this.isRunningOrStarting() || this is Initializing - // TODO add missing localized texts - val uiText: String - get() = when (this) { - is Stopped -> "Stopped" - is Starting -> "Starting" - is Running -> "Running" - is Stopping -> "Stopping" - is ErrorStarting -> "Error starting: ${cause.message}" - is Initializing -> "Setting up wallet..." - } + fun uiText(context: Context): String = when (this) { + is Stopped -> context.getString(R.string.other__node_stopped) + is Starting -> context.getString(R.string.other__node_starting) + is Running -> context.getString(R.string.other__node_running) + is Stopping -> context.getString(R.string.other__node_stopping) + is ErrorStarting -> context.getString(R.string.other__node_error_starting, cause.message ?: "") + is Initializing -> context.getString(R.string.other__node_initializing) + } fun asHealth() = when (this) { Running -> HealthState.READY diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 59fa2ebc0..83107fede 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -88,7 +88,7 @@ class BlocktankRepo @Inject constructor( flow { while (currentCoroutineContext().isActive) { emit(Unit) - delay(Env.blocktankOrderRefreshInterval) + delay(Env.lspOrdersRefreshInterval) } }.flowOn(bgDispatcher) .onEach { refreshOrders() } @@ -201,8 +201,8 @@ class BlocktankRepo @Inject constructor( description: String = Env.DEFAULT_INVOICE_MESSAGE, ): Result = withContext(bgDispatcher) { try { - if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val lspBalance = getDefaultLspBalance(clientBalance = amountSats) val channelSizeSat = amountSats + lspBalance @@ -230,7 +230,7 @@ class BlocktankRepo @Inject constructor( channelExpiryWeeks: UInt = DEFAULT_CHANNEL_EXPIRY_WEEKS, ): Result = withContext(bgDispatcher) { try { - if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked + if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() val options = defaultCreateOrderOptions(clientBalanceSat = spendingBalanceSats) @@ -323,7 +323,7 @@ class BlocktankRepo @Inject constructor( } private suspend fun defaultCreateOrderOptions(clientBalanceSat: ULong): CreateOrderOptions { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val timestamp = nowTimestamp().toString() val signature = lightningService.sign("channelOpen-$timestamp") @@ -350,7 +350,7 @@ class BlocktankRepo @Inject constructor( } val satsPerEur = getSatsPerEur() - ?: throw ServiceError.CurrencyRateUnavailable + ?: throw ServiceError.CurrencyRateUnavailable() val params = DefaultLspBalanceParams( clientBalanceSat = clientBalance, @@ -363,10 +363,10 @@ class BlocktankRepo @Inject constructor( fun calculateLiquidityOptions(clientBalanceSat: ULong): Result { val blocktankInfo = blocktankState.value.info - ?: return Result.failure(ServiceError.BlocktankInfoUnavailable) + ?: return Result.failure(ServiceError.BlocktankInfoUnavailable()) val satsPerEur = getSatsPerEur() - ?: return Result.failure(ServiceError.CurrencyRateUnavailable) + ?: return Result.failure(ServiceError.CurrencyRateUnavailable()) val existingChannelsTotalSat = totalBtChannelsValueSats(blocktankInfo) @@ -466,7 +466,7 @@ class BlocktankRepo @Inject constructor( } private suspend fun claimGiftCodeWithoutLiquidity(code: String, amount: ULong): GiftClaimResult { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val order = ServiceQueue.CORE.background { giftOrder(clientNodeId = nodeId, code = "blocktank-gift-code:$code") diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 791204e64..29a5b23cc 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -628,7 +628,7 @@ class LightningRepo @Inject constructor( callback: String, domain: String, ): Result = runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val result = lnurlAuth( @@ -671,9 +671,9 @@ class LightningRepo @Inject constructor( require(address.isNotEmpty()) { "Send address cannot be empty" } val transactionSpeed = speed ?: settingsStore.data.first().defaultTransactionSpeed - val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow().toUInt() + val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow() - // if utxos are manually specified, use them, otherwise run auto coin select if enabled + // use passed utxos if specified, otherwise run auto coin select if enabled val finalUtxosToSpend = utxosToSpend ?: determineUtxosToSpend( sats = sats, satsPerVByte = satsPerVByte, @@ -697,7 +697,7 @@ class LightningRepo @Inject constructor( txId = txId, address = address, isReceive = false, - feeRate = satsPerVByte.toULong(), + feeRate = satsPerVByte, isTransfer = isTransfer, channelId = channelId ?: "", ) @@ -709,7 +709,7 @@ class LightningRepo @Inject constructor( suspend fun determineUtxosToSpend( sats: ULong, - satsPerVByte: UInt, + satsPerVByte: ULong, ): List? = withContext(bgDispatcher) { return@withContext runCatching { val settings = settingsStore.data.first() @@ -767,7 +767,7 @@ class LightningRepo @Inject constructor( ): Result = withContext(bgDispatcher) { return@withContext try { val transactionSpeed = speed ?: settingsStore.data.first().defaultTransactionSpeed - val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow().toUInt() + val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow() val addressOrDefault = address ?: cacheStore.data.first().onchainAddress @@ -909,22 +909,18 @@ class LightningRepo @Inject constructor( suspend fun bumpFeeByRbf( originalTxId: Txid, - satsPerVByte: UInt, + satsPerVByte: ULong, ): Result = executeWhenNodeRunning("bumpFeeByRbf") { try { if (originalTxId.isBlank()) { return@executeWhenNodeRunning Result.failure( - IllegalArgumentException( - "originalTxId is null or empty: $originalTxId" - ) + IllegalArgumentException("originalTxId is null or empty: $originalTxId") ) } if (satsPerVByte <= 0u) { return@executeWhenNodeRunning Result.failure( - IllegalArgumentException( - "satsPerVByte invalid: $satsPerVByte" - ) + IllegalArgumentException("satsPerVByte invalid: $satsPerVByte") ) } @@ -948,7 +944,7 @@ class LightningRepo @Inject constructor( suspend fun accelerateByCpfp( originalTxId: Txid, - satsPerVByte: UInt, + satsPerVByte: ULong, destinationAddress: Address, ): Result = executeWhenNodeRunning("accelerateByCpfp") { try { @@ -973,7 +969,7 @@ class LightningRepo @Inject constructor( val newDestinationTxId = lightningService.accelerateByCpfp( txid = originalTxId, satsPerVByte = satsPerVByte, - destinationAddress = destinationAddress, + toAddress = destinationAddress, ) Logger.debug( "accelerateByCpfp success, newDestinationTxId: $newDestinationTxId originalTxId: $originalTxId, satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress" diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index b361e67e9..fc5bae3c4 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -347,7 +347,8 @@ class WalletRepo @Inject constructor( count: Int = 20, ): Result> = withContext(bgDispatcher) { return@withContext try { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) @@ -405,7 +406,7 @@ class WalletRepo @Inject constructor( bitcoinAddress = bitcoinAddress, amountSats = amountSats, message = message, - lightningInvoice = lightningInvoice + lightningInvoice = lightningInvoice, ) } @@ -443,7 +444,7 @@ class WalletRepo @Inject constructor( val hash = paymentHash() if (hash != null) return@withContext hash val address = getOnchainAddress() - return@withContext if (address.isEmpty()) null else address + return@withContext address.ifEmpty { null } } // Pre-activity metadata tag management diff --git a/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt index 9d9829127..9bdd491bd 100644 --- a/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt @@ -60,6 +60,7 @@ class WidgetsRepo @Inject constructor( private val _refreshStates = MutableStateFlow( WidgetType.entries.associateWith { false } ) + val refreshStates: StateFlow> = _refreshStates.asStateFlow() init { diff --git a/app/src/main/java/to/bitkit/services/AppUpdaterService.kt b/app/src/main/java/to/bitkit/services/AppUpdaterService.kt index 4ab68adae..eedf28331 100644 --- a/app/src/main/java/to/bitkit/services/AppUpdaterService.kt +++ b/app/src/main/java/to/bitkit/services/AppUpdaterService.kt @@ -37,5 +37,5 @@ class AppUpdaterService @Inject constructor( } sealed class AppUpdaterError(message: String) : AppError(message) { - data class InvalidResponse(override val message: String) : AppUpdaterError(message) + class InvalidResponse(override val message: String) : AppUpdaterError(message) } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 441357454..a46a354eb 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1387,7 +1387,7 @@ class BlocktankService( } suspend fun open(orderId: String): IBtOrder { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val latestOrder = ServiceQueue.CORE.background { getOrders(orderIds = listOf(orderId), filter = null, refresh = true).firstOrNull() diff --git a/app/src/main/java/to/bitkit/services/CurrencyService.kt b/app/src/main/java/to/bitkit/services/CurrencyService.kt index 463db9bd6..7ddee3181 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -33,10 +33,10 @@ class CurrencyService @Inject constructor( } } - throw lastError ?: CurrencyError.Unknown + throw lastError ?: CurrencyError.Unknown() } } sealed class CurrencyError(message: String) : AppError(message) { - data object Unknown : CurrencyError("Unknown error occurred while fetching rates") + class Unknown : CurrencyError("Unknown error occurred while fetching rates") } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index e4bfce649..2a443a68b 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -73,7 +73,7 @@ class LightningService @Inject constructor( private val _syncStatusChanged = MutableSharedFlow(extraBufferCapacity = 1) val syncStatusChanged: SharedFlow = _syncStatusChanged.asSharedFlow() - private var trustedPeers: List = Env.trustedLnPeers + private lateinit var trustedPeers: List suspend fun setup( walletIndex: Int, @@ -82,7 +82,7 @@ class LightningService @Inject constructor( trustedPeers: List? = null, channelMigration: ChannelDataMigration? = null, ) { - Logger.debug("Building node…") + Logger.debug("Building node…", context = TAG) val config = config(walletIndex, trustedPeers) node = build( @@ -93,7 +93,7 @@ class LightningService @Inject constructor( channelMigration, ) - Logger.info("LDK node setup") + Logger.info("LDK node setup", context = TAG) } private fun config( @@ -102,11 +102,10 @@ class LightningService @Inject constructor( ): Config { val dirPath = Env.ldkStoragePath(walletIndex) - trustedPeers?.takeIf { it.isNotEmpty() }?.let { - this.trustedPeers = it - } ?: run { - Logger.info("Using fallback trusted peers from Env (${Env.trustedLnPeers.size})", context = TAG) + this.trustedPeers = trustedPeers?.takeIf { it.isNotEmpty() } ?: Env.trustedLnPeers.also { + Logger.warn("Missing trusted peers from LSP, falling back to preconfigured env peers", context = TAG) } + val trustedPeerNodeIds = this.trustedPeers.map { it.nodeId } return defaultConfig().copy( @@ -141,8 +140,10 @@ class LightningService @Inject constructor( ) } + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() setEntropyBip39Mnemonic( - mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound, + mnemonic = mnemonic, passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name), ) } @@ -152,7 +153,8 @@ class LightningService @Inject constructor( val lnurlAuthServerUrl = Env.lnurlAuthServerUrl val fixedHeaders = emptyMap() Logger.verbose( - "Building ldk-node with \n\t vssUrl: '$vssUrl'\n\t lnurlAuthServerUrl: '$lnurlAuthServerUrl'" + "Building node with \n\t vssUrl: '$vssUrl'\n\t lnurlAuthServerUrl: '$lnurlAuthServerUrl'", + context = TAG, ) if (lnurlAuthServerUrl.isNotEmpty()) { builder.buildWithVssStore(vssUrl, vssStoreId, lnurlAuthServerUrl, fixedHeaders) @@ -169,17 +171,17 @@ class LightningService @Inject constructor( private suspend fun Builder.configureGossipSource(customRgsServerUrl: String?) { val rgsServerUrl = customRgsServerUrl ?: settingsStore.data.first().rgsServerUrl if (rgsServerUrl != null) { - Logger.info("Using gossip source: RGS server '$rgsServerUrl'") + Logger.info("Using gossip source: RGS server '$rgsServerUrl'", context = TAG) setGossipSourceRgs(rgsServerUrl) } else { - Logger.info("Using gossip source: P2P") + Logger.info("Using gossip source: P2P", context = TAG) setGossipSourceP2p() } } private suspend fun Builder.configureChainSource(customServerUrl: String? = null) { val serverUrl = customServerUrl ?: settingsStore.data.first().electrumServer - Logger.info("Using onchain source Electrum Sever url: $serverUrl") + Logger.info("Using onchain source Electrum Sever url: $serverUrl", context = TAG) setChainSourceElectrum( serverUrl = serverUrl, config = ElectrumSyncConfig( @@ -193,9 +195,9 @@ class LightningService @Inject constructor( } suspend fun start(timeout: Duration? = null, onEvent: NodeEventHandler? = null) { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() - Logger.debug("Starting node…") + Logger.debug("Starting node…", context = TAG) ServiceQueue.LDK.background { try { @@ -210,26 +212,26 @@ class LightningService @Inject constructor( shouldListenForEvents = true launch { try { - Logger.debug("LDK event listener started") + Logger.debug("LDK event listener started", context = TAG) if (timeout != null) { withTimeout(timeout) { listenForEvents(eventHandler) } } else { listenForEvents(eventHandler) } } catch (e: Exception) { - Logger.error("LDK event listener error", e) + Logger.error("LDK event listener error", e, context = TAG) } } } - Logger.info("Node started") + Logger.info("Node started", context = TAG) } suspend fun stop() { shouldListenForEvents = false - val node = this.node ?: throw ServiceError.NodeNotStarted + val node = this.node ?: throw ServiceError.NodeNotStarted() - Logger.debug("Stopping node…") + Logger.debug("Stopping node…", context = TAG) ServiceQueue.LDK.background { try { node.stop() @@ -239,51 +241,32 @@ class LightningService @Inject constructor( this@LightningService.node = null } } - Logger.info("Node stopped") + Logger.info("Node stopped", context = TAG) } fun wipeStorage(walletIndex: Int) { - if (node != null) throw ServiceError.NodeStillRunning - Logger.warn("Wiping lightning storage…") + if (node != null) throw ServiceError.NodeStillRunning() + Logger.warn("Wiping LDK storage…", context = TAG) Path(Env.ldkStoragePath(walletIndex)).toFile().deleteRecursively() - Logger.info("Lightning wallet wiped") + Logger.info("LDK storage wiped", context = TAG) } suspend fun sync() { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() - Logger.verbose("Syncing LDK…") + Logger.verbose("Syncing LDK…", context = TAG) ServiceQueue.LDK.background { node.syncWallets() - // launch { setMaxDustHtlcExposureForCurrentChannels() } } _syncStatusChanged.tryEmit(Unit) - Logger.debug("LDK synced") - } - - // private fun setMaxDustHtlcExposureForCurrentChannels() { - // if (Env.network != Network.REGTEST) { - // Logger.debug("Not updating channel config for non-regtest network") - // return - // } - // val node = this.node ?: throw ServiceError.NodeNotStarted - // runCatching { - // for (channel in node.listChannels()) { - // val config = channel.config - // config.maxDustHtlcExposure = MaxDustHtlcExposure.FixedLimit(limitMsat = 999_999_UL * 1000u) - // node.updateChannelConfig(channel.userChannelId, channel.counterpartyNodeId, config) - // Logger.info("Updated channel config for: ${channel.userChannelId}") - // } - // }.onFailure { - // Logger.error("Failed to update channel config", it) - // } - // } + Logger.debug("LDK synced", context = TAG) + } suspend fun sign(message: String): String { - val node = this.node ?: throw ServiceError.NodeNotSetup - val msg = runCatching { message.uByteList }.getOrNull() ?: throw ServiceError.InvalidNodeSigningMessage + val node = this.node ?: throw ServiceError.NodeNotSetup() + val msg = runCatching { message.uByteList }.getOrNull() ?: throw ServiceError.InvalidNodeSigningMessage() return ServiceQueue.LDK.background { node.signMessage(msg) @@ -291,7 +274,7 @@ class LightningService @Inject constructor( } suspend fun newAddress(): String { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { node.onchainPayment().newAddress() @@ -300,15 +283,15 @@ class LightningService @Inject constructor( // region peers suspend fun connectToTrustedPeers() { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() ServiceQueue.LDK.background { for (peer in trustedPeers) { try { node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Connected to trusted peer: $peer") + Logger.info("Connected to trusted peer: $peer", context = TAG) } catch (e: NodeException) { - Logger.error("Peer connect error: $peer", LdkError(e)) + Logger.error("Peer connect error: $peer", LdkError(e), context = TAG) } } @@ -321,13 +304,13 @@ class LightningService @Inject constructor( val trustedConnected = trustedPeers.count { it.nodeId in connectedPeerIds } if (trustedConnected == 0 && trustedPeers.isNotEmpty()) { - Logger.warn("No trusted peers connected, falling back to Env peers", context = TAG) + Logger.warn("No trusted peers connected, falling back to preconfigured env peers", context = TAG) for (peer in Env.trustedLnPeers) { try { node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Connected to fallback peer: $peer") + Logger.info("Connected to fallback peer: $peer", context = TAG) } catch (e: NodeException) { - Logger.error("Fallback peer connect error: $peer", LdkError(e)) + Logger.error("Fallback peer connect error: $peer", LdkError(e), context = TAG) } } } else { @@ -336,36 +319,36 @@ class LightningService @Inject constructor( } suspend fun connectPeer(peer: PeerDetails): Result { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() val uri = peer.uri return ServiceQueue.LDK.background { try { - Logger.debug("Connecting peer: $uri") + Logger.debug("Connecting peer: $uri", context = TAG) node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Peer connected: $uri") + Logger.info("Peer connected: $uri", context = TAG) Result.success(Unit) } catch (e: NodeException) { val error = LdkError(e) - Logger.error("Peer connect error: $uri", error) + Logger.error("Peer connect error: $uri", error, context = TAG) Result.failure(error) } } } suspend fun disconnectPeer(peer: PeerDetails) { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() val uri = peer.uri - Logger.debug("Disconnecting peer: $uri") + Logger.debug("Disconnecting peer: $uri", context = TAG) try { ServiceQueue.LDK.background { node.disconnect(peer.nodeId) } - Logger.info("Peer disconnected: $uri") + Logger.info("Peer disconnected: $uri", context = TAG) } catch (e: NodeException) { - Logger.warn("Peer disconnect error: $uri", LdkError(e)) + Logger.warn("Peer disconnect error: $uri", LdkError(e), context = TAG) } } @@ -397,12 +380,12 @@ class LightningService @Inject constructor( pushToCounterpartySats: ULong? = null, channelConfig: ChannelConfig? = null, ): Result { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { try { val pushToCounterpartyMsat = pushToCounterpartySats?.let { it * 1000u } - Logger.debug("Initiating channel open (sats: $channelAmountSats) with peer: ${peer.uri}") + Logger.debug("Initiating channel open (sats: '$channelAmountSats') with: '${peer.uri}'", context = TAG) val userChannelId = node.openChannel( peer.nodeId, @@ -420,12 +403,12 @@ class LightningService @Inject constructor( channelConfig, ) - Logger.info("Channel open initiated, result: $result") + Logger.info("Channel open initiated, result: $result", context = TAG) Result.success(result) } catch (e: NodeException) { val error = LdkError(e) - Logger.error("Error initiating channel open", error) + Logger.error("Error initiating channel open", error, context = TAG) Result.failure(error) } } @@ -437,7 +420,7 @@ class LightningService @Inject constructor( force: Boolean = false, forceCloseReason: String? = null, ) { - val node = this.node ?: throw ServiceError.NodeNotStarted + val node = this.node ?: throw ServiceError.NodeNotStarted() val channelId = channel.channelId val userChannelId = channel.userChannelId val counterpartyNodeId = channel.counterpartyNodeId @@ -469,12 +452,12 @@ class LightningService @Inject constructor( fun canReceive(): Boolean { val channels = this.channels if (channels == null) { - Logger.warn("canReceive = false: Channels not available") + Logger.warn("canReceive = false: Channels not available", context = TAG) return false } if (channels.none { it.isChannelReady }) { - Logger.warn("canReceive = false: Found no LN channel ready to enable receive: $channels") + Logger.warn("canReceive = false: Found no LN channel ready to enable receive: '$channels'", context = TAG) return false } @@ -482,7 +465,7 @@ class LightningService @Inject constructor( } suspend fun receive(sat: ULong? = null, description: String, expirySecs: UInt = 3600u): String { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() val message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE } @@ -509,14 +492,14 @@ class LightningService @Inject constructor( fun canSend(amountSats: ULong): Boolean { val channels = this.channels if (channels == null) { - Logger.warn("Channels not available") + Logger.warn("Channels not available", context = TAG) return false } val totalNextOutboundHtlcLimitSats = channels.totalNextOutboundHtlcLimitSats() if (totalNextOutboundHtlcLimitSats < amountSats) { - Logger.warn("Insufficient outbound capacity: $totalNextOutboundHtlcLimitSats < $amountSats") + Logger.warn("Insufficient outbound capacity: $totalNextOutboundHtlcLimitSats < $amountSats", context = TAG) return false } @@ -526,26 +509,29 @@ class LightningService @Inject constructor( suspend fun send( address: Address, sats: ULong, - satsPerVByte: UInt, + satsPerVByte: ULong, utxosToSpend: List? = null, isMaxAmount: Boolean = false, ): Txid { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() - Logger.info("Sending $sats sats to $address, satsPerVByte=$satsPerVByte, isMaxAmount = $isMaxAmount") + Logger.info( + "Sending $sats sats to $address, satsPerVByte=$satsPerVByte, isMaxAmount = $isMaxAmount", + context = TAG, + ) return ServiceQueue.LDK.background { if (isMaxAmount) { node.onchainPayment().sendAllToAddress( address = address, retainReserve = true, - feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte.toULong()), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), ) } else { node.onchainPayment().sendToAddress( address = address, amountSats = sats, - feeRate = convertVByteToKwu(satsPerVByte), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), utxosToSpend = utxosToSpend, ) } @@ -553,9 +539,9 @@ class LightningService @Inject constructor( } suspend fun send(bolt11: String, sats: ULong? = null): PaymentId { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() - Logger.debug("Paying bolt11: $bolt11") + Logger.debug("Paying bolt11: $bolt11", context = TAG) val bolt11Invoice = runCatching { Bolt11Invoice.fromStr(bolt11) } .getOrElse { e -> throw LdkError(e as NodeException) } @@ -573,7 +559,7 @@ class LightningService @Inject constructor( } suspend fun estimateRoutingFees(bolt11: String): Result { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { return@background try { @@ -590,7 +576,7 @@ class LightningService @Inject constructor( } suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { return@background try { @@ -610,7 +596,7 @@ class LightningService @Inject constructor( // region utxo selection suspend fun listSpendableOutputs(): Result> { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { return@background try { @@ -626,17 +612,17 @@ class LightningService @Inject constructor( suspend fun selectUtxosWithAlgorithm( targetAmountSats: ULong, - satsPerVByte: UInt, + satsPerVByte: ULong, algorithm: CoinSelectionAlgorithm, utxos: List?, ): Result> { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { return@background try { val result = node.onchainPayment().selectUtxosWithAlgorithm( targetAmountSats = targetAmountSats, - feeRate = convertVByteToKwu(satsPerVByte), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), algorithm = algorithm, utxos = utxos, ) @@ -651,16 +637,16 @@ class LightningService @Inject constructor( // endregion // region boost - suspend fun bumpFeeByRbf(txid: Txid, satsPerVByte: UInt): Txid { - val node = this.node ?: throw ServiceError.NodeNotSetup + suspend fun bumpFeeByRbf(txid: Txid, satsPerVByte: ULong): Txid { + val node = this.node ?: throw ServiceError.NodeNotSetup() - Logger.info("Bumping fee for tx $txid with satsPerVByte=$satsPerVByte") + Logger.info("RBF for txid='$txid' using satsPerVByte='$satsPerVByte'", context = TAG) return ServiceQueue.LDK.background { return@background try { node.onchainPayment().bumpFeeByRbf( txid = txid, - feeRate = convertVByteToKwu(satsPerVByte), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), ) } catch (e: NodeException) { throw LdkError(e) @@ -670,19 +656,19 @@ class LightningService @Inject constructor( suspend fun accelerateByCpfp( txid: Txid, - satsPerVByte: UInt, - destinationAddress: Address, + satsPerVByte: ULong, + toAddress: Address, ): Txid { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() - Logger.info("Accelerating tx $txid by CPFP, satsPerVByte=$satsPerVByte, destinationAddress=$destinationAddress") + Logger.info("CPFP for txid='$txid' using satsPerVByte='$satsPerVByte', to address='$toAddress'", context = TAG) return ServiceQueue.LDK.background { return@background try { node.onchainPayment().accelerateByCpfp( txid = txid, - feeRate = convertVByteToKwu(satsPerVByte), - destinationAddress = destinationAddress, + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), + destinationAddress = toAddress, ) } catch (e: NodeException) { throw LdkError(e) @@ -693,9 +679,9 @@ class LightningService @Inject constructor( // region fee suspend fun calculateCpfpFeeRate(parentTxid: Txid): FeeRate { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() - Logger.info("Calculating CPFP fee for parentTxid $parentTxid") + Logger.debug("Calculating CPFP fee for parentTxid $parentTxid", context = TAG) return ServiceQueue.LDK.background { return@background try { @@ -712,13 +698,14 @@ class LightningService @Inject constructor( suspend fun calculateTotalFee( address: Address, amountSats: ULong, - satsPerVByte: UInt, + satsPerVByte: ULong, utxosToSpend: List? = null, ): ULong { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.verbose( - "Calculating fee for $amountSats sats to $address, UTXOs=${utxosToSpend?.size}, satsPerVByte=$satsPerVByte" + "Calculating fee for $amountSats sats to $address, ${utxosToSpend?.size} UTXOs, satsPerVByte=$satsPerVByte", + context = TAG, ) return ServiceQueue.LDK.background { @@ -726,10 +713,13 @@ class LightningService @Inject constructor( val fee = node.onchainPayment().calculateTotalFee( address = address, amountSats = amountSats, - feeRate = convertVByteToKwu(satsPerVByte), + feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), utxosToSpend = utxosToSpend, ) - Logger.verbose("Calculated fee=$fee for $amountSats sats to $address, satsPerVByte=$satsPerVByte") + Logger.debug( + "Calculated fee='$fee' for $amountSats sats to $address, satsPerVByte=$satsPerVByte", + context = TAG, + ) fee } catch (e: NodeException) { throw LdkError(e) @@ -744,16 +734,16 @@ class LightningService @Inject constructor( suspend fun listenForEvents(onEvent: NodeEventHandler? = null) = withContext(bgDispatcher) { while (shouldListenForEvents) { val node = this@LightningService.node ?: let { - Logger.error(ServiceError.NodeNotStarted.message.orEmpty()) + Logger.error(ServiceError.NodeNotStarted().message.orEmpty(), context = TAG) return@withContext } val event = node.nextEventAsync() - Logger.debug("LDK-node event fired: ${jsonLogOf(event)}") + Logger.debug("LDK event fired: ${jsonLogOf(event)}", context = TAG) try { node.eventHandled() - Logger.verbose("LDK-node eventHandled: $event") + Logger.verbose("LDK eventHandled: '$event'", context = TAG) } catch (e: NodeException) { - Logger.verbose("LDK eventHandled error: $event", LdkError(e)) + Logger.verbose("LDK eventHandled error: '$event'", LdkError(e), context = TAG) } onEvent?.invoke(event) } @@ -761,7 +751,7 @@ class LightningService @Inject constructor( // endregion suspend fun getAddressBalance(address: String): ULong { - val node = this.node ?: throw ServiceError.NodeNotSetup + val node = this.node ?: throw ServiceError.NodeNotSetup() return ServiceQueue.LDK.background { try { node.getAddressBalance(addressStr = address) @@ -791,6 +781,7 @@ class LightningService @Inject constructor( Logger.error("Node not available for network graph dump", context = TAG) return } + val nodeIdPreviewLength = 20 val sb = StringBuilder() sb.appendLine("\n\n=== ROUTE NOT FOUND - NETWORK GRAPH DUMP ===\n") @@ -850,7 +841,7 @@ class LightningService @Inject constructor( sb.appendLine(" Total peers: ${peers.size}") peers.forEachIndexed { index, peer -> - sb.appendLine(" Peer ${index + 1}: ${peer.nodeId.take(NODE_ID_PREVIEW_LENGTH)}... @ ${peer.address}") + sb.appendLine(" Peer ${index + 1}: ${peer.nodeId.take(nodeIdPreviewLength)}... @ ${peer.address}") sb.appendLine(" - Connected: ${peer.isConnected}, Persisted: ${peer.isPersisted}") } @@ -893,15 +884,15 @@ class LightningService @Inject constructor( val nodeId = peer.nodeId if (allNodes.any { it == nodeId }) { foundTrustedNodes++ - sb.appendLine(" OK: ${nodeId.take(NODE_ID_PREVIEW_LENGTH)}... found in graph") + sb.appendLine(" OK: ${nodeId.take(nodeIdPreviewLength)}... found in graph") } else { - sb.appendLine(" MISSING: ${nodeId.take(NODE_ID_PREVIEW_LENGTH)}... NOT in graph") + sb.appendLine(" MISSING: ${nodeId.take(nodeIdPreviewLength)}... NOT in graph") } } sb.appendLine(" Summary: $foundTrustedNodes/${trustedPeers.size} trusted peers found in graph") // Show first 10 nodes - val nodesToShow = minOf(NETWORK_GRAPH_PREVIEW_LIMIT, allNodes.size) + val nodesToShow = minOf(10, allNodes.size) sb.appendLine("\n First $nodesToShow nodes:") allNodes.take(nodesToShow).forEachIndexed { index, nodeId -> sb.appendLine(" ${index + 1}. $nodeId") @@ -931,7 +922,7 @@ class LightningService @Inject constructor( } suspend fun exportNetworkGraphToFile(outputDir: String): Result { - val node = this.node ?: return Result.failure(ServiceError.NodeNotSetup) + val node = this.node ?: return Result.failure(ServiceError.NodeNotSetup()) return withContext(bgDispatcher) { runCatching { @@ -960,8 +951,6 @@ class LightningService @Inject constructor( companion object { private const val TAG = "LightningService" - private const val NODE_ID_PREVIEW_LENGTH = 20 - private const val NETWORK_GRAPH_PREVIEW_LIMIT = 10 } } @@ -975,16 +964,3 @@ data class NetworkGraphInfo( class TrustedPeerForceCloseException : Exception( "Cannot force close channel with trusted peer. Force close is disabled for Blocktank LSP channels." ) - -// region helpers -/** - * TODO remove, replace all usages with [FeeRate.fromSatPerVbUnchecked] - * */ -@Deprecated("replace all usages with [FeeRate.fromSatPerVbUnchecked]") -private fun convertVByteToKwu(satsPerVByte: UInt): FeeRate { - // 1 vbyte = 4 weight units, so 1 sats/vbyte = 250 sats/kwu - val satPerKwu = satsPerVByte.toULong() * 250u - // Ensure we're above the minimum relay fee - return FeeRate.fromSatPerKwu(maxOf(satPerKwu, 253u)) // FEERATE_FLOOR_SATS_PER_KW is 253 in LDK -} -// endregion diff --git a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt index d29e8b95b..955b775d9 100644 --- a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt +++ b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt @@ -7,7 +7,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.Keychain.Key import to.bitkit.di.BgDispatcher import to.bitkit.env.Env -import to.bitkit.env.Env.DERIVATION_NAME +import to.bitkit.env.Env.derivationName import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toHex import to.bitkit.utils.Crypto @@ -24,12 +24,12 @@ class LspNotificationsService @Inject constructor( private val crypto: Crypto, ) { suspend fun registerDevice(deviceToken: String) = withContext(bgDispatcher) { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() Logger.debug("Registering device for notifications…") val timestamp = nowTimestamp() - val messageToSign = "$DERIVATION_NAME$deviceToken$timestamp" + val messageToSign = "$derivationName$deviceToken$timestamp" val signature = lightningService.sign(messageToSign) diff --git a/app/src/main/java/to/bitkit/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt index fc1062e6c..1613df269 100644 --- a/app/src/main/java/to/bitkit/services/RNBackupClient.kt +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -59,7 +59,7 @@ class RNBackupClient @Inject constructor( suspend fun listFiles(fileGroup: String? = "ldk"): RNBackupListResponse? = withContext(ioDispatcher) { runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup + ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -81,7 +81,7 @@ class RNBackupClient @Inject constructor( suspend fun retrieve(label: String, fileGroup: String? = null): ByteArray? = withContext(ioDispatcher) { runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup + ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -109,7 +109,7 @@ class RNBackupClient @Inject constructor( suspend fun retrieveChannelMonitor(channelId: String): ByteArray? = withContext(ioDispatcher) { runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup + ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -236,7 +236,7 @@ class RNBackupClient @Inject constructor( } if (!challengeResponse.status.isSuccess()) { - throw RNBackupError.AuthFailed + throw RNBackupError.AuthFailed() } val challengeResult = challengeResponse.body() @@ -254,7 +254,7 @@ class RNBackupClient @Inject constructor( } if (!authResponse.status.isSuccess()) { - throw RNBackupError.AuthFailed + throw RNBackupError.AuthFailed() } authResponse.body().also { cachedBearer = it } @@ -355,8 +355,8 @@ data class RNBackupListResponse( ) sealed class RNBackupError(message: String) : AppError(message) { - data object NotSetup : RNBackupError("RN backup client not setup") - data object AuthFailed : RNBackupError("Authentication failed") - data class RequestFailed(override val message: String) : RNBackupError(message) - data class DecryptFailed(override val message: String) : RNBackupError(message) + class NotSetup : RNBackupError("RN backup client not setup") + class AuthFailed : RNBackupError("Authentication failed") + class RequestFailed(override val message: String) : RNBackupError(message) + class DecryptFailed(override val message: String) : RNBackupError(message) } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d7f26ff9c..5f4fc8b91 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -391,9 +391,9 @@ fun ContentView( } is Sheet.Receive -> { - val walletUiState by walletViewModel.uiState.collectAsState() + val walletState by walletViewModel.walletState.collectAsState() ReceiveSheet( - walletState = walletUiState, + walletState = walletState, navigateToExternalConnection = { navController.navigate(ExternalConnection()) appViewModel.hideSheet() @@ -778,7 +778,7 @@ private fun NavGraphBuilder.home( drawerState: DrawerState, ) { composable { - val uiState by walletViewModel.uiState.collectAsStateWithLifecycle() + val isRefreshing by walletViewModel.isRefreshing.collectAsStateWithLifecycle() val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle() val hazeState = rememberHazeState() @@ -794,7 +794,7 @@ private fun NavGraphBuilder.home( .hazeSource(hazeState) ) { HomeScreen( - mainUiState = uiState, + isRefreshing = isRefreshing, drawerState = drawerState, rootNavController = navController, walletNavController = navController, @@ -834,11 +834,11 @@ private fun NavGraphBuilder.home( exitTransition = { Transitions.slideOutHorizontally }, ) { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() - val uiState by walletViewModel.uiState.collectAsStateWithLifecycle() + val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() SpendingWalletScreen( - uiState = uiState, + channels = lightningState.channels, lightningActivities = lightningActivities.orEmpty(), onAllActivityButtonClick = { navController.navigateToAllActivity() }, onActivityItemClick = { navController.navigateToActivityItem(it) }, diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 77723f287..c868ab197 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import to.bitkit.R import to.bitkit.androidServices.LightningNodeService import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.models.NewTransactionSheetDetails @@ -76,10 +77,9 @@ class MainActivity : FragmentActivity() { initNotificationChannel() initNotificationChannel( - // TODO Transifex id = CHANNEL_ID_NODE, - name = "Lightning node notification", - desc = "Channel for LightningNodeService", + name = getString(R.string.notification_channel_node_name), + desc = getString(R.string.notification_channel_node_desc), importance = NotificationManager.IMPORTANCE_LOW ) appViewModel.handleDeeplinkIntent(intent) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 320165885..ff439a5b7 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -48,6 +48,7 @@ import to.bitkit.ext.uri import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.LightningState import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption import to.bitkit.ui.components.ChannelStatusUi @@ -66,7 +67,6 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.MainUiState import kotlin.time.Clock.System.now import kotlin.time.ExperimentalTime @@ -79,14 +79,14 @@ fun NodeInfoScreen( val settings = settingsViewModel ?: return val context = LocalContext.current - val uiState by wallet.uiState.collectAsStateWithLifecycle() + val isRefreshing by wallet.isRefreshing.collectAsStateWithLifecycle() val isDevModeEnabled by settings.isDevModeEnabled.collectAsStateWithLifecycle() val lightningState by wallet.lightningState.collectAsStateWithLifecycle() Content( - uiState = uiState, + lightningState = lightningState, + isRefreshing = isRefreshing, isDevModeEnabled = isDevModeEnabled, - balanceDetails = lightningState.balances, onBack = { navController.popBackStack() }, onRefresh = { wallet.onPullToRefresh() }, onDisconnectPeer = { wallet.disconnectPeer(it) }, @@ -103,9 +103,9 @@ fun NodeInfoScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun Content( - uiState: MainUiState, + lightningState: LightningState, + isRefreshing: Boolean = false, isDevModeEnabled: Boolean, - balanceDetails: BalanceDetails? = null, onBack: () -> Unit = {}, onRefresh: () -> Unit = {}, onDisconnectPeer: (PeerDetails) -> Unit = {}, @@ -118,7 +118,7 @@ private fun Content( actions = { DrawerNavIcon() }, ) PullToRefreshBox( - isRefreshing = uiState.isRefreshing, + isRefreshing = isRefreshing, onRefresh = onRefresh, ) { Column( @@ -127,17 +127,17 @@ private fun Content( .verticalScroll(rememberScrollState()) ) { NodeIdSection( - nodeId = uiState.nodeId, + nodeId = lightningState.nodeId, onCopy = onCopy, ) if (isDevModeEnabled) { NodeStateSection( - nodeLifecycleState = uiState.nodeLifecycleState, - nodeStatus = uiState.nodeStatus, + nodeLifecycleState = lightningState.nodeLifecycleState, + nodeStatus = lightningState.nodeStatus, ) - balanceDetails?.let { details -> + lightningState.balances?.let { details -> WalletBalancesSection(balanceDetails = details) if (details.lightningBalances.isNotEmpty()) { @@ -145,16 +145,16 @@ private fun Content( } } - if (uiState.channels.isNotEmpty()) { + if (lightningState.channels.isNotEmpty()) { ChannelsSection( - channels = uiState.channels, + channels = lightningState.channels, onCopy = onCopy, ) } - if (uiState.peers.isNotEmpty()) { + if (lightningState.peers.isNotEmpty()) { PeersSection( - peers = uiState.peers, + peers = lightningState.peers, onDisconnectPeer = onDisconnectPeer, onCopy = onCopy, ) @@ -191,11 +191,12 @@ private fun NodeStateSection( nodeLifecycleState: NodeLifecycleState, nodeStatus: NodeStatus?, ) { + val context = LocalContext.current Column(modifier = Modifier.fillMaxWidth()) { SectionHeader("Node State") SettingsTextButtonRow( title = stringResource(R.string.lightning__status), - value = nodeLifecycleState.uiText, + value = nodeLifecycleState.uiText(context), ) nodeStatus?.let { status -> @@ -457,7 +458,7 @@ private fun Preview() { AppThemeSurface { Content( isDevModeEnabled = false, - uiState = MainUiState( + lightningState = LightningState( nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", ), ) @@ -472,7 +473,7 @@ private fun PreviewDevMode() { val syncTime = now().epochSeconds.toULong() Content( isDevModeEnabled = true, - uiState = MainUiState( + lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Running, nodeStatus = NodeStatus( isRunning = true, @@ -489,7 +490,7 @@ private fun PreviewDevMode() { latestPathfindingScoresSyncTimestamp = null, ), nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - peers = listOf(Peers.staging), + peers = listOf(Peers.stag), channels = listOf( createChannelDetails().copy( channelId = "abc123def456789012345678901234567890123456789012345678901234567890", @@ -519,40 +520,40 @@ private fun PreviewDevMode() { inboundHtlcMaximumMsat = 200000000UL, ), ), - ), - balanceDetails = BalanceDetails( - totalOnchainBalanceSats = 1000000UL, - spendableOnchainBalanceSats = 900000UL, - totalAnchorChannelsReserveSats = 50000UL, - totalLightningBalanceSats = 500000UL, - lightningBalances = listOf( - LightningBalance.ClaimableOnChannelClose( - channelId = "abc123def456789012345678901234567890123456789012345678901234567890", - counterpartyNodeId = "0248a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - amountSatoshis = 250000UL, - transactionFeeSatoshis = 1000UL, - outboundPaymentHtlcRoundedMsat = 0UL, - outboundForwardedHtlcRoundedMsat = 0UL, - inboundClaimingHtlcRoundedMsat = 0UL, - inboundHtlcRoundedMsat = 0UL, - ), - LightningBalance.ClaimableAwaitingConfirmations( - channelId = "def456789012345678901234567890123456789012345678901234567890abc123", - counterpartyNodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - amountSatoshis = 150000UL, - confirmationHeight = 850005U, - source = BalanceSource.COUNTERPARTY_FORCE_CLOSED, - ), - LightningBalance.MaybeTimeoutClaimableHtlc( - channelId = "789012345678901234567890123456789012345678901234567890abc123def456", - counterpartyNodeId = "0448a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - amountSatoshis = 100000UL, - claimableHeight = 850010U, - paymentHash = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - outboundPayment = true, + balances = BalanceDetails( + totalOnchainBalanceSats = 1000000UL, + spendableOnchainBalanceSats = 900000UL, + totalAnchorChannelsReserveSats = 50000UL, + totalLightningBalanceSats = 500000UL, + lightningBalances = listOf( + LightningBalance.ClaimableOnChannelClose( + channelId = "abc123def456789012345678901234567890123456789012345678901234567890", + counterpartyNodeId = "0248a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", + amountSatoshis = 250000UL, + transactionFeeSatoshis = 1000UL, + outboundPaymentHtlcRoundedMsat = 0UL, + outboundForwardedHtlcRoundedMsat = 0UL, + inboundClaimingHtlcRoundedMsat = 0UL, + inboundHtlcRoundedMsat = 0UL, + ), + LightningBalance.ClaimableAwaitingConfirmations( + channelId = "def456789012345678901234567890123456789012345678901234567890abc123", + counterpartyNodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", + amountSatoshis = 150000UL, + confirmationHeight = 850005U, + source = BalanceSource.COUNTERPARTY_FORCE_CLOSED, + ), + LightningBalance.MaybeTimeoutClaimableHtlc( + channelId = "789012345678901234567890123456789012345678901234567890abc123def456", + counterpartyNodeId = "0448a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", + amountSatoshis = 100000UL, + claimableHeight = 850010U, + paymentHash = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + outboundPayment = true, + ), ), + pendingBalancesFromChannelClosures = listOf(), ), - pendingBalancesFromChannelClosures = listOf(), ), ) } diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index 58b2ccbda..029472950 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -1,6 +1,6 @@ package to.bitkit.ui -import android.Manifest +import android.Manifest.permission import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -9,10 +9,14 @@ import android.app.PendingIntent.FLAG_ONE_SHOT import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP +import android.content.pm.PackageManager import android.media.RingtoneManager import android.os.Build import android.os.Bundle +import android.provider.Settings import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import to.bitkit.R import to.bitkit.ext.notificationManager import to.bitkit.ext.notificationManagerCompat @@ -66,7 +70,7 @@ internal fun Context.pushNotification( // Only check permission if running on Android 13+ (SDK 33+) val needsPermissionGrant = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - requiresPermission(Manifest.permission.POST_NOTIFICATIONS) + requiresPermission(permission.POST_NOTIFICATIONS) if (!needsPermissionGrant) { val builder = notificationBuilder(extras) @@ -87,4 +91,20 @@ internal fun Context.pushNotification( } } +fun Context.openNotificationSettings() { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + runCatching { startActivity(intent) } + .onFailure { Logger.error("Failed to open notification settings", e = it, context = TAG) } +} + +fun Context.areNotificationsEnabled(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(this, permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + NotificationManagerCompat.from(this).areNotificationsEnabled() + } + private const val TAG = "Notifications" diff --git a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt index f36ac8bce..ab850572f 100644 --- a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt +++ b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R @@ -50,7 +51,10 @@ fun NotificationPreview( verticalArrangement = Arrangement.SpaceBetween ) { BodySSB(text = title, color = Colors.Black) - val textDescription = if (showDetails) description else "Open Bitkit to see details" // TODO Transifex + val textDescription = when (showDetails) { + true -> description + else -> stringResource(R.string.notification_received_body_hidden) + } AnimatedContent(targetState = textDescription) { text -> Footnote(text = text, color = Colors.Gray3) } diff --git a/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt b/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt index e6c122822..7b72984ad 100644 --- a/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt +++ b/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.scaffold +import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -16,7 +17,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -29,30 +29,28 @@ import to.bitkit.ui.LocalDrawerState import to.bitkit.ui.components.Title import to.bitkit.ui.theme.AppThemeSurface -@Composable @OptIn(ExperimentalMaterial3Api::class) +@Composable fun AppTopBar( titleText: String?, onBackClick: (() -> Unit)?, modifier: Modifier = Modifier, - icon: Painter? = null, + @DrawableRes icon: Int? = null, actions: @Composable (RowScope.() -> Unit) = {}, ) { CenterAlignedTopAppBar( navigationIcon = { - if (onBackClick != null) { - BackNavIcon(onBackClick) - } + onBackClick?.let { BackNavIcon(it) } }, title = { - if (titleText != null) { + titleText?.let { text -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - icon?.let { painter -> + icon?.let { Icon( - painter = painter, + painter = painterResource(icon), contentDescription = null, tint = Color.Unspecified, modifier = Modifier @@ -60,12 +58,12 @@ fun AppTopBar( .size(32.dp) ) } - Title(text = titleText, maxLines = 1) + Title(text = text, maxLines = 1) } } }, actions = actions, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent, ), @@ -147,7 +145,7 @@ private fun Preview2() { AppTopBar( titleText = "Title And Icon", onBackClick = {}, - icon = painterResource(R.drawable.ic_ln_circle), + icon = R.drawable.ic_ln_circle, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt index 0c5071c03..8d8e11c83 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt @@ -33,7 +33,7 @@ fun CreateProfileScreen( Spacer(Modifier.weight(1f)) Display( - text = "Comming soon", + text = stringResource(R.string.other__coming_soon), color = Colors.White ) Spacer(Modifier.weight(1f)) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt index 82d50b63f..b2e806b47 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R -import to.bitkit.env.TransactionDefaults +import to.bitkit.env.Defaults import to.bitkit.ui.LocalBalances import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMB @@ -45,7 +45,7 @@ fun FundingScreen( ) { val balances = LocalBalances.current val canTransfer = remember(balances.totalOnchainSats) { - balances.totalOnchainSats >= TransactionDefaults.recommendedBaseFee + balances.totalOnchainSats >= Defaults.recommendedBaseFee } var showNoFundsAlert by remember { mutableStateOf(false) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt index ef77ad01d..b72503af6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt @@ -53,8 +53,8 @@ fun SavingsAdvancedScreen( val wallet = walletViewModel ?: return val transfer = transferViewModel ?: return - val walletState by wallet.uiState.collectAsStateWithLifecycle() - val openChannels = walletState.channels.filterOpen() + val lightningState by wallet.lightningState.collectAsStateWithLifecycle() + val openChannels = lightningState.channels.filterOpen() var selectedChannelIds by remember { mutableStateOf(setOf()) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index 4aeeac17a..f7351c939 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -54,8 +54,8 @@ fun SavingsConfirmScreen( val transfer = transferViewModel ?: return val wallet = walletViewModel ?: return - val walletState by wallet.uiState.collectAsStateWithLifecycle() - val openChannels = walletState.channels.filterOpen() + val lightningState by wallet.lightningState.collectAsStateWithLifecycle() + val openChannels = lightningState.channels.filterOpen() val hasMultiple = openChannels.size > 1 diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index dd2c4cc60..86198aeca 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -56,10 +57,10 @@ import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.NotificationUtils import to.bitkit.ui.utils.RequestNotificationPermissions import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.SettingsViewModel @@ -99,14 +100,12 @@ fun SpendingConfirmScreen( onLearnMoreClick = onLearnMoreClick, onAdvancedClick = onAdvancedClick, onConfirm = onConfirm, - onUseDefaultLspBalanceClick = { viewModel.onUseDefaultLspBalanceClick() }, - onTransferToSpendingConfirm = { order -> viewModel.onTransferToSpendingConfirm(order) }, + onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, + onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, order = order, hasNotificationPermission = notificationsGranted, - onSwitchClick = { - NotificationUtils.openNotificationSettings(context) - }, - isAdvanced = isAdvanced + onSwitchClick = { context.openNotificationSettings() }, + isAdvanced = isAdvanced, ) } @@ -140,7 +139,7 @@ private fun Content( modifier = Modifier .fillMaxWidth() .padding(horizontal = 60.dp) - .align(alignment = Alignment.BottomCenter) + .align(Alignment.BottomCenter) .padding(bottom = 76.dp) ) } @@ -158,10 +157,7 @@ private fun Content( val lspBalance = order.lspBalanceSat VerticalSpacer(32.dp) - Display( - text = stringResource(R.string.lightning__transfer__confirm) - .withAccent(accentColor = Colors.Purple) - ) + Display(stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) VerticalSpacer(8.dp) Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt index b3c53885d..987e755df 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/components/ProgressSteps.kt @@ -24,8 +24,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import to.bitkit.R import to.bitkit.ui.components.BodySSB import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt index fbaae5a19..9fd0426b5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt @@ -66,7 +66,7 @@ fun ExternalConfirmScreen( Content( uiState = uiState, - onConfirm = { viewModel.onConfirm() }, + onConfirm = viewModel::onConfirm, onNetworkFeeClick = onNetworkFeeClick, onBackClick = onBackClick, ) @@ -96,9 +96,7 @@ private fun Content( val totalFee = uiState.amount.sats + networkFee Spacer(modifier = Modifier.height(16.dp)) - Display( - text = stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple) - ) + Display(stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) Spacer(modifier = Modifier.height(8.dp)) Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt index 5d45f2430..2fe68d868 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt @@ -35,9 +35,9 @@ import androidx.lifecycle.SavedStateHandle import kotlinx.coroutines.flow.filterNotNull import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.ext.from import to.bitkit.ext.getClipboardText import to.bitkit.ext.host +import to.bitkit.ext.of import to.bitkit.ext.port import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM @@ -220,7 +220,7 @@ private fun ExternalConnectionContent( ) PrimaryButton( text = stringResource(R.string.common__continue), - onClick = { onContinueClick(PeerDetails.from(nodeId = nodeId, host = host, port = port)) }, + onClick = { onContinueClick(PeerDetails.of(nodeId = nodeId, host = host, port = port)) }, enabled = isValid, isLoading = uiState.isLoading, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index 6c3e0924e..3e6c6479e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -18,7 +18,7 @@ import org.lightningdevkit.ldknode.UserChannelId import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult -import to.bitkit.ext.parse +import to.bitkit.ext.of import to.bitkit.ext.watchUntil import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -84,7 +84,7 @@ class ExternalNodeViewModel @Inject constructor( fun parseNodeUri(uriString: String) { viewModelScope.launch { - val result = runCatching { PeerDetails.parse(uriString) } + val result = runCatching { PeerDetails.of(uriString) } if (result.isSuccess) { _uiState.update { it.copy(peer = result.getOrNull()) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt index 78105bfa3..5f6a13d4b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt @@ -173,7 +173,7 @@ private fun InfoRow( private fun Preview() { AppThemeSurface { Content( - uiState = LnurlChannelUiState(peer = Peers.staging), + uiState = LnurlChannelUiState(peer = Peers.stag), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt index 21891070d..52401cc90 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.ext.parse +import to.bitkit.ext.of import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.ui.Routes @@ -38,7 +38,7 @@ class LnurlChannelViewModel @Inject constructor( viewModelScope.launch { lightningRepo.fetchLnurlChannelInfo(params.uri) .onSuccess { channelInfo -> - val peer = runCatching { PeerDetails.parse(channelInfo.uri) }.getOrElse { + val peer = runCatching { PeerDetails.of(channelInfo.uri) }.getOrElse { errorToast(it) return@onSuccess } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 5e8a6c64d..dd5f376c9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -115,13 +115,12 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel -import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.WalletViewModel @Composable fun HomeScreen( - mainUiState: MainUiState, + isRefreshing: Boolean, drawerState: DrawerState, rootNavController: NavController, walletNavController: NavHostController, @@ -157,7 +156,7 @@ fun HomeScreen( } Content( - mainUiState = mainUiState, + isRefreshing = isRefreshing, homeUiState = homeUiState, rootNavController = rootNavController, walletNavController = walletNavController, @@ -279,7 +278,7 @@ fun HomeScreen( @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable private fun Content( - mainUiState: MainUiState, + isRefreshing: Boolean, homeUiState: HomeUiState, rootNavController: NavController, walletNavController: NavController, @@ -313,11 +312,11 @@ private fun Content( val pullToRefreshState = rememberPullToRefreshState() PullToRefreshBox( state = pullToRefreshState, - isRefreshing = mainUiState.isRefreshing, + isRefreshing = isRefreshing, onRefresh = onRefresh, indicator = { Indicator( - isRefreshing = mainUiState.isRefreshing, + isRefreshing = isRefreshing, state = pullToRefreshState, modifier = Modifier .padding(top = heightStatusBar) @@ -679,7 +678,7 @@ private fun TopBar( ) } }, - colors = TopAppBarDefaults.largeTopAppBarColors(Color.Transparent), + colors = TopAppBarDefaults.topAppBarColors(Color.Transparent), modifier = Modifier.fillMaxWidth() ) } @@ -709,7 +708,7 @@ private fun Preview() { AppThemeSurface { Box { Content( - mainUiState = MainUiState(), + isRefreshing = false, homeUiState = HomeUiState( showWidgets = true, ), @@ -733,7 +732,7 @@ private fun PreviewEmpty() { AppThemeSurface { Box { Content( - mainUiState = MainUiState(), + isRefreshing = false, homeUiState = HomeUiState( showEmptyState = true, ), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index d071dfe6d..7b2b48671 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -82,7 +82,7 @@ fun SavingsWalletScreen( ScreenColumn(noBackground = true) { AppTopBar( titleText = stringResource(R.string.wallet__savings__title), - icon = painterResource(R.drawable.ic_btc_circle), + icon = R.drawable.ic_btc_circle, onBackClick = onBackClick, actions = { DrawerNavIcon() @@ -112,7 +112,7 @@ fun SavingsWalletScreen( if (canTransfer) { SecondaryButton( onClick = onTransferToSpendingClick, - text = "Transfer To Spending", // TODO add missing localized text + text = stringResource(R.string.wallet__transfer_to_spending), icon = { Icon( painter = painterResource(R.drawable.ic_transfer), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 82460dca1..5aeac50d9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.Activity +import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.ext.createChannelDetails import to.bitkit.models.BalanceState @@ -43,11 +44,10 @@ import to.bitkit.ui.screens.wallets.activity.utils.previewLightningActivityItems import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.MainUiState @Composable fun SpendingWalletScreen( - uiState: MainUiState, + channels: List, lightningActivities: List, onAllActivityButtonClick: () -> Unit, onActivityItemClick: (String) -> Unit, @@ -61,9 +61,9 @@ fun SpendingWalletScreen( val hasActivity = lightningActivities.isNotEmpty() mutableStateOf(hasLnFunds && !hasActivity) } - val canTransfer by remember(balances.totalLightningSats, uiState.channels.size) { + val canTransfer by remember(balances.totalLightningSats, channels.size) { val hasLnBalance = balances.totalLightningSats > 0uL - val hasChannels = uiState.channels.isNotEmpty() + val hasChannels = channels.isNotEmpty() mutableStateOf(hasLnBalance && hasChannels) } @@ -84,7 +84,7 @@ fun SpendingWalletScreen( ScreenColumn(noBackground = true) { AppTopBar( titleText = stringResource(R.string.wallet__spending__title), - icon = painterResource(R.drawable.ic_ln_circle), + icon = R.drawable.ic_ln_circle, onBackClick = onBackClick, actions = { DrawerNavIcon() @@ -114,7 +114,7 @@ fun SpendingWalletScreen( if (canTransfer) { SecondaryButton( onClick = onTransferToSavingsClick, - text = "Transfer To Savings", // TODO add missing localized text + text = stringResource(R.string.wallet__transfer_to_savings), icon = { Icon( painter = painterResource(R.drawable.ic_transfer), @@ -153,9 +153,7 @@ private fun Preview() { AppThemeSurface { Box { SpendingWalletScreen( - uiState = MainUiState( - channels = listOf(createChannelDetails()) - ), + channels = listOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -175,9 +173,7 @@ private fun PreviewTransfer() { AppThemeSurface { Box { SpendingWalletScreen( - uiState = MainUiState( - channels = listOf(createChannelDetails()) - ), + channels = listOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -200,9 +196,7 @@ private fun PreviewNoActivity() { AppThemeSurface { Box { SpendingWalletScreen( - uiState = MainUiState( - channels = listOf(createChannelDetails()) - ), + channels = listOf(createChannelDetails()), lightningActivities = emptyList(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -222,7 +216,7 @@ private fun PreviewEmpty() { AppThemeSurface { Box { SpendingWalletScreen( - uiState = MainUiState(), + channels = emptyList(), lightningActivities = emptyList(), onAllActivityButtonClick = {}, onActivityItemClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 0e2a543f7..b3568a750 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -57,7 +57,7 @@ import to.bitkit.ext.timestamp import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue -import to.bitkit.models.FeeRate +import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel @@ -436,7 +436,7 @@ private fun ActivityDetailContent( text = when { isTransferToSpending -> stringResource(R.string.wallet__activity_transfer_to_spending) isTransferFromSpending -> stringResource(R.string.wallet__activity_transfer_to_savings) - isSelfSend -> "Sent to myself" // TODO add missing localized text + isSelfSend -> stringResource(R.string.wallet__activity_sent_self) else -> stringResource(R.string.wallet__activity_payment) }, color = Colors.White64, @@ -739,8 +739,8 @@ private fun StatusSection( var statusTestTag: String? = null if (item.v1.isTransfer) { - val duration = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) - .removeEstimationSymbol() + val context = LocalContext.current + val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) statusText = stringResource(R.string.wallet__activity_transfer_pending) .replace("{duration}", duration) statusTestTag = "StatusTransfer" @@ -953,6 +953,3 @@ private fun isBoostCompleted( return activity.boostTxIds.any { boostTxDoesExist[it] == true } } } - -// TODO remove this method after transifex update -private fun String.removeEstimationSymbol() = this.replace("±", "") diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt index 6c47947fa..07580448a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt @@ -255,7 +255,7 @@ private fun Content( ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Previous month", + contentDescription = stringResource(R.string.wallet__activity_previous_month), tint = Colors.Brand ) } @@ -268,7 +268,7 @@ private fun Content( ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Next month", + contentDescription = stringResource(R.string.wallet__activity_next_month), tint = Colors.Brand ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index b7c9d7638..f6681ec7e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -37,7 +38,7 @@ import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue import to.bitkit.ext.txType -import to.bitkit.models.FeeRate +import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies @@ -117,6 +118,7 @@ fun ActivityRow( isTransfer = isTransfer, isCpfpChild = isCpfpChild ) + val context = LocalContext.current val subtitleText = when (item) { is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) } is Activity.Onchain -> { @@ -128,27 +130,25 @@ fun ActivityRow( isTransfer && isSent -> if (item.v1.confirmed) { stringResource(R.string.wallet__activity_transfer_spending_done) } else { - val duration = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) + val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) stringResource(R.string.wallet__activity_transfer_spending_pending) - .replace("{duration}", duration.removeEstimationSymbol()) + .replace("{duration}", duration) } isTransfer && !isSent -> if (item.v1.confirmed) { stringResource(R.string.wallet__activity_transfer_savings_done) } else { - val duration = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) + val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) stringResource(R.string.wallet__activity_transfer_savings_pending) - .replace("{duration}", duration.removeEstimationSymbol()) + .replace("{duration}", duration) } confirmed == true -> formattedTime(timestamp) else -> { - val feeDescription = FeeRate.getFeeDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_confirms_in).replace( - "{feeRateDescription}", - feeDescription - ) + val feeDescription = context.getFeeShortDescription(item.v1.feeRate, feeRates) + stringResource(R.string.wallet__activity_confirms_in) + .replace("{feeRateDescription}", feeDescription) } } } @@ -323,9 +323,6 @@ private fun formattedTime(timestamp: ULong): String { } } -// TODO remove this method after transifex update -private fun String.removeEstimationSymbol() = this.replace("±", "") - private class ActivityItemsPreviewProvider : PreviewParameterProvider { override val values: Sequence get() = previewActivityItems.asSequence() } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt index f88eba327..15ecf20d8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt @@ -65,7 +65,7 @@ fun ReceiveAmountScreen( val app = appViewModel ?: return val wallet = walletViewModel ?: return val blocktank = blocktankViewModel ?: return - val walletState by wallet.uiState.collectAsStateWithLifecycle() + val lightningState by wallet.lightningState.collectAsStateWithLifecycle() val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() var isCreatingInvoice by remember { mutableStateOf(false) } @@ -90,7 +90,7 @@ fun ReceiveAmountScreen( scope.launch { isCreatingInvoice = true runCatching { - require(walletState.nodeLifecycleState == NodeLifecycleState.Running) { + require(lightningState.nodeLifecycleState == NodeLifecycleState.Running) { "Should not be able to land on this screen if the node is not running." } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt index 611224d24..442133b71 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt @@ -39,10 +39,10 @@ import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.NotificationUtils import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.SettingsViewModel @@ -107,9 +107,7 @@ fun ReceiveConfirmScreen( receiveAmountFormatted = receiveAmountFormatted, onLearnMoreClick = onLearnMore, isAdditional = isAdditional, - onSystemSettingsClick = { - NotificationUtils.openNotificationSettings(context) - }, + onSystemSettingsClick = { context.openNotificationSettings() }, hasNotificationPermission = notificationsGranted, onContinueClick = { onContinue(entry.invoice) }, onBackClick = onBack, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt index 5f3b9c801..4cfc6b879 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt @@ -131,14 +131,14 @@ private fun Content( FillHeight() BodyM( - text = "Enable background setup to safely exit Bitkit while your balance is being configured.", + text = stringResource(R.string.wallet__receive_liquidity__bg_setup_desc), color = Colors.White64 ) VerticalSpacer(15.dp) SettingsSwitchRow( - title = "Set up in background", + title = stringResource(R.string.wallet__receive_liquidity__bg_setup_switch), isChecked = hasNotificationPermission, colors = AppSwitchDefaults.colorsPurple, onClick = onSwitchClick, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 09484c735..8b48768e4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -56,6 +56,8 @@ import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.ext.truncate import to.bitkit.models.NodeLifecycleState +import to.bitkit.repositories.LightningState +import to.bitkit.repositories.WalletState import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview @@ -77,14 +79,14 @@ import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.MainUiState @Suppress("CyclomaticComplexMethod") @OptIn(FlowPreview::class) @Composable fun ReceiveQrScreen( cjitInvoice: String?, - walletState: MainUiState, + walletState: WalletState, + lightningState: LightningState, onClickEditInvoice: () -> Unit, onClickReceiveCjit: () -> Unit, modifier: Modifier = Modifier, @@ -93,7 +95,7 @@ fun ReceiveQrScreen( SetMaxBrightness() val haptic = LocalHapticFeedback.current - val hasUsableChannels = walletState.channels.any { it.isChannelReady } + val hasUsableChannels = lightningState.channels.any { it.isChannelReady } var showDetails by remember { mutableStateOf(false) } @@ -113,7 +115,7 @@ fun ReceiveQrScreen( walletState.bolt11, walletState.onchainAddress, cjitInvoice, - walletState.nodeLifecycleState + lightningState.nodeLifecycleState ) { visibleTabs.associateWith { tab -> getInvoiceForTab( @@ -121,7 +123,7 @@ fun ReceiveQrScreen( bip21 = walletState.bip21, bolt11 = walletState.bolt11, cjitInvoice = cjitInvoice, - isNodeRunning = walletState.nodeLifecycleState.isRunning(), + isNodeRunning = lightningState.nodeLifecycleState.isRunning(), onchainAddress = walletState.onchainAddress ) } @@ -174,9 +176,9 @@ fun ReceiveQrScreen( } } - val showingCjitOnboarding = remember(walletState, cjitInvoice, hasUsableChannels) { + val showingCjitOnboarding = remember(lightningState, cjitInvoice, hasUsableChannels) { !hasUsableChannels && - walletState.nodeLifecycleState.isRunning() && + lightningState.nodeLifecycleState.isRunning() && cjitInvoice.isNullOrEmpty() } @@ -273,7 +275,7 @@ fun ReceiveQrScreen( Spacer(Modifier.height(24.dp)) - AnimatedVisibility(visible = walletState.nodeLifecycleState.isRunning()) { + AnimatedVisibility(visible = lightningState.nodeLifecycleState.isRunning()) { val showCjitButton = showingCjitOnboarding && selectedTab == ReceiveTab.SPENDING PrimaryButton( text = stringResource( @@ -467,7 +469,7 @@ fun CjitOnBoardingView(modifier: Modifier = Modifier) { @Composable private fun ReceiveDetailsView( tab: ReceiveTab, - walletState: MainUiState, + walletState: WalletState, cjitInvoice: String?, onClickEditInvoice: () -> Unit, modifier: Modifier = Modifier, @@ -639,9 +641,11 @@ private fun PreviewSavingsMode() { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = null, - walletState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Running, + walletState = WalletState( onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", + ), + lightningState = LightningState( + nodeLifecycleState = NodeLifecycleState.Running, channels = emptyList() ), onClickEditInvoice = {}, @@ -703,9 +707,7 @@ private fun PreviewAutoMode() { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = null, - walletState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Running, - channels = listOf(mockChannel), + walletState = WalletState( onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq", @@ -713,6 +715,10 @@ private fun PreviewAutoMode() { "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq", ), + lightningState = LightningState( + nodeLifecycleState = NodeLifecycleState.Running, + channels = listOf(mockChannel), + ), onClickEditInvoice = {}, modifier = Modifier.sheetHeight(), initialTab = ReceiveTab.AUTO, @@ -771,12 +777,14 @@ private fun PreviewSpendingMode() { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = null, - walletState = MainUiState( - nodeLifecycleState = NodeLifecycleState.Running, - channels = listOf(mockChannel), + walletState = WalletState( bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq" ), + lightningState = LightningState( + nodeLifecycleState = NodeLifecycleState.Running, + channels = listOf(mockChannel), + ), onClickEditInvoice = {}, modifier = Modifier.sheetHeight(), initialTab = ReceiveTab.SPENDING, @@ -793,7 +801,8 @@ private fun PreviewNodeNotReady() { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = null, - walletState = MainUiState( + walletState = WalletState(), + lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Starting, ), onClickReceiveCjit = {}, @@ -811,7 +820,8 @@ private fun PreviewSmall() { BottomSheetPreview { ReceiveQrScreen( cjitInvoice = null, - walletState = MainUiState( + walletState = WalletState(), + lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Running, ), onClickEditInvoice = {}, @@ -835,7 +845,7 @@ private fun PreviewDetailsMode() { ) { ReceiveDetailsView( tab = ReceiveTab.AUTO, - walletState = MainUiState( + walletState = WalletState( onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79...", ), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 3cee808dc..d948c29e8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -17,19 +17,19 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.serialization.Serializable import to.bitkit.repositories.LightningState +import to.bitkit.repositories.WalletState import to.bitkit.ui.screens.wallets.send.AddTagScreen +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.utils.NotificationUtils import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.AmountInputViewModel -import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SettingsViewModel @Composable fun ReceiveSheet( navigateToExternalConnection: () -> Unit, - walletState: MainUiState, + walletState: WalletState, editInvoiceAmountViewModel: AmountInputViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel(), ) { @@ -67,6 +67,7 @@ fun ReceiveSheet( ReceiveQrScreen( cjitInvoice = cjitInvoice.value, walletState = walletState, + lightningState = lightningState, onClickReceiveCjit = { if (lightningState.isGeoBlocked) { navController.navigate(ReceiveRoute.GeoBlock) @@ -130,9 +131,7 @@ fun ReceiveSheet( onContinue = { navController.popBackStack() }, onBack = { navController.popBackStack() }, hasNotificationPermission = notificationsGranted, - onSwitchClick = { - NotificationUtils.openNotificationSettings(context) - }, + onSwitchClick = { context.openNotificationSettings() }, ) } } @@ -147,9 +146,7 @@ fun ReceiveSheet( isAdditional = true, onBack = { navController.popBackStack() }, hasNotificationPermission = notificationsGranted, - onSwitchClick = { - NotificationUtils.openNotificationSettings(context) - }, + onSwitchClick = { context.openNotificationSettings() }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index 215902134..292aeaca3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -60,7 +60,6 @@ import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.AmountInputUiState import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.LnurlParams -import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState @@ -70,7 +69,7 @@ import to.bitkit.viewmodels.previewAmountInputViewModel @Composable fun SendAmountScreen( uiState: SendUiState, - walletUiState: MainUiState, + nodeLifecycleState: NodeLifecycleState, canGoBack: Boolean, onBack: () -> Unit, onEvent: (SendEvent) -> Unit, @@ -99,7 +98,7 @@ fun SendAmountScreen( } SendAmountContent( - walletUiState = walletUiState, + nodeLifecycleState = nodeLifecycleState, uiState = uiState, amountInputViewModel = amountInputViewModel, currencies = currencies, @@ -125,7 +124,7 @@ fun SendAmountScreen( @Suppress("ViewModelForwarding") @Composable fun SendAmountContent( - walletUiState: MainUiState, + nodeLifecycleState: NodeLifecycleState, uiState: SendUiState, amountInputViewModel: AmountInputViewModel, modifier: Modifier = Modifier, @@ -154,7 +153,7 @@ fun SendAmountContent( onBack = onBack, ) - when (walletUiState.nodeLifecycleState) { + when (nodeLifecycleState) { is NodeLifecycleState.Running -> { SendAmountNodeRunning( amountInputViewModel = amountInputViewModel, @@ -328,7 +327,7 @@ private fun PreviewLightningNoAmount() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, ), @@ -351,7 +350,7 @@ private fun PreviewUnified() { ) } SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, isUnified = true, @@ -371,7 +370,7 @@ private fun PreviewOnchain() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.ONCHAIN, ), @@ -389,7 +388,7 @@ private fun PreviewInitializing() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Initializing), + nodeLifecycleState = NodeLifecycleState.Initializing, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, ), @@ -406,7 +405,7 @@ private fun PreviewWithdraw() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, lnurl = LnurlParams.LnurlWithdraw( @@ -435,7 +434,7 @@ private fun PreviewLnurlPay() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, lnurl = LnurlParams.LnurlPay( @@ -465,7 +464,7 @@ private fun PreviewSmallScreen() { AppThemeSurface { BottomSheetPreview { SendAmountContent( - walletUiState = MainUiState(nodeLifecycleState = NodeLifecycleState.Running), + nodeLifecycleState = NodeLifecycleState.Running, uiState = SendUiState( payMethod = SendMethod.LIGHTNING, ), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt index 33c03430c..0cc33ade3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.di.BgDispatcher -import to.bitkit.env.TransactionDefaults +import to.bitkit.env.Defaults import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo @@ -133,8 +133,8 @@ class SendCoinSelectionViewModel @Inject constructor( } private fun validateCoinSelection(totalSelectedSat: ULong, totalRequiredSat: ULong): Boolean { - return totalSelectedSat > TransactionDefaults.dustLimit && - totalRequiredSat > TransactionDefaults.dustLimit && + return totalSelectedSat > Defaults.dustLimit && + totalRequiredSat > Defaults.dustLimit && totalSelectedSat >= totalRequiredSat } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt index 186b85b88..bcaef17eb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt @@ -54,7 +54,7 @@ fun SendQuickPayScreen( DisposableEffect(Unit) { onDispose { - app.resetQuickPayData() + app.resetQuickPay() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt index bae5d34bf..f2f7b6f65 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/withdraw/WithdrawErrorScreen.kt @@ -56,8 +56,7 @@ fun WithdrawErrorScreen( VerticalSpacer(46.dp) BodyM( - // TODO add missing localized text - text = "Your withdrawal was unsuccessful. Please scan the QR code again or contact support.", + text = stringResource(R.string.wallet__withdraw_error), color = Colors.White64, ) diff --git a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt index 558d127f2..903b61124 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -124,7 +124,7 @@ private fun BackupSettingsScreenContent( if (Env.isE2eTest && allSynced) { Icon( painter = painterResource(R.drawable.ic_check_circle), - contentDescription = "All Synced", + contentDescription = null, tint = Colors.Green, modifier = Modifier .padding(end = 4.dp) diff --git a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt index 47778d650..c09d03807 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt @@ -50,7 +50,7 @@ fun BlocktankRegtestScreen( val coroutineScope = rememberCoroutineScope() val wallet = walletViewModel ?: return val app = appViewModel ?: return - val uiState by wallet.uiState.collectAsStateWithLifecycle() + val walletState by wallet.walletState.collectAsStateWithLifecycle() ScreenColumn { AppTopBar( @@ -65,7 +65,7 @@ fun BlocktankRegtestScreen( .verticalScroll(rememberScrollState()) .imePadding() ) { - var depositAddress by remember { mutableStateOf(uiState.onchainAddress) } + var depositAddress by remember { mutableStateOf(walletState.onchainAddress) } var depositAmount by remember { mutableStateOf("100000") } var mineBlockCount by remember { mutableStateOf("1") } var paymentInvoice by remember { mutableStateOf("") } diff --git a/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt index 3b21f3a2d..9c0276207 100644 --- a/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/LanguageSettingsScreen.kt @@ -8,10 +8,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R import to.bitkit.models.Language import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.settings.SettingsButtonRow @@ -53,7 +55,7 @@ private fun Content( modifier = modifier.screen() ) { AppTopBar( - titleText = "Language", // TODO Transifex + titleText = stringResource(R.string.settings__language_title), onBackClick = onBackClick, actions = { DrawerNavIcon() } ) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt index 9555246b5..dbe193a4c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/CoinSelectPreferenceScreen.kt @@ -143,8 +143,8 @@ private fun Content( // ) SettingsButtonRow( - title = "Branch and Bound", // TODO add missing localized text - description = "Finds exact amount matches to minimize change", // TODO add missing localized text + title = stringResource(R.string.settings__cs__bnb_title), + description = stringResource(R.string.settings__cs__bnb_desc), value = SettingsButtonValue.BooleanValue( uiState.coinSelectionPreference == CoinSelectionPreference.BranchAndBound ), @@ -153,8 +153,8 @@ private fun Content( ) SettingsButtonRow( - title = "Single Random Draw", // TODO add missing localized text - description = "Random selection for privacy", // TODO add missing localized text + title = stringResource(R.string.settings__cs__srd_title), + description = stringResource(R.string.settings__cs__srd_desc), value = SettingsButtonValue.BooleanValue( uiState.coinSelectionPreference == CoinSelectionPreference.SingleRandomDraw ), diff --git a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt index 9fe6a8799..3f1e8c174 100644 --- a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt @@ -31,7 +31,6 @@ import to.bitkit.R import to.bitkit.ext.startActivityAppSettings import to.bitkit.ext.toLocalizedTimestamp import to.bitkit.models.HealthState -import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.AppHealthState import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyMSB @@ -262,7 +261,7 @@ private fun Preview() { backups = HealthState.READY, ), backupSubtitle = now().minus(3.minutes).toEpochMilliseconds().toLocalizedTimestamp(), - nodeSubtitle = NodeLifecycleState.Running.uiText, + nodeSubtitle = "Running", ), ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt index 44199deca..5c2f1d03c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusViewModel.kt @@ -48,7 +48,7 @@ class AppStatusViewModel @Inject constructor( backupSubtitle = computeBackupSubtitle(healthState.backups, backupStatuses), nodeSubtitle = when (healthState.node) { HealthState.ERROR -> context.getString(R.string.settings__status__lightning_node__error) - else -> lightningState.nodeLifecycleState.uiText + else -> lightningState.nodeLifecycleState.uiText(context) }, ) }.collect { newState -> diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt index 08204ea77..73c8a8457 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsIntroScreen.kt @@ -36,7 +36,7 @@ fun BackgroundPaymentsIntroScreen( modifier = modifier.screen() ) { AppTopBar( - titleText = "Background Payments", // Todo Transifex + titleText = stringResource(R.string.settings__bg__title), onBackClick = onBack, actions = { DrawerNavIcon() }, ) @@ -66,11 +66,11 @@ fun BackgroundPaymentsIntroContent( ) Display( - text = "GET PAID\nPASSIVELY".withAccent(accentColor = Colors.Blue), - color = Colors.White + text = stringResource(R.string.settings__bg__intro_title).withAccent(accentColor = Colors.Blue), + color = Colors.White, ) VerticalSpacer(8.dp) - BodyM(text = "Turn on notifications to get paid, even when your Bitkit app is closed.", color = Colors.White64) + BodyM(text = stringResource(R.string.settings__bg__intro_desc), color = Colors.White64) VerticalSpacer(32.dp) PrimaryButton( text = stringResource(R.string.common__continue), diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index 562bbfe62..bb26bad83 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -27,52 +28,48 @@ import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.shared.util.screen +import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.NotificationUtils import to.bitkit.ui.utils.RequestNotificationPermissions import to.bitkit.viewmodels.SettingsViewModel @Composable fun BackgroundPaymentsSettings( - onBack: () -> Unit, settingsViewModel: SettingsViewModel = hiltViewModel(), + onBack: () -> Unit, ) { val context = LocalContext.current val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() val showNotificationDetails by settingsViewModel.showNotificationDetails.collectAsStateWithLifecycle() RequestNotificationPermissions( - onPermissionChange = { granted -> - settingsViewModel.setNotificationPreference(granted) - }, - showPermissionDialog = false + onPermissionChange = settingsViewModel::setNotificationPreference, + showPermissionDialog = false, ) Content( - onBack = onBack, - onSystemSettingsClick = { - NotificationUtils.openNotificationSettings(context) - }, hasPermission = notificationsGranted, showDetails = showNotificationDetails, + onBack = onBack, + onSystemSettingsClick = context::openNotificationSettings, toggleNotificationDetails = settingsViewModel::toggleNotificationDetails, ) } @Composable private fun Content( + hasPermission: Boolean, + showDetails: Boolean, onBack: () -> Unit, onSystemSettingsClick: () -> Unit, toggleNotificationDetails: () -> Unit, - hasPermission: Boolean, - showDetails: Boolean, ) { Column( modifier = Modifier.screen() ) { AppTopBar( - titleText = "Background Payments", + titleText = stringResource(R.string.settings__bg__title), onBackClick = onBack, actions = { DrawerNavIcon() }, ) @@ -85,15 +82,14 @@ private fun Content( VerticalSpacer(16.dp) SettingsSwitchRow( - title = "Get paid when Bitkit is closed", + title = stringResource(R.string.settings__bg__switch_title), isChecked = hasPermission, - onClick = onSystemSettingsClick + onClick = onSystemSettingsClick, ) if (hasPermission) { - @Suppress("MaxLineLength") // TODO transifex BodyM( - text = "Background payments are enabled. You can receive funds even when the app is closed (if your device is connected to the internet).", + text = stringResource(R.string.settings__bg__enabled), color = Colors.White64, modifier = Modifier.padding(vertical = 16.dp), ) @@ -104,14 +100,14 @@ private fun Content( modifier = Modifier.padding(vertical = 16.dp) ) { BodyMB( - text = "Background payments are disabled, because you have denied notifications.", + text = stringResource(R.string.settings__bg__disabled), color = Colors.Red, ) } NotificationPreview( enabled = hasPermission, - title = "Payment Received", + title = stringResource(R.string.notification_received_title), description = "₿ 21 000", showDetails = showDetails, modifier = Modifier.fillMaxWidth() @@ -120,12 +116,12 @@ private fun Content( VerticalSpacer(32.dp) Text13Up( - text = "Privacy", + text = stringResource(R.string.settings__bg__privacy_header), color = Colors.White64 ) SettingsButtonRow( - "Include amount in notifications", + stringResource(R.string.settings__bg__include_amount), value = SettingsButtonValue.BooleanValue(showDetails), onClick = toggleNotificationDetails, ) @@ -133,18 +129,16 @@ private fun Content( VerticalSpacer(32.dp) Text13Up( - text = "Notifications", + text = stringResource(R.string.settings__bg__notifications_header), color = Colors.White64 ) VerticalSpacer(16.dp) SecondaryButton( - "Customize in Android Bitkit Settings", - icon = { - Image(painter = painterResource(R.drawable.ic_bell), contentDescription = null) - }, - onClick = onSystemSettingsClick + stringResource(R.string.settings__bg__customize), + icon = { Image(painter = painterResource(R.drawable.ic_bell), contentDescription = null) }, + onClick = onSystemSettingsClick, ) } } @@ -155,11 +149,11 @@ private fun Content( private fun Preview1() { AppThemeSurface { Content( + hasPermission = true, + showDetails = true, onBack = {}, onSystemSettingsClick = {}, toggleNotificationDetails = {}, - hasPermission = true, - showDetails = true, ) } } @@ -169,11 +163,11 @@ private fun Preview1() { private fun Preview2() { AppThemeSurface { Content( + hasPermission = false, + showDetails = false, onBack = {}, onSystemSettingsClick = {}, toggleNotificationDetails = {}, - hasPermission = false, - showDetails = false, ) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt index cc63bfc56..1e188b51c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/general/GeneralSettingsScreen.kt @@ -109,7 +109,7 @@ private fun GeneralSettingsContent( .verticalScroll(rememberScrollState()) ) { SettingsButtonRow( - title = "Language", + title = stringResource(R.string.settings__language_title), value = SettingsButtonValue.StringValue(selectedLanguage), onClick = onLanguageSettingsClick, modifier = Modifier.testTag("LanguageSettings") @@ -155,9 +155,11 @@ private fun GeneralSettingsContent( modifier = Modifier.testTag("QuickpaySettings") ) SettingsButtonRow( - title = "Background Payments", // TODO Transifex + title = stringResource(R.string.settings__bg__title), onClick = onBgPaymentsClick, - value = SettingsButtonValue.StringValue(if (notificationsGranted) "On" else "Off"), + value = SettingsButtonValue.StringValue( + stringResource(if (notificationsGranted) R.string.settings__bg__on else R.string.settings__bg__off) + ), modifier = Modifier.testTag("BackgroundPaymentSettings") ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 3081689de..e67caecf8 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -97,8 +97,7 @@ fun ChannelDetailScreen( val paidOrders by viewModel.blocktankRepo.blocktankState.collectAsStateWithLifecycle() val isClosedChannel = uiState.closedChannels.any { it.details.channelId == channel.details.channelId } - val txDetails by viewModel.txDetails.collectAsStateWithLifecycle() - val walletState by wallet.uiState.collectAsStateWithLifecycle() + val lightningState by wallet.lightningState.collectAsStateWithLifecycle() // Fetch transaction details for funding transaction if available LaunchedEffect(channel.details.fundingTxo?.txid) { @@ -140,7 +139,7 @@ fun ChannelDetailScreen( val intent = Intent(Intent.ACTION_VIEW, url.toUri()) context.startActivity(intent) }, - onSupport = { order -> contactSupport(order, channel, walletState.nodeId, context) }, + onSupport = { order -> contactSupport(order, channel, lightningState.nodeId, context) }, onCloseConnection = { navController.navigate(Routes.CloseConnection) }, ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt index 01ea002d1..5e2799ff7 100644 --- a/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/transactionSpeed/CustomFeeSettingsScreen.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R -import to.bitkit.env.TransactionDefaults +import to.bitkit.env.Defaults import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.ConvertedAmount import to.bitkit.models.TransactionSpeed @@ -50,7 +50,7 @@ fun CustomFeeSettingsScreen( var input by remember { mutableStateOf((customFeeRate.value as? TransactionSpeed.Custom)?.satsPerVByte?.toString() ?: "") } - val totalFee = TransactionDefaults.recommendedBaseFee * (input.toUIntOrNull() ?: 0u) + val totalFee = Defaults.recommendedBaseFee * (input.toUIntOrNull() ?: 0u) LaunchedEffect(input) { val inputNum = input.toLongOrNull() ?: 0 diff --git a/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt index feae729ea..7daaf880d 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BackgroundPaymentsIntroSheet.kt @@ -6,7 +6,9 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import to.bitkit.R import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsIntroContent @@ -27,9 +29,7 @@ fun BackgroundPaymentsIntroSheet( .navigationBarsPadding() .testTag("background_payments_intro_sheet") ) { - SheetTopBar( - titleText = "Background Payments", // Todo Transifex - ) + SheetTopBar(titleText = stringResource(R.string.settings__bg__title)) BackgroundPaymentsIntroContent(onContinue = onContinue) } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt index f9d38456a..2ba8313cb 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt @@ -302,7 +302,7 @@ private fun CustomModeContent( backgroundColor = Colors.Red16, enabled = uiState.decreaseEnabled, onClick = { onChangeAmount(false) }, - contentDescription = "Reduce fee", + contentDescription = stringResource(R.string.wallet__boost_decrease_fee), modifier = Modifier.testTag(BoostTransactionTestTags.DECREASE_FEE_BUTTON) ) @@ -349,7 +349,7 @@ private fun CustomModeContent( backgroundColor = Colors.Green16, enabled = uiState.increaseEnabled, onClick = { onChangeAmount(true) }, - contentDescription = "Increase fee", + contentDescription = stringResource(R.string.wallet__boost_increase_fee), modifier = Modifier.testTag(BoostTransactionTestTags.INCREASE_FEE_BUTTON) ) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index 22d219d20..e2b5d8004 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -1,11 +1,13 @@ package to.bitkit.ui.sheets +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -16,8 +18,10 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.ext.BoostType import to.bitkit.ext.boostType import to.bitkit.ext.nowTimestamp +import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.utils.Logger @@ -26,9 +30,11 @@ import javax.inject.Inject @HiltViewModel class BoostTransactionViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, private val activityRepo: ActivityRepo, + private val blocktankRepo: BlocktankRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(BoostTransactionUiState()) @@ -37,7 +43,7 @@ class BoostTransactionViewModel @Inject constructor( private val _boostTransactionEffect = MutableSharedFlow(extraBufferCapacity = 1) val boostTransactionEffect = _boostTransactionEffect.asSharedFlow() - private companion object { + companion object { const val TAG = "BoostTransactionViewModel" const val MAX_FEE_PERCENTAGE = 0.5 const val MAX_FEE_RATE = 100UL @@ -133,6 +139,8 @@ class BoostTransactionViewModel @Inject constructor( val currentFee = activity?.v1?.fee ?: 0u val isIncreaseEnabled = totalFee < maxTotalFee && feeRate < MAX_FEE_RATE val isDecreaseEnabled = totalFee > currentFee && feeRate > minFeeRate + val feeRates = blocktankRepo.blocktankState.value.info?.onchain?.feeRates + val estimateTime = context.getFeeShortDescription(feeRate, feeRates) _uiState.update { it.copy( @@ -141,6 +149,7 @@ class BoostTransactionViewModel @Inject constructor( increaseEnabled = isIncreaseEnabled, decreaseEnabled = isDecreaseEnabled, loading = false, + estimateTime = estimateTime, ) } } @@ -180,7 +189,7 @@ class BoostTransactionViewModel @Inject constructor( private suspend fun handleRbfBoost(activity: Activity.Onchain) { lightningRepo.bumpFeeByRbf( - satsPerVByte = _uiState.value.feeRate.toUInt(), + satsPerVByte = _uiState.value.feeRate, originalTxId = activity.v1.txId ).fold( onSuccess = { newTxId -> @@ -194,7 +203,7 @@ class BoostTransactionViewModel @Inject constructor( private suspend fun handleCpfpBoost(activity: Activity.Onchain) { lightningRepo.accelerateByCpfp( - satsPerVByte = _uiState.value.feeRate.toUInt(), + satsPerVByte = _uiState.value.feeRate, originalTxId = activity.v1.txId, destinationAddress = walletRepo.getOnchainAddress(), ).fold( @@ -370,6 +379,6 @@ data class BoostTransactionUiState( val increaseEnabled: Boolean = true, val boosting: Boolean = false, val loading: Boolean = false, - val estimateTime: String = "±10-20 minutes", // TODO: Implement dynamic time estimation + val estimateTime: String = "", val isRbf: Boolean = false, ) diff --git a/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt index 36c951e96..711b13ebd 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/LnurlAuthSheet.kt @@ -62,13 +62,11 @@ private fun Content( .navigationBarsPadding() .padding(horizontal = 16.dp) ) { - // TODO add missing localized text - SheetTopBar(titleText = "Log In") + SheetTopBar(titleText = stringResource(R.string.other__lnurl_auth_login_title)) VerticalSpacer(16.dp) BodyM( - // TODO add missing localized text - text = "Log in to {domain}?".replace("{domain}", domain), + text = stringResource(R.string.other__lnurl_auth_login_prompt).replace("{domain}", domain), color = Colors.White64, ) @@ -93,9 +91,8 @@ private fun Content( .weight(1f) .testTag("LnurlAuthCancel") ) - // TODO add missing localized text PrimaryButton( - text = "Log In", + text = stringResource(R.string.other__lnurl_auth_login_button), onClick = onContinue, fullWidth = false, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 5c4f49e59..2998520c5 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -56,7 +56,7 @@ fun SendSheet( // always reset state on new user-initiated send if (startDestination == SendRoute.Recipient) { appViewModel.resetSendState() - appViewModel.resetQuickPayData() + appViewModel.resetQuickPay() } } Column( @@ -111,10 +111,10 @@ fun SendSheet( } composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - val walletUiState by walletViewModel.uiState.collectAsStateWithLifecycle() + val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() SendAmountScreen( uiState = uiState, - walletUiState = walletUiState, + nodeLifecycleState = lightningState.nodeLifecycleState, canGoBack = startDestination != SendRoute.Amount, onBack = { if (!navController.popBackStack()) { @@ -167,12 +167,12 @@ fun SendSheet( } composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() - val walletUiState by walletViewModel.uiState.collectAsStateWithLifecycle() + val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() SendConfirmScreen( savedStateHandle = it.savedStateHandle, uiState = uiState, - isNodeRunning = walletUiState.nodeLifecycleState.isRunning(), + isNodeRunning = lightningState.nodeLifecycleState.isRunning(), canGoBack = startDestination != SendRoute.Confirm, onBack = { if (!navController.popBackStack()) { diff --git a/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt b/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt deleted file mode 100644 index 61445ae23..000000000 --- a/app/src/main/java/to/bitkit/ui/utils/NotificationUtils.kt +++ /dev/null @@ -1,49 +0,0 @@ -package to.bitkit.ui.utils - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import android.provider.Settings -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat -import to.bitkit.utils.Logger - -object NotificationUtils { - /** - * Opens the Android system notification settings for the app. - * On Android 8.0+ (API 26+), opens the app's notification settings. - * On older versions, opens the general app settings. - */ - fun openNotificationSettings(context: Context) { - val intent = - Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } - - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - runCatching { - context.startActivity(intent) - }.onFailure { e -> - Logger.error("Failed to open notification settings", e = e, context = "NotificationUtils") - } - } - - /** - * Checks if notification permissions are granted. - * For Android 13+ (API 33+), checks the POST_NOTIFICATIONS permission. - * For older versions, checks if notifications are enabled via NotificationManagerCompat. - */ - fun areNotificationsEnabled(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - } else { - NotificationManagerCompat.from(context).areNotificationsEnabled() - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt index 490ff9d03..978554396 100644 --- a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt +++ b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import to.bitkit.ui.areNotificationsEnabled @Composable fun RequestNotificationPermissions( @@ -30,7 +31,7 @@ fun RequestNotificationPermissions( val requiresPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU var isGranted by remember { - mutableStateOf(NotificationUtils.areNotificationsEnabled(context)) + mutableStateOf(context.areNotificationsEnabled()) } // Permission request launcher @@ -43,7 +44,7 @@ fun RequestNotificationPermissions( // Request permission on first composition if needed LaunchedEffect(Unit) { - val currentPermissionState = NotificationUtils.areNotificationsEnabled(context) + val currentPermissionState = context.areNotificationsEnabled() isGranted = currentPermissionState currentOnPermissionChange(currentPermissionState) @@ -56,7 +57,7 @@ fun RequestNotificationPermissions( DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - val currentPermissionState = NotificationUtils.areNotificationsEnabled(context) + val currentPermissionState = context.areNotificationsEnabled() if (currentPermissionState != isGranted) { isGranted = currentPermissionState currentOnPermissionChange(currentPermissionState) diff --git a/app/src/main/java/to/bitkit/utils/Crypto.kt b/app/src/main/java/to/bitkit/utils/Crypto.kt index dbce66c8e..d040500ff 100644 --- a/app/src/main/java/to/bitkit/utils/Crypto.kt +++ b/app/src/main/java/to/bitkit/utils/Crypto.kt @@ -61,7 +61,7 @@ class Crypto @Inject constructor() { } } } catch (e: Exception) { - throw CryptoError.SecurityProviderSetupFailed + throw CryptoError.SecurityProviderSetupFailed() } } @@ -80,7 +80,7 @@ class Crypto @Inject constructor() { publicKey = publicKey, ) } catch (e: Exception) { - throw CryptoError.KeypairGenerationFailed + throw CryptoError.KeypairGenerationFailed() } } @@ -111,7 +111,7 @@ class Crypto @Inject constructor() { return baseSecret } catch (e: Exception) { - throw CryptoError.SharedSecretGenerationFailed + throw CryptoError.SharedSecretGenerationFailed() } } @@ -139,7 +139,7 @@ class Crypto @Inject constructor() { return cipher.doFinal(encryptedPayload.cipher + encryptedPayload.tag) } catch (e: Exception) { - throw CryptoError.DecryptionFailed + throw CryptoError.DecryptionFailed() } } @@ -171,7 +171,7 @@ class Crypto @Inject constructor() { val recId = calculateRecoveryId(r, s, messageHash, privateKeyBigInt, curve) formatSignature(recId, r, s) } - }.getOrElse { throw CryptoError.SigningFailed } + }.getOrElse { throw CryptoError.SigningFailed() } fun getPublicKey(privateKey: ByteArray): ByteArray = runCatching { val keyFactory = KeyFactory.getInstance("EC", "BC") @@ -180,7 +180,7 @@ class Crypto @Inject constructor() { val publicKeyPoint = params.g.multiply((privateKeyObj as ECPrivateKey).d) publicKeyPoint.getEncoded(true) - }.getOrElse { throw CryptoError.PublicKeyCreationFailed } + }.getOrElse { throw CryptoError.PublicKeyCreationFailed() } private fun calculateRecoveryId( r: BigInteger, @@ -214,7 +214,7 @@ class Crypto @Inject constructor() { continue } } - throw CryptoError.SigningFailed + throw CryptoError.SigningFailed() } private fun formatSignature(recId: Int, r: BigInteger, s: BigInteger): String { @@ -231,10 +231,10 @@ class Crypto @Inject constructor() { } sealed class CryptoError(message: String) : AppError(message) { - data object SharedSecretGenerationFailed : CryptoError("Shared secret generation failed") - data object SecurityProviderSetupFailed : CryptoError("Security provider setup failed") - data object KeypairGenerationFailed : CryptoError("Keypair generation failed") - data object DecryptionFailed : CryptoError("Decryption failed") - data object SigningFailed : CryptoError("Signing failed") - data object PublicKeyCreationFailed : CryptoError("Public key creation failed") + class SharedSecretGenerationFailed : CryptoError("Shared secret generation failed") + class SecurityProviderSetupFailed : CryptoError("Security provider setup failed") + class KeypairGenerationFailed : CryptoError("Keypair generation failed") + class DecryptionFailed : CryptoError("Decryption failed") + class SigningFailed : CryptoError("Signing failed") + class PublicKeyCreationFailed : CryptoError("Public key creation failed") } diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 9796db4bf..8855617fd 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -5,33 +5,17 @@ package to.bitkit.utils import org.lightningdevkit.ldknode.BuildException import org.lightningdevkit.ldknode.NodeException -// TODO add cause as inner exception -open class AppError(override val message: String? = null) : Exception(message) { - companion object { - @Suppress("ConstPropertyName") - private const val serialVersionUID = 1L - } - - constructor(cause: Throwable) : this("${cause::class.simpleName}='${cause.message}'") - - fun readResolve(): Any { - // Return a new instance of the class, or handle it if needed - return this - } -} +open class AppError(override val message: String? = null) : Exception(message) sealed class ServiceError(message: String) : AppError(message) { - data object NodeNotSetup : ServiceError("Node is not setup") - data object NodeNotStarted : ServiceError("Node is not started") - data object NodeStartTimeout : ServiceError("Node took too long to start") - class LdkNodeSqliteAlreadyExists(path: String) : ServiceError("LDK-node SQLite file already exists at $path") - data object LdkToLdkNodeMigration : ServiceError("LDK to LDK-node migration issue") - data object MnemonicNotFound : ServiceError("Mnemonic not found") - data object NodeStillRunning : ServiceError("Node is still running") - data object InvalidNodeSigningMessage : ServiceError("Invalid node signing message") - data object CurrencyRateUnavailable : ServiceError("Currency rate unavailable") - data object BlocktankInfoUnavailable : ServiceError("Blocktank info not available") - data object GeoBlocked : ServiceError("Geo blocked user") + class NodeNotSetup : ServiceError("Node is not setup") + class NodeNotStarted : ServiceError("Node is not started") + class MnemonicNotFound : ServiceError("Mnemonic not found") + class NodeStillRunning : ServiceError("Node is still running") + class InvalidNodeSigningMessage : ServiceError("Invalid node signing message") + class CurrencyRateUnavailable : ServiceError("Currency rate unavailable") + class BlocktankInfoUnavailable : ServiceError("Blocktank info not available") + class GeoBlocked : ServiceError("Geo blocked user") } // region ldk @@ -129,6 +113,4 @@ class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") } // endregion -/** Check if the throwable is a TxSyncTimeout exception. */ -fun Throwable.isTxSyncTimeout(): Boolean = - this is NodeException.TxSyncTimeout || cause is NodeException.TxSyncTimeout +fun Throwable.isTxSyncTimeout(): Boolean = this is NodeException.TxSyncTimeout || cause is NodeException.TxSyncTimeout diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 31186901d..2b3b2b3aa 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -60,8 +60,8 @@ import to.bitkit.data.resetPin import to.bitkit.di.BgDispatcher import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.env.Defaults import to.bitkit.env.Env -import to.bitkit.env.TransactionDefaults import to.bitkit.ext.WatchResult import to.bitkit.ext.amountOnClose import to.bitkit.ext.getClipboardText @@ -820,7 +820,7 @@ class AppViewModel @Inject constructor( } } - SendMethod.ONCHAIN -> amount > TransactionDefaults.dustLimit.toULong() + SendMethod.ONCHAIN -> amount > Defaults.dustLimit.toULong() } } @@ -853,7 +853,7 @@ class AppViewModel @Inject constructor( private suspend fun handleScan(result: String) = withContext(bgDispatcher) { // always reset state on new scan resetSendState() - resetQuickPayData() + resetQuickPay() @Suppress("ForbiddenComment") // TODO: wrap `decode` from bindings in a `CoreService` method and call that one val scan = runCatching { decode(result) } @@ -971,8 +971,8 @@ class AppViewModel @Inject constructor( if (!lightningRepo.canSend(invoice.amountSatoshis)) { toast( type = Toast.ToastType.ERROR, - title = "Insufficient Funds", - description = "You do not have enough funds to send this payment." + title = context.getString(R.string.wallet__error_insufficient_funds_title), + description = context.getString(R.string.wallet__error_insufficient_funds_msg) ) return } @@ -1317,7 +1317,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { - toast(Exception("Error fetching lnurl invoice")) + toast(Exception(context.getString(R.string.wallet__error_lnurl_invoice_fetch))) hideSheet() return } @@ -1330,7 +1330,7 @@ class AppViewModel @Inject constructor( val validatedAddress = runCatching { validateBitcoinAddress(address) } .getOrElse { e -> Logger.error("Invalid bitcoin send address: '$address'", e, context = TAG) - toast(Exception("Invalid bitcoin send address")) + toast(Exception(context.getString(R.string.wallet__error_invalid_bitcoin_address))) hideSheet() return } @@ -1354,8 +1354,8 @@ class AppViewModel @Inject constructor( Logger.error(msg = "Error sending onchain payment", e = e, context = TAG) toast( type = Toast.ToastType.ERROR, - title = "Error Sending", - description = e.message ?: "Unknown error" + title = context.getString(R.string.wallet__error_sending_title), + description = e.message ?: context.getString(R.string.common__error_desc) ) hideSheet() } @@ -1564,7 +1564,7 @@ class AppViewModel @Inject constructor( } } - fun resetQuickPayData() = _quickPayData.update { null } + fun resetQuickPay() = _quickPayData.update { null } /** Reselect utxos for current amount & speed then refresh fees using updated utxos */ private fun refreshOnchainSendIfNeeded() { @@ -1584,13 +1584,11 @@ class AppViewModel @Inject constructor( .mapCatching { satsPerVByte -> lightningRepo.determineUtxosToSpend( sats = currentState.amount, - satsPerVByte = satsPerVByte.toUInt(), + satsPerVByte = satsPerVByte, ) } .onSuccess { utxos -> - _sendUiState.update { - it.copy(selectedUtxos = utxos) - } + _sendUiState.update { it.copy(selectedUtxos = utxos) } } } refreshFeeEstimates() @@ -1810,7 +1808,11 @@ class AppViewModel @Inject constructor( } fun toast(error: Throwable) { - toast(type = Toast.ToastType.ERROR, title = "Error", description = error.message ?: "Unknown error") + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = error.message ?: context.getString(R.string.common__error_desc) + ) } fun toast(toast: Toast) { diff --git a/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt index 2ea8afa21..d8aced251 100644 --- a/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.data.backup.VssBackupClient import to.bitkit.di.BgDispatcher -import to.bitkit.ext.parse +import to.bitkit.ext.of import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.services.NetworkGraphInfo @@ -77,7 +77,7 @@ class LdkDebugViewModel @Inject constructor( viewModelScope.launch(bgDispatcher) { _uiState.update { it.copy(isLoading = true) } runCatching { - val peer = PeerDetails.parse(uri) + val peer = PeerDetails.of(uri) lightningRepo.connectPeer(peer) }.onSuccess { result -> result.onSuccess { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 7895faf4b..7e79459ec 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -1,11 +1,13 @@ package to.bitkit.viewmodels +import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -16,16 +18,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import org.lightningdevkit.ldknode.ChannelDataMigration -import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PeerDetails +import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher -import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -45,6 +42,7 @@ import kotlin.time.Duration.Companion.seconds @HiltViewModel class WalletViewModel @Inject constructor( + @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, @@ -79,10 +77,8 @@ class WalletViewModel @Inject constructor( private val _restoreState = MutableStateFlow(RestoreState.Initial) val restoreState: StateFlow = _restoreState.asStateFlow() - private val _uiState = MutableStateFlow(MainUiState()) - - @Deprecated("Prioritize get the wallet and lightning states from LightningRepo or WalletRepo") - val uiState = _uiState.asStateFlow() + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing = _isRefreshing.asStateFlow() private var syncJob: Job? = null @@ -150,35 +146,11 @@ class WalletViewModel @Inject constructor( viewModelScope.launch { walletState.collect { state -> walletExists = state.walletExists - _uiState.update { - it.copy( - onchainAddress = state.onchainAddress, - bolt11 = state.bolt11, - bip21 = state.bip21, - bip21AmountSats = state.bip21AmountSats, - bip21Description = state.bip21Description, - selectedTags = state.selectedTags, - ) - } if (state.walletExists && _restoreState.value == RestoreState.InProgress.Wallet) { restoreFromBackup() } } } - - viewModelScope.launch { - lightningState.collect { state -> - _uiState.update { - it.copy( - nodeId = state.nodeId, - nodeStatus = state.nodeStatus, - nodeLifecycleState = state.nodeLifecycleState, - peers = state.peers, - channels = state.channels, - ) - } - } - } } private suspend fun restoreFromBackup() { @@ -319,11 +291,11 @@ class WalletViewModel @Inject constructor( lightningRepo.clearPendingSync() syncJob = viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true) } + _isRefreshing.value = true try { walletRepo.syncNodeAndWallet(source = SyncSource.MANUAL) } finally { - _uiState.update { it.copy(isRefreshing = false) } + _isRefreshing.value = false } } } @@ -334,15 +306,15 @@ class WalletViewModel @Inject constructor( .onSuccess { ToastEventBus.send( type = Toast.ToastType.INFO, - title = "Success", - description = "Peer disconnected." + title = context.getString(R.string.common__success), + description = "Peer disconnected.," ) } .onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Error", - description = error.message ?: "Unknown error" + title = context.getString(R.string.common__error), + description = error.message ?: context.getString(R.string.common__error_desc) ) } } @@ -355,8 +327,8 @@ class WalletViewModel @Inject constructor( walletRepo.updateBip21Invoice(amountSats).onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, - title = "Error updating invoice", - description = error.message ?: "Unknown error" + title = context.getString(R.string.wallet__error_invoice_update), + description = error.message ?: context.getString(R.string.common__error_desc) ) } } @@ -432,22 +404,6 @@ class WalletViewModel @Inject constructor( } } -// TODO rename to walletUiState -data class MainUiState( - val nodeId: String = "", - val onchainAddress: String = "", - val bolt11: String = "", - val bip21: String = "", - val nodeStatus: NodeStatus? = null, - val nodeLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped, - val peers: List = emptyList(), - val channels: List = emptyList(), - val isRefreshing: Boolean = false, - val bip21AmountSats: ULong? = null, - val bip21Description: String = "", - val selectedTags: List = listOf(), -) - sealed interface RestoreState { data object Initial : RestoreState sealed interface InProgress : RestoreState { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd0d18aeb..34da703c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,6 +73,9 @@ Max Default Preview + Error + Unknown error + Success Instant ±2-10 seconds 2-10s @@ -363,6 +366,7 @@ <bold>It appears Bitkit does not have permission to access your camera.</bold>\n\nTo utilize this feature in the future you will need to enable camera permissions for this app from your phone\'s settings. Clipboard Data Detected Do you want to be redirected to the relevant screen? + Coming soon Insufficient Savings Insufficient Spending Balance More ₿ needed to pay this Bitcoin invoice. @@ -391,6 +395,9 @@ Channel Requested Successfully requested channel from: {peer} Successfully requested channel. + Log In + Log in to {domain}? + Log In Sign In Failed (LNURL) An error occurred when you attempted to sign in. {raw} Signed In @@ -403,6 +410,12 @@ Incorrect LNURL withdraw params, min/max not set correctly. Withdraw Requested Your withdraw was successfully requested. Waiting for payment. + Error starting: %1$s + Setting up wallet… + Running + Starting + Stopped + Stopping Open Phone Settings Transfer Failed Unable to add LSP node as a peer at this time. @@ -610,6 +623,23 @@ If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. Quickpay threshold * Bitkit QuickPay exclusively supports payments from your Spending Balance. + Background Payments + On + Off + Background payments are enabled. You can receive funds even when the app is closed (if your device is connected to the internet). + Background payments are disabled, because you have denied notifications. + GET PAID\n<accent>PASSIVELY</accent> + Turn on notifications to get paid, even when your Bitkit app is closed. + Customize in Android Bitkit Settings + Include amount in notifications + Notifications + Privacy + Get paid when Bitkit is closed + Branch and Bound + Finds exact amount matches to minimize change + Single Random Draw + Random selection for privacy + Language Security And Privacy Swipe balance to hide Hide balance on open @@ -855,8 +885,6 @@ Unable To Delete Profile Unable To Pay Contact The contact you’re trying to send to hasn’t enabled payments. - Deprecated - Slashauth is deprecated. Please use Bitkit Beta. Wallet Activity Contacts @@ -991,6 +1019,8 @@ Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nBitkit needs to increase the receiving capacity of your spending balance to process this payment. Spending Balance Liquidity Additional Spending Balance Liquidity + Enable background setup to safely exit Bitkit while your balance is being configured. + Set up in background Transaction Failed Failed to send funds to your spending account. You will receive @@ -1006,19 +1036,20 @@ No activity yet Receive some funds to get started Sent + Sent to myself Received Pending Failed Boost Fee Boosted incoming transaction Transfer - From Spending (±{duration}) + From Spending ({duration}) From Spending - From Savings (±{duration}) + From Savings ({duration}) From Savings To Spending To Savings - Transfer (±{duration}) + Transfer ({duration}) Confirms in {feeRateDescription} Boosting. Confirms in {feeRateDescription} Fee potentially too low @@ -1066,6 +1097,8 @@ Sent Received Other + Next month + Previous month Savings Spending Savings @@ -1073,6 +1106,8 @@ Spending <accent>Send\nbitcoin</accent>\nto your\nspending balance Incoming Transfer: + Transfer To Savings + Transfer To Spending Transaction Invalid Boost Boost Transaction @@ -1085,13 +1120,23 @@ Your transaction may settle faster if you include an additional network fee. Here is a recommendation: Use Suggested Fee Swipe To Boost + Reduce fee + Increase fee Received Bitcoin Received Instant Bitcoin + Peer disconnected. Transaction Creation Failed An error occurred. Please try again {raw} Transaction Broadcast Failed An error occurred when broadcasting your transaction. {raw} Please check your connection and try again.\n{message} + Insufficient Funds + You do not have enough funds to send this payment. + Invalid bitcoin send address + Error updating invoice + Error fetching lnurl invoice + Error Sending + Your withdrawal was unsuccessful. Please scan the QR code again or contact support. Select Range Clear Apply @@ -1164,6 +1209,8 @@ Channel opened Pending Ready to send + Lightning node notification + Channel for LightningNodeService Lightning error Payment failed Please try again @@ -1172,7 +1219,6 @@ Payment Received Bitkit is running in background so you can receive Lightning payments Stop App - Unknown error Via new channel Lightning Wallet Sync Time Ready diff --git a/app/src/test/java/to/bitkit/ext/PeerDetailsTest.kt b/app/src/test/java/to/bitkit/ext/PeerDetailsTest.kt index c2cc0aee7..960f951ae 100644 --- a/app/src/test/java/to/bitkit/ext/PeerDetailsTest.kt +++ b/app/src/test/java/to/bitkit/ext/PeerDetailsTest.kt @@ -53,7 +53,7 @@ class PeerDetailsTest : BaseUnitTest() { fun `parse correctly parses full connection string`() { val uri = "028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc@34.65.86.104:9400" - val peer = PeerDetails.parse(uri) + val peer = PeerDetails.of(uri) assertEquals("028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc", peer.nodeId) assertEquals("34.65.86.104:9400", peer.address) @@ -66,7 +66,7 @@ class PeerDetailsTest : BaseUnitTest() { val invalidUri = "node123example.com:9735" val exception = assertFailsWith { - PeerDetails.parse(invalidUri) + PeerDetails.of(invalidUri) } assertTrue(exception.message!!.contains("Invalid uri format")) @@ -77,7 +77,7 @@ class PeerDetailsTest : BaseUnitTest() { val invalidUri = "node123@example.com" val exception = assertFailsWith { - PeerDetails.parse(invalidUri) + PeerDetails.of(invalidUri) } assertTrue(exception.message!!.contains("Invalid uri format")) @@ -85,7 +85,7 @@ class PeerDetailsTest : BaseUnitTest() { @Test fun `from creates PeerDetails with correct values`() { - val peer = PeerDetails.from( + val peer = PeerDetails.of( nodeId = "node123", host = "example.com", port = "9735", diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index fba21ae9c..a113f3e17 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -32,7 +32,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.ext.createChannelDetails -import to.bitkit.ext.from +import to.bitkit.ext.of import to.bitkit.models.BalanceState import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.NodeLifecycleState @@ -206,7 +206,7 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `openChannel should fail when node is not running`() = test { - val testPeer = PeerDetails.from("nodeId", "host", "9735") + val testPeer = PeerDetails.of("nodeId", "host", "9735") val result = sut.openChannel(testPeer, 100000uL) assertTrue(result.isFailure) } @@ -214,7 +214,7 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `openChannel should succeed when node is running`() = test { startNodeForTesting() - val peer = PeerDetails.from("nodeId", "host", "9735") + val peer = PeerDetails.of("nodeId", "host", "9735") val userChannelId = "testChannelId" val channelAmountSats = 100_000uL whenever(lightningService.openChannel(peer, channelAmountSats, null, null)) @@ -350,7 +350,7 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `disconnectPeer should fail when node is not running`() = test { - val testPeer = PeerDetails.from("nodeId", "host", "9735") + val testPeer = PeerDetails.of("nodeId", "host", "9735") val result = sut.disconnectPeer(testPeer) assertTrue(result.isFailure) } @@ -358,7 +358,7 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `disconnectPeer should succeed when node is running`() = test { startNodeForTesting() - val testPeer = PeerDetails.from("nodeId", "host", "9735") + val testPeer = PeerDetails.of("nodeId", "host", "9735") whenever(lightningService.disconnectPeer(any())).thenReturn(Unit) val result = sut.disconnectPeer(testPeer) diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 8f55f55a1..df2b089cd 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -1,5 +1,6 @@ package to.bitkit.ui +import android.content.Context import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @@ -15,7 +16,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore -import to.bitkit.ext.from +import to.bitkit.ext.of import to.bitkit.models.BalanceState import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -33,6 +34,7 @@ import to.bitkit.viewmodels.WalletViewModel class WalletViewModelTest : BaseUnitTest() { private lateinit var sut: WalletViewModel + private val context = mock() private val walletRepo = mock() private val lightningRepo = mock() private val settingsStore = mock() @@ -47,11 +49,13 @@ class WalletViewModelTest : BaseUnitTest() { @Before fun setUp() = runBlocking { + whenever(context.getString(any())).thenReturn("") whenever(walletRepo.walletState).thenReturn(walletState) whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(migrationService.isMigrationChecked()).thenReturn(true) sut = WalletViewModel( + context = context, bgDispatcher = testDispatcher, walletRepo = walletRepo, lightningRepo = lightningRepo, @@ -93,7 +97,7 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `disconnectPeer should call lightningRepo disconnectPeer`() = test { - val testPeer = PeerDetails.from("nodeId", "host", "9735") + val testPeer = PeerDetails.of("nodeId", "host", "9735") val testError = Exception("Test error") whenever(lightningRepo.disconnectPeer(testPeer)).thenReturn(Result.failure(testError)) @@ -235,6 +239,7 @@ class WalletViewModelTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) val testSut = WalletViewModel( + context = context, bgDispatcher = testDispatcher, walletRepo = testWalletRepo, lightningRepo = testLightningRepo, @@ -274,6 +279,7 @@ class WalletViewModelTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) val testSut = WalletViewModel( + context = context, bgDispatcher = testDispatcher, walletRepo = testWalletRepo, lightningRepo = testLightningRepo, diff --git a/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt index 8cb5bf270..dc5ff93a1 100644 --- a/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/appStatus/AppStatusViewModelTest.kt @@ -36,6 +36,11 @@ class AppStatusViewModelTest : BaseUnitTest() { fun setUp() { whenever(context.getString(R.string.settings__status__backup__error)).thenReturn(failedBackupSubtitle) whenever(context.getString(R.string.settings__status__backup__ready)).thenReturn(readyBackupSubtitle) + whenever(context.getString(R.string.other__node_stopped)).thenReturn("Stopped") + whenever(context.getString(R.string.other__node_starting)).thenReturn("Starting") + whenever(context.getString(R.string.other__node_running)).thenReturn("Running") + whenever(context.getString(R.string.other__node_stopping)).thenReturn("Stopping") + whenever(context.getString(R.string.other__node_initializing)).thenReturn("Setting up wallet…") whenever(healthRepo.healthState).thenReturn(MutableStateFlow(AppHealthState())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(cacheStore.backupStatuses).thenReturn(flowOf(emptyMap())) diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index c726bed62..8b9e64ff1 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -1,10 +1,16 @@ package to.bitkit.ui.sheets +import android.content.Context import app.cash.turbine.test import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.FeeRates +import com.synonym.bitkitcore.IBtInfo +import com.synonym.bitkitcore.IBtInfoOnchain import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -15,55 +21,79 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import org.mockito.kotlin.wheneverBlocking +import to.bitkit.R import to.bitkit.ext.create import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.BlocktankState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.sheets.BoostTransactionViewModel.Companion.MAX_FEE_RATE import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue class BoostTransactionViewModelTest : BaseUnitTest() { - private lateinit var sut: BoostTransactionViewModel - private val lightningRepo: LightningRepo = mock() - private val walletRepo: WalletRepo = mock() - private val activityRepo: ActivityRepo = mock() + private val context = mock() + private val lightningRepo = mock() + private val walletRepo = mock() + private val activityRepo = mock() + private val blocktankRepo = mock() + + private val onchain = mock() + private val mockBtInfo = mock() + private val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u) + private val blocktankState = MutableStateFlow(BlocktankState(info = mockBtInfo)) // Test data private val mockTxId = "test_txid_123" - private val mockNewTxId = "new_txid_456" - private val mockAddress = "bc1rt1test123" - private val testFeeRate = 10UL - private val testTotalFee = 1000UL + private val newTxId = "new_txid_456" + private val address = "bc1rt1test123" + private val feeRate = 10UL + private val totalFee = 1000UL private val testValue = 50000UL - private val mockOnchainActivity = OnchainActivity.create( + private val onchainActivity = OnchainActivity.create( id = "test_id", txType = PaymentType.SENT, txId = mockTxId, value = testValue, fee = 500UL, - address = mockAddress, + address = address, timestamp = 1234567890UL, feeRate = 10UL, ) - private val mockActivitySent = Activity.Onchain(v1 = mockOnchainActivity) + private val activitySent = Activity.Onchain(onchainActivity) + + private val fastFeeTime = "±10m" + private val normalFeeTime = "±20m" + private val flowFeeTime = "±1h" + private val minFeeTime = "+2h" @Before - fun setUp() { + fun setUp() = runBlocking { + whenever(context.getString(R.string.fee__fast__shortDescription)).thenReturn(fastFeeTime) + whenever(context.getString(R.string.fee__normal__shortDescription)).thenReturn(normalFeeTime) + whenever(context.getString(R.string.fee__slow__shortDescription)).thenReturn(flowFeeTime) + whenever(context.getString(R.string.fee__minimum__shortDescription)).thenReturn(minFeeTime) + whenever(onchain.feeRates).thenReturn(feeRates) + whenever(mockBtInfo.onchain).thenReturn(onchain) + whenever(blocktankRepo.blocktankState).thenReturn(blocktankState) + whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.success(emptyList())) + whenever(lightningRepo.syncAsync()).thenReturn(Job()) + sut = BoostTransactionViewModel( + context = context, lightningRepo = lightningRepo, walletRepo = walletRepo, - activityRepo = activityRepo + activityRepo = activityRepo, + blocktankRepo = blocktankRepo, ) - wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) - whenever(lightningRepo.syncAsync()).thenReturn(Job()) } @Test @@ -82,14 +112,13 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `setupActivity should set loading state initially`() = runTest { - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) - .thenReturn(Result.success(testFeeRate)) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(feeRate)) whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(testTotalFee)) + .thenReturn(Result.success(totalFee)) sut.uiState.test { awaitItem() // initial state - sut.setupActivity(mockActivitySent) + sut.setupActivity(activitySent) val loadingState = awaitItem() assertTrue(loadingState.loading) @@ -99,12 +128,11 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `setupActivity should call correct repository methods for sent transaction`() = runTest { - whenever(lightningRepo.getFeeRateForSpeed(eq(TransactionSpeed.Fast), anyOrNull())) - .thenReturn(Result.success(testFeeRate)) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(feeRate)) whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(testTotalFee)) + .thenReturn(Result.success(totalFee)) - sut.setupActivity(mockActivitySent) + sut.setupActivity(activitySent) verify(lightningRepo).getFeeRateForSpeed(eq(TransactionSpeed.Fast), anyOrNull()) verify(lightningRepo).calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) @@ -112,12 +140,10 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `setupActivity should call CPFP method for received transaction`() = runTest { - val receivedActivity = Activity.Onchain( - v1 = mockOnchainActivity.copy(txType = PaymentType.RECEIVED) - ) + val receivedActivity = Activity.Onchain(onchainActivity.copy(txType = PaymentType.RECEIVED)) whenever(lightningRepo.calculateCpfpFeeRate(eq(mockTxId))) - .thenReturn(Result.success(testFeeRate)) + .thenReturn(Result.success(feeRate)) sut.setupActivity(receivedActivity) @@ -146,12 +172,11 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `onChangeAmount should emit OnMaxFee when at maximum rate`() = runTest { - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) - .thenReturn(Result.success(100UL)) // MAX_FEE_RATE + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(MAX_FEE_RATE)) whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(testTotalFee)) + .thenReturn(Result.success(totalFee)) - sut.setupActivity(mockActivitySent) + sut.setupActivity(activitySent) sut.boostTransactionEffect.test { sut.onChangeAmount(increase = true) @@ -161,12 +186,11 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `onChangeAmount should emit OnMinFee when at minimum rate`() = runTest { - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) - .thenReturn(Result.success(1UL)) // MIN_FEE_RATE + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1UL)) // MIN_FEE_RATE whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(testTotalFee)) + .thenReturn(Result.success(totalFee)) - sut.setupActivity(mockActivitySent) + sut.setupActivity(activitySent) sut.boostTransactionEffect.test { sut.onChangeAmount(increase = false) @@ -176,47 +200,30 @@ class BoostTransactionViewModelTest : BaseUnitTest() { @Test fun `setupActivity failure should emit OnBoostFailed`() = runTest { - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) - .thenReturn(Result.failure(Exception("Fee estimation failed"))) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.failure(Exception("error"))) sut.boostTransactionEffect.test { - sut.setupActivity(mockActivitySent) + sut.setupActivity(activitySent) assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) } } @Test fun `successful CPFP boost should call correct repository methods`() = runTest { - val receivedActivity = Activity.Onchain( - v1 = mockOnchainActivity.copy(txType = PaymentType.RECEIVED) - ) + val receivedActivity = Activity.Onchain(onchainActivity.copy(txType = PaymentType.RECEIVED)) - whenever(lightningRepo.calculateCpfpFeeRate(any())) - .thenReturn(Result.success(testFeeRate)) + whenever(lightningRepo.calculateCpfpFeeRate(any())).thenReturn(Result.success(feeRate)) whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(testTotalFee)) - whenever(walletRepo.getOnchainAddress()) - .thenReturn(mockAddress) - whenever(lightningRepo.accelerateByCpfp(any(), any(), any())) - .thenReturn(Result.success(mockNewTxId)) - - val newActivity = mockOnchainActivity.copy( - txType = PaymentType.SENT, - txId = mockNewTxId, - isBoosted = true - ) + .thenReturn(Result.success(totalFee)) + whenever(walletRepo.getOnchainAddress()).thenReturn(address) + whenever(lightningRepo.accelerateByCpfp(any(), any(), any())).thenReturn(Result.success(newTxId)) - whenever( - activityRepo.findActivityByPaymentId( - paymentHashOrTxId = any(), - type = any(), - txType = any(), - retry = any(), - ) - ).thenReturn(Result.success(Activity.Onchain(v1 = newActivity))) + val newActivity = onchainActivity.copy(txType = PaymentType.SENT, txId = newTxId, isBoosted = true) - whenever(activityRepo.updateActivity(any(), any(), any())) - .thenReturn(Result.success(Unit)) + whenever(activityRepo.findActivityByPaymentId(any(), any(), any(), any())) + .thenReturn(Result.success(Activity.Onchain(newActivity))) + + whenever(activityRepo.updateActivity(any(), any(), any())).thenReturn(Result.success(Unit)) sut.setupActivity(receivedActivity) @@ -230,4 +237,70 @@ class BoostTransactionViewModelTest : BaseUnitTest() { verify(activityRepo).updateActivity(any(), any(), any()) verify(activityRepo, never()).deleteActivity(any()) } + + // region estimateTime dynamic tier tests + + @Test + fun `estimateTime shows fast description when fee rate at or above fast threshold`() = runTest { + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(25UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + + sut.uiState.test { + awaitItem() + sut.setupActivity(activitySent) + awaitItem() + val state = awaitItem() + assertEquals(fastFeeTime, state.estimateTime) + } + } + + @Test + fun `estimateTime shows normal description when fee rate between mid and fast`() = runTest { + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(15UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + + sut.uiState.test { + awaitItem() + sut.setupActivity(activitySent) + awaitItem() + val state = awaitItem() + assertEquals(normalFeeTime, state.estimateTime) + } + } + + @Test + fun `estimateTime shows slow description when fee rate between slow and mid`() = runTest { + val lowFeeActivity = Activity.Onchain(onchainActivity.copy(feeRate = 1UL)) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(7UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + + sut.uiState.test { + awaitItem() // initial state + sut.setupActivity(lowFeeActivity) + awaitItem() // loading state + val state = awaitItem() + assertEquals(flowFeeTime, state.estimateTime) + } + } + + @Test + fun `estimateTime shows minimum description when fee rate below slow threshold`() = runTest { + val lowFeeActivity = Activity.Onchain(onchainActivity.copy(feeRate = 1UL)) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(3UL)) + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(totalFee)) + + sut.uiState.test { + awaitItem() // initial state + sut.setupActivity(lowFeeActivity) + awaitItem() // loading state + val state = awaitItem() + assertEquals(minFeeTime, state.estimateTime) + } + } + + // endregion } diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index eb38f327a..09c2c011f 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -2,7 +2,7 @@ package to.bitkit.utils import org.junit.Before import org.junit.Test -import to.bitkit.env.Env.DERIVATION_NAME +import to.bitkit.env.Env.derivationName import to.bitkit.ext.fromBase64 import to.bitkit.ext.fromHex import to.bitkit.ext.toBase64 @@ -28,13 +28,13 @@ class CryptoTest { val sharedSecret = sut.generateSharedSecret(privateKey, publicKey.toHex()) assertEquals(33, sharedSecret.size) - val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), DERIVATION_NAME) + val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), derivationName) assertEquals(32, sharedSecretHash.size) } @Test fun `it should decrypt payload it encrypted`() { - val derivationName = DERIVATION_NAME + val derivationName = derivationName // Step 1: Client generates a key pair val clientKeys = sut.generateKeyPair() diff --git a/build.gradle.kts b/build.gradle.kts index 3e0ebc458..6b4fab0da 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.compose.stability.analyzer) apply false alias(libs.plugins.google.services) apply false alias(libs.plugins.hilt.android) apply false // https://github.com/google/dagger/releases/ alias(libs.plugins.kotlin.android) apply false diff --git a/docs/strings.md b/docs/strings.md new file mode 100644 index 000000000..f411b80f7 --- /dev/null +++ b/docs/strings.md @@ -0,0 +1,118 @@ +# Untranslated Strings Tracker + +This document tracks hardcoded strings in the codebase. Strings in dev-only screens do not need translation. + +## Dev-Only Strings (No Translation Needed) + +These screens are only accessible in development builds and contain hardcoded strings that don't need localization. + +### DevSettingsScreen.kt +| Line | String | +|------|--------| +| 52 | Fee Settings | +| 53 | Channel Orders | +| 54 | LDK Debug | +| 56 | LOGS | +| 57 | Logs | +| 59 | Export Logs | +| 66 | REGTEST | +| 68 | Blocktank Regtest | +| 71 | APP CACHE | +| 74 | Reset Settings State | +| 77 | Settings state reset | +| 81 | Reset All Activities | +| 84 | Activities removed | +| 88 | Reset Backup State | +| 91 | Backup state reset | +| 95 | Reset Widgets State | +| 98 | Widgets state reset | +| 102 | Refresh Currency Rates | +| 105 | Currency rates refreshed | +| 109 | Reset App Database | +| 112 | Database state reset | +| 116 | Reset Blocktank State | +| 119 | Blocktank state reset | +| 123 | Reset Cache Store | +| 126 | Cache store reset | +| 130 | Wipe App | +| 133 | Wallet wiped | +| 137 | DEBUG | +| 140 | Generate Test Activities | +| 144 | Generated $count test activities | +| 148 | Fake New BG Receive | +| 151 | Restart app to see the payment received sheet | +| 155 | Open Channel To Trusted Peer | +| 161 | NOTIFICATIONS | +| 164 | Register For LSP Notifications | +| 170 | Test LSP Notification | + +### LdkDebugScreen.kt +| Line | String | +|------|--------| +| 96 | LDK Debug | +| 105 | ADD PEER | +| 109 | pubkey@host:port | +| 120 | Add Peer | +| 127 | Paste & Add | +| 135 | NETWORK GRAPH | +| 137 | Log Graph Info | +| 149 | Export to File | +| 160 | VSS | +| 162 | List Keys | +| 164 | found | +| 191 | Delete key | +| 205 | Delete All | +| 211 | NODE | +| 213 | Restart | +| 225 | Delete All VSS Keys? | +| 226 | This will permanently delete all... | +| 227 | Delete All | + +### BlocktankRegtestScreen.kt +| Line | String | +|------|--------| +| 57 | Blocktank Regtest | +| 81 | These actions are executed on the staging Blocktank server node. | +| 84 | DEPOSIT | +| 97 | Amount (sats) | +| 104 | Depositing... / Make Deposit | +| 116 | Success | +| 117 | Deposit successful. TxID: ... | +| 123 | Failed to deposit | +| 136 | MINING | +| 146 | Block Count | +| 180 | Mining... / Mine Blocks | +| 162 | Success | +| 163 | Successfully mined $count blocks | +| 169 | Failed to mine | +| 185 | LIGHTNING PAYMENT | +| 189 | Invoice | +| 197 | Amount (optional, sats) | +| 204 | Pay Invoice | +| 215 | Success | +| 216 | Payment successful. ID: ... | +| 222 | Failed to pay invoice from LND | +| 232 | CHANNEL CLOSE | +| 236 | Funding TxID | +| 244 | Vout | +| 253 | Force Close After (seconds) | +| 260 | Close Channel | +| 279 | Success | +| 280 | Channel closed. Closing TxID: ... | + +### LogsScreen.kt +- All strings are technical log display (no localization needed) + +### ChannelOrdersScreen.kt +- All strings are technical channel order data (no localization needed) + +## Preview Functions + +Hardcoded strings in `@Preview` functions throughout the codebase do not need translation as they are only visible in Android Studio previews, not to end users. + +## Borderline Cases + +| File | Line | String | Notes | +|------|------|--------|-------| +| NotificationPreview.kt | 63 | 3m ago | Placeholder in notification mockup | +| BackgroundPaymentsSettings.kt | 111 | ₿ 21 000 | Example amount in preview | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95a33d23b..8c24a1d2b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.12.0" +agp = "8.13.2" camera = "1.5.2" detekt = "1.23.8" hilt = "2.57.2" @@ -28,6 +28,7 @@ camera-view = { module = "androidx.camera:camera-view", version.ref = "camera" } compose-bom = { group = "androidx.compose", name = "compose-bom", version = "2025.12.00" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } compose-material3 = { module = "androidx.compose.material3:material3" } +compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing" } compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } @@ -92,6 +93,7 @@ haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = [plugins] android-application = { id = "com.android.application", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +compose-stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.6" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } google-services = { id = "com.google.gms.google-services", version = "4.4.4" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }