diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afff04e76..a4a8eee65 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,8 @@ +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag + plugins { alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) @@ -10,7 +13,6 @@ plugins { android { namespace = "to.bitkit" compileSdk = 34 - ndkVersion = "26.1.10909125" // probably required by LDK bindings? - safer to keep it for now. defaultConfig { applicationId = "to.bitkit" minSdk = 28 @@ -54,10 +56,6 @@ android { buildConfig = true compose = true } - composeOptions { - // https://developer.android.com/jetpack/androidx/releases/compose-kotlin#pre-release_kotlin_compatibility - kotlinCompilerExtensionVersion = "1.5.14" - } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -66,11 +64,18 @@ android { @Suppress("UnstableApiUsage") testOptions { unitTests { - // isReturnDefaultValues = true // mockito - // isIncludeAndroidResources = true // robolectric + isReturnDefaultValues = true // mockito + isIncludeAndroidResources = true // robolectric } } } +composeCompiler { + featureFlags = setOf( + ComposeFeatureFlag.StrongSkipping.disabled(), + ComposeFeatureFlag.OptimizeNonSkippingGroups, + ) + reportsDestination = layout.buildDirectory.dir("compose_compiler") +} dependencies { implementation(fileTree("libs") { include("*.aar") }) implementation(platform(libs.kotlin.bom)) @@ -81,6 +86,7 @@ dependencies { implementation(libs.datastore.preferences) // BDK + LDK implementation(libs.bdk.android) + implementation(libs.bitcoinj.core) implementation(libs.ldk.node.android) // Firebase implementation(platform(libs.firebase.bom)) @@ -96,12 +102,12 @@ dependencies { // Compose implementation(platform(libs.compose.bom)) androidTestImplementation(platform(libs.compose.bom)) - implementation(libs.material3) - implementation(libs.material.icons.extended) - implementation(libs.ui.tooling.preview) - debugImplementation(libs.ui.tooling) - debugImplementation(libs.ui.test.manifest) - androidTestImplementation(libs.ui.test.junit4) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) + androidTestImplementation(libs.compose.ui.test.junit4) // Compose Navigation implementation(libs.navigation.compose) androidTestImplementation(libs.navigation.testing) @@ -127,17 +133,19 @@ dependencies { ksp(libs.room.compiler) testImplementation(libs.room.testing) // Test + Debug - androidTestImplementation(libs.espresso.core) - androidTestImplementation(libs.junit.ext) androidTestImplementation(kotlin("test")) + androidTestImplementation(libs.test.core) + androidTestImplementation(libs.test.coroutines) + androidTestImplementation(libs.test.espresso.core) + androidTestImplementation(libs.test.junit.ext) testImplementation(kotlin("test")) - testImplementation(libs.junit.junit) - // testImplementation("androidx.test:core:1.6.1") - // testImplementation("org.mockito:mockito-core:5.12.0") - // testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") - // testImplementation("org.robolectric:robolectric:4.13") - // Other - implementation(libs.guava) // for ByteArray.toHex()+ + testImplementation(libs.test.core) + testImplementation(libs.test.coroutines) + testImplementation(libs.test.junit) + testImplementation(libs.test.junit.ext) + testImplementation(libs.test.mockito.kotlin) + testImplementation(libs.test.robolectric) + testImplementation(libs.test.turbine) } ksp { // cool but strict: https://developer.android.com/jetpack/androidx/releases/room#2.6.0 diff --git a/app/src/androidTest/java/to/bitkit/data/keychain/KeychainStoreTest.kt b/app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt similarity index 87% rename from app/src/androidTest/java/to/bitkit/data/keychain/KeychainStoreTest.kt rename to app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt index 0e6c7514e..fc175279e 100644 --- a/app/src/androidTest/java/to/bitkit/data/keychain/KeychainStoreTest.kt +++ b/app/src/androidTest/java/to/bitkit/data/keychain/KeychainTest.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -14,20 +13,20 @@ import org.junit.Test import org.junit.runner.RunWith import to.bitkit.data.AppDb import to.bitkit.data.entities.ConfigEntity -import to.bitkit.test.BaseTest +import to.bitkit.shared.KeychainError +import to.bitkit.test.BaseAndroidTest import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) -@OptIn(ExperimentalCoroutinesApi::class) -class KeychainStoreTest : BaseTest() { +class KeychainTest : BaseAndroidTest() { private val appContext by lazy { ApplicationProvider.getApplicationContext() } private lateinit var db: AppDb - private lateinit var sut: KeychainStore + private lateinit var sut: Keychain @Before fun setUp() { @@ -42,7 +41,7 @@ class KeychainStoreTest : BaseTest() { } } - sut = KeychainStore( + sut = Keychain( db, appContext, testDispatcher, @@ -70,7 +69,7 @@ class KeychainStoreTest : BaseTest() { val key = "key" sut.saveString(key, "value1") - assertFailsWith { sut.saveString(key, "value2") } + assertFailsWith { sut.saveString(key, "value2") } } @Test diff --git a/app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt b/app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt index 3c0573229..f1adc5af5 100644 --- a/app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt +++ b/app/src/androidTest/java/to/bitkit/services/LdkMigrationTest.kt @@ -30,7 +30,7 @@ class LdkMigrationTest { runBlocking { start() } assertTrue { nodeId == "02cd08b7b375e4263849121f9f0ffb2732a0b88d0fb74487575ac539b374f45a55" } - assertTrue { channels.isNotEmpty() } + assertTrue { channels?.isNotEmpty() == true } runBlocking { stop() } } diff --git a/app/src/androidTest/java/to/bitkit/test/BaseAndroidTest.kt b/app/src/androidTest/java/to/bitkit/test/BaseAndroidTest.kt new file mode 100644 index 000000000..74e63e431 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/test/BaseAndroidTest.kt @@ -0,0 +1,20 @@ +package to.bitkit.test + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class BaseAndroidTest( + testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) { + @get:Rule + val coroutinesTestRule = MainDispatcherRule(testDispatcher) + + protected val testDispatcher get() = coroutinesTestRule.testDispatcher + + protected fun test(block: suspend TestScope.() -> Unit) = runTest(testDispatcher) { block() } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 12d610c5d..15613a8d7 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -1,12 +1,8 @@ - + - \ No newline at end of file + tools:ignore="MissingApplicationIcon" /> + diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index e4c10c2b6..68a71d9f1 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -7,11 +7,12 @@ import android.os.Bundle import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import to.bitkit.env.Env import javax.inject.Inject import kotlin.reflect.typeOf @HiltAndroidApp -internal class App : Application(), Configuration.Provider { +internal open class App : Application(), Configuration.Provider { @Inject lateinit var workerFactory: HiltWorkerFactory diff --git a/app/src/main/java/to/bitkit/Constants.kt b/app/src/main/java/to/bitkit/Constants.kt deleted file mode 100644 index 88fc03663..000000000 --- a/app/src/main/java/to/bitkit/Constants.kt +++ /dev/null @@ -1,113 +0,0 @@ -@file:Suppress("unused") - -package to.bitkit - -import android.util.Log -import org.lightningdevkit.ldknode.PeerDetails -import to.bitkit.Tag.APP -import to.bitkit.env.Network -import to.bitkit.ext.ensureDir -import kotlin.io.path.Path -import org.lightningdevkit.ldknode.Network as LdkNetwork - -// region globals -internal object Tag { - const val FCM = "FCM" - const val LDK = "LDK" - const val LSP = "LSP" - const val BDK = "BDK" - const val DEV = "DEV" - const val APP = "APP" - const val PERF = "PERF" -} - -internal const val HOST = "10.0.2.2" -internal const val REST = "https://electrs-regtest.synonym.to" -internal const val SEED = "universe more push obey later jazz huge buzz magnet team muscle robust" - -internal val PEER_REMOTE = LnPeer( - nodeId = "033f4d3032ce7f54224f4bd9747b50b7cd72074a859758e40e1ca46ffa79a34324", - host = HOST, - port = "9736", -) - -internal val PEER = LnPeer( - nodeId = "02faf2d1f5dc153e8931d8444c4439e46a81cb7eeadba8562e7fec3690c261ce87", - host = HOST, - port = "9737", -) -// endregion - -// region env -internal object Env { - val isDebug = BuildConfig.DEBUG - - object Storage { - private var base = "" - fun init(basePath: String) { - require(basePath.isNotEmpty()) { "Base storage path cannot be empty" } - base = basePath - Log.i(APP, "Storage path: $basePath") - } - - val ldk get() = storagePathOf(0, network.id, "ldk") - val bdk get() = storagePathOf(0, network.id, "bdk") - - private fun storagePathOf(walletIndex: Int, network: String, dir: String): String { - require(base.isNotEmpty()) { "Base storage path cannot be empty" } - val absolutePath = Path(base, network, "wallet$walletIndex", dir) - .toFile() - .ensureDir() - .absolutePath - Log.d(APP, "$dir storage path: $absolutePath") - return absolutePath - } - } - - val network = Network.Regtest - - val trustedLnPeers = listOf( - PEER_REMOTE, - // PEER, - ) - - val ldkRgsServerUrl: String? - get() = when (network.ldk) { - LdkNetwork.BITCOIN -> "https://rapidsync.lightningdevkit.org/snapshot/" - else -> null - } -} -// endregion - -data class LnPeer( - val nodeId: String, - val host: String, - val port: String, - val isConnected: Boolean = false, - val isPersisted: Boolean = false, -) { - constructor( - nodeId: String, - address: String, - isConnected: Boolean = false, - isPersisted: Boolean = false, - ) : this( - nodeId, - address.substringBefore(":"), - address.substringAfter(":"), - isConnected, - isPersisted, - ) - - val address get() = "$host:$port" - override fun toString() = "$nodeId@${address}" - - companion object { - fun PeerDetails.toLnPeer() = LnPeer( - nodeId = nodeId, - address = address, - isConnected = isConnected, - isPersisted = isPersisted, - ) - } -} diff --git a/app/src/main/java/to/bitkit/async/ServiceQueue.kt b/app/src/main/java/to/bitkit/async/ServiceQueue.kt index b8dd2f3fd..1a8147168 100644 --- a/app/src/main/java/to/bitkit/async/ServiceQueue.kt +++ b/app/src/main/java/to/bitkit/async/ServiceQueue.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.ExecutorCoroutineDispatcher import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.withContext -import to.bitkit.Tag.APP +import to.bitkit.env.Tag.APP import to.bitkit.ext.callerName import to.bitkit.shared.measured import java.util.concurrent.Executors diff --git a/app/src/main/java/to/bitkit/data/AppDb.kt b/app/src/main/java/to/bitkit/data/AppDb.kt index 371d4ba5d..c35c69e36 100644 --- a/app/src/main/java/to/bitkit/data/AppDb.kt +++ b/app/src/main/java/to/bitkit/data/AppDb.kt @@ -15,8 +15,8 @@ import androidx.work.WorkerParameters import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import to.bitkit.BuildConfig -import to.bitkit.Env import to.bitkit.data.entities.ConfigEntity +import to.bitkit.env.Env @Database( entities = [ diff --git a/app/src/main/java/to/bitkit/data/LspApi.kt b/app/src/main/java/to/bitkit/data/BlocktankClient.kt similarity index 74% rename from app/src/main/java/to/bitkit/data/LspApi.kt rename to app/src/main/java/to/bitkit/data/BlocktankClient.kt index 0dff0fb8f..9ac3c5b0b 100644 --- a/app/src/main/java/to/bitkit/data/LspApi.kt +++ b/app/src/main/java/to/bitkit/data/BlocktankClient.kt @@ -5,28 +5,32 @@ import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import kotlinx.serialization.Serializable +import to.bitkit.shared.BlocktankError import javax.inject.Inject +import javax.inject.Singleton -interface LspApi { - suspend fun registerDeviceForNotifications(payload: RegisterDeviceRequest) - suspend fun testNotification(deviceToken: String, payload: TestNotificationRequest) -} - -class BlocktankApi @Inject constructor( +@Singleton +class BlocktankClient @Inject constructor( private val client: HttpClient, -) : LspApi { +) { private val baseUrl = "https://api.stag.blocktank.to" private val notificationsApi = "$baseUrl/notifications/api/device" - override suspend fun registerDeviceForNotifications(payload: RegisterDeviceRequest) { + suspend fun registerDeviceForNotifications(payload: RegisterDeviceRequest) { post(notificationsApi, payload) } - override suspend fun testNotification(deviceToken: String, payload: TestNotificationRequest) { + suspend fun testNotification(deviceToken: String, payload: TestNotificationRequest) { post("$notificationsApi/$deviceToken/test-notification", payload) } - private suspend inline fun post(url: String, payload: T) = client.post(url) { setBody(payload) } + private suspend inline fun post(url: String, payload: T): HttpResponse { + val response = client.post(url) { setBody(payload) } + return when (val statusCode = response.status.value) { + !in 200..299 -> throw BlocktankError.InvalidResponse(statusCode) + else -> response + } + } } @Serializable diff --git a/app/src/main/java/to/bitkit/data/RestApi.kt b/app/src/main/java/to/bitkit/data/RestApi.kt deleted file mode 100644 index 3a3be5f1a..000000000 --- a/app/src/main/java/to/bitkit/data/RestApi.kt +++ /dev/null @@ -1,96 +0,0 @@ -package to.bitkit.data - -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import to.bitkit.REST -import to.bitkit.ext.toHex -import javax.inject.Inject - -interface RestApi { - suspend fun getLatestBlockHash(): String - suspend fun getLatestBlockHeight(): Int - suspend fun broadcastTx(tx: ByteArray): String - suspend fun getTx(txid: String): Tx - suspend fun getTxHex(txid: String): String - suspend fun getHeader(hash: String): String - suspend fun getMerkleProof(txid: String): MerkleProof - suspend fun getOutputSpent(txid: String, outputIndex: Int): OutputSpent -} - -class EsploraApi @Inject constructor( - private val client: HttpClient, -) : RestApi { - override suspend fun getLatestBlockHash(): String { - val httpResponse: HttpResponse = client.get("$REST/blocks/tip/hash") - return httpResponse.body() - } - - override suspend fun getLatestBlockHeight(): Int { - val httpResponse: HttpResponse = client.get("$REST/blocks/tip/height") - return httpResponse.body().toInt() - } - - override suspend fun broadcastTx(tx: ByteArray): String { - val response: HttpResponse = client.post("$REST/tx") { - setBody(tx.toHex()) - } - - return response.body() - } - - override suspend fun getTx(txid: String): Tx { - return client.get("$REST/tx/${txid}").body() - } - - override suspend fun getTxHex(txid: String): String { - return client.get("$REST/tx/${txid}/hex").body() - } - - override suspend fun getHeader(hash: String): String { - return client.get("$REST/block/${hash}/header").body() - } - - override suspend fun getMerkleProof(txid: String): MerkleProof { - return client.get("$REST/tx/${txid}/merkle-proof").body() - } - - override suspend fun getOutputSpent(txid: String, outputIndex: Int): OutputSpent { - return client.get("$REST/tx/${txid}/outspend/${outputIndex}").body() - } -} - -@Serializable -data class Tx( - val txid: String, - val status: TxStatus, -) - -@Serializable -data class TxStatus( - @SerialName("confirmed") - val isConfirmed: Boolean, - @SerialName("block_height") - val blockHeight: Int? = null, - @SerialName("block_hash") - val blockHash: String? = null, -) - -@Serializable -data class OutputSpent( - val spent: Boolean, -) - -@Serializable -data class MerkleProof( - @SerialName("block_height") - val blockHeight: Int, - @Suppress("ArrayInDataClass") - val merkle: Array, - val pos: Int, -) diff --git a/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt index 74ccc213f..33f1a20e4 100644 --- a/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt +++ b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt @@ -54,11 +54,11 @@ class AndroidKeyStore( return spec } - fun encrypt(data: String): ByteArray { + fun encrypt(data: ByteArray): ByteArray { val secretKey = keyStore.getKey(alias, password) as SecretKey val cipher = Cipher.getInstance(transformation).apply { init(Cipher.ENCRYPT_MODE, secretKey) } - val encryptedData = cipher.doFinal(data.toByteArray(Charsets.UTF_8)) + val encryptedData = cipher.doFinal(data) val iv = cipher.iv check(iv.size == ivLength) { "Unexpected IV length: ${iv.size} ≠ $ivLength" } @@ -66,7 +66,7 @@ class AndroidKeyStore( return iv + encryptedData } - fun decrypt(data: ByteArray): String { + fun decrypt(data: ByteArray): ByteArray { val secretKey = keyStore.getKey(alias, password) as SecretKey // Extract the IV from the beginning of the encrypted data @@ -77,6 +77,6 @@ class AndroidKeyStore( val cipher = Cipher.getInstance(transformation).apply { init(Cipher.DECRYPT_MODE, secretKey, spec) } val decryptedDataBytes = cipher.doFinal(actualEncryptedData) - return decryptedDataBytes.decodeToString() + return decryptedDataBytes } } diff --git a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt similarity index 54% rename from app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt rename to app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 1f6f67cc5..71d192272 100644 --- a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -1,5 +1,3 @@ -@file:Suppress("unused") - package to.bitkit.data.keychain import android.content.Context @@ -11,15 +9,20 @@ import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first -import to.bitkit.Tag.APP import to.bitkit.async.BaseCoroutineScope import to.bitkit.data.AppDb import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.env.Network +import to.bitkit.env.Tag.APP import to.bitkit.ext.fromBase64 import to.bitkit.ext.toBase64 +import to.bitkit.shared.KeychainError import javax.inject.Inject +import javax.inject.Singleton -class KeychainStore @Inject constructor( +@Singleton +class Keychain @Inject constructor( private val db: AppDb, @ApplicationContext private val context: Context, @IoDispatcher private val dispatcher: CoroutineDispatcher, @@ -28,27 +31,41 @@ class KeychainStore @Inject constructor( private val keyStore by lazy { AndroidKeyStore(alias) } private val Context.keychain by preferencesDataStore(alias, scope = this) - val snapshot get() = runBlocking { context.keychain.data.first() } + val snapshot get() = runBlocking(this.coroutineContext) { context.keychain.data.first() } - fun loadString(key: String): String? = load(key)?.let { keyStore.decrypt(it) } + fun loadString(key: String): String? = load(key)?.decodeToString() - private fun load(key: String): ByteArray? { - return snapshot[key.indexed]?.fromBase64() + fun load(key: String): ByteArray? { + try { + return snapshot[key.indexed]?.fromBase64()?.let { + keyStore.decrypt(it) + } + } catch (e: Exception) { + throw KeychainError.FailedToLoad(key) + } } - suspend fun saveString(key: String, value: String) = save(key, value.let { keyStore.encrypt(it) }) + suspend fun saveString(key: String, value: String) = save(key, value.toByteArray()) - private suspend fun save(key: String, encryptedValue: ByteArray) { - require(!exists(key)) { "Entry $key exists. Explicitly delete it first to update value." } - context.keychain.edit { it[key.indexed] = encryptedValue.toBase64() } + suspend fun save(key: String, value: ByteArray) { + if (exists(key)) throw KeychainError.FailedToSaveAlreadyExists(key) + try { + val encryptedValue = keyStore.encrypt(value) + context.keychain.edit { it[key.indexed] = encryptedValue.toBase64() } + } catch (e: Exception) { + throw KeychainError.FailedToSave(key) + } Log.i(APP, "Saved to keychain: $key") } suspend fun delete(key: String) { - context.keychain.edit { it.remove(key.indexed) } - - Log.d(APP, "Deleted from keychain: $key ") + try { + context.keychain.edit { it.remove(key.indexed) } + } catch (e: Exception) { + throw KeychainError.FailedToDelete(key) + } + Log.d(APP, "Deleted from keychain: $key") } fun exists(key: String): Boolean { @@ -56,6 +73,8 @@ class KeychainStore @Inject constructor( } suspend fun wipe() { + if (!Env.isDebug || Env.network != Network.Regtest) throw KeychainError.KeychainWipeNotAllowed() + val keys = snapshot.asMap().keys context.keychain.edit { it.clear() } @@ -67,4 +86,8 @@ class KeychainStore @Inject constructor( val walletIndex = runBlocking { db.configDao().getAll().first() }.first().walletIndex return "${this}_$walletIndex".let(::stringPreferencesKey) } + + enum class Key { + PUSH_NOTIFICATION_PRIVATE_KEY, + } } diff --git a/app/src/main/java/to/bitkit/di/HttpModule.kt b/app/src/main/java/to/bitkit/di/HttpModule.kt index e0bcdd697..a4f2542dc 100644 --- a/app/src/main/java/to/bitkit/di/HttpModule.kt +++ b/app/src/main/java/to/bitkit/di/HttpModule.kt @@ -15,10 +15,6 @@ import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json -import to.bitkit.data.BlocktankApi -import to.bitkit.data.EsploraApi -import to.bitkit.data.LspApi -import to.bitkit.data.RestApi import javax.inject.Singleton val json = Json { @@ -47,21 +43,9 @@ object HttpModule { install(ContentNegotiation) { json(json = json) } - defaultRequest { // Set default request properties + defaultRequest { contentType(ContentType.Application.Json) } } } - - @Provides - @Singleton - fun provideLspApi(blocktankApi: BlocktankApi): LspApi { - return blocktankApi - } - - @Provides - @Singleton - fun provideRestApi(esploraApi: EsploraApi): RestApi { - return esploraApi - } } diff --git a/app/src/main/java/to/bitkit/di/ServicesModule.kt b/app/src/main/java/to/bitkit/di/ServicesModule.kt index 654d8458e..5a86610de 100644 --- a/app/src/main/java/to/bitkit/di/ServicesModule.kt +++ b/app/src/main/java/to/bitkit/di/ServicesModule.kt @@ -1,4 +1,4 @@ -@file:Suppress("unused", "UNUSED_PARAMETER") +@file:Suppress("UNUSED_PARAMETER") package to.bitkit.di @@ -7,8 +7,8 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher -import to.bitkit.services.BitcoinService import to.bitkit.services.LightningService +import to.bitkit.services.OnChainService @Module @InstallIn(SingletonComponent::class) @@ -23,7 +23,7 @@ object ServicesModule { @Provides fun provideBitcoinService( @BgDispatcher bgDispatcher: CoroutineDispatcher, - ): BitcoinService { - return BitcoinService.shared + ): OnChainService { + return OnChainService.shared } } diff --git a/app/src/main/java/to/bitkit/di/ViewModelModule.kt b/app/src/main/java/to/bitkit/di/ViewModelModule.kt index 4b5931975..9d1d5e274 100644 --- a/app/src/main/java/to/bitkit/di/ViewModelModule.kt +++ b/app/src/main/java/to/bitkit/di/ViewModelModule.kt @@ -1,11 +1,15 @@ package to.bitkit.di +import com.google.firebase.messaging.FirebaseMessaging import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher +import to.bitkit.data.AppDb +import to.bitkit.data.keychain.Keychain import to.bitkit.services.BlocktankService +import to.bitkit.services.OnChainService import to.bitkit.ui.SharedViewModel import javax.inject.Singleton @@ -16,11 +20,18 @@ object ViewModelModule { @Provides fun provideSharedViewModel( @BgDispatcher bgDispatcher: CoroutineDispatcher, + appDb: AppDb, + keychain: Keychain, blocktankService: BlocktankService, + onChainService: OnChainService, ): SharedViewModel { return SharedViewModel( bgDispatcher, + appDb, + keychain, blocktankService, + onChainService, + firebaseMessaging = FirebaseMessaging.getInstance(), ) } } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt new file mode 100644 index 000000000..accac8517 --- /dev/null +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -0,0 +1,51 @@ +package to.bitkit.env + +import android.util.Log +import to.bitkit.BuildConfig +import to.bitkit.env.Tag.APP +import to.bitkit.ext.ensureDir +import kotlin.io.path.Path +import org.lightningdevkit.ldknode.Network as LdkNetwork + +internal const val SEED = "universe more push obey later jazz huge buzz magnet team muscle robust" + +internal object Env { + val isDebug = BuildConfig.DEBUG + val network = Network.Regtest + val trustedLnPeers = listOf( + LnPeers.remote, + // Peers.local, + ) + val ldkRgsServerUrl: String? + get() = when (network.ldk) { + LdkNetwork.BITCOIN -> "https://rapidsync.lightningdevkit.org/snapshot/" + else -> null + } + val esploraUrl: String + get() = when (network) { + Network.Regtest -> "https://electrs-regtest.synonym.to" + else -> TODO("Not yet implemented") + } + + object Storage { + private var base = "" + fun init(basePath: String) { + require(basePath.isNotEmpty()) { "Base storage path cannot be empty" } + base = basePath + Log.i(APP, "Storage path: $basePath") + } + + val ldk get() = storagePathOf(0, network.id, "ldk") + val bdk get() = storagePathOf(0, network.id, "bdk") + + private fun storagePathOf(walletIndex: Int, network: String, dir: String): String { + require(base.isNotEmpty()) { "Base storage path cannot be empty" } + val absolutePath = Path(base, network, "wallet$walletIndex", dir) + .toFile() + .ensureDir() + .absolutePath + Log.d(APP, "$dir storage path: $absolutePath") + return absolutePath + } + } +} diff --git a/app/src/main/java/to/bitkit/env/LnPeer.kt b/app/src/main/java/to/bitkit/env/LnPeer.kt new file mode 100644 index 000000000..6e2498cf1 --- /dev/null +++ b/app/src/main/java/to/bitkit/env/LnPeer.kt @@ -0,0 +1,43 @@ +package to.bitkit.env + +import org.lightningdevkit.ldknode.PeerDetails + +data class LnPeer( + val nodeId: String, + val host: String, + val port: String, +) { + constructor( + nodeId: String, + address: String, + ) : this( + nodeId, + address.substringBefore(":"), + address.substringAfter(":"), + ) + + val address get() = "$host:$port" + override fun toString() = "$nodeId@${address}" + + companion object { + fun PeerDetails.toLnPeer() = LnPeer( + nodeId = nodeId, + address = address, + ) + } +} + +internal object LnPeers { + private const val HOST = "10.0.2.2" + + val remote = LnPeer( + nodeId = "033f4d3032ce7f54224f4bd9747b50b7cd72074a859758e40e1ca46ffa79a34324", + host = HOST, + port = "9737", + ) + val local = LnPeer( + nodeId = "02faf2d1f5dc153e8931d8444c4439e46a81cb7eeadba8562e7fec3690c261ce87", + host = HOST, + port = "9738", + ) +} diff --git a/app/src/main/java/to/bitkit/env/Tag.kt b/app/src/main/java/to/bitkit/env/Tag.kt new file mode 100644 index 000000000..d1fa057e8 --- /dev/null +++ b/app/src/main/java/to/bitkit/env/Tag.kt @@ -0,0 +1,11 @@ +package to.bitkit.env + +internal object Tag { + const val FCM = "FCM" + const val LDK = "LDK" + const val LSP = "LSP" + const val BDK = "BDK" + const val DEV = "DEV" + const val APP = "APP" + const val PERF = "PERF" +} diff --git a/app/src/main/java/to/bitkit/ext/ByteArray.kt b/app/src/main/java/to/bitkit/ext/ByteArray.kt index dfdfc1afc..cfab705ad 100644 --- a/app/src/main/java/to/bitkit/ext/ByteArray.kt +++ b/app/src/main/java/to/bitkit/ext/ByteArray.kt @@ -3,38 +3,31 @@ package to.bitkit.ext import android.util.Base64 -import com.google.common.io.BaseEncoding import java.io.ByteArrayOutputStream import java.io.ObjectOutputStream -fun ByteArray.toHex(): String { - return BaseEncoding.base16().encode(this).lowercase() -} - -// TODO check if this can be replaced with existing ByteArray.toHex() +// region hex val ByteArray.hex: String get() = joinToString("") { "%02x".format(it) } -val String.hex: ByteArray get() { - require(length % 2 == 0) { "Cannot convert string of uneven length to hex ByteArray: $this" } - return chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray() -} - -fun String.asByteArray(): ByteArray { - return BaseEncoding.base16().decode(this.uppercase()) -} - -fun Any.convertToByteArray(): ByteArray { - val bos = ByteArrayOutputStream() - val oos = ObjectOutputStream(bos) - oos.writeObject(this) - oos.flush() - return bos.toByteArray() -} +val String.hex: ByteArray + get() { + require(length % 2 == 0) { "Cannot convert string of uneven length to hex ByteArray: $this" } + return chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + } +// endregion +// region base64 fun ByteArray.toBase64(flags: Int = Base64.DEFAULT): String = Base64.encodeToString(this, flags) fun String.fromBase64(flags: Int = Base64.DEFAULT): ByteArray = Base64.decode(this, flags) +// endregion + +fun Any.convertToByteArray(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + ObjectOutputStream(byteArrayOutputStream).use { it.writeObject(this) } + return byteArrayOutputStream.toByteArray() +} val String.uByteList get() = this.toByteArray(Charsets.UTF_8).map { it.toUByte() } diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt index 57eb28d62..5d387f633 100644 --- a/app/src/main/java/to/bitkit/ext/Context.kt +++ b/app/src/main/java/to/bitkit/ext/Context.kt @@ -10,8 +10,8 @@ import android.util.Log import android.widget.Toast import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat -import to.bitkit.Tag.APP import to.bitkit.currentActivity +import to.bitkit.env.Tag.APP import to.bitkit.ui.MainActivity import java.io.File import java.io.FileOutputStream diff --git a/app/src/main/java/to/bitkit/ext/List.kt b/app/src/main/java/to/bitkit/ext/List.kt deleted file mode 100644 index 2f4f329bf..000000000 --- a/app/src/main/java/to/bitkit/ext/List.kt +++ /dev/null @@ -1,8 +0,0 @@ -package to.bitkit.ext - -import androidx.compose.runtime.snapshots.SnapshotStateList - -fun SnapshotStateList.syncTo(list: List) { - clear() - this += list -} diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index 84dfd386a..0f3b71a4a 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -12,8 +12,8 @@ import com.google.firebase.messaging.RemoteMessage import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString -import to.bitkit.Tag.FCM import to.bitkit.di.json +import to.bitkit.env.Tag.FCM import to.bitkit.ui.pushNotification import java.util.Date diff --git a/app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt b/app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt index ff5973e34..397e0bfff 100644 --- a/app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt @@ -8,9 +8,8 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import to.bitkit.Tag.FCM +import to.bitkit.env.Tag.FCM import to.bitkit.services.LightningService -import to.bitkit.services.payInvoice import to.bitkit.services.warmupNode @HiltWorker diff --git a/app/src/main/java/to/bitkit/services/BlocktankService.kt b/app/src/main/java/to/bitkit/services/BlocktankService.kt index db1b42ae6..db454ac65 100644 --- a/app/src/main/java/to/bitkit/services/BlocktankService.kt +++ b/app/src/main/java/to/bitkit/services/BlocktankService.kt @@ -2,13 +2,17 @@ package to.bitkit.services import android.util.Log import kotlinx.coroutines.CoroutineDispatcher -import to.bitkit.Tag.LSP +import org.bitcoinj.core.ECKey import to.bitkit.async.BaseCoroutineScope import to.bitkit.async.ServiceQueue -import to.bitkit.data.LspApi +import to.bitkit.data.BlocktankClient import to.bitkit.data.RegisterDeviceRequest import to.bitkit.data.TestNotificationRequest +import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.Keychain.Key import to.bitkit.di.BgDispatcher +import to.bitkit.env.Tag.LSP +import to.bitkit.shared.ServiceError import java.time.Instant import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit @@ -16,12 +20,14 @@ import javax.inject.Inject class BlocktankService @Inject constructor( @BgDispatcher bgDispatcher: CoroutineDispatcher, - private val lspApi: LspApi, + private val client: BlocktankClient, private val lightningService: LightningService, + private val keychain: Keychain, ) : BaseCoroutineScope(bgDispatcher) { + // region notifications suspend fun registerDevice(deviceToken: String) { - val nodeId = requireNotNull(lightningService.nodeId) { "Node not started" } + val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted Log.d(LSP, "Registering device for notifications…") @@ -30,8 +36,15 @@ class BlocktankService @Inject constructor( val signature = lightningService.sign(messageToSign) - // TODO: Use actual public key to enable decryption of the push notification payload - val publicKey = "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f" + val keypair = ECKey() + val publicKey = keypair.publicKeyAsHex + Log.d(LSP, "Notification encryption public key: $publicKey") + + // New keypair for each token registration + if (keychain.exists(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name)) { + keychain.delete(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name) + } + keychain.save(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name, keypair.privKeyBytes) val payload = RegisterDeviceRequest( deviceToken = deviceToken, @@ -43,7 +56,7 @@ class BlocktankService @Inject constructor( ) ServiceQueue.LSP.background { - lspApi.registerDeviceForNotifications(payload) + client.registerDeviceForNotifications(payload) } } @@ -59,7 +72,8 @@ class BlocktankService @Inject constructor( ) ServiceQueue.LSP.background { - lspApi.testNotification(deviceToken, payload) + client.testNotification(deviceToken, payload) } } + // endregion } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 9954d00ee..712905f1d 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -4,21 +4,28 @@ import android.util.Log import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import org.lightningdevkit.ldknode.AnchorChannelsConfig +import org.lightningdevkit.ldknode.BalanceDetails +import org.lightningdevkit.ldknode.BuildException import org.lightningdevkit.ldknode.Builder +import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.LogLevel import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.NodeException +import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.defaultConfig -import to.bitkit.Env -import to.bitkit.LnPeer -import to.bitkit.LnPeer.Companion.toLnPeer -import to.bitkit.REST -import to.bitkit.SEED -import to.bitkit.Tag.LDK import to.bitkit.async.BaseCoroutineScope import to.bitkit.async.ServiceQueue import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.env.LnPeer +import to.bitkit.env.LnPeer.Companion.toLnPeer +import to.bitkit.env.SEED +import to.bitkit.env.Tag.LDK import to.bitkit.ext.uByteList +import to.bitkit.shared.LdkError +import to.bitkit.shared.ServiceError import javax.inject.Inject class LightningService @Inject constructor( @@ -30,7 +37,7 @@ class LightningService @Inject constructor( } } - lateinit var node: Node + var node: Node? = null fun setup(mnemonic: String = SEED) { val dir = Env.Storage.ldk @@ -50,7 +57,7 @@ class LightningService @Inject constructor( ) }) .apply { - setEsploraServer(REST) + setEsploraServer(Env.esploraUrl) if (Env.ldkRgsServerUrl != null) { setGossipSourceRgs(requireNotNull(Env.ldkRgsServerUrl)) } else { @@ -61,167 +68,193 @@ class LightningService @Inject constructor( Log.d(LDK, "Setting up node…") - node = builder.build() + node = try { + builder.build() + } catch (e: BuildException) { + throw LdkError(e) + } Log.i(LDK, "Node set up") } suspend fun start() { - assertNodeIsInitialised() + val node = this.node ?: throw ServiceError.NodeNotSetup Log.d(LDK, "Starting node…") - ServiceQueue.LDK.background { node.start() } - Log.i(LDK, "Node started") connectToTrustedPeers() } suspend fun stop() { + val node = this.node ?: throw ServiceError.NodeNotStarted + Log.d(LDK, "Stopping node…") ServiceQueue.LDK.background { node.stop() } + node.close().also { this.node = null } Log.i(LDK, "Node stopped.") } - private suspend fun connectToTrustedPeers() { - ServiceQueue.LDK.background { - for (peer in Env.trustedLnPeers) { - connectPeer(peer) - } - } + fun wipeStorage() { + if (node != null) throw ServiceError.NodeStillRunning + TODO("Not yet implemented") } suspend fun sync() { - Log.d(LDK, "Syncing node…") + val node = this.node ?: throw ServiceError.NodeNotSetup + Log.d(LDK, "Syncing node…") ServiceQueue.LDK.background { node.syncWallets() // setMaxDustHtlcExposureForCurrentChannels() } - Log.i(LDK, "Node synced") } suspend fun sign(message: String): String { - assertNodeIsInitialised() + val node = this.node ?: throw ServiceError.NodeNotSetup + val msg = runCatching { message.uByteList }.getOrNull() ?: throw ServiceError.InvalidNodeSigningMessage return ServiceQueue.LDK.background { - node.signMessage(message.uByteList) + node.signMessage(msg) + } + } + + // region peers + private suspend fun connectToTrustedPeers() { + for (peer in Env.trustedLnPeers) { + connectPeer(peer) } } - private fun assertNodeIsInitialised() = check(::node.isInitialized) { "LDK node is not initialised" } + suspend fun connectPeer(peer: LnPeer) { + val node = this.node ?: throw ServiceError.NodeNotSetup - // region state - val nodeId: String get() = node.nodeId() - val balances get() = node.listBalances() - val status get() = node.status() - val peers get() = node.listPeers().map { it.toLnPeer() } - val channels get() = node.listChannels() - val payments get() = node.listPayments() - // endregion -} + Log.d(LDK, "Connecting peer: $peer") -// region peers -internal fun LightningService.connectPeer(peer: LnPeer) { - Log.d(LDK, "Connecting peer: $peer") - val res = runCatching { - node.connect(peer.nodeId, peer.address, persist = true) - } - Log.i(LDK, "Connection ${if (res.isSuccess) "succeeded" else "failed"} with: $peer") -} -// endregion - -// region channels -internal suspend fun LightningService.openChannel(peer: LnPeer) { - - // sendToAddress - // mine 6 blocks & wait for esplora to pick up block - // wait for esplora to pick up tx - sync() - - ServiceQueue.LDK.background { - node.connectOpenChannel( - nodeId = peer.nodeId, - address = peer.address, - channelAmountSats = 50000u, - pushToCounterpartyMsat = null, - channelConfig = null, - announceChannel = true, - ) + try { + ServiceQueue.LDK.background { + node.connect(peer.nodeId, peer.address, persist = true) + } + Log.i(LDK, "Connection succeeded with: $peer") + } catch(e: NodeException) { + Log.w(LDK, "Connection failed with: $peer", LdkError(e)) + } } + // endregion + + // region channels + suspend fun openChannel(peer: LnPeer) { + val node = this.node ?: throw ServiceError.NodeNotSetup - sync() + // sendToAddress + // mine 6 blocks & wait for esplora to pick up block + // wait for esplora to pick up tx + sync() - val pendingEvent = node.nextEventAsync() - check(pendingEvent is Event.ChannelPending) { "Expected ChannelPending event, got $pendingEvent" } - node.eventHandled() + ServiceQueue.LDK.background { + node.connectOpenChannel( + nodeId = peer.nodeId, + address = peer.address, + channelAmountSats = 50000u, + pushToCounterpartyMsat = null, + channelConfig = null, + announceChannel = true, + ) + } - Log.d(LDK, "Channel pending with peer: ${peer.address}") - Log.d(LDK, "Channel funding txid: ${pendingEvent.fundingTxo.txid}") + sync() - // wait for counterparty to pickup event: ChannelPending - // wait for esplora to pick up tx: fundingTx - // mine 6 blocks & wait for esplora to pick up block - sync() + val pendingEvent = node.nextEventAsync() + check(pendingEvent is Event.ChannelPending) { "Expected ChannelPending event, got $pendingEvent" } + node.eventHandled() - val readyEvent = node.nextEventAsync() - check(readyEvent is Event.ChannelReady) { "Expected ChannelReady event, got $readyEvent" } - node.eventHandled() + Log.d(LDK, "Channel pending with peer: ${peer.address}") + Log.d(LDK, "Channel funding txid: ${pendingEvent.fundingTxo.txid}") - // wait for counterparty to pickup event: ChannelReady + // wait for counterparty to pickup event: ChannelPending + // wait for esplora to pick up tx: fundingTx + // mine 6 blocks & wait for esplora to pick up block + sync() - Log.i(LDK, "Channel ready: ${readyEvent.userChannelId}") -} + val readyEvent = node.nextEventAsync() + check(readyEvent is Event.ChannelReady) { "Expected ChannelReady event, got $readyEvent" } + node.eventHandled() -internal suspend fun LightningService.closeChannel(userChannelId: String, counterpartyNodeId: String) { - node.closeChannel(userChannelId, counterpartyNodeId) + // wait for counterparty to pickup event: ChannelReady - val event = node.nextEventAsync() - check(event is Event.ChannelClosed) { "Expected ChannelClosed event, got $event" } - node.eventHandled() + Log.i(LDK, "Channel ready: ${readyEvent.userChannelId}") + } - // mine 1 block & wait for esplora to pick up block - sync() + suspend fun closeChannel(userChannelId: String, counterpartyNodeId: String) { + val node = this.node ?: throw ServiceError.NodeNotStarted - Log.i(LDK, "Channel closed: $userChannelId") -} -// endregion + ServiceQueue.LDK.background { + node.closeChannel(userChannelId, counterpartyNodeId) + } -// region payments -internal fun LightningService.createInvoice(): String { - return node.bolt11Payment().receive(amountMsat = 112u, description = "description", expirySecs = 7200u) -} + val event = node.nextEventAsync() + check(event is Event.ChannelClosed) { "Expected ChannelClosed event, got $event" } + node.eventHandled() + + // mine 1 block & wait for esplora to pick up block + sync() -internal suspend fun LightningService.payInvoice(invoice: String): Boolean { - Log.d(LDK, "Paying invoice: $invoice") + Log.i(LDK, "Channel closed: $userChannelId") + } + // endregion - node.bolt11Payment().send(invoice) - node.eventHandled() + // region payments + suspend fun createInvoice(amountSat: ULong, description: String, expirySecs: UInt): String { + val node = this.node ?: throw ServiceError.NodeNotSetup - when (val event = node.nextEventAsync()) { - is Event.PaymentSuccessful -> { - Log.i(LDK, "Payment successful for invoice: $invoice") + return ServiceQueue.LDK.background { + node.bolt11Payment().receive(amountMsat = amountSat * 1000u, description, expirySecs) } + } + + suspend fun payInvoice(invoice: String): Boolean { + val node = this.node ?: throw ServiceError.NodeNotSetup + + Log.d(LDK, "Paying invoice: $invoice") - is Event.PaymentFailed -> { - Log.e(LDK, "Payment error: ${event.reason}") - return false + ServiceQueue.LDK.background { + node.bolt11Payment().send(invoice) } + node.eventHandled() + + when (val event = node.nextEventAsync()) { + is Event.PaymentSuccessful -> { + Log.i(LDK, "Payment successful for invoice: $invoice") + return true + } - else -> { - Log.e(LDK, "Expected PaymentSuccessful/PaymentFailed event, got $event") - return false + is Event.PaymentFailed -> { + Log.e(LDK, "Payment error: ${event.reason}") + return false + } + + else -> { + Log.e(LDK, "Expected PaymentSuccessful/PaymentFailed event, got $event") + return false + } } } + // endregion - return true + // region state + val nodeId: String? get() = node?.nodeId() + val balances: BalanceDetails? get() = node?.listBalances() + val status: NodeStatus? get() = node?.status() + val peers: List? get() = node?.listPeers()?.map { it.toLnPeer() } + val channels: List? get() = node?.listChannels() + val payments: List? get() = node?.listPayments() + // endregion } -// endregion internal suspend fun warmupNode() { runCatching { @@ -230,7 +263,7 @@ internal suspend fun warmupNode() { start() sync() } - BitcoinService.shared.apply { + OnChainService.shared.apply { setup() fullScan() } diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 0feab329f..6d8ab85a0 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -7,9 +7,10 @@ import android.database.sqlite.SQLiteOpenHelper import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import org.ldk.structs.KeysManager -import to.bitkit.Env -import to.bitkit.Tag.LDK +import to.bitkit.env.Env +import to.bitkit.env.Tag.LDK import to.bitkit.ext.hex +import to.bitkit.shared.ServiceError import java.io.File import javax.inject.Inject import kotlin.io.path.Path @@ -26,8 +27,7 @@ class MigrationService @Inject constructor( // Skip if db already exists if (file.exists()) { - Log.d(LDK, "Migration skipped: ldk-node db exists at: $file") - return + throw ServiceError.LdkNodeSqliteAlreadyExists(file.path) } val path = file.path @@ -72,9 +72,9 @@ class MigrationService @Inject constructor( for (monitor in monitors) { val channelMonitor = read32BytesChannelMonitor(monitor, entropySource, signerProvider).takeIf { it.is_ok } ?.let { it as? ChannelMonitorDecodeResultTuple }?.res?._b - ?: throw Error("Could not read channel monitor using read32BytesChannelMonitor") + ?: throw ServiceError.LdkToLdkNodeMigration val fundingTx = channelMonitor._funding_txo._a._txid?.reversedArray()?.hex - ?: throw Error("Could not read txid from funding tx OutPoint of channel monitor") + ?: throw ServiceError.LdkToLdkNodeMigration val index = channelMonitor._funding_txo._a._index val key = "${fundingTx}_$index" @@ -114,6 +114,6 @@ class MigrationService @Inject constructor( private const val KEY = "key" private const val VALUE = "value" private const val LDK_DB_NAME = "$LDK_NODE_DATA.sqlite" - private const val LDK_DB_VERSION = 2 // TODO: check on each ldk-node version update + private const val LDK_DB_VERSION = 2 } } diff --git a/app/src/main/java/to/bitkit/services/BitcoinService.kt b/app/src/main/java/to/bitkit/services/OnChainService.kt similarity index 70% rename from app/src/main/java/to/bitkit/services/BitcoinService.kt rename to app/src/main/java/to/bitkit/services/OnChainService.kt index 67fbd081a..6e3aa4246 100644 --- a/app/src/main/java/to/bitkit/services/BitcoinService.kt +++ b/app/src/main/java/to/bitkit/services/OnChainService.kt @@ -3,29 +3,30 @@ package to.bitkit.services import android.util.Log import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import org.bitcoindevkit.Balance import org.bitcoindevkit.Descriptor import org.bitcoindevkit.DescriptorSecretKey import org.bitcoindevkit.EsploraClient import org.bitcoindevkit.KeychainKind import org.bitcoindevkit.Mnemonic import org.bitcoindevkit.Wallet -import to.bitkit.Env -import to.bitkit.REST -import to.bitkit.SEED -import to.bitkit.Tag.BDK import to.bitkit.async.BaseCoroutineScope -import to.bitkit.di.BgDispatcher import to.bitkit.async.ServiceQueue +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.env.SEED +import to.bitkit.env.Tag.BDK +import to.bitkit.shared.ServiceError import javax.inject.Inject import kotlin.io.path.Path import kotlin.io.path.pathString -class BitcoinService @Inject constructor( +class OnChainService @Inject constructor( @BgDispatcher bgDispatcher: CoroutineDispatcher, ) : BaseCoroutineScope(bgDispatcher) { companion object { val shared by lazy { - BitcoinService(Dispatchers.Default) + OnChainService(Dispatchers.Default) } } @@ -33,10 +34,10 @@ class BitcoinService @Inject constructor( private val stopGap = 20_UL private var hasSynced = false - private val esploraClient by lazy { EsploraClient(url = REST) } + private val esploraClient by lazy { EsploraClient(url = Env.esploraUrl) } private val dbPath by lazy { Path(Env.Storage.bdk, "db.sqlite") } - private lateinit var wallet: Wallet + private var wallet: Wallet? = null suspend fun setup() { val network = Env.network.bdk @@ -57,7 +58,23 @@ class BitcoinService @Inject constructor( Log.i(BDK, "Wallet set up") } + fun stop() { + Log.d(BDK, "Stopping onchain wallet…") + wallet?.close().also { wallet = null } + Log.i(BDK, "Onchain wallet stopped") + } + + fun wipeStorage() { + if (wallet != null) throw ServiceError.OnchainWalletStillRunning + + Log.w(BDK, "Wiping onchain wallet storage…") + dbPath.toFile()?.parentFile?.deleteRecursively() + Log.i(BDK, "Onchain wallet storage wiped") + } + + // region scan suspend fun syncWithRevealedSpks() { + val wallet = this.wallet ?: throw ServiceError.OnchainWalletNotInitialized Log.d(BDK, "Wallet syncing…") ServiceQueue.BDK.background { @@ -71,6 +88,7 @@ class BitcoinService @Inject constructor( } suspend fun fullScan() { + val wallet = this.wallet ?: throw ServiceError.OnchainWalletNotInitialized Log.d(BDK, "Wallet full scan…") ServiceQueue.BDK.background { @@ -84,22 +102,17 @@ class BitcoinService @Inject constructor( Log.i(BDK, "Wallet fully scanned") } - - fun wipeStorage() { - Log.w(BDK, "Wiping wallet storage…") - - dbPath.toFile()?.parentFile?.deleteRecursively() - - Log.i(BDK, "Wallet storage wiped") - } + // endregion // region state - val balance get() = if (hasSynced) wallet.getBalance() else null + val balance: Balance? get() = if (hasSynced) wallet?.getBalance() else null suspend fun getAddress(): String { + val wallet = this.wallet ?: throw ServiceError.OnchainWalletNotInitialized + return ServiceQueue.BDK.background { - val addressInfo = wallet.revealNextAddress(KeychainKind.EXTERNAL).address - addressInfo.asString() + val addressInfo = wallet.revealNextAddress(KeychainKind.EXTERNAL) + addressInfo.address.asString() } } // endregion diff --git a/app/src/main/java/to/bitkit/shared/Errors.kt b/app/src/main/java/to/bitkit/shared/Errors.kt new file mode 100644 index 000000000..8cc500bcb --- /dev/null +++ b/app/src/main/java/to/bitkit/shared/Errors.kt @@ -0,0 +1,432 @@ +// @file:Suppress("unused") + +package to.bitkit.shared + +import org.bitcoindevkit.AddressException +import org.bitcoindevkit.Bip32Exception +import org.bitcoindevkit.Bip39Exception +import org.bitcoindevkit.CalculateFeeException +import org.bitcoindevkit.CannotConnectException +import org.bitcoindevkit.CreateTxException +import org.bitcoindevkit.DescriptorException +import org.bitcoindevkit.DescriptorKeyException +import org.bitcoindevkit.ElectrumException +import org.bitcoindevkit.EsploraException +import org.bitcoindevkit.ExtractTxException +import org.bitcoindevkit.FeeRateException +import org.bitcoindevkit.ParseAmountException +import org.bitcoindevkit.PersistenceException +import org.bitcoindevkit.PsbtParseException +import org.bitcoindevkit.SignerException +import org.bitcoindevkit.TransactionException +import org.bitcoindevkit.TxidParseException +import org.bitcoindevkit.WalletCreationException +import org.lightningdevkit.ldknode.BuildException +import org.lightningdevkit.ldknode.NodeException + +open class AppError(override val message: String) : Exception(message) { + companion object { + @Suppress("ConstPropertyName") + private const val serialVersionUID = 1L + } + + fun readResolve(): Any { + // Return a new instance of the class, or handle it if needed + return this + } +} + +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 OnchainWalletNotInitialized : ServiceError("Onchain wallet not created") + class LdkNodeSqliteAlreadyExists(path: String) : ServiceError("LDK-node SQLite file already exists at $path") + data object LdkToLdkNodeMigration : ServiceError("LDK to LDK-node migration issue") + class MnemonicNotFound : ServiceError("Mnemonic not found") + data object NodeStillRunning : ServiceError("Node is still running") + data object OnchainWalletStillRunning : ServiceError("Onchain wallet is still running") + data object InvalidNodeSigningMessage : ServiceError("Invalid node signing message") +} + +sealed class KeychainError(message: String) : AppError(message) { + class FailedToDelete(key: String) : KeychainError("Failed to delete $key from keychain.") + class FailedToLoad(key: String) : KeychainError("Failed to load $key from keychain.") + class FailedToSave(key: String) : KeychainError("Failed to save to $key keychain.") + class FailedToSaveAlreadyExists(key: String) : + KeychainError("Key $key already exists in keychain. Explicitly delete key before attempting to update value.") + + class KeychainWipeNotAllowed : KeychainError("Wiping keychain is only allowed in debug mode for regtest") +} + +sealed class BlocktankError(message: String) : AppError(message) { + class InvalidResponse(status: Int) : BlocktankError("Invalid response status code $status.") + class InvalidJson : BlocktankError("Invalid JSON.") +} + +// region ldk +class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") { + constructor(inner: BuildException) : this(LdkException.Build(inner)) + constructor(inner: NodeException) : this(LdkException.Node(inner)) + + override val message get() = inner.message ?: super.message + + sealed interface LdkException { + val message: String? + + class Build(exception: BuildException) : LdkException { + override val message = when (exception) { + is BuildException.InvalidChannelMonitor -> "Invalid channel monitor." + is BuildException.InvalidSeedBytes -> "Invalid seed bytes." + is BuildException.InvalidSeedFile -> "Invalid seed file." + is BuildException.InvalidSystemTime -> "Invalid system time." + is BuildException.InvalidListeningAddresses -> "Invalid listening addresses." + is BuildException.ReadFailed -> "Read failed." + is BuildException.WriteFailed -> "Write failed." + is BuildException.StoragePathAccessFailed -> "Storage path access failed." + is BuildException.KvStoreSetupFailed -> "KV store setup failed." + is BuildException.WalletSetupFailed -> "Wallet setup failed." + is BuildException.LoggerSetupFailed -> "Logger setup failed." + else -> exception.message + }?.let { "LDK Build error: $it" } + } + + class Node(exception: NodeException) : LdkException { + override val message = when (exception) { + is NodeException.AlreadyRunning -> "The node is already running." + is NodeException.NotRunning -> "The node is not running." + is NodeException.OnchainTxCreationFailed -> "Failed to create on-chain transaction." + is NodeException.ConnectionFailed -> "Connection failed." + is NodeException.InvoiceCreationFailed -> "Invoice creation failed." + is NodeException.InvoiceRequestCreationFailed -> "Invoice request creation failed." + is NodeException.OfferCreationFailed -> "Offer creation failed." + is NodeException.RefundCreationFailed -> "Refund creation failed." + is NodeException.PaymentSendingFailed -> "Payment sending failed." + is NodeException.ProbeSendingFailed -> "Probe sending failed." + is NodeException.ChannelCreationFailed -> "Channel creation failed." + is NodeException.ChannelClosingFailed -> "Channel closing failed." + is NodeException.ChannelConfigUpdateFailed -> "Channel configuration update failed." + is NodeException.PersistenceFailed -> "Persistence failed." + is NodeException.FeerateEstimationUpdateFailed -> "Feerate estimation update failed." + is NodeException.FeerateEstimationUpdateTimeout -> "Feerate estimation update timeout." + is NodeException.WalletOperationFailed -> "Wallet operation failed." + is NodeException.WalletOperationTimeout -> "Wallet operation timeout." + is NodeException.OnchainTxSigningFailed -> "On-chain transaction signing failed." + is NodeException.MessageSigningFailed -> "Message signing failed." + is NodeException.TxSyncFailed -> "Transaction synchronization failed." + is NodeException.TxSyncTimeout -> "Transaction synchronization timeout." + is NodeException.GossipUpdateFailed -> "Gossip update failed." + is NodeException.GossipUpdateTimeout -> "Gossip update timeout." + is NodeException.LiquidityRequestFailed -> "Liquidity request failed." + is NodeException.InvalidAddress -> "Invalid address." + is NodeException.InvalidSocketAddress -> "Invalid socket address." + is NodeException.InvalidPublicKey -> "Invalid public key." + is NodeException.InvalidSecretKey -> "Invalid secret key." + is NodeException.InvalidOfferId -> "Invalid offer ID." + is NodeException.InvalidNodeId -> "Invalid node ID." + is NodeException.InvalidPaymentId -> "Invalid payment ID." + is NodeException.InvalidPaymentHash -> "Invalid payment hash." + is NodeException.InvalidPaymentPreimage -> "Invalid payment preimage." + is NodeException.InvalidPaymentSecret -> "Invalid payment secret." + is NodeException.InvalidAmount -> "Invalid amount." + is NodeException.InvalidInvoice -> "Invalid invoice." + is NodeException.InvalidOffer -> "Invalid offer." + is NodeException.InvalidRefund -> "Invalid refund." + is NodeException.InvalidChannelId -> "Invalid channel ID." + is NodeException.InvalidNetwork -> "Invalid network." + is NodeException.DuplicatePayment -> "Duplicate payment." + is NodeException.UnsupportedCurrency -> "Unsupported currency." + is NodeException.InsufficientFunds -> "Insufficient funds." + is NodeException.LiquiditySourceUnavailable -> "Liquidity source unavailable." + is NodeException.LiquidityFeeTooHigh -> "Liquidity fee too high." + else -> exception.message + }?.let { "LDK Node error: $it" } + } + } +} +// endregion + +// region bdk +class BdkError(private val inner: BdkException) : AppError("Unknown BDK error.") { + constructor(inner: AddressException) : this(BdkException.Address(inner)) + constructor(inner: Bip32Exception) : this(BdkException.Bip32(inner)) + constructor(inner: Bip39Exception) : this(BdkException.Bip39(inner)) + constructor(inner: CalculateFeeException) : this(BdkException.CalculateFee(inner)) + constructor(inner: CannotConnectException) : this(BdkException.CannotConnect(inner)) + constructor(inner: CreateTxException) : this(BdkException.CreateTx(inner)) + constructor(inner: DescriptorException) : this(BdkException.Descriptor(inner)) + constructor(inner: DescriptorKeyException) : this(BdkException.DescriptorKey(inner)) + constructor(inner: ElectrumException) : this(BdkException.Electrum(inner)) + constructor(inner: EsploraException) : this(BdkException.Esplora(inner)) + constructor(inner: ExtractTxException) : this(BdkException.ExtractTx(inner)) + constructor(inner: FeeRateException) : this(BdkException.FeeRate(inner)) + constructor(inner: ParseAmountException) : this(BdkException.ParseAmount(inner)) + constructor(inner: PersistenceException) : this(BdkException.Persistence(inner)) + constructor(inner: PsbtParseException) : this(BdkException.PsbtParse(inner)) + constructor(inner: SignerException) : this(BdkException.Signer(inner)) + constructor(inner: TransactionException) : this(BdkException.Transaction(inner)) + constructor(inner: TxidParseException) : this(BdkException.TxidParse(inner)) + constructor(inner: WalletCreationException) : this(BdkException.WalletCreation(inner)) + + override val message get() = inner.message ?: super.message + + sealed interface BdkException { + val message: String? + + class Address(exception: AddressException) : BdkException { + override val message = when (exception) { + is AddressException.OtherAddressErr -> "An unspecified address error occurred." + is AddressException.Base58 -> "Base58 encoding issue in the address." + is AddressException.Bech32 -> "Bech32 encoding issue in the address." + is AddressException.WitnessProgram -> "Witness program in address is invalid or corrupted." + is AddressException.ExcessiveScriptSize -> "The script size in the address is too large." + is AddressException.NetworkValidation -> "Network validation failed for the address." + is AddressException.UncompressedPubkey -> "Address contains an uncompressed public key." + is AddressException.UnrecognizedScript -> "Address script is not recognized or invalid." + is AddressException.WitnessVersion -> "Witness version in address is incorrect." + else -> exception.message + }?.let { "BDK Address error: $it" } + } + + class Bip32(exception: Bip32Exception) : BdkException { + override val message = when (exception) { + is Bip32Exception.Base58 -> "Base58 encoding issue in BIP32 key." + is Bip32Exception.InvalidChildNumber -> "Invalid child number in BIP32 key derivation." + is Bip32Exception.UnknownException -> "An unknown BIP32 error occurred." + is Bip32Exception.Hex -> "Hexadecimal encoding error in BIP32 key." + is Bip32Exception.CannotDeriveFromHardenedKey -> "Unable to derive key from a hardened key." + is Bip32Exception.InvalidDerivationPathFormat -> "Invalid format in BIP32 derivation path." + is Bip32Exception.InvalidPublicKeyHexLength -> "Public key hex length is invalid." + is Bip32Exception.Secp256k1 -> "SECP256k1 curve error in BIP32 key." + is Bip32Exception.UnknownVersion -> "Unknown BIP32 version." + is Bip32Exception.WrongExtendedKeyLength -> "Extended key length is incorrect." + is Bip32Exception.InvalidChildNumberFormat -> "Child number format in BIP32 key is invalid." + else -> exception.message + }?.let { "BIP32 error: $it" } + } + + class Bip39(exception: Bip39Exception) : BdkException { + override val message = when (exception) { + is Bip39Exception.AmbiguousLanguages -> "The specified language for BIP39 is ambiguous." + is Bip39Exception.BadEntropyBitCount -> "The entropy bit count in BIP39 is incorrect." + is Bip39Exception.BadWordCount -> "Word count in BIP39 mnemonic is incorrect." + is Bip39Exception.InvalidChecksum -> "Checksum validation failed in BIP39 mnemonic." + is Bip39Exception.UnknownWord -> "Unknown word found in BIP39 mnemonic." + else -> exception.message + }?.let { "BDK BIP39 error: $it" } + } + + class CalculateFee(exception: CalculateFeeException) : BdkException { + override val message = when (exception) { + is CalculateFeeException.MissingTxOut -> "Transaction output is missing during fee calculation." + is CalculateFeeException.NegativeFee -> "Calculated fee is negative." + else -> exception.message + }?.let { "BDK Calculate fee error: $it" } + } + + class CannotConnect(exception: CannotConnectException) : BdkException { + override val message = when (exception) { + is CannotConnectException.Include -> "Include-specific connection issue occurred." + else -> exception.message + }?.let { "BDK Cannot connect error: $it" } + } + + class CreateTx(exception: CreateTxException) : BdkException { + override val message = when (exception) { + is CreateTxException.ChangePolicyDescriptor -> "Error with change policy descriptor." + is CreateTxException.CoinSelection -> "Coin selection issue." + is CreateTxException.Descriptor -> "Descriptor issue." + is CreateTxException.FeeRateTooLow -> "Fee rate specified is too low." + is CreateTxException.FeeTooLow -> "The total fee is too low." + is CreateTxException.InsufficientFunds -> "Insufficient funds available." + is CreateTxException.LockTime -> "Lock time error." + is CreateTxException.MiniscriptPsbt -> "Miniscript PSBT error." + is CreateTxException.MissingKeyOrigin -> "Missing key origin information." + is CreateTxException.MissingNonWitnessUtxo -> "Missing non-witness UTXO." + is CreateTxException.NoRecipients -> "No recipients specified for the transaction." + is CreateTxException.NoUtxosSelected -> "No UTXOs selected for the transaction." + is CreateTxException.OutputBelowDustLimit -> "Transaction output is below the dust limit." + is CreateTxException.Persist -> "Persistence issue occurred during transaction creation." + is CreateTxException.Policy -> "Policy issue during transaction creation." + is CreateTxException.Psbt -> "PSBT issue during transaction creation." + is CreateTxException.RbfSequence -> "Replace-by-fee (RBF) sequence error." + is CreateTxException.RbfSequenceCsv -> "RBF sequence with CSV error." + is CreateTxException.SpendingPolicyRequired -> "Spending policy required but not provided." + is CreateTxException.UnknownUtxo -> "Unknown UTXO encountered during transaction creation." + is CreateTxException.Version0 -> "Version 0 specific issue." + is CreateTxException.Version1Csv -> "Version 1 CSV specific issue." + else -> exception.message + }?.let { "BDK Transaction creation error: $it" } + } + + class Descriptor(exception: DescriptorException) : BdkException { + override val message = when (exception) { + is DescriptorException.Base58 -> "Base58 encoding issue with the descriptor." + is DescriptorException.Bip32 -> "BIP32 specific error with the descriptor." + is DescriptorException.HardenedDerivationXpub -> "Error with hardened derivation in descriptor." + is DescriptorException.Hex -> "Hexadecimal encoding issue with the descriptor." + is DescriptorException.InvalidDescriptorCharacter -> "Invalid character found in descriptor." + is DescriptorException.InvalidDescriptorChecksum -> "Descriptor checksum validation failed." + is DescriptorException.InvalidHdKeyPath -> "Invalid HD key path in descriptor." + is DescriptorException.Key -> "Key issue in descriptor." + is DescriptorException.Miniscript -> "Miniscript issue with descriptor." + is DescriptorException.MultiPath -> "Multipath issue in descriptor." + is DescriptorException.Pk -> "Public key issue in descriptor." + is DescriptorException.Policy -> "Policy issue in descriptor." + else -> exception.message + }?.let { "BDK Descriptor error: $it" } + } + + class DescriptorKey(exception: DescriptorKeyException) : BdkException { + override val message = when (exception) { + is DescriptorKeyException.Bip32 -> "BIP32 key issue." + is DescriptorKeyException.InvalidKeyType -> "Invalid key type encountered." + is DescriptorKeyException.Parse -> "Error parsing the descriptor key." + else -> exception.message + }?.let { "BDK Descriptor key error: $it" } + } + + class Electrum(exception: ElectrumException) : BdkException { + override val message = when (exception) { + is ElectrumException.AllAttemptsErrored -> "All attempts to connect to Electrum server failed." + is ElectrumException.AlreadySubscribed -> "Already subscribed to the Electrum server." + is ElectrumException.Bitcoin -> "Bitcoin-specific error with Electrum server." + is ElectrumException.CouldNotCreateConnection -> "Failed to create a connection with Electrum server." + is ElectrumException.CouldntLockReader -> "Unable to lock reader for Electrum server." + is ElectrumException.Hex -> "Hexadecimal encoding issue with Electrum server response." + is ElectrumException.InvalidDnsNameException -> "Invalid DNS name provided for Electrum server." + is ElectrumException.InvalidResponse -> "Received an invalid response from the Electrum server." + is ElectrumException.IoException -> "I/O error occurred with Electrum server." + is ElectrumException.Json -> "JSON parsing error with Electrum server response." + is ElectrumException.Message -> "Message-related error with Electrum server." + is ElectrumException.MissingDomain -> "Missing domain in Electrum server configuration." + is ElectrumException.Mpsc -> "MPSC-related error with Electrum server." + is ElectrumException.NotSubscribed -> "Not subscribed to Electrum server." + is ElectrumException.Protocol -> "Protocol error with Electrum server." + is ElectrumException.RequestAlreadyConsumed -> "Request already consumed by Electrum server." + is ElectrumException.SharedIoException -> "Shared I/O error occurred in Electrum server." + else -> exception.message + }?.let { "BDK Electrum error: $it" } + } + + class Esplora(exception: EsploraException) : BdkException { + override val message = when (exception) { + is EsploraException.BitcoinEncoding -> "Bitcoin encoding error in Esplora." + is EsploraException.HeaderHashNotFound -> "Header hash not found in Esplora response." + is EsploraException.HeaderHeightNotFound -> "Header height not found in Esplora response." + is EsploraException.HexToArray -> "Error converting hex to array in Esplora." + is EsploraException.HexToBytes -> "Error converting hex to bytes in Esplora." + is EsploraException.HttpResponse -> "HTTP response error from Esplora server." + is EsploraException.InvalidHttpHeaderName -> "Invalid HTTP header name in Esplora response." + is EsploraException.InvalidHttpHeaderValue -> "Invalid HTTP header value in Esplora response." + is EsploraException.Minreq -> "Minimum requirements error in Esplora." + is EsploraException.Parsing -> "Parsing error with Esplora data." + is EsploraException.RequestAlreadyConsumed -> "Request already consumed by Esplora server." + is EsploraException.StatusCode -> "Unexpected status code from Esplora server." + is EsploraException.TransactionNotFound -> "Transaction not found in Esplora." + else -> exception.message + }?.let { "BDK Esplora error: $it" } + } + + class ExtractTx(exception: ExtractTxException) : BdkException { + override val message = when (exception) { + is ExtractTxException.AbsurdFeeRate -> "Absurd fee rate encountered." + is ExtractTxException.MissingInputValue -> "Missing input value." + is ExtractTxException.OtherExtractTxErr -> "An unspecified error occurred." + is ExtractTxException.SendingTooMuch -> "Sending amount exceeds allowable limits." + else -> exception.message + }?.let { "BDK Extract transaction error: $it" } + } + + class FeeRate(exception: FeeRateException) : BdkException { + override val message = when (exception) { + is FeeRateException.ArithmeticOverflow -> "Arithmetic overflow occurred while calculating fee rate." + else -> exception.message + }?.let { "BDK Fee rate error: $it" } + } + + class ParseAmount(exception: ParseAmountException) : BdkException { + override val message = when (exception) { + is ParseAmountException.InputTooLarge -> "Input amount is too large to parse." + is ParseAmountException.InvalidCharacter -> "Invalid character found in amount parsing." + is ParseAmountException.InvalidFormat -> "Amount format is invalid." + is ParseAmountException.Negative -> "Negative amount encountered where not expected." + is ParseAmountException.OtherParseAmountErr -> "An unspecified error occurred while parsing amount." + is ParseAmountException.PossiblyConfusingDenomination -> "Confusing denomination in amount parsing." + is ParseAmountException.TooBig -> "Amount is too large to process." + is ParseAmountException.TooPrecise -> "Amount precision is too high." + is ParseAmountException.UnknownDenomination -> "Unknown denomination encountered in amount parsing." + else -> exception.message + }?.let { "BDK Amount parsing error: $it" } + } + + class Persistence(exception: PersistenceException) : BdkException { + override val message = when (exception) { + is PersistenceException.Write -> "Write operation failed in persistence layer." + else -> exception.message + }?.let { "BDK Persistence error: $it" } + } + + class PsbtParse(exception: PsbtParseException) : BdkException { + override val message = when (exception) { + is PsbtParseException.Base64Encoding -> "Base64 encoding issue with PSBT." + is PsbtParseException.PsbtEncoding -> "Error in PSBT encoding." + else -> exception.message + }?.let { "BDK PSBT parsing error: $it" } + } + + class Signer(exception: SignerException) : BdkException { + override val message = when (exception) { + is SignerException.External -> "External signer error." + is SignerException.InputIndexOutOfRange -> "Input index out of range during signing." + is SignerException.InvalidKey -> "Invalid key encountered during signing." + is SignerException.InvalidNonWitnessUtxo -> "Invalid non-witness UTXO encountered." + is SignerException.InvalidSighash -> "Invalid sighash encountered during signing." + is SignerException.MiniscriptPsbt -> "Miniscript PSBT issue during signing." + is SignerException.MissingHdKeypath -> "Missing HD keypath in signing process." + is SignerException.MissingKey -> "Missing key in signing process." + is SignerException.MissingNonWitnessUtxo -> "Missing non-witness UTXO during signing." + is SignerException.MissingWitnessScript -> "Missing witness script during signing." + is SignerException.MissingWitnessUtxo -> "Missing witness UTXO during signing." + is SignerException.NonStandardSighash -> "Non-standard sighash encountered." + is SignerException.SighashException -> "Sighash exception encountered during signing." + is SignerException.UserCanceled -> "Signing operation was canceled by the user." + else -> exception.message + }?.let { "BDK Signer error: $it" } + } + + class Transaction(exception: TransactionException) : BdkException { + override val message = when (exception) { + is TransactionException.InvalidChecksum -> "Invalid checksum in transaction." + is TransactionException.Io -> "I/O error occurred with transaction processing." + is TransactionException.NonMinimalVarInt -> "Non-minimal VARINT encountered in transaction." + is TransactionException.OtherTransactionErr -> "An unspecified transaction error occurred." + is TransactionException.OversizedVectorAllocation -> "Vector allocation in transaction is oversized." + is TransactionException.ParseFailed -> "Transaction parsing failed." + is TransactionException.UnsupportedSegwitFlag -> "Unsupported SegWit flag encountered." + else -> exception.message + }?.let { "BDK Transaction error: $it" } + } + + class TxidParse(exception: TxidParseException) : BdkException { + override val message = when (exception) { + is TxidParseException.InvalidTxid -> "Invalid TXID format encountered." + else -> exception.message + }?.let { "BDK TXID parsing error: $it" } + } + + class WalletCreation(exception: WalletCreationException) : BdkException { + override val message = when (exception) { + is WalletCreationException.Descriptor -> "Descriptor error during wallet creation." + is WalletCreationException.InvalidMagicBytes -> "Invalid magic bytes encountered in wallet creation." + is WalletCreationException.Io -> "I/O error during wallet creation." + is WalletCreationException.LoadedDescriptorDoesNotMatch -> "Loaded descriptor doesn't match expected." + is WalletCreationException.LoadedGenesisDoesNotMatch -> "Loaded genesis block doesn't match expected." + is WalletCreationException.LoadedNetworkDoesNotMatch -> "Loaded network doesn't match expected." + is WalletCreationException.NotInitialized -> "Wallet has not been initialized." + is WalletCreationException.Persist -> "Persistence issue encountered during wallet creation." + else -> exception.message + }?.let { "BDK Wallet creation error: $it" } + } + } +} +// endregion diff --git a/app/src/main/java/to/bitkit/shared/Perf.kt b/app/src/main/java/to/bitkit/shared/Perf.kt index b424ef77c..b39ea367e 100644 --- a/app/src/main/java/to/bitkit/shared/Perf.kt +++ b/app/src/main/java/to/bitkit/shared/Perf.kt @@ -1,7 +1,7 @@ package to.bitkit.shared import android.util.Log -import to.bitkit.Tag.PERF +import to.bitkit.env.Tag.PERF import kotlin.system.measureTimeMillis internal inline fun measured( diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 5898b4961..cd25cc5ee 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -7,15 +7,15 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.NotificationAdd @@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.NotificationsNone import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -39,11 +40,14 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint @@ -56,52 +60,22 @@ import to.bitkit.ui.theme.AppThemeSurface @AndroidEntryPoint class MainActivity : ComponentActivity() { - private val viewModel by viewModels() - private val sharedViewModel by viewModels() + val viewModel by viewModels() + val walletViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - sharedViewModel.logInstanceHashCode() setContent { enableEdgeToEdge() AppThemeSurface { - MainScreen(viewModel) { - WalletScreen(viewModel) { - - Card(modifier = Modifier.fillMaxWidth()) { - Text( - text = "Debug", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp), - ) - Row( - horizontalArrangement = Arrangement.SpaceAround, - modifier = Modifier.fillMaxWidth(), - ) { - TextButton(viewModel::debugDb) { Text("Debug DB") } - TextButton(viewModel::debugKeychain) { Text("Debug Keychain") } - TextButton(viewModel::debugWipeBdk) { Text("Wipe BDK") } - } + MainScreen(walletViewModel) { + val uiState = walletViewModel.uiState.collectAsStateWithLifecycle() + Crossfade(uiState, label = "ContentCrossfade") { + when (val state = it.value) { + is MainUiState.Loading -> LoadingScreen() + is MainUiState.Content -> WalletScreen(walletViewModel, state, debugUi(state)) + is MainUiState.Error -> ErrorScreen(state) } - - Card(modifier = Modifier.fillMaxWidth()) { - Text( - text = "Notifications", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp), - ) - Row( - horizontalArrangement = Arrangement.SpaceAround, - modifier = Modifier.fillMaxWidth(), - ) { - TextButton(sharedViewModel::registerForNotifications) { Text("Register Device") } - TextButton(viewModel::debugLspNotifications) { Text("LSP Notification") } - } - } - - Peers(viewModel.peers, viewModel::togglePeerConnection) - Channels(viewModel.channels, viewModel::closeChannel) } } } @@ -109,10 +83,11 @@ class MainActivity : ComponentActivity() { } } +// region scaffold @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MainScreen( - viewModel: MainViewModel = hiltViewModel(), + viewModel: WalletViewModel = hiltViewModel(), startContent: @Composable () -> Unit = {}, ) { val navController = rememberNavController() @@ -148,8 +123,7 @@ private fun MainScreen( modifier = Modifier, ) } - } - ) + }) }, bottomBar = { NavigationBar(tonalElevation = 5.dp) { @@ -179,9 +153,8 @@ private fun MainScreen( ) { padding -> Box( modifier = Modifier - .padding(padding) .fillMaxSize() - .verticalScroll(rememberScrollState()), + .padding(padding) ) { AppNavHost( navController = navController, @@ -192,7 +165,38 @@ private fun MainScreen( } } } +// endregion + +@Composable +fun LoadingScreen() { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator() + } +} +@Composable +fun ErrorScreen(uiState: MainUiState.Error) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + ) { + Text( + text = uiState.title, + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = uiState.message, + style = MaterialTheme.typography.titleSmall, + ) + } +} + +// region helpers @Composable private fun NotificationButton() { val context = LocalContext.current @@ -214,9 +218,7 @@ private fun NotificationButton() { pushNotification( title = "Bitkit Notification", text = "Short custom notification description", - bigText = "Much longer text that cannot fit one line " + - "because the lightning channel has been updated " + - "via a push notification bro…", + bigText = "Much longer text that cannot fit one line " + "because the lightning channel has been updated " + "via a push notification bro…", ) } Unit @@ -232,3 +234,40 @@ private fun NotificationButton() { ) } } +// endregion + +// region debug +fun MainActivity.debugUi(uiState: MainUiState.Content) = @Composable { + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Debug", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp), + ) + Row( + horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier.fillMaxWidth(), + ) { + TextButton(viewModel::debugDb) { Text("Debug DB") } + TextButton(viewModel::debugKeychain) { Text("Debug Keychain") } + TextButton(viewModel::debugWipeBdk) { Text("Wipe BDK") } + } + } + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.notifications), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp), + ) + Row( + horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier.fillMaxWidth(), + ) { + TextButton(viewModel::registerForNotifications) { Text("Register Device") } + TextButton(viewModel::debugLspNotifications) { Text("LSP Notification") } + } + } + Peers(uiState.peers, walletViewModel::disconnectPeer) + Channels(uiState.channels, walletViewModel::closeChannel) +} +// endregion diff --git a/app/src/main/java/to/bitkit/ui/MainViewModel.kt b/app/src/main/java/to/bitkit/ui/MainViewModel.kt deleted file mode 100644 index 30fb887e3..000000000 --- a/app/src/main/java/to/bitkit/ui/MainViewModel.kt +++ /dev/null @@ -1,163 +0,0 @@ -package to.bitkit.ui - -import android.util.Log -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.google.firebase.messaging.FirebaseMessaging -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await -import org.lightningdevkit.ldknode.ChannelDetails -import to.bitkit.LnPeer -import to.bitkit.SEED -import to.bitkit.Tag.DEV -import to.bitkit.data.AppDb -import to.bitkit.data.keychain.KeychainStore -import to.bitkit.di.BgDispatcher -import to.bitkit.ext.syncTo -import to.bitkit.services.BitcoinService -import to.bitkit.services.BlocktankService -import to.bitkit.services.LightningService -import to.bitkit.services.closeChannel -import to.bitkit.services.connectPeer -import to.bitkit.services.createInvoice -import to.bitkit.services.openChannel -import to.bitkit.services.payInvoice -import javax.inject.Inject - -@HiltViewModel -class MainViewModel @Inject constructor( - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val bitcoinService: BitcoinService, - private val lightningService: LightningService, - private val blocktankService: BlocktankService, - private val keychain: KeychainStore, - private val appDb: AppDb, -) : ViewModel() { - val ldkNodeId = mutableStateOf("Loading…") - val ldkBalance = mutableStateOf("Loading…") - val btcAddress = mutableStateOf("Loading…") - val btcBalance = mutableStateOf("Loading…") - val mnemonic = mutableStateOf(SEED) - - val peers = mutableStateListOf() - val channels = mutableStateListOf() - - private val node = lightningService.node - - init { - viewModelScope.launch { - sync() - } - } - - private suspend fun sync() { - bitcoinService.syncWithRevealedSpks() - ldkNodeId.value = lightningService.nodeId - ldkBalance.value = lightningService.balances.totalLightningBalanceSats.toString() - btcAddress.value = bitcoinService.getAddress() - btcBalance.value = bitcoinService.balance?.total?.toSat().toString() - mnemonic.value = SEED - peers.syncTo(lightningService.peers) - channels.syncTo(lightningService.channels) - } - - fun getNewAddress() { - btcAddress.value = node.onchainPayment().newAddress() - } - - fun connectPeer(peer: LnPeer) { - lightningService.connectPeer(peer) - peers.replaceAll { - it.run { copy(isConnected = it.nodeId == nodeId) } - } - channels.syncTo(lightningService.channels) - } - - fun disconnectPeer(nodeId: String) { - node.disconnect(nodeId) - peers.replaceAll { - it.takeIf { it.nodeId == nodeId }?.copy(isConnected = false) ?: it - } - channels.syncTo(lightningService.channels) - } - - fun payInvoice(invoice: String) { - viewModelScope.launch(bgDispatcher) { - lightningService.payInvoice(invoice) - sync() - } - } - - fun createInvoice() = lightningService.createInvoice() - - fun openChannel() { - viewModelScope.launch(bgDispatcher) { - lightningService.openChannel(peers.first()) - sync() - } - } - - fun closeChannel(channel: ChannelDetails) { - viewModelScope.launch(bgDispatcher) { - lightningService.closeChannel(channel.userChannelId, channel.counterpartyNodeId) - sync() - } - } - - fun refresh() { - viewModelScope.launch { - "Refreshing…".also { - ldkNodeId.value = it - ldkBalance.value = it - btcAddress.value = it - btcBalance.value = it - } - peers.clear() - channels.clear() - - delay(50) - lightningService.sync() - sync() - } - } - - // region debug - fun debugDb() { - viewModelScope.launch { - appDb.configDao().getAll().collect { - Log.d(DEV, "${it.count()} entities in DB: $it") - } - } - } - - fun debugKeychain() { - viewModelScope.launch { - val key = "test" - if (keychain.exists(key)) { - keychain.delete(key) - } - keychain.saveString(key, "testValue") - } - } - - fun debugWipeBdk() { - bitcoinService.wipeStorage() - } - - fun debugLspNotifications() { - viewModelScope.launch(bgDispatcher) { - val token = FirebaseMessaging.getInstance().token.await() - blocktankService.testNotification(token) - } - } - - // endregion -} - -fun MainViewModel.togglePeerConnection(peer: LnPeer) = - if (peer.isConnected) disconnectPeer(peer.nodeId) else connectPeer(peer) diff --git a/app/src/main/java/to/bitkit/ui/Nav.kt b/app/src/main/java/to/bitkit/ui/Nav.kt index c9d02d9e5..554728dc4 100644 --- a/app/src/main/java/to/bitkit/ui/Nav.kt +++ b/app/src/main/java/to/bitkit/ui/Nav.kt @@ -24,7 +24,7 @@ import to.bitkit.ui.settings.PeersScreen fun AppNavHost( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: MainViewModel = hiltViewModel(), + viewModel: WalletViewModel = hiltViewModel(), walletScreen: @Composable () -> Unit = {}, ) { // val screenViewModel = viewModel() diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index f5876e2c2..35e1b30b2 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -18,8 +18,8 @@ import androidx.core.app.NotificationCompat import com.google.android.gms.tasks.OnCompleteListener import com.google.firebase.messaging.FirebaseMessaging import to.bitkit.R -import to.bitkit.Tag.FCM import to.bitkit.currentActivity +import to.bitkit.env.Tag.FCM import to.bitkit.ext.notificationManager import to.bitkit.ext.notificationManagerCompat import to.bitkit.ext.requiresPermission diff --git a/app/src/main/java/to/bitkit/ui/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/SettingsScreen.kt index d452ae2b4..8fa324622 100644 --- a/app/src/main/java/to/bitkit/ui/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/SettingsScreen.kt @@ -21,12 +21,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController import to.bitkit.R +import to.bitkit.env.SEED import to.bitkit.ui.shared.InfoField @Composable fun SettingsScreen( navController: NavController, - viewModel: MainViewModel, + viewModel: WalletViewModel, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -59,7 +60,7 @@ fun SettingsScreen( text = "Bitcoin Wallet", style = MaterialTheme.typography.titleMedium, ) - Mnemonic(viewModel.mnemonic.value) + Mnemonic(SEED) // TODO use value from viewModel.uiState } } @@ -95,6 +96,7 @@ private fun Mnemonic( InfoField( value = mnemonic, label = stringResource(R.string.mnemonic), + maxLength = 52, trailingIcon = { CopyToClipboardButton(mnemonic) }, ) } diff --git a/app/src/main/java/to/bitkit/ui/SharedViewModel.kt b/app/src/main/java/to/bitkit/ui/SharedViewModel.kt index 8aa401dcd..8af2b7dce 100644 --- a/app/src/main/java/to/bitkit/ui/SharedViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/SharedViewModel.kt @@ -9,30 +9,33 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.tasks.await -import to.bitkit.Tag.DEV -import to.bitkit.Tag.LSP +import to.bitkit.data.AppDb +import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.env.Tag.APP +import to.bitkit.env.Tag.DEV +import to.bitkit.env.Tag.LSP import to.bitkit.services.BlocktankService +import to.bitkit.services.OnChainService import javax.inject.Inject @HiltViewModel class SharedViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val db: AppDb, + private val keychain: Keychain, private val blocktankService: BlocktankService, + private val onChainService: OnChainService, + private val firebaseMessaging: FirebaseMessaging, ) : ViewModel() { fun warmupNode() { // TODO make it concurrent, and wait for all to finish before trying to access `lightningService.node`, etc… - logInstanceHashCode() runBlocking { to.bitkit.services.warmupNode() } } - fun logInstanceHashCode() { - Log.d(DEV, "${this::class.java.simpleName} hashCode: ${hashCode()}") - } - fun registerForNotifications(fcmToken: String? = null) { viewModelScope.launch(bgDispatcher) { - val token = fcmToken ?: FirebaseMessaging.getInstance().token.await() + val token = fcmToken ?: firebaseMessaging.token.await() runCatching { blocktankService.registerDevice(token) @@ -41,4 +44,38 @@ class SharedViewModel @Inject constructor( } } } + + // region debug + fun debugDb() { + viewModelScope.launch { + db.configDao().getAll().collect { + Log.d(DEV, "${it.count()} entities in DB: $it") + } + } + } + + fun debugKeychain() { + viewModelScope.launch { + val key = "test" + if (keychain.exists(key)) { + val value = keychain.loadString(key) + Log.d(APP, "Keychain entry: $key = $value") + keychain.delete(key) + } + keychain.saveString(key, "testValue") + } + } + + fun debugWipeBdk() { + onChainService.stop() + onChainService.wipeStorage() + } + + fun debugLspNotifications() { + viewModelScope.launch(bgDispatcher) { + val token = FirebaseMessaging.getInstance().token.await() + blocktankService.testNotification(token) + } + } + // endregion } diff --git a/app/src/main/java/to/bitkit/ui/WalletScreen.kt b/app/src/main/java/to/bitkit/ui/WalletScreen.kt index 67462b8b8..4a258e7af 100644 --- a/app/src/main/java/to/bitkit/ui/WalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/WalletScreen.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ContentCopy @@ -13,8 +15,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager @@ -27,15 +27,15 @@ import to.bitkit.ui.shared.moneyString @Composable fun WalletScreen( - viewModel: MainViewModel, + viewModel: WalletViewModel, + uiState: MainUiState.Content, content: @Composable () -> Unit = {}, ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier, + modifier = Modifier.verticalScroll(rememberScrollState()), ) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - val ldkBalance by remember { viewModel.ldkBalance } Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom, @@ -45,23 +45,19 @@ fun WalletScreen( text = stringResource(R.string.lightning), style = MaterialTheme.typography.titleLarge, ) - Row { - Text( - text = moneyString(ldkBalance), - style = MaterialTheme.typography.titleSmall, - ) - } + Text( + text = moneyString(uiState.ldkBalance), + style = MaterialTheme.typography.titleSmall, + ) } - - val nodeId by remember { viewModel.ldkNodeId } InfoField( - value = nodeId, + value = uiState.ldkNodeId, label = stringResource(R.string.node_id), - trailingIcon = { CopyToClipboardButton(nodeId) }, + maxLength = 44, + trailingIcon = { CopyToClipboardButton(uiState.ldkNodeId) }, ) } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - val btcBalance by remember { viewModel.btcBalance } Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom, @@ -71,18 +67,16 @@ fun WalletScreen( text = stringResource(R.string.wallet), style = MaterialTheme.typography.titleLarge, ) - Row { - Text( - text = moneyString(btcBalance), - style = MaterialTheme.typography.titleSmall, - ) - } + Text( + text = moneyString(uiState.btcBalance), + style = MaterialTheme.typography.titleSmall, + ) } - val address by remember { viewModel.btcAddress } InfoField( - value = address, + value = uiState.btcAddress, label = stringResource(R.string.address), + maxLength = 36, trailingIcon = { Row { IconButton(onClick = viewModel::getNewAddress) { @@ -92,7 +86,7 @@ fun WalletScreen( modifier = Modifier.size(16.dp), ) } - CopyToClipboardButton(address) + CopyToClipboardButton(uiState.btcAddress) } }, ) @@ -112,4 +106,3 @@ internal fun CopyToClipboardButton(text: String) { ) } } - diff --git a/app/src/main/java/to/bitkit/ui/WalletViewModel.kt b/app/src/main/java/to/bitkit/ui/WalletViewModel.kt new file mode 100644 index 000000000..a4c0db6c7 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/WalletViewModel.kt @@ -0,0 +1,138 @@ +package to.bitkit.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.lightningdevkit.ldknode.ChannelDetails +import to.bitkit.di.BgDispatcher +import to.bitkit.env.LnPeer +import to.bitkit.env.SEED +import to.bitkit.services.LightningService +import to.bitkit.services.OnChainService +import to.bitkit.shared.ServiceError +import javax.inject.Inject + +@HiltViewModel +class WalletViewModel @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val onChainService: OnChainService, + private val lightningService: LightningService, +) : ViewModel() { + private val node by lazy { lightningService.node ?: throw ServiceError.NodeNotSetup } + + private val _uiState = MutableStateFlow(MainUiState.Loading) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + delay(1000) // TODO replace with actual load time of ldk-node warmUp + sync() + } + } + + private suspend fun sync() { + lightningService.sync() + onChainService.syncWithRevealedSpks() + _uiState.value = MainUiState.Content( + ldkNodeId = lightningService.nodeId.orEmpty(), + ldkBalance = lightningService.balances?.totalLightningBalanceSats.toString(), + btcAddress = onChainService.getAddress(), + btcBalance = onChainService.balance?.total?.toSat().toString(), + mnemonic = SEED, + peers = lightningService.peers.orEmpty(), + channels = lightningService.channels.orEmpty(), + ) + } + + fun getNewAddress() { + updateContentState { it.copy(btcAddress = node.onchainPayment().newAddress()) } + } + + fun connectPeer(peer: LnPeer) { + viewModelScope.launch { + lightningService.connectPeer(peer) + updateContentState { + it.copy(peers = lightningService.peers.orEmpty()) + } + } + } + + fun disconnectPeer(peer: LnPeer) { + node.disconnect(peer.nodeId) + + updateContentState { + it.copy(peers = lightningService.peers.orEmpty()) + } + } + + fun payInvoice(invoice: String) { + viewModelScope.launch(bgDispatcher) { + lightningService.payInvoice(invoice) + sync() + } + } + + fun createInvoice(): String { + return runBlocking { lightningService.createInvoice(112u, "description", 7200u) } + } + + fun openChannel() { + val contentState = _uiState.value as? MainUiState.Content ?: error("No peer connected to open channel.") + viewModelScope.launch(bgDispatcher) { + lightningService.openChannel(contentState.peers.first()) + sync() + } + } + + fun closeChannel(channel: ChannelDetails) { + viewModelScope.launch(bgDispatcher) { + lightningService.closeChannel(channel.userChannelId, channel.counterpartyNodeId) + sync() + } + } + + private fun updateContentState(update: (MainUiState.Content) -> MainUiState.Content) { + val stateValue = this._uiState.value + if (stateValue is MainUiState.Content) { + this._uiState.value = update(stateValue) + } + } + + // region debug + fun refresh() { + _uiState.value = MainUiState.Loading + viewModelScope.launch { + delay(500) + sync() + } + } + // endregion +} + +// region state +sealed class MainUiState { + data object Loading : MainUiState() + data class Content( + val ldkNodeId: String, + val ldkBalance: String, + val btcAddress: String, + val btcBalance: String, + val mnemonic: String, + val peers: List, + val channels: List, + ) : MainUiState() + + data class Error( + val title: String = "Error Title", + val message: String = "Error short description.", + ) : MainUiState() + + fun asContent() = this as? Content +} +// endregion diff --git a/app/src/main/java/to/bitkit/ui/settings/ChannelsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/ChannelsScreen.kt index 39fbcb1d9..f73a3eec6 100644 --- a/app/src/main/java/to/bitkit/ui/settings/ChannelsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/ChannelsScreen.kt @@ -2,34 +2,38 @@ package to.bitkit.ui.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import to.bitkit.ui.MainViewModel +import to.bitkit.ui.MainUiState +import to.bitkit.ui.WalletViewModel import to.bitkit.ui.shared.Channels @Composable fun ChannelsScreen( - viewModel: MainViewModel, + viewModel: WalletViewModel, ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier, ) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), + Card { + Text("⚠️ Please return to Home screen to see your updates…", Modifier.padding(12.dp)) + } + val peers = remember { (viewModel.uiState.value as? MainUiState.Content?)?.peers.orEmpty() } + val channels = remember { (viewModel.uiState.value as? MainUiState.Content)?.channels.orEmpty() } + Button( + onClick = { viewModel.openChannel() }, + enabled = peers.isNotEmpty() ) { - Button( - onClick = { viewModel.openChannel() }, - enabled = viewModel.peers.isNotEmpty() - ) { - Text("Open Channel") - } + Text("Open Channel") } - Channels(viewModel.channels, viewModel::closeChannel) + Channels(channels, viewModel::closeChannel) PayInvoice(viewModel::payInvoice) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/PaymentsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/PaymentsScreen.kt index 9dec9365f..0ed18332e 100644 --- a/app/src/main/java/to/bitkit/ui/settings/PaymentsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/PaymentsScreen.kt @@ -17,12 +17,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.ui.CopyToClipboardButton -import to.bitkit.ui.MainViewModel +import to.bitkit.ui.WalletViewModel import to.bitkit.ui.shared.InfoField @Composable fun PaymentsScreen( - viewModel: MainViewModel, + viewModel: WalletViewModel, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -34,6 +34,7 @@ fun PaymentsScreen( InfoField( value = invoiceToSend, label = "Send invoice", + maxLength = 44, trailingIcon = { CopyToClipboardButton(invoiceToSend) }, ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/PeersScreen.kt b/app/src/main/java/to/bitkit/ui/settings/PeersScreen.kt index d49c308fa..45c5df2db 100644 --- a/app/src/main/java/to/bitkit/ui/settings/PeersScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/PeersScreen.kt @@ -3,7 +3,9 @@ package to.bitkit.ui.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -15,29 +17,30 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import to.bitkit.LnPeer -import to.bitkit.PEER import to.bitkit.R -import to.bitkit.ui.MainViewModel +import to.bitkit.env.LnPeer +import to.bitkit.env.LnPeers +import to.bitkit.ext.toast +import to.bitkit.ui.MainUiState +import to.bitkit.ui.WalletViewModel import to.bitkit.ui.shared.InfoField import to.bitkit.ui.shared.Peers -import to.bitkit.ui.togglePeerConnection @Composable fun PeersScreen( - viewModel: MainViewModel, + viewModel: WalletViewModel, ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier, ) { - var pubKey by remember { mutableStateOf(PEER.nodeId) } - val host by remember { mutableStateOf(PEER.host) } - var port by remember { mutableStateOf(PEER.port) } - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + Card { + Text("⚠️ Please return to Home screen to see your updates…", Modifier.padding(12.dp)) + } + var pubKey by remember { mutableStateOf(LnPeers.local.nodeId) } + val host by remember { mutableStateOf(LnPeers.local.host) } + var port by remember { mutableStateOf(LnPeers.local.port) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "Connect to a Peer", style = MaterialTheme.typography.titleMedium, @@ -49,7 +52,7 @@ fun PeersScreen( textStyle = MaterialTheme.typography.labelSmall, modifier = Modifier.fillMaxWidth(), ) - InfoField(value = host, label = "Host") + InfoField(value = host, label = "Host", maxLength = 72) OutlinedTextField( label = { Text("Port") }, value = port, @@ -57,10 +60,14 @@ fun PeersScreen( textStyle = MaterialTheme.typography.labelSmall, modifier = Modifier.fillMaxWidth(), ) - Button(onClick = { viewModel.connectPeer(LnPeer(pubKey, host, port)) }) { + Button(onClick = { + viewModel.connectPeer(LnPeer(pubKey, host, port)) + toast("Peer connected.") + }) { Text(stringResource(R.string.connect)) } } - Peers(viewModel.peers, viewModel::togglePeerConnection) + val peers = remember { viewModel.uiState.value.asContent()?.peers.orEmpty() } + Peers(peers, viewModel::disconnectPeer) } } diff --git a/app/src/main/java/to/bitkit/ui/shared/Channels.kt b/app/src/main/java/to/bitkit/ui/shared/Channels.kt index 5ee93b876..8b3d29809 100644 --- a/app/src/main/java/to/bitkit/ui/shared/Channels.kt +++ b/app/src/main/java/to/bitkit/ui/shared/Channels.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -37,7 +36,7 @@ import to.bitkit.R @Composable internal fun Channels( - channels: SnapshotStateList, + channels: List, onChannelClose: (ChannelDetails) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { diff --git a/app/src/main/java/to/bitkit/ui/shared/InfoField.kt b/app/src/main/java/to/bitkit/ui/shared/InfoField.kt index 7c998dd21..5f4daed1a 100644 --- a/app/src/main/java/to/bitkit/ui/shared/InfoField.kt +++ b/app/src/main/java/to/bitkit/ui/shared/InfoField.kt @@ -13,6 +13,7 @@ import to.bitkit.ui.shared.util.ellipsisVisualTransformation internal fun InfoField( value: String, label: String, + maxLength: Int, trailingIcon: @Composable (() -> Unit)? = null, onValueChange: (String) -> Unit = {}, ) { @@ -32,7 +33,7 @@ internal fun InfoField( ) }, textStyle = MaterialTheme.typography.labelSmall, - visualTransformation = ellipsisVisualTransformation(40), + visualTransformation = ellipsisVisualTransformation(maxLength), modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/java/to/bitkit/ui/shared/Peers.kt b/app/src/main/java/to/bitkit/ui/shared/Peers.kt index 151ab1be8..17b62591f 100644 --- a/app/src/main/java/to/bitkit/ui/shared/Peers.kt +++ b/app/src/main/java/to/bitkit/ui/shared/Peers.kt @@ -1,15 +1,24 @@ package to.bitkit.ui.shared import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -17,19 +26,19 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import to.bitkit.LnPeer import to.bitkit.R +import to.bitkit.env.LnPeer +import to.bitkit.ext.toast @Composable internal fun Peers( - peers: SnapshotStateList, - onToggle: (LnPeer) -> Unit, + peers: List, + onDisconnect: (LnPeer) -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -41,11 +50,11 @@ internal fun Peers( ) Spacer(modifier = Modifier.weight(1f)) Text( - text = peers.filter { it.isConnected }.size.toString(), + text = peers.size.toString(), style = MaterialTheme.typography.titleMedium, ) } - peers.sortedByDescending { it.isConnected }.forEachIndexed { i, it -> + peers.forEachIndexed { i, it -> if (i > 0 && peers.size > 1) { HorizontalDivider() } @@ -53,38 +62,32 @@ internal fun Peers( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - TogglePeerIcon(it.isConnected) { onToggle(it) } + Box( + modifier = Modifier + .size(8.dp) + .background(color = colorScheme.primary, shape = CircleShape) + ) Text( text = it.nodeId, style = MaterialTheme.typography.labelSmall, overflow = TextOverflow.Ellipsis, maxLines = 1, + modifier = Modifier.weight(1f) ) + IconButton( + onClick = { + onDisconnect(it) + toast("Peer disconnected.") + }, + ) { + Icon( + imageVector = Icons.Outlined.CloudOff, + contentDescription = stringResource(R.string.disconnect), + tint = colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } } } } } - -@Composable -private fun TogglePeerIcon( - isActive: Boolean, - onClick: () -> Unit, -) { - val (icon, color) = Pair( - if (isActive) Icons.Default.Cloud else Icons.Default.CloudOff, - if (isActive) colorScheme.primary else colorScheme.error, - ) - IconButton( - onClick = onClick, - modifier = Modifier - .border(BorderStroke(1.2.dp, colorScheme.onBackground.copy(alpha = .2f)), MaterialTheme.shapes.medium) - .size(28.dp), - ) { - Icon( - imageVector = icon, - contentDescription = stringResource(R.string.status), - tint = color, - modifier = Modifier.size(16.dp), - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/shared/util/EllipsisVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/shared/util/EllipsisVisualTransformation.kt index 002115db2..401b45ed5 100644 --- a/app/src/main/java/to/bitkit/ui/shared/util/EllipsisVisualTransformation.kt +++ b/app/src/main/java/to/bitkit/ui/shared/util/EllipsisVisualTransformation.kt @@ -1,21 +1,28 @@ package to.bitkit.ui.shared.util -import androidx.compose.runtime.Composable import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation -@Composable fun ellipsisVisualTransformation( maxLength: Int, ellipsis: String = "…", -) = VisualTransformation { - val text = if (it.length > maxLength) { - buildAnnotatedString { - append(it.take(maxLength - ellipsis.length)) - append(ellipsis) - } - } else it - TransformedText(text, OffsetMapping.Identity) +) = VisualTransformation { originalText -> + val transformedText = if (originalText.length > maxLength) buildAnnotatedString { + append(originalText.take(maxLength - ellipsis.length)) + append(ellipsis) + } + else originalText + + val oldLen = originalText.length + val newLen = transformedText.length + + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int) = if (offset <= maxLength - ellipsis.length) offset else newLen + + override fun transformedToOriginal(offset: Int) = if (offset <= maxLength - ellipsis.length) offset else oldLen + } + + TransformedText(transformedText, offsetMapping) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 57729faba..85a0faf50 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,11 +9,13 @@ Balance Close Connect + Disconnect Home Invoice Lightning Mnemonic Node Id + Notifications Pay Peers sat diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 862899309..bf61882c1 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,4 +1,4 @@ - +