diff --git a/.gitignore b/.gitignore index 1e5d56713..34535f178 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ local.properties # Secrets -google-services.json \ No newline at end of file +google-services.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dff3a0217..c1ec10b90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,14 +68,15 @@ android { } dependencies { + implementation(fileTree("libs") { include("*.aar") }) + // BDK & LDK implementation("org.bitcoindevkit:bdk-android:0.30.0") - implementation(fileTree("libs") { include("*.aar") }) - // implementation("org.lightningdevkit:ldk-node-jvm:0.3.0") + implementation("org.lightningdevkit:ldk-node-android:0.3.0") implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.appcompat:appcompat:1.7.0") - implementation("androidx.activity:activity-compose:1.9.0") + implementation("androidx.activity:activity-compose:1.9.1") // Firebase implementation(platform("com.google.firebase:firebase-bom:33.1.2")) @@ -83,19 +84,14 @@ dependencies { implementation("com.google.firebase:firebase-analytics") // Lifecycle - val lifecycleVersion = "2.8.3" + val lifecycleVersion = "2.8.4" // ViewModel implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion") - // ViewModel utilities for Compose - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion") - // LiveData - implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion") - // Lifecycles only (without ViewModel or LiveData) - implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion") - // Lifecycle utilities for Compose - implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion") - // Saved state module for ViewModel - implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion") // ViewModel utils for Compose + implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion") // LiveData + implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion") // Lifecycles wo ViewModel/LiveData + implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion") // Lifecycle utils for Compose + implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion") // Saved state for ViewModel // Compose Navigation val composeNavigationVersion = "2.7.7" @@ -121,7 +117,7 @@ dependencies { // Material Design implementation("com.google.android.material:material:1.12.0") implementation("androidx.compose.material3:material3:1.2.1") - implementation("androidx.compose.material:material-icons-extended:1.7.0-beta05") + implementation("androidx.compose.material:material-icons-extended:1.7.0-beta06") // Ktor val ktorVersion = "2.3.8" @@ -142,4 +138,4 @@ dependencies { // Other implementation("com.google.guava:guava:31.1-android") // for ByteArray.toHex()+ -} \ No newline at end of file +} diff --git a/app/libs/.gitkeep b/app/libs/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/libs/LDK-release.aar b/app/libs/LDK-release.aar deleted file mode 100644 index 7ca5e10d2..000000000 Binary files a/app/libs/LDK-release.aar and /dev/null differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 54188f32c..e40a0426f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,7 +33,7 @@ @@ -63,4 +63,4 @@ android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="@string/app_notifications_channel_id" /> - \ No newline at end of file + diff --git a/app/src/main/java/to/bitkit/Constants.kt b/app/src/main/java/to/bitkit/Constants.kt index cddf4d845..6db142a86 100644 --- a/app/src/main/java/to/bitkit/Constants.kt +++ b/app/src/main/java/to/bitkit/Constants.kt @@ -1,11 +1,76 @@ package to.bitkit -import org.bitcoindevkit.Network +import android.util.Log +import to.bitkit.Tag.LDK +import kotlin.io.path.Path +import org.bitcoindevkit.Network as BdkNetwork +import org.lightningdevkit.ldknode.Network as LdkNetwork -internal const val _DEV = "_DEV" -internal const val _FCM = "_FCM" -internal const val _LDK = "_LDK" -internal const val _BDK = "_BDK" +object Tag { + internal const val FCM = "FCM" + internal const val LDK = "LDK" + internal const val BDK = "BDK" + internal const val DEV = "DEV" + internal const val APP = "APP" +} -internal val BDK_NETWORK = Network.REGTEST -internal val LDK_NETWORK get() = org.ldk.enums.Network.LDKNetwork_Regtest +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 = LnPeer( + nodeId = "02faf2d1f5dc153e8931d8444c4439e46a81cb7eeadba8562e7fec3690c261ce87", + host = HOST, + port = "9736", +) + +internal object Env { + val isDebug = BuildConfig.DEBUG + + object LdkStorage { + lateinit var path: String + + fun init(base: String): String { + require(base.isNotEmpty()) { "Base path for LDK storage cannot be empty" } + return Path(base, Network.ldk.name.lowercase(), "ldk") + .toFile() + // .also { + // if (!it.mkdirs()) throw Error("Cannot create LDK data directory") + // } + .absolutePath + .also { + path = it + Log.d(LDK, "Storage path: $it") + } + } + } + + object Network { + val ldk: LdkNetwork = LdkNetwork.REGTEST + val bdk = BdkNetwork.REGTEST + } + + val trustedLnPeers = listOf( + PEER, + ) + + val ldkRgsServerUrl: String? + get() = when (Network.ldk) { + LdkNetwork.BITCOIN -> "https://rapidsync.lightningdevkit.org/snapshot/" + else -> null + } +} + +data class LnPeer( + val nodeId: String, + val host: String, + val port: String, +) { + constructor(nodeId: String, address: String) : this( + nodeId, + address.substringBefore(":"), + address.substringAfter(":"), + ) + + fun address() = "$host:$port" + override fun toString() = "$nodeId@${address()}" +} diff --git a/app/src/main/java/to/bitkit/LauncherActivity.kt b/app/src/main/java/to/bitkit/LauncherActivity.kt index ee7773f0a..92bc5c022 100644 --- a/app/src/main/java/to/bitkit/LauncherActivity.kt +++ b/app/src/main/java/to/bitkit/LauncherActivity.kt @@ -2,16 +2,11 @@ package to.bitkit import android.content.Intent import android.os.Bundle -import android.util.Log import androidx.appcompat.app.AppCompatActivity -import to.bitkit.bdk.Bdk -import to.bitkit.ldk.Ldk -import to.bitkit.ldk.init -import to.bitkit.ldk.ldkDir +import to.bitkit.ldk.warmupNode import to.bitkit.ui.MainActivity import to.bitkit.ui.initNotificationChannel import to.bitkit.ui.logFcmToken -import java.io.File class LauncherActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -22,61 +17,3 @@ class LauncherActivity : AppCompatActivity() { startActivity(Intent(this, MainActivity::class.java)) } } - -internal fun warmupNode(absolutePath: String): Boolean { - initDataDir(absolutePath) - - val latestBlockHeight = Bdk.getHeight() - val latestBlockHash = Bdk.getBlockHash(latestBlockHeight) - - val channelManagerFile = File("$ldkDir/channel-manager.bin") - val serializedChannelManager = channelManagerFile - .takeIf { it.exists() } - ?.absoluteFile?.readBytes() - - val serializedChannelMonitors = readChannelMonitorFromDisk() - - return Ldk.init( - Bdk.getLdkEntropy(), - latestBlockHeight.toInt(), - latestBlockHash, - serializedChannelManager, - serializedChannelMonitors, - ) -} - -private fun initDataDir(absolutePath: String) { - ldkDir = "$absolutePath/bitkit" - val dir = File(ldkDir) - if (!dir.exists()) { - dir.mkdir() - } - - // Initialize the LDK data directory if necessary. - ldkDir += "/ldk-data" - val ldkDirPath = File(ldkDir) - if (!ldkDirPath.exists()) { - ldkDirPath.mkdir() - Log.d(_LDK, "Ldk dir: $ldkDirPath") - } -} - -private fun readChannelMonitorFromDisk(): Array { - val channelMonitorDirectory = File("$ldkDir/channels/") - if (channelMonitorDirectory.isDirectory) { - val files = channelMonitorDirectory.list() - if (files.isNullOrEmpty()) { - return emptyArray() - } - - val channelMonitorList = mutableListOf() - files.forEach { - channelMonitorList.add(File("${channelMonitorDirectory}/${it}").readBytes()) - } - return channelMonitorList.toTypedArray() - } - - channelMonitorDirectory.mkdir() - Log.d(_LDK, "New channels dir: $channelMonitorDirectory") - return emptyArray() -} diff --git a/app/src/main/java/to/bitkit/bdk/Bdk.kt b/app/src/main/java/to/bitkit/bdk/Bdk.kt deleted file mode 100644 index 774050cbf..000000000 --- a/app/src/main/java/to/bitkit/bdk/Bdk.kt +++ /dev/null @@ -1,219 +0,0 @@ -package to.bitkit.bdk - -import android.util.Log -import org.bitcoindevkit.AddressIndex -import org.bitcoindevkit.Blockchain -import org.bitcoindevkit.BlockchainConfig -import org.bitcoindevkit.DatabaseConfig -import org.bitcoindevkit.DerivationPath -import org.bitcoindevkit.Descriptor -import org.bitcoindevkit.DescriptorSecretKey -import org.bitcoindevkit.EsploraConfig -import org.bitcoindevkit.KeychainKind -import org.bitcoindevkit.Mnemonic -import org.bitcoindevkit.PartiallySignedTransaction -import org.bitcoindevkit.Progress -import org.bitcoindevkit.Script -import org.bitcoindevkit.Transaction -import org.bitcoindevkit.TxBuilder -import org.bitcoindevkit.Wallet -import org.bitcoindevkit.WordCount -import org.ldk.structs.Result_NoneAPIErrorZ -import org.ldk.structs.Result_ThirtyTwoBytesAPIErrorZ -import org.ldk.structs.UserConfig -import org.ldk.util.UInt128 -import to.bitkit.BDK_NETWORK -import to.bitkit._BDK -import to.bitkit._LDK -import to.bitkit.bdk.Bdk.wallet -import to.bitkit.ext.toByteArray -import to.bitkit.ext.toHex -import to.bitkit.ldk.Ldk -import to.bitkit.ldk.ldkDir -import to.bitkit.ui.REST -import java.io.File - -object Bdk { - lateinit var wallet: Wallet - private val blockchain = createBlockchain() - - init { - initWallet() - } - - private fun initWallet() { - val mnemonic = loadMnemonic() - val key = DescriptorSecretKey(BDK_NETWORK, Mnemonic.fromString(mnemonic), null) - - wallet = Wallet( - Descriptor.newBip84(key, KeychainKind.INTERNAL, BDK_NETWORK), - Descriptor.newBip84(key, KeychainKind.EXTERNAL, BDK_NETWORK), - BDK_NETWORK, - DatabaseConfig.Memory, - ) - - Log.d(_BDK, "Created/restored wallet with mnemonic $mnemonic") - } - - fun sync() { - wallet.sync( - blockchain = blockchain, - progress = object : Progress { - override fun update(progress: Float, message: String?) { - Log.d(_BDK, "updating wallet $progress $message") - } - } - ) - } - - fun getHeight(): UInt { - try { - return blockchain.getHeight() - } catch (ex: Exception) { - throw Error("Esplora server is not running.", ex) - } - } - - fun getBlockHash(height: UInt): String { - try { - return blockchain.getBlockHash(height) - } catch (ex: Exception) { - throw Error("Esplora server is not running.", ex) - } - } - - @OptIn(ExperimentalUnsignedTypes::class) - fun getLdkEntropy(): ByteArray { - val mnemonic = loadMnemonic() - val key = DescriptorSecretKey( - network = BDK_NETWORK, - mnemonic = Mnemonic.fromString(mnemonic), - password = null, - ) - val derivationPath = DerivationPath("m/535h") - val child = key.derive(derivationPath) - val entropy = child.secretBytes().toUByteArray().toByteArray() - - Log.d(_LDK, "LDK entropy: ${entropy.toHex()}") - return entropy - } - - @OptIn(ExperimentalUnsignedTypes::class) - fun buildFundingTx(value: Long, script: ByteArray): Transaction { - sync() - val rawOutputScript = script.toUByteArray().asList() - val outputScript = Script(rawOutputScript) - val feeRate = 4.0F - val (psbt, _) = TxBuilder() - .addRecipient(outputScript, value.toULong()) - .feeRate(feeRate) - .finish(wallet) - sign(psbt) - val rawTx = psbt.extractTx().serialize().toUByteArray().toByteArray() - - Log.d(_BDK, "Raw funding tx: ${rawTx.toHex()}") - - return psbt.extractTx() - } - - private fun sign(psbt: PartiallySignedTransaction) { - wallet.sign(psbt, null) - } - - fun broadcastRawTx(tx: Transaction) { - val blockchain = createBlockchain() - blockchain.broadcast(tx) - - Log.d(_BDK, "Broadcasted transaction ID: ${tx.txid()}") - } - - private fun createBlockchain(): Blockchain { - return Blockchain( - BlockchainConfig.Esplora( - EsploraConfig(REST, null, 5u, 20u, null) - ) - ) - } - - private fun loadMnemonic(): String { - return try { - mnemonicPhrase() - } catch (e: Throwable) { - // if mnemonic doesn't exist, generate one and save it - Log.d(_BDK, "No mnemonic backup, we'll create a new wallet") - val mnemonic = Mnemonic(WordCount.WORDS12).asString() - mnemonicFile.writeText(mnemonic) - mnemonic - } - } -} - -private val mnemonicFile = File("$ldkDir/mnemonic.txt") - -internal fun mnemonicPhrase(): String { - return mnemonicFile.readText() -} - -internal fun btcAddress(): String { - return wallet.getAddress(AddressIndex.LastUnused).address.asString() -} - -internal fun newAddress(): String { - val new = wallet.getAddress(AddressIndex.New).address.asString() - Log.d(_BDK, "New bitcoin address: $new") - return new -} - -internal fun btcBalance(): String { - val balance = wallet.getBalance() - Log.d(_BDK, "BTC balance: $balance") - return "${balance.total}" -} - -internal object Channel { - fun open(pubKey: String) { - Ldk.Channel.temporaryId = null - - val amount: Long = 100000 - val pushMSat: Long = 0 - val userId = UInt128(42L) - - val userConfig = UserConfig.with_default().apply { - // set the following to false to open a private channel - // _channel_handshake_config = ChannelHandshakeConfig.with_default().apply { - // _announced_channel = false - // } - } - - val result = Ldk.channelManager.create_channel( - pubKey.toByteArray(), - amount, - pushMSat, - userId, - userConfig, - ) - - if (result !is Result_ThirtyTwoBytesAPIErrorZ) { - Log.d(_LDK, "ERROR: failed to open channel with: $pubKey") - } - - if (result.is_ok) { - Log.d(_LDK, "EVENT: initiated channel with peer: $pubKey") - } - } - - fun close(channelId: String, pubKey: String) { - val res = Ldk.channelManager.close_channel( - channelId.toByteArray(), - pubKey.toByteArray(), - ) - - if (res is Result_NoneAPIErrorZ.Result_NoneAPIErrorZ_Err) { - Log.d(_LDK, "ERROR: failed to close channel with: $pubKey") - } - - if (res.is_ok) { - Log.d(_LDK, "EVENT: initiated channel close with peer: $pubKey") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/bdk/BitcoinService.kt b/app/src/main/java/to/bitkit/bdk/BitcoinService.kt new file mode 100644 index 000000000..a55b94630 --- /dev/null +++ b/app/src/main/java/to/bitkit/bdk/BitcoinService.kt @@ -0,0 +1,67 @@ +package to.bitkit.bdk + +import android.util.Log +import org.bitcoindevkit.AddressIndex +import org.bitcoindevkit.Blockchain +import org.bitcoindevkit.BlockchainConfig +import org.bitcoindevkit.DatabaseConfig +import org.bitcoindevkit.Descriptor +import org.bitcoindevkit.DescriptorSecretKey +import org.bitcoindevkit.EsploraConfig +import org.bitcoindevkit.KeychainKind +import org.bitcoindevkit.Mnemonic +import org.bitcoindevkit.Progress +import org.bitcoindevkit.Wallet +import to.bitkit.Env +import to.bitkit.REST +import to.bitkit.SEED +import to.bitkit.Tag.BDK + +internal class BitcoinService { + companion object { + val shared by lazy { + BitcoinService() + } + } + + private val wallet by lazy { + val network = Env.Network.bdk + val mnemonic = Mnemonic.fromString(SEED) + val key = DescriptorSecretKey(network, mnemonic, null) + + Wallet( + descriptor = Descriptor.newBip84(key, KeychainKind.INTERNAL, network), + changeDescriptor = Descriptor.newBip84(key, KeychainKind.EXTERNAL, network), + network = network, + databaseConfig = DatabaseConfig.Memory, + ) + } + private val blockchain by lazy { + Blockchain( + BlockchainConfig.Esplora( + EsploraConfig( + baseUrl = REST, + proxy = null, + concurrency = 5u, + stopGap = 20u, + timeout = null, + ) + ) + ) + } + + fun sync() { + wallet.sync( + blockchain = blockchain, + progress = object : Progress { + override fun update(progress: Float, message: String?) { + Log.d(BDK, "Updating wallet: $progress $message") + } + } + ) + } + + // region State + val balance get() = wallet.getBalance() + val address get() = wallet.getAddress(AddressIndex.LastUnused).address.asString() +} diff --git a/app/src/main/java/to/bitkit/data/Models.kt b/app/src/main/java/to/bitkit/data/Models.kt index c52d64dac..cbdbd4306 100644 --- a/app/src/main/java/to/bitkit/data/Models.kt +++ b/app/src/main/java/to/bitkit/data/Models.kt @@ -3,12 +3,6 @@ package to.bitkit.data import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -class WatchedTransaction( - val id: ByteArray, - @Suppress("unused") - val scriptPubKey: ByteArray, -) - @Serializable data class Tx( val txid: String, @@ -25,13 +19,6 @@ data class TxStatus( val blockHash: String? = null, ) -class ConfirmedTx( - val tx: ByteArray, - val blockHeight: Int, - val blockHeader: String, - val merkleProofPos: Int, -) - @Serializable data class OutputSpent( val spent: Boolean, @@ -44,4 +31,4 @@ data class MerkleProof( @Suppress("ArrayInDataClass") val merkle: Array, val pos: Int, -) \ No newline at end of file +) diff --git a/app/src/main/java/to/bitkit/data/RestApi.kt b/app/src/main/java/to/bitkit/data/RestApi.kt index aa4d19b62..c64072b06 100644 --- a/app/src/main/java/to/bitkit/data/RestApi.kt +++ b/app/src/main/java/to/bitkit/data/RestApi.kt @@ -1,24 +1,13 @@ package to.bitkit.data -import android.util.Log 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 to.bitkit._LDK -import to.bitkit.ext.toByteArray +import to.bitkit.REST import to.bitkit.ext.toHex -import to.bitkit.ldk.Ldk -import to.bitkit.ldk.ldkDir -import to.bitkit.ui.HOST -import to.bitkit.ui.PEER -import to.bitkit.ui.PORT -import to.bitkit.ui.REST -import java.io.File -import java.io.FileWriter -import java.net.InetSocketAddress import javax.inject.Inject interface RestApi { @@ -30,13 +19,6 @@ interface RestApi { suspend fun getHeader(hash: String): String suspend fun getMerkleProof(txid: String): MerkleProof suspend fun getOutputSpent(txid: String, outputIndex: Int): OutputSpent - suspend fun connectPeer( - pubKeyHex: String = PEER, - hostname: String = HOST, - port: Int = PORT.toInt(), - ): Boolean - - suspend fun disconnectPeer(pubKeyHex: String): Boolean } class EsploraApi @Inject constructor( @@ -79,51 +61,4 @@ class EsploraApi @Inject constructor( override suspend fun getOutputSpent(txid: String, outputIndex: Int): OutputSpent { return client.get("$REST/tx/${txid}/outspend/${outputIndex}").body() } - - override suspend fun connectPeer( - pubKeyHex: String, - hostname: String, - port: Int, - ): Boolean { - Log.d(_LDK, "Connecting peer: $pubKeyHex") - try { - val nioPeerHandler = Ldk.channelManagerConstructor.nio_peer_handler!! - nioPeerHandler.connect( - pubKeyHex.toByteArray(), - InetSocketAddress(hostname, port), 5555 - ) - Log.d(_LDK, "Connected peer: $pubKeyHex") - - val file = File("$ldkDir/peers.txt") - - if (!file.exists()) { - file.createNewFile() - } - - // Open a FileWriter to write to the file (append mode) - val fileWriter = FileWriter(file, true) - - // Write the IP address to the file - fileWriter.write("$pubKeyHex@$hostname:$port") - fileWriter.write(System.lineSeparator()) // Add a newline for readability - - // Close the FileWriter - fileWriter.close() - return true - } catch (e: Exception) { - Log.d(_LDK, "Failed to connect peer:\n" + e.message) - return false - } - } - - override suspend fun disconnectPeer(pubKeyHex: String): Boolean { - try { - val nioPeerHandler = Ldk.channelManagerConstructor.nio_peer_handler!! - nioPeerHandler.disconnect(pubKeyHex.toByteArray()) - return true - } catch (e: Exception) { - Log.d(_LDK, "Failed to disconnect peer:\n" + e.message) - return false - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/to/bitkit/data/Syncer.kt b/app/src/main/java/to/bitkit/data/Syncer.kt deleted file mode 100644 index e354a3924..000000000 --- a/app/src/main/java/to/bitkit/data/Syncer.kt +++ /dev/null @@ -1,168 +0,0 @@ -package to.bitkit.data - -import android.util.Log -import org.ldk.structs.TwoTuple_usizeTransactionZ -import to.bitkit._DEV -import to.bitkit._LDK -import to.bitkit.bdk.Bdk -import to.bitkit.ext.toByteArray -import to.bitkit.ext.toHex -import to.bitkit.ldk.Ldk -import to.bitkit.ldk.LdkEventHandler -import to.bitkit.ldk.LdkFilter -import javax.inject.Inject - -interface Syncer { - suspend fun sync() -} - -class LdkSyncer @Inject constructor( - private val restApi: RestApi, -) : Syncer { - override suspend fun sync() { - Log.d(_DEV, "BDK & LDK syncing…") - - Bdk.sync() - - val channelManager = Ldk.channelManager - val chainMonitor = Ldk.chainMonitor - - val confirmedTxs = mutableListOf() - - // Sync unconfirmed transactions - val relevantTxIds = Ldk.Relevant.txs.map { it.id.reversedArray().toHex() } - Log.d(_DEV, "Syncing '${relevantTxIds.size}' relevant txs…") - - for (txId in relevantTxIds) { - Log.d(_DEV, "Checking relevant tx confirmation status: $txId") - val tx: Tx = restApi.getTx(txId) - if (tx.status.isConfirmed) { - Log.d(_DEV, "Adding confirmed TX") - val txHex = restApi.getTxHex(txId) - val blockHeader = restApi.getHeader(requireNotNull(tx.status.blockHash)) - val merkleProof = restApi.getMerkleProof(txId) - if (tx.status.blockHeight == merkleProof.blockHeight) { - Log.d(_DEV, "Caching confirmed TX") - confirmedTxs += ConfirmedTx( - tx = txHex.toByteArray(), - blockHeight = tx.status.blockHeight, - blockHeader = blockHeader, - merkleProofPos = merkleProof.pos, - ) - } - } else { - Log.d(_LDK, "Marking unconfirmed TX") - channelManager.as_Confirm().transaction_unconfirmed(txId.toByteArray()) - chainMonitor.as_Confirm().transaction_unconfirmed(txId.toByteArray()) - } - } - - // Add confirmed Tx from filter Transaction Output - val relevantOutputs = Ldk.Relevant.outputs - if (relevantOutputs.isNotEmpty()) { - for (output in relevantOutputs) { - val outpoint = output._outpoint - val txId = outpoint._txid.reversedArray().toHex() - val outputSpent = restApi.getOutputSpent(txId, outpoint._index.toInt()) - if (outputSpent.spent) { - val tx = restApi.getTx(txId) - if (tx.status.isConfirmed) { - val txHex = restApi.getTxHex(txId) - val blockHeader = restApi.getHeader(requireNotNull(tx.status.blockHash)) - val merkleProof = restApi.getMerkleProof(txId) - if (tx.status.blockHeight == merkleProof.blockHeight) { - confirmedTxs += ConfirmedTx( - tx = txHex.toByteArray(), - blockHeight = tx.status.blockHeight, - blockHeader = blockHeader, - merkleProofPos = merkleProof.pos - ) - } - } - } - - } - } - - // Add confirmed Tx from filtered Transaction Ids - val filteredTxs = LdkFilter.txIds - if (filteredTxs.isNotEmpty()) { - Log.d(_DEV, "Getting Filtered TXs") - for (txid in filteredTxs) { - val txId = txid.reversedArray().toHex() - val tx = restApi.getTx(txId) - if (tx.status.isConfirmed) { - val txHex = restApi.getTxHex(txId) - val blockHeader = restApi.getHeader(requireNotNull(tx.status.blockHash)) - val merkleProof = restApi.getMerkleProof(txId) - if (tx.status.blockHeight == merkleProof.blockHeight) { - confirmedTxs += ConfirmedTx( - tx = txHex.toByteArray(), - blockHeight = tx.status.blockHeight, - blockHeader = blockHeader, - merkleProofPos = merkleProof.pos - ) - } - } - } - } - - // Add confirmed Tx from filter Transaction Output - val filteredOutputs = LdkFilter.outputs - if (filteredOutputs.isNotEmpty()) { - for (output in filteredOutputs) { - val outpoint = output._outpoint - val outputIndex = outpoint._index - val txId = outpoint._txid.reversedArray().toHex() - val outputSpent = restApi.getOutputSpent(txId, outputIndex.toInt()) - if (outputSpent.spent) { - val tx = restApi.getTx(txId) - if (tx.status.isConfirmed) { - val txHex = restApi.getTxHex(txId) - val blockHeader = restApi.getHeader(requireNotNull(tx.status.blockHash)) - val merkleProof = restApi.getMerkleProof(txId) - if (tx.status.blockHeight == merkleProof.blockHeight) { - confirmedTxs += ConfirmedTx( - tx = txHex.toByteArray(), - blockHeight = tx.status.blockHeight, - blockHeader = blockHeader, - merkleProofPos = merkleProof.pos, - ) - } - } - } - } - } - - confirmedTxs.sortWith( - compareBy { it.blockHeight }.thenBy { it.merkleProofPos } - ) - - // Sync confirmed transactions - for (cTx in confirmedTxs) { - channelManager.as_Confirm().transactions_confirmed( - cTx.blockHeader.toByteArray(), - arrayOf(TwoTuple_usizeTransactionZ.of(cTx.merkleProofPos.toLong(), cTx.tx)), - cTx.blockHeight, - ) - - chainMonitor.as_Confirm().transactions_confirmed( - cTx.blockHeader.toByteArray(), - arrayOf(TwoTuple_usizeTransactionZ.of(cTx.merkleProofPos.toLong(), cTx.tx)), - cTx.blockHeight, - ) - } - - // Sync best block - val height = restApi.getLatestBlockHeight() - val hash = restApi.getLatestBlockHash() - val header = restApi.getHeader(hash).toByteArray() - - channelManager.as_Confirm().best_block_updated(header, height) - chainMonitor.as_Confirm().best_block_updated(header, height) - - Ldk.channelManagerConstructor.chain_sync_completed(LdkEventHandler, true) - - Log.d(_DEV, "BDK & LDK synced.") - } -} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/di/LdkModule.kt b/app/src/main/java/to/bitkit/di/LightningModule.kt similarity index 58% rename from app/src/main/java/to/bitkit/di/LdkModule.kt rename to app/src/main/java/to/bitkit/di/LightningModule.kt index 9cd6aa229..0eba354f0 100644 --- a/app/src/main/java/to/bitkit/di/LdkModule.kt +++ b/app/src/main/java/to/bitkit/di/LightningModule.kt @@ -4,13 +4,12 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import to.bitkit.data.LdkSyncer -import to.bitkit.data.Syncer +import to.bitkit.ldk.LightningService @Suppress("unused") @Module @InstallIn(SingletonComponent::class) -abstract class LdkModule { +abstract class LightningModule { @Binds - abstract fun bindSyncer(ldkSyncer: LdkSyncer): Syncer -} \ No newline at end of file + abstract fun bindLightningService(service: LightningService): LightningService +} diff --git a/app/src/main/java/to/bitkit/ext/List.kt b/app/src/main/java/to/bitkit/ext/List.kt new file mode 100644 index 000000000..2f4f329bf --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/List.kt @@ -0,0 +1,8 @@ +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 new file mode 100644 index 000000000..64c0a2969 --- /dev/null +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -0,0 +1,75 @@ +package to.bitkit.fcm + +import android.util.Log +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import to.bitkit.Tag.FCM +import java.util.Date + +internal class FcmService : FirebaseMessagingService() { + private lateinit var token: String + + /** + * Act on received messages + * + * To generate notifications as a result of a received FCM message, see: + * [MyFirebaseMessagingService.sendNotification](https://github.com/firebase/snippets-android/blob/ae9bd6ff8eccfb3eeba863d41eaca2b0e77eaa01/messaging/app/src/main/java/com/google/firebase/example/messaging/kotlin/MyFirebaseMessagingService.kt#L89-L124) + * + * [Debug messages not received](https://goo.gl/39bRNJ) + */ + override fun onMessageReceived(message: RemoteMessage) { + Log.d(FCM, "New FCM at: ${Date(message.sentTime)}") + + message.notification?.run { + Log.d(FCM, "FCM title: $title") + Log.d(FCM, "FCM body: $body") + } + + if (message.data.isNotEmpty()) { + Log.d(FCM, "FCM data: ${message.data}") + + if (message.needsScheduling()) { + scheduleJob(message.data) + } else { + handleNow(message.data) + } + } + } + + /** + * Handle message within 10 seconds. + */ + private fun handleNow(data: Map) { + Log.e(FCM, "FCM handler not implemented for: $data") + } + + /** + * Schedule async work via WorkManager for tasks of 10+ seconds. + */ + private fun scheduleJob(messageData: Map) { + val work = OneTimeWorkRequest.Builder(Wake2PayWorker::class.java) + .setInputData( + Data.Builder() + .putString("bolt11", messageData["bolt11"].orEmpty()) + .build() + ) + .build() + WorkManager.getInstance(this) + .beginWith(work) + .enqueue() + } + + private fun RemoteMessage.needsScheduling(): Boolean { + return notification == null && + data.containsKey("bolt11") + } + + override fun onNewToken(token: String) { + this.token = token + Log.d(FCM, "FCM registration token refreshed: $token") + } +} + diff --git a/app/src/main/java/to/bitkit/fcm/MessagingService.kt b/app/src/main/java/to/bitkit/fcm/MessagingService.kt deleted file mode 100644 index 8beb018fa..000000000 --- a/app/src/main/java/to/bitkit/fcm/MessagingService.kt +++ /dev/null @@ -1,133 +0,0 @@ -package to.bitkit.fcm - -import android.content.Context -import android.util.Log -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.Data -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.delay -import to.bitkit._FCM -import to.bitkit.data.RestApi -import to.bitkit.data.Syncer -import to.bitkit.ui.payInvoice -import to.bitkit.warmupNode -import java.util.Date - -internal class MessagingService : FirebaseMessagingService() { - private lateinit var token: String - - /** - * Act on received messages - * - * To generate notifications as a result of a received FCM message, see: - * [MyFirebaseMessagingService.sendNotification](https://github.com/firebase/snippets-android/blob/ae9bd6ff8eccfb3eeba863d41eaca2b0e77eaa01/messaging/app/src/main/java/com/google/firebase/example/messaging/kotlin/MyFirebaseMessagingService.kt#L89-L124) - */ - override fun onMessageReceived(message: RemoteMessage) { - Log.d(_FCM, "--- new FCM ---") - Log.d(_FCM, "\n") - Log.d(_FCM, "at: \t ${Date(message.sentTime)}") - - // Not getting messages here? See why this may be: https://goo.gl/39bRNJ - message.notification?.let { - Log.d(_FCM, "title: \t ${it.title}") - Log.d(_FCM, "body: \t ${it.body}") - } - - // Check if message contains a data payload. - if (message.data.isNotEmpty()) { - Log.d(_FCM, "data: \t ${message.data}") - Log.d(_FCM, "\n") - - if (message.needsScheduling()) { - scheduleJob(message.data) - } else { - handleNow(message.data) - } - } - Log.d(_FCM, "--- end FCM ---") - } - - /** - * Handle message within 10 seconds. - */ - private fun handleNow(data: Map) { - val bolt11 = data["bolt11"].orEmpty() - if (bolt11.isNotEmpty()) { - payInvoice(bolt11) - return - } - Log.d(_FCM, "handleNow() not yet implemented") - } - - /** - * Schedule async work using WorkManager for tasks of 10+ seconds. - */ - private fun scheduleJob(messageData: Map) { - val work = OneTimeWorkRequest.Builder(PayWorker::class.java) - .setInputData( - Data.Builder() - .putString("bolt11", messageData["bolt11"].orEmpty()) - .build() - ) - .build() - WorkManager.getInstance(this) - .beginWith(work) - .enqueue() - } - - private fun RemoteMessage.needsScheduling(): Boolean { - return notification == null && - data.containsKey("bolt11") - } - - override fun onNewToken(token: String) { - this.token = token - Log.d(_FCM, "onNewToken: $token") - } -} - -@HiltWorker -class PayWorker @AssistedInject constructor( - @Assisted private val appContext: Context, - @Assisted private val workerParams: WorkerParameters, - private val syncer: Syncer, - private val restApi: RestApi, -) : CoroutineWorker(appContext, workerParams) { - override suspend fun doWork(): Result { - Log.d(_FCM, "Node waking up from notification…") - warmupNode(appContext.filesDir.absolutePath) - .let { Log.d(_FCM, "Node wakeup result: $it…") } - - Log.d(_FCM, "Syncing BDK & LDK from notification…") - syncer.sync() - - restApi.connectPeer() - .let { Log.d(_FCM, "Connect peer from notification result: $it") } - - workerParams.inputData.getString("bolt11")?.let { bolt11 -> - delay(1500) // sleep on bg queue - val isSuccess = payInvoice(bolt11) - if (isSuccess) { - return Result.success() - } else { - return Result.failure( - Data.Builder() - .putString("reason:", "payment error") - .build() - ) - } - } - return Result.failure( - Data.Builder() - .putString("reason:", "bolt11 field missing") - .build() - ) - } -} diff --git a/app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt b/app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt new file mode 100644 index 000000000..27436a8ed --- /dev/null +++ b/app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt @@ -0,0 +1,43 @@ +package to.bitkit.fcm + +import android.content.Context +import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import to.bitkit.Tag.FCM +import to.bitkit.ldk.LightningService +import to.bitkit.ldk.payInvoice +import to.bitkit.ldk.warmupNode + +@HiltWorker +class Wake2PayWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted private val workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + Log.d(FCM, "Node wakeup from notification…") + + warmupNode(appContext.filesDir.absolutePath) + + val bolt11 = workerParams.inputData.getString("bolt11") ?: return Result.failure( + Data.Builder() + .putString("reason", "bolt11 field missing") + .build() + ) + + val isSuccess = LightningService.shared.payInvoice(bolt11) + return if (isSuccess) { + Result.success() + } else { + Result.failure( + Data.Builder() + .putString("reason", "payment error") + .build() + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ldk/Ldk.kt b/app/src/main/java/to/bitkit/ldk/Ldk.kt deleted file mode 100644 index 6f92074c1..000000000 --- a/app/src/main/java/to/bitkit/ldk/Ldk.kt +++ /dev/null @@ -1,218 +0,0 @@ -package to.bitkit.ldk - -import android.util.Log -import org.ldk.batteries.ChannelManagerConstructor -import org.ldk.batteries.NioPeerHandler -import org.ldk.structs.BroadcasterInterface -import org.ldk.structs.ChainMonitor -import org.ldk.structs.ChannelHandshakeConfig -import org.ldk.structs.ChannelHandshakeLimits -import org.ldk.structs.ChannelManager -import org.ldk.structs.FeeEstimator -import org.ldk.structs.Filter -import org.ldk.structs.Logger -import org.ldk.structs.MultiThreadedLockableScore -import org.ldk.structs.NetworkGraph -import org.ldk.structs.Option_FilterZ -import org.ldk.structs.PeerManager -import org.ldk.structs.Persist -import org.ldk.structs.ProbabilisticScorer -import org.ldk.structs.ProbabilisticScoringDecayParameters -import org.ldk.structs.ProbabilisticScoringFeeParameters -import org.ldk.structs.Result_NetworkGraphDecodeErrorZ -import org.ldk.structs.Result_ProbabilisticScorerDecodeErrorZ -import org.ldk.structs.UserConfig -import org.ldk.structs.WatchedOutput -import to.bitkit.LDK_NETWORK -import to.bitkit._LDK -import to.bitkit.bdk.Bdk -import to.bitkit.data.WatchedTransaction -import to.bitkit.ext.toByteArray -import java.io.File -import java.net.InetSocketAddress - -@JvmField -var ldkDir: String = "" - -object Ldk { - lateinit var channelManager: ChannelManager - lateinit var keysManager: LdkKeysManager - lateinit var chainMonitor: ChainMonitor - lateinit var channelManagerConstructor: ChannelManagerConstructor - lateinit var nioPeerHandler: NioPeerHandler - lateinit var peerManager: PeerManager - lateinit var networkGraph: NetworkGraph - lateinit var scorer: MultiThreadedLockableScore - - object Channel { - var temporaryId: ByteArray? = null - var counterpartyNodeId: ByteArray? = null - } - - object Relevant { - val txs = arrayListOf() - val outputs = arrayListOf() - } - - object Events { - var fundingGenerationReady = arrayOf() - var channelClosed = arrayOf() - var registerTx = arrayOf() - var registerOutput = arrayOf() - } -} - -fun Ldk.init( - entropy: ByteArray, - latestBlockHeight: Int, - latestBlockHash: String, - serializedChannelManager: ByteArray?, - serializedChannelMonitors: Array, -): Boolean { - Log.d(_LDK, "Starting LDK version: ${org.ldk.impl.version.get_ldk_java_bindings_version()}") - - val feeEstimator: FeeEstimator = FeeEstimator.new_impl(LdkFeeEstimator) - val logger: Logger = Logger.new_impl(LdkLogger) - val txBroadcaster: BroadcasterInterface = BroadcasterInterface.new_impl(LdkBroadcaster) - val persister: Persist = Persist.new_impl(LdkPersister) - - initNetworkGraph(logger) - - val filter = Filter.new_impl(LdkFilter) - chainMonitor = ChainMonitor.of( - Option_FilterZ.some(filter), - txBroadcaster, - logger, - feeEstimator, - persister, - ) - - initKeysManager(entropy) - initProbabilisticScorer(logger) - - val channelHandShakeConfig = ChannelHandshakeConfig.with_default().apply { - _minimum_depth = 1 - _announced_channel = false - } - val channelHandshakeLimits = ChannelHandshakeLimits.with_default().apply { - _max_minimum_depth = 1 - } - val userConfig = UserConfig.with_default().apply { - _channel_handshake_config = channelHandShakeConfig - _channel_handshake_limits = channelHandshakeLimits - _accept_inbound_channels = true - } - - try { - val entropySource = keysManager.inner.as_EntropySource() - val nodeSigner = keysManager.inner.as_NodeSigner() - val signerProvider = keysManager.inner.as_SignerProvider() - val pScoringDecayParams = ProbabilisticScoringDecayParameters.with_default() - val pScoringFeeParams = ProbabilisticScoringFeeParameters.with_default() - - val constructor = if (serializedChannelManager?.isNotEmpty() == true) { - // Restore from disk - ChannelManagerConstructor( - serializedChannelManager, - serializedChannelMonitors, - userConfig, - entropySource, - nodeSigner, - signerProvider, - feeEstimator, - chainMonitor, - filter, - networkGraph.write(), - pScoringDecayParams, - pScoringFeeParams, - scorer.write(), - null, - txBroadcaster, - logger, - ) - } else { - // Start from scratch - ChannelManagerConstructor( - LDK_NETWORK, - userConfig, - latestBlockHash.toByteArray(), - latestBlockHeight, - entropySource, - nodeSigner, - signerProvider, - feeEstimator, - chainMonitor, - networkGraph, - pScoringDecayParams, - pScoringFeeParams, - null, - txBroadcaster, - logger, - ) - } - channelManagerConstructor = constructor - channelManager = constructor.channel_manager - peerManager = constructor.peer_manager - nioPeerHandler = constructor.nio_peer_handler - networkGraph = constructor.net_graph - - constructor.chain_sync_completed(LdkEventHandler, true) - nioPeerHandler.bind_listener(InetSocketAddress("127.0.0.1", 9777)) - - return true - } catch (e: Exception) { - Log.d(_LDK, "Error starting LDK:\n" + e.message) - return false - } -} - -private fun initKeysManager(entropy: ByteArray) { - val timeMillis = System.currentTimeMillis() - val startTimeSecs = timeMillis / 1000 - val startTimeNano = (timeMillis * 1000).toInt() - Ldk.keysManager = LdkKeysManager( - entropy, - startTimeSecs, - startTimeNano, - Bdk.wallet, - ) -} - -private fun initNetworkGraph(logger: Logger) { - val graphFile = File("$ldkDir/network-graph.bin") - if (graphFile.exists()) { - when (val graph = NetworkGraph.read(graphFile.readBytes(), logger)) { - is Result_NetworkGraphDecodeErrorZ.Result_NetworkGraphDecodeErrorZ_OK -> { - Ldk.networkGraph = graph.res - } - } - Log.d(_LDK, "Network graph found and loaded from disk.") - } else { - Ldk.networkGraph = NetworkGraph.of(LDK_NETWORK, logger) - Log.d(_LDK, "Network graph not found on disk, synced from scratch.") - } -} - -private fun initProbabilisticScorer(logger: Logger) { - val decayParams = ProbabilisticScoringDecayParameters.with_default() - val scorerFile = File("$ldkDir/scorer.bin") - val scorer = if (scorerFile.exists()) { - val read = ProbabilisticScorer - .read(scorerFile.readBytes(), decayParams, Ldk.networkGraph, logger) - if (read.is_ok) { - check(read is Result_ProbabilisticScorerDecodeErrorZ.Result_ProbabilisticScorerDecodeErrorZ_OK) - Log.d(_LDK, "Probabilistic Scorer found and loaded from disk.") - // return: - read.res - } else { - Log.d(_LDK, "Error loading Probabilistic Scorer, started from scratch.") - // return: - ProbabilisticScorer.of(decayParams, Ldk.networkGraph, logger) - } - } else { - Log.d(_LDK, "Probabilistic Scorer not found on disk, started from scratch.") - // return: - ProbabilisticScorer.of(decayParams, Ldk.networkGraph, logger) - } - Ldk.scorer = MultiThreadedLockableScore.of(scorer.as_Score()) -} diff --git a/app/src/main/java/to/bitkit/ldk/LdkBroadcaster.kt b/app/src/main/java/to/bitkit/ldk/LdkBroadcaster.kt deleted file mode 100644 index d9104535a..000000000 --- a/app/src/main/java/to/bitkit/ldk/LdkBroadcaster.kt +++ /dev/null @@ -1,28 +0,0 @@ -package to.bitkit.ldk - -import android.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.bitcoindevkit.Transaction -import org.ldk.structs.BroadcasterInterface -import to.bitkit._LDK -import to.bitkit.bdk.Bdk -import to.bitkit.ext.toHex - -object LdkBroadcaster : BroadcasterInterface.BroadcasterInterfaceInterface { - @OptIn(ExperimentalUnsignedTypes::class) - override fun broadcast_transactions(txs: Array?) { - txs?.let { transactions -> - CoroutineScope(Dispatchers.IO).launch { - transactions.forEach { txByteArray -> - val uByteArray = txByteArray.toUByteArray() - val transaction = Transaction(uByteArray.toList()) - - Bdk.broadcastRawTx(transaction) - Log.d(_LDK, "Broadcasted raw tx: ${txByteArray.toHex()}") - } - } - } ?: throw (IllegalStateException("Broadcaster error: can't broadcast a null transaction")) - } -} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/ldk/LdkEventHandler.kt b/app/src/main/java/to/bitkit/ldk/LdkEventHandler.kt deleted file mode 100644 index ce39ff52e..000000000 --- a/app/src/main/java/to/bitkit/ldk/LdkEventHandler.kt +++ /dev/null @@ -1,211 +0,0 @@ -package to.bitkit.ldk - -import android.util.Log -import org.bitcoindevkit.Address -import org.ldk.batteries.ChannelManagerConstructor -import org.ldk.structs.ClosureReason -import org.ldk.structs.Event -import org.ldk.structs.Result_NoneAPIErrorZ -import org.ldk.structs.Result_TransactionNoneZ -import org.ldk.structs.TxOut -import org.ldk.util.UInt128 -import to.bitkit._LDK -import to.bitkit.bdk.Bdk -import to.bitkit.bdk.newAddress -import to.bitkit.ext.toHex -import to.bitkit.ldk.Ldk.channelManager -import kotlin.random.Random -import kotlin.reflect.typeOf - -object LdkEventHandler : ChannelManagerConstructor.EventHandler { - override fun handle_event(event: Event) { - Log.d(_LDK, "LdkEventHandler: handle_event: $event") - handleEvent(event) - } - - override fun persist_manager(channelManagerBytes: ByteArray?) { - if (channelManagerBytes != null) { - Log.d(_LDK, "LdkEventHandler: persist_manager") - persist("channel-manager.bin", channelManagerBytes) - } - } - - override fun persist_network_graph(networkGraph: ByteArray?) { - if (networkGraph !== null) { - Log.d(_LDK, "LdkEventHandler: persist_network_graph") - persist("network-graph.bin", networkGraph) - } - } - - override fun persist_scorer(scorer: ByteArray?) { - if (scorer !== null) { - Log.d(_LDK, "LdkEventHandler: persist_scorer") - persist("scorer.bin", scorer) - } - } -} - -@OptIn(ExperimentalUnsignedTypes::class) -private fun handleEvent(event: Event) { - when (event) { - is Event.FundingGenerationReady -> { - Log.d(_LDK, "event: FundingGenerationReady") - if (event.output_script.size == 34 && - event.output_script[0].toInt() == 0 && - event.output_script[1].toInt() == 32 - ) { - val rawTx = Bdk.buildFundingTx(event.channel_value_satoshis, event.output_script) - try { - val fundingTx = channelManager.funding_transaction_generated( - event.temporary_channel_id, - event.counterparty_node_id, - rawTx.serialize().toUByteArray().toByteArray() - ) - when (fundingTx) { - is Result_NoneAPIErrorZ.Result_NoneAPIErrorZ_OK -> - Log.d(_LDK, "Funding tx generated") - - is Result_NoneAPIErrorZ.Result_NoneAPIErrorZ_Err -> - Log.d(_LDK, "Funding tx error: ${fundingTx.err}") - } - } catch (e: Exception) { - Log.d(_LDK, "FundingGenerationReady error: ${e.message}") - } - } - } - - is Event.OpenChannelRequest -> { - Log.d(_LDK, "event: OpenChannelRequest") - val json = JsonBuilder() - .put("counterparty_node_id", event.counterparty_node_id.toHex()) - .put("temporary_channel_id", event.temporary_channel_id.toHex()) - .put("push_sat", (event.push_msat.toInt() / 1000).toString()) - .put("funding_satoshis", event.funding_satoshis.toString()) - .put("channel_type", event.channel_type.toString()) - json.persist("$ldkDir/events_open_channel_request") - Ldk.Events.fundingGenerationReady += json.toString() - Ldk.Channel.temporaryId = event.temporary_channel_id - Ldk.Channel.counterpartyNodeId = event.counterparty_node_id - val userChannelId = UInt128(Random.nextLong(0, 100)) - val res = channelManager.accept_inbound_channel( - event.temporary_channel_id, - event.counterparty_node_id, - userChannelId, - ) - res?.let { - if (it.is_ok) { - Log.d(_LDK, "OpenChannelRequest accepted") - } else { - Log.d(_LDK, "OpenChannelRequest rejected") - } - } - } - - is Event.ChannelClosed -> { - Log.d(_LDK, "event: ChannelClosed") - val json = JsonBuilder() - .put("channel_id", event.channel_id.toHex()) - .put("user_channel_id", event.user_channel_id.toString()) - - val reason = event.reason - if (reason is ClosureReason.CommitmentTxConfirmed) { - json.put("reason", "CommitmentTxConfirmed") - } - if (reason is ClosureReason.CooperativeClosure) { - json.put("reason", "CooperativeClosure") - } - if (reason is ClosureReason.CounterpartyForceClosed) { - json.put("reason", "CounterpartyForceClosed") - json.put("text", reason.peer_msg.toString()) - } - if (reason is ClosureReason.DisconnectedPeer) { - json.put("reason", typeOf().toString()) - } - if (reason is ClosureReason.HolderForceClosed) { - json.put("reason", "HolderForceClosed") - } - if (reason is ClosureReason.OutdatedChannelManager) { - json.put("reason", "OutdatedChannelManager") - } - if (reason is ClosureReason.ProcessingError) { - json.put("reason", "ProcessingError") - json.put("text", reason.err) - } - json.persist("$ldkDir/events_channel_closed") - Ldk.Events.channelClosed += json.toString() - } - - is Event.ChannelPending -> { - Log.d(_LDK, "event: ChannelPending") - val json = JsonBuilder() - .put("channel_id", event.channel_id.toHex()) - .put("tx_id", event.funding_txo._txid.toHex()) - .put("user_channel_id", event.user_channel_id.toString()) - json.persist("$ldkDir/events_channel_pending") - } - - is Event.ChannelReady -> { - Log.d(_LDK, "event: ChannelReady") - val json = JsonBuilder() - .put("channel_id", event.channel_id.toHex()) - .put("user_channel_id", event.user_channel_id.toString()) - json.persist("$ldkDir/events_channel_ready") - } - - is Event.PaymentSent -> { - Log.d(_LDK, "event: PaymentSent") - } - - is Event.PaymentFailed -> { - Log.d(_LDK, "event: PaymentFailed") - } - - is Event.PaymentPathFailed -> { - Log.d(_LDK, "event: PaymentPathFailed: ${event.failure}") - } - - is Event.PendingHTLCsForwardable -> { - Log.d(_LDK, "event: PendingHTLCsForwardable") - channelManager.process_pending_htlc_forwards() - } - - is Event.SpendableOutputs -> { - Log.d(_LDK, "event: SpendableOutputs") - val outputs = event.outputs - try { - val address = newAddress() - val script = Address(address).scriptPubkey().toBytes().toUByteArray().toByteArray() - val txOut: Array = arrayOf() - val res = Ldk.keysManager.inner.spend_spendable_outputs( - outputs, - txOut, - script, - 1000, - null - ) - if (res != null) { - if (res.is_ok) { - val tx = (res as Result_TransactionNoneZ.Result_TransactionNoneZ_OK).res - val txs: Array = arrayOf() - txs.plus(tx) - - LdkBroadcaster.broadcast_transactions(txs) - } - } - } catch (e: Exception) { - Log.d(_LDK, "PaymentClaimable Error: ${e.message}") - } - } - - is Event.PaymentClaimable -> { - Log.d(_LDK, "event: PaymentClaimable") - if (event.payment_hash != null) { - channelManager.claim_funds(event.payment_hash) - } - } - - is Event.PaymentClaimed -> { - Log.d(_LDK, "event ClaimedPayment: ${event.payment_hash}") - } - } -} diff --git a/app/src/main/java/to/bitkit/ldk/LdkFeeEstimator.kt b/app/src/main/java/to/bitkit/ldk/LdkFeeEstimator.kt deleted file mode 100644 index bcc5f5d01..000000000 --- a/app/src/main/java/to/bitkit/ldk/LdkFeeEstimator.kt +++ /dev/null @@ -1,27 +0,0 @@ -package to.bitkit.ldk - -import org.ldk.enums.ConfirmationTarget -import org.ldk.structs.FeeEstimator - -object LdkFeeEstimator : FeeEstimator.FeeEstimatorInterface { - private const val DEFAULT_FEE = 500 - private const val MAX_ALLOWED_NON_ANCHOR_CHANNEL_REMOTE_FEE = 500 - private const val CHANNEL_CLOSE_MIN = 1000 - private const val ONCHAIN_SWEEP = 1000 - - override fun get_est_sat_per_1000_weight(confirmationTarget: ConfirmationTarget?): Int { - return when (confirmationTarget) { - ConfirmationTarget.LDKConfirmationTarget_MaxAllowedNonAnchorChannelRemoteFee -> - MAX_ALLOWED_NON_ANCHOR_CHANNEL_REMOTE_FEE - - ConfirmationTarget.LDKConfirmationTarget_ChannelCloseMinimum -> - CHANNEL_CLOSE_MIN - - ConfirmationTarget.LDKConfirmationTarget_OnChainSweep -> - ONCHAIN_SWEEP - - else -> - DEFAULT_FEE - } - } -} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/ldk/LdkFilter.kt b/app/src/main/java/to/bitkit/ldk/LdkFilter.kt deleted file mode 100644 index abd4ad455..000000000 --- a/app/src/main/java/to/bitkit/ldk/LdkFilter.kt +++ /dev/null @@ -1,53 +0,0 @@ -package to.bitkit.ldk - -import android.util.Log -import org.ldk.structs.Filter -import org.ldk.structs.WatchedOutput -import to.bitkit._LDK -import to.bitkit.data.WatchedTransaction -import to.bitkit.ext.toHex - -object LdkFilter : Filter.FilterInterface { - var txIds = arrayOf() - var outputs = arrayOf() - - override fun register_tx(txid: ByteArray, scriptPubkey: ByteArray) { - Log.d(_LDK, "LdkTxFilter: register_tx") - - txIds += txid - val txIdHex = txid.reversedArray().toHex() - val scriptPubkeyHex = scriptPubkey.toHex() - - val json = JsonBuilder() - .put("txid", txIdHex) - .put("script_pubkey", scriptPubkeyHex) - json.persist("$ldkDir/events_register_tx") - Ldk.Events.registerTx += json.toString() - - Ldk.Relevant.txs += WatchedTransaction(txid, scriptPubkey) - - Log.d(_LDK, "Relevant LDK txs updated:\n" + Ldk.Relevant.txs.toString()) - } - - override fun register_output(output: WatchedOutput) { - Log.d(_LDK, "LdkTxFilter: register_output") - - outputs += output - val index = output._outpoint._index.toString() - val scriptPubkey = output._script_pubkey.toHex() - - val json = JsonBuilder() - .put("index", index) - .put("script_pubkey", scriptPubkey) - json.persist("$ldkDir/events_register_output") - - Ldk.Events.registerOutput += json.toString() - Ldk.Relevant.outputs += WatchedOutput.of( - output._block_hash, - output._outpoint, - output._script_pubkey - ) - - Log.d(_LDK, "Relevant LDK outputs updated:\n" + Ldk.Relevant.outputs.toString()) - } -} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/ldk/LdkKeysManager.kt b/app/src/main/java/to/bitkit/ldk/LdkKeysManager.kt deleted file mode 100644 index 880c4e3a9..000000000 --- a/app/src/main/java/to/bitkit/ldk/LdkKeysManager.kt +++ /dev/null @@ -1,95 +0,0 @@ -package to.bitkit.ldk - -import org.bitcoindevkit.AddressIndex -import org.bitcoindevkit.Payload -import org.bitcoindevkit.Wallet -import org.ldk.structs.KeysManager -import org.ldk.structs.Option_u32Z -import org.ldk.structs.Result_CVec_u8ZNoneZ -import org.ldk.structs.Result_ShutdownScriptInvalidShutdownScriptZ -import org.ldk.structs.Result_ShutdownScriptInvalidShutdownScriptZ.Result_ShutdownScriptInvalidShutdownScriptZ_OK -import org.ldk.structs.Result_ShutdownScriptNoneZ -import org.ldk.structs.Result_TransactionNoneZ -import org.ldk.structs.Result_WriteableEcdsaChannelSignerDecodeErrorZ -import org.ldk.structs.ShutdownScript -import org.ldk.structs.SignerProvider -import org.ldk.structs.SpendableOutputDescriptor -import org.ldk.structs.TxOut -import org.ldk.structs.WriteableEcdsaChannelSigner -import org.ldk.util.UInt128 -import org.ldk.util.WitnessVersion -import to.bitkit.ext.convertToByteArray - -@Suppress("unused") -class LdkKeysManager( - seed: ByteArray, - startTimeSecs: Long, - startTimeNano: Int, - var wallet: Wallet, -) { - var inner: KeysManager = KeysManager.of(seed, startTimeSecs, startTimeNano) - var signerProvider = LdkSignerProvider() - - fun spendSpendableOutputs( - descriptors: Array, - outputs: Array, - changeDestinationScript: ByteArray, - feerateSatPer1000Weight: Int, - locktime: Option_u32Z, - ): Result_TransactionNoneZ { - return inner.spend_spendable_outputs( - descriptors, - outputs, - changeDestinationScript, - feerateSatPer1000Weight, - locktime, - ) - } - - inner class LdkSignerProvider : SignerProvider.SignerProviderInterface { - override fun generate_channel_keys_id(p0: Boolean, p1: Long, p2: UInt128?): ByteArray { - return inner.as_SignerProvider().generate_channel_keys_id(p0, p1, p2) - } - - override fun derive_channel_signer(p0: Long, p1: ByteArray?): WriteableEcdsaChannelSigner { - return inner.as_SignerProvider().derive_channel_signer(p0, p1) - } - - override fun read_chan_signer(p0: ByteArray?): Result_WriteableEcdsaChannelSignerDecodeErrorZ { - return inner.as_SignerProvider().read_chan_signer(p0) - } - - /** - * Returns the destination and shutdown scripts derived by the BDK wallet. - */ - override fun get_destination_script(): Result_CVec_u8ZNoneZ { - val address = wallet.getAddress(AddressIndex.New).address - val res = Result_CVec_u8ZNoneZ.ok(convertToByteArray(address.scriptPubkey())) - if (res.is_ok) { - return res - } - return Result_CVec_u8ZNoneZ.err() - } - - @OptIn(ExperimentalUnsignedTypes::class) - override fun get_shutdown_scriptpubkey(): Result_ShutdownScriptNoneZ { - val address = wallet.getAddress(AddressIndex.New).address - - return when (val payload = address.payload()) { - is Payload.WitnessProgram -> { - val result: Result_ShutdownScriptInvalidShutdownScriptZ = - ShutdownScript.new_witness_program( - WitnessVersion(payload.version.name.toByte()), - payload.program.toUByteArray().toByteArray() - ) - Result_ShutdownScriptNoneZ.ok((result as Result_ShutdownScriptInvalidShutdownScriptZ_OK).res) - } - - else -> { - Result_ShutdownScriptNoneZ.err() - } - } - } - } -} - diff --git a/app/src/main/java/to/bitkit/ldk/LdkLogger.kt b/app/src/main/java/to/bitkit/ldk/LdkLogger.kt deleted file mode 100644 index 12b423d93..000000000 --- a/app/src/main/java/to/bitkit/ldk/LdkLogger.kt +++ /dev/null @@ -1,23 +0,0 @@ -package to.bitkit.ldk - -import android.util.Log -import org.ldk.structs.Logger -import org.ldk.structs.Record -import to.bitkit._LDK -import java.io.File - -object LdkLogger : Logger.LoggerInterface { - override fun log(record: Record?) { - val rawLog = record?._args.toString() - val file = File("$ldkDir/logs.txt") - - try { - if (!file.exists()) { - file.createNewFile() - } - file.appendText("$rawLog\n") - } catch (e: Exception) { - Log.d(_LDK, "LdkLogger error: ${e.message}") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/ldk/LdkPersister.kt b/app/src/main/java/to/bitkit/ldk/LdkPersister.kt deleted file mode 100644 index e4dbf2e9e..000000000 --- a/app/src/main/java/to/bitkit/ldk/LdkPersister.kt +++ /dev/null @@ -1,89 +0,0 @@ -package to.bitkit.ldk - -import android.util.Log -import org.ldk.enums.ChannelMonitorUpdateStatus -import org.ldk.structs.ChannelMonitor -import org.ldk.structs.ChannelMonitorUpdate -import org.ldk.structs.MonitorUpdateId -import org.ldk.structs.OutPoint -import org.ldk.structs.Persist -import to.bitkit._LDK -import to.bitkit.ext.toHex -import java.io.File - -object LdkPersister : Persist.PersistInterface { - private fun persist(id: OutPoint?, data: ByteArray?) { - if (id != null && data != null) { - persist("channels/${id.to_channel_id().toHex()}.bin", data) - } - } - - override fun persist_new_channel( - id: OutPoint?, - data: ChannelMonitor?, - updateId: MonitorUpdateId?, - ): ChannelMonitorUpdateStatus? { - return try { - if (data != null && id != null) { - Log.d(_LDK, "persist_new_channel: ${id.to_channel_id().toHex()}") - persist(id, data.write()) - } - ChannelMonitorUpdateStatus.LDKChannelMonitorUpdateStatus_Completed - } catch (e: Exception) { - Log.d(_LDK, "Failed to write to file: ${e.message}") - ChannelMonitorUpdateStatus.LDKChannelMonitorUpdateStatus_UnrecoverableError - } - } - - override fun update_persisted_channel( - id: OutPoint?, - update: ChannelMonitorUpdate?, - data: ChannelMonitor?, - updateId: MonitorUpdateId, - ): ChannelMonitorUpdateStatus? { - // Consider returning ChannelMonitorUpdateStatus_InProgress for async backups - return try { - if (data != null && id != null) { - Log.d(_LDK, "update_persisted_channel: ${id.to_channel_id().toHex()}") - persist(id, data.write()) - } - ChannelMonitorUpdateStatus.LDKChannelMonitorUpdateStatus_Completed - } catch (e: Exception) { - Log.d(_LDK, "Failed to write to file: ${e.message}") - ChannelMonitorUpdateStatus.LDKChannelMonitorUpdateStatus_UnrecoverableError - } - } -} - -fun persist(to: String, data: ByteArray?) { - val fileName = "$ldkDir/$to" - val file = File(fileName) - if (data != null) { - Log.d(_LDK, "Writing to file: $fileName") - file.writeBytes(data) - } -} - -class JsonBuilder { - private var json: String = "" - - fun put(key: String, value: String?): JsonBuilder { - if (json.isNotEmpty()) json += ',' - json += "\"$key\":\"$value\"" - return this - } - - override fun toString(): String { - return "{$json}" - } - - fun persist(to: String) { - val dir = File(to) - if (!dir.exists()) { - dir.mkdir() - } - - File("$to/${System.currentTimeMillis()}.json") - .writeText(this.toString()) - } -} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/ldk/LightningService.kt b/app/src/main/java/to/bitkit/ldk/LightningService.kt new file mode 100644 index 000000000..2b6ed383e --- /dev/null +++ b/app/src/main/java/to/bitkit/ldk/LightningService.kt @@ -0,0 +1,183 @@ +package to.bitkit.ldk + +import android.util.Log +import org.lightningdevkit.ldknode.AnchorChannelsConfig +import org.lightningdevkit.ldknode.Builder +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.LogLevel +import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.defaultConfig +import to.bitkit.Env +import to.bitkit.LnPeer +import to.bitkit.REST +import to.bitkit.SEED +import to.bitkit.Tag.LDK +import to.bitkit.bdk.BitcoinService + +class LightningService { + companion object { + val shared by lazy { + LightningService() + } + } + + lateinit var node: Node + + fun init(cwd: String) { + val dir = Env.LdkStorage.init(cwd) + + val builder = Builder.fromConfig( + defaultConfig().apply { + storageDirPath = dir + logDirPath = dir + network = Env.Network.ldk + logLevel = LogLevel.TRACE + + trustedPeers0conf = Env.trustedLnPeers.map { it.nodeId } + anchorChannelsConfig = AnchorChannelsConfig( + trustedPeersNoReserve = trustedPeers0conf, + perChannelReserveSats = 2000u, // TODO set correctly + ) + }) + .apply { + setEsploraServer(REST) + if (Env.ldkRgsServerUrl != null) { + setGossipSourceRgs(requireNotNull(Env.ldkRgsServerUrl)) + } else { + setGossipSourceP2p() + } + setEntropyBip39Mnemonic(mnemonic = SEED, passphrase = null) + } + + Log.d(LDK, "Building node...") + + node = builder.build() + + Log.i(LDK, "Node initialised.") + } + + fun start() { + check(::node.isInitialized) { "LDK node is not initialised" } + Log.d(LDK, "Starting node...") + + node.start() + + Log.i(LDK, "Node started.") + connectToTrustedPeers() + } + + private fun connectToTrustedPeers() { + for (peer in Env.trustedLnPeers) { + connectPeer(peer) + } + } + + fun sync() { + node.syncWallets() + } + + // region State + val nodeId: String get() = node.nodeId() + val balances get() = node.listBalances() + val status get() = node.status() + val peers get() = node.listPeers() + val channels get() = node.listChannels() + val payments get() = node.listPayments() +} + +internal fun LightningService.connectPeer(peer: LnPeer) { + Log.d(LDK, "Connecting peer: $peer") + val res = runCatching { + node.connect(peer.nodeId, peer.address(), persist = true) + } + Log.d(LDK, "Connection ${if (res.isSuccess) "succeeded" else "failed"} with: $peer") +} + +internal suspend fun LightningService.openChannel() { + val peer = peers.first() + + // sendToAddress + // mine 6 blocks & wait for esplora to pick up block + // wait for esplora to pick up tx + + sync() + node.connectOpenChannel( + nodeId = peer.nodeId, + address = peer.address, + channelAmountSats = 50000u, + pushToCounterpartyMsat = null, + channelConfig = null, + announceChannel = true, + ) + sync() + + val pendingEvent = node.nextEventAsync() + check(pendingEvent is Event.ChannelPending) { "Expected ChannelPending event, got $pendingEvent" } + Log.d(LDK, "Channel pending with peer: ${peer.address}") + node.eventHandled() + + val fundingTxid = pendingEvent.fundingTxo.txid + Log.d(LDK, "Channel funding txid: $fundingTxid") + + // 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 readyEvent = node.nextEventAsync() + check(readyEvent is Event.ChannelReady) + node.eventHandled() + + // wait for counterparty to pickup event: ChannelReady + val userChannelId = readyEvent.userChannelId + Log.i(LDK, "Channel ready: $userChannelId") +} + +internal suspend fun LightningService.closeChannel(userChannelId: String, counterpartyNodeId: String) { + node.closeChannel(userChannelId, counterpartyNodeId) + + val event = node.nextEventAsync() + check(event is Event.ChannelClosed) + Log.i(LDK, "Channel closed: $userChannelId") + node.eventHandled() + + // mine 1 block & wait for esplora to pick up block + sync() +} + +internal fun LightningService.createInvoice(): String { + return node.bolt11Payment().receive(amountMsat = 112u, description = "description", expirySecs = 7200u) +} + +internal suspend fun LightningService.payInvoice(invoice: String): Boolean { + Log.d(LDK, "Paying invoice: $invoice") + + node.bolt11Payment().send(invoice) + + val event = node.nextEventAsync() + if (event is Event.PaymentSuccessful) { + Log.i(LDK, "Payment successful for invoice: $invoice") + } else if (event is Event.PaymentFailed) { + Log.e(LDK, "Payment error: ${event.reason}") + return false + } + node.eventHandled() + + return true +} + +internal fun warmupNode(cwd: String) { + runCatching { + LightningService.shared.apply { + init(cwd) + start() + sync() + } + BitcoinService.shared.apply { + sync() + } + }.onFailure { + Log.e(LDK, "Warmup error:", it) + } +} diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 9c6cac3c7..7f7b99d0d 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -8,20 +8,21 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize 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 +import androidx.compose.material.icons.filled.NotificationsNone import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -44,6 +45,8 @@ import dagger.hilt.android.AndroidEntryPoint import to.bitkit.R import to.bitkit.ext.requiresPermission import to.bitkit.ext.toast +import to.bitkit.ui.shared.Channels +import to.bitkit.ui.shared.Peers import to.bitkit.ui.theme.AppThemeSurface @AndroidEntryPoint @@ -57,45 +60,9 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() AppThemeSurface { MainScreen(viewModel) { - val context = LocalContext.current - WalletScreen(viewModel) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "Other", - style = MaterialTheme.typography.titleLarge, - ) - - var canPush by remember { - mutableStateOf(!context.requiresPermission(notificationPermission)) - } - - // Request Permissions - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { - canPush = it - toast("Permission ${if (it) "Granted" else "Denied"}") - } - - val onNotificationsClick = { - if (context.requiresPermission(notificationPermission)) { - permissionLauncher.launch(notificationPermission) - } else { - 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…", - ) - } - Unit - } - Button(onClick = onNotificationsClick) { - Text(text = if (canPush) "Notify" else "Request Permissions") - } - } + Peers(viewModel.peers, viewModel::togglePeerConnection) + Channels(viewModel.channels, viewModel::closeChannel) } } } @@ -141,7 +108,8 @@ private fun MainScreen( Text(stringResource(R.string.app_name)) }, actions = { - IconButton(viewModel::sync) { + NotificationButton() + IconButton(viewModel::refresh) { Icon( imageVector = Icons.Default.Refresh, contentDescription = stringResource(R.string.sync), @@ -177,13 +145,59 @@ private fun MainScreen( } }, ) { padding -> - Box(Modifier.padding(padding)) { + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { AppNavHost( navController = navController, viewModel = viewModel, - startDestination = Routes.Wallet.destination, - startContent = startContent, + walletScreen = startContent, + modifier = Modifier.padding(24.dp), ) } } } + +@Composable +private fun NotificationButton() { + val context = LocalContext.current + var canPush by remember { + mutableStateOf(!context.requiresPermission(notificationPermission)) + } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { + canPush = it + toast("Permission ${if (it) "Granted" else "Denied"}") + } + + val onClick = { + if (context.requiresPermission(notificationPermission)) { + permissionLauncher.launch(notificationPermission) + } else { + 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…", + ) + } + Unit + } + val icon by remember { + derivedStateOf { if (canPush) Icons.Default.NotificationAdd else Icons.Default.NotificationsNone } + } + IconButton(onClick = onClick) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier, + ) + } +} + diff --git a/app/src/main/java/to/bitkit/ui/MainViewModel.kt b/app/src/main/java/to/bitkit/ui/MainViewModel.kt index 3b28da274..ece3c75a9 100644 --- a/app/src/main/java/to/bitkit/ui/MainViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/MainViewModel.kt @@ -1,6 +1,5 @@ package to.bitkit.ui -import android.util.Log import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel @@ -9,208 +8,115 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.ldk.enums.Currency -import org.ldk.enums.RetryableSendFailure -import org.ldk.structs.Bolt11Invoice -import org.ldk.structs.ChannelDetails -import org.ldk.structs.Logger -import org.ldk.structs.Option_u16Z -import org.ldk.structs.Option_u64Z -import org.ldk.structs.PaymentError -import org.ldk.structs.Result_Bolt11InvoiceParseOrSemanticErrorZ -import org.ldk.structs.Result_Bolt11InvoiceSignOrCreationErrorZ -import org.ldk.structs.Result_ThirtyTwoBytesPaymentErrorZ -import org.ldk.structs.Retry -import org.ldk.structs.UtilMethods -import to.bitkit._LDK -import to.bitkit.bdk.btcAddress -import to.bitkit.bdk.btcBalance -import to.bitkit.bdk.mnemonicPhrase -import to.bitkit.bdk.newAddress -import to.bitkit.data.RestApi -import to.bitkit.data.Syncer -import to.bitkit.di.IoDispatcher -import to.bitkit.ext.toHex -import to.bitkit.ldk.Ldk -import to.bitkit.ldk.LdkLogger +import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.PeerDetails +import to.bitkit.LnPeer +import to.bitkit.SEED +import to.bitkit.bdk.BitcoinService +import to.bitkit.di.BgDispatcher +import to.bitkit.ext.syncTo +import to.bitkit.ldk.LightningService +import to.bitkit.ldk.closeChannel +import to.bitkit.ldk.connectPeer +import to.bitkit.ldk.createInvoice +import to.bitkit.ldk.openChannel +import to.bitkit.ldk.payInvoice import javax.inject.Inject -const val HOST = "10.0.2.2" -const val REST = "http://$HOST:3002" -const val PORT = "9736" -const val PEER = "02faf2d1f5dc153e8931d8444c4439e46a81cb7eeadba8562e7fec3690c261ce87" - @HiltViewModel class MainViewModel @Inject constructor( - private val syncer: Syncer, - private val restApi: RestApi, - @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, ) : ViewModel() { - val btcAddress = mutableStateOf(btcAddress()) - val ldkNodeId = mutableStateOf(ldkNodeId()) - val ldkBalance = mutableStateOf(ldkLocalBalance()) + val ldkNodeId = mutableStateOf("Loading…") + val ldkBalance = mutableStateOf("Loading…") + val btcAddress = mutableStateOf("Loading…") val btcBalance = mutableStateOf("Loading…") - val channels = mutableStateListOf(*ldkUsableChannels()) - val peers = mutableStateListOf() - val mnemonic = mutableStateOf(mnemonicPhrase()) + val mnemonic = mutableStateOf(SEED) - init { - sync() - } + val peers = mutableStateListOf() + val channels = mutableStateListOf() - fun sync() { - btcBalance.value = "Syncing…" - viewModelScope.launch(ioDispatcher) { - delay(500) - syncer.sync() - delay(1500) - syncPeers() - syncChannels() - syncBalance() + val lightningService = LightningService.shared + private val bitcoinService = BitcoinService.shared + + private val node = lightningService.node + + init { + viewModelScope.launch { + sync() } } - private fun syncBalance() { - btcBalance.value = btcBalance() - ldkBalance.value = ldkLocalBalance() + fun sync() { + ldkNodeId.value = lightningService.nodeId + ldkBalance.value = lightningService.balances.totalLightningBalanceSats.toString() + btcAddress.value = bitcoinService.address + btcBalance.value = bitcoinService.balance.total.toString() + mnemonic.value = SEED + peers.syncTo(lightningService.peers) + channels.syncTo(lightningService.channels) } fun getNewAddress() { - btcAddress.value = newAddress() + btcAddress.value = node.onchainPayment().newAddress() } - private fun syncChannels() { - channels.clear() - channels.addAll(ldkUsableChannels()) - } - - fun connectPeer(pubKey: String = PEER, port: String = PORT) = with(viewModelScope) { - launch(ioDispatcher) { - val didConnect = restApi.connectPeer(pubKey, HOST, port.toInt()) - if (didConnect) { - delay(250) - syncPeers() - } + fun connectPeer(peer: LnPeer) { + lightningService.connectPeer(peer) + peers.replaceAll { + it.run { copy(isConnected = it.nodeId == nodeId) } } + channels.syncTo(lightningService.channels) } - private fun disconnectPeer() = with(viewModelScope) { - launch(ioDispatcher) { - val didDisconnect = restApi.disconnectPeer(PEER) - if (didDisconnect) { - delay(250) - syncPeers() - } + fun disconnectPeer(nodeId: String) { + node.disconnect(nodeId) + peers.replaceAll { + it.takeIf { it.nodeId == nodeId }?.copy(isConnected = false) ?: it } + channels.syncTo(lightningService.channels) } - fun togglePeerConnection() { - if (peers.contains(PEER)) { - disconnectPeer() - } else { - connectPeer() + fun payInvoice(invoice: String) { + viewModelScope.launch(bgDispatcher) { + lightningService.payInvoice(invoice) + sync() } - syncChannels() } - private fun syncPeers() { - peers.clear() - peers.addAll(getPeers()) - syncChannels() - } + fun createInvoice() = lightningService.createInvoice() - private fun getPeers(): List { - val peerManager = Ldk.channelManagerConstructor.peer_manager - return peerManager?._peer_node_ids?.map { it._a.toHex() }.orEmpty() + fun openChannel() { + viewModelScope.launch(bgDispatcher) { + lightningService.openChannel() + sync() + } } - fun createInvoice( - description: String = "coffee", - mSats: Long = 10000L, - ): String { - val logger: Logger = Logger.new_impl(LdkLogger) - - val invoice = UtilMethods.create_invoice_from_channelmanager( - Ldk.channelManager, - Ldk.keysManager.inner.as_NodeSigner(), - logger, - Currency.LDKCurrency_Regtest, - Option_u64Z.some(mSats), - description, - 300, - Option_u16Z.some(144), - ) - - val encoded = - (invoice as Result_Bolt11InvoiceSignOrCreationErrorZ.Result_Bolt11InvoiceSignOrCreationErrorZ_OK).res - return encoded.to_str() + fun closeChannel(channel: ChannelDetails) { + viewModelScope.launch(bgDispatcher) { + lightningService.closeChannel(channel.userChannelId, channel.counterpartyNodeId) + sync() + } } } -internal fun ldkNodeId(): String { - return Ldk.channelManager._our_node_id?.toHex() ?: throw Error("Node not initialized") -} - -internal fun ldkUsableChannels(): Array { - return Ldk.channelManager.list_channels().orEmpty() -} - -internal fun ldkLocalBalance(): String { - val localBalance = ldkUsableChannels().sumOf { it._balance_msat } / 1000 - Log.d(_LDK, "LN balance: $localBalance") - return localBalance.toString() -} - -internal fun payInvoice(bolt11Invoice: String): Boolean { - Log.d(_LDK, "Paying invoice: $bolt11Invoice") - - val invoice = decodeInvoice(bolt11Invoice) - - val res = UtilMethods.pay_invoice( - invoice, - Retry.attempts(6), - Ldk.channelManagerConstructor.channel_manager, - ) - if (res.is_ok) { - Log.d(_LDK, "Payment successful") - return true - } - - val error = res as? Result_ThirtyTwoBytesPaymentErrorZ.Result_ThirtyTwoBytesPaymentErrorZ_Err - val invoiceError = error?.err as? PaymentError.Invoice - if (invoiceError != null) { - Log.d(_LDK, "Payment failed: $invoiceError") - return true - } - - when (val failure = (error?.err as? PaymentError.Sending)?.sending) { - RetryableSendFailure.LDKRetryableSendFailure_DuplicatePayment -> - Log.e(_LDK, "Payment failed: DuplicatePayment") - - RetryableSendFailure.LDKRetryableSendFailure_PaymentExpired -> - Log.e(_LDK, "Payment failed: PaymentExpired") - - RetryableSendFailure.LDKRetryableSendFailure_RouteNotFound -> - Log.e(_LDK, "Payment failed: RouteNotFound") +fun MainViewModel.refresh() { + viewModelScope.launch { + "Refreshing…".also { + ldkNodeId.value = it + ldkBalance.value = it + btcAddress.value = it + btcBalance.value = it + } + peers.clear() + channels.clear() - else -> - Log.e(_LDK, "Payment failed with unknown error: $failure") + delay(50) + lightningService.sync() + sync() } - return false } -internal fun decodeInvoice(bolt11Invoice: String): Bolt11Invoice? { - val res = Bolt11Invoice.from_str(bolt11Invoice) - if (res is Result_Bolt11InvoiceParseOrSemanticErrorZ.Result_Bolt11InvoiceParseOrSemanticErrorZ_Err) { - Log.d(_LDK, "Unable to parse invoice ${res.err}") - } - val invoice = - (res as Result_Bolt11InvoiceParseOrSemanticErrorZ.Result_Bolt11InvoiceParseOrSemanticErrorZ_OK).res - - if (res.is_ok) { - Log.d(_LDK, "Invoice parsed successfully") - } else { - Log.d(_LDK, "Unable to parse invoice") - } - return invoice -} \ No newline at end of file +fun MainViewModel.togglePeerConnection(peer: PeerDetails) = + if (peer.isConnected) disconnectPeer(peer.nodeId) else connectPeer(LnPeer(peer.nodeId, peer.address)) diff --git a/app/src/main/java/to/bitkit/ui/Nav.kt b/app/src/main/java/to/bitkit/ui/Nav.kt index 8117b5bde..c9d02d9e5 100644 --- a/app/src/main/java/to/bitkit/ui/Nav.kt +++ b/app/src/main/java/to/bitkit/ui/Nav.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.outlined.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController @@ -22,17 +23,18 @@ import to.bitkit.ui.settings.PeersScreen @Composable fun AppNavHost( navController: NavHostController, + modifier: Modifier = Modifier, viewModel: MainViewModel = hiltViewModel(), - startDestination: String = "", - startContent: @Composable () -> Unit = {}, + walletScreen: @Composable () -> Unit = {}, ) { // val screenViewModel = viewModel() with(Routes) { NavHost( navController = navController, - startDestination = startDestination, + startDestination = Wallet.destination, + modifier = modifier, ) { - composable(Wallet.destination) { startContent() } + composable(Wallet.destination) { walletScreen() } composable(Settings.destination) { SettingsScreen(navController, viewModel) } composable(Peers.destination) { PeersScreen(viewModel) } composable(Channels.destination) { ChannelsScreen(viewModel) } diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index f7caf57cb..f63e0319d 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -14,7 +14,7 @@ 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._FCM +import to.bitkit.Tag.FCM import to.bitkit.currentActivity import to.bitkit.ext.notificationManager import to.bitkit.ext.notificationManagerCompat @@ -85,10 +85,10 @@ internal fun Activity.pushNotification( fun logFcmToken() { FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> if (!task.isSuccessful) { - Log.w(_FCM, "FCM registration token error:\n", task.exception) + Log.w(FCM, "FCM registration token error:", task.exception) return@OnCompleteListener } val token = task.result - Log.d(_FCM, "FCM registration token: $token") + Log.d(FCM, "FCM registration token: $token") }) } diff --git a/app/src/main/java/to/bitkit/ui/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/SettingsScreen.kt index 4bc6f4357..d452ae2b4 100644 --- a/app/src/main/java/to/bitkit/ui/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/SettingsScreen.kt @@ -4,13 +4,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.ArrowForwardIos import androidx.compose.material3.Button @@ -24,6 +21,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController import to.bitkit.R +import to.bitkit.ui.shared.InfoField @Composable fun SettingsScreen( @@ -32,10 +30,7 @@ fun SettingsScreen( ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp) - .verticalScroll(rememberScrollState()), + modifier = Modifier, ) { Text( text = "Settings", @@ -97,14 +92,9 @@ private fun SettingButton(label: String, onClick: () -> Unit) { private fun Mnemonic( mnemonic: String, ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxSize(), - ) { - InfoField( - value = mnemonic, - label = stringResource(R.string.mnemonic), - trailingIcon = { CopyToClipboardButton(mnemonic) }, - ) - } + InfoField( + value = mnemonic, + label = stringResource(R.string.mnemonic), + trailingIcon = { CopyToClipboardButton(mnemonic) }, + ) } diff --git a/app/src/main/java/to/bitkit/ui/WalletScreen.kt b/app/src/main/java/to/bitkit/ui/WalletScreen.kt index e9d1fcadd..67462b8b8 100644 --- a/app/src/main/java/to/bitkit/ui/WalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/WalletScreen.kt @@ -3,64 +3,82 @@ package to.bitkit.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField 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 import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import to.bitkit.R +import to.bitkit.ui.shared.InfoField +import to.bitkit.ui.shared.moneyString @Composable fun WalletScreen( viewModel: MainViewModel, content: @Composable () -> Unit = {}, ) { - Spacer(modifier = Modifier.size(48.dp)) Column( verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp), + modifier = Modifier, ) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = stringResource(R.string.lightning), - style = MaterialTheme.typography.titleLarge, - ) + val ldkBalance by remember { viewModel.ldkBalance } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.lightning), + style = MaterialTheme.typography.titleLarge, + ) + Row { + Text( + text = moneyString(ldkBalance), + style = MaterialTheme.typography.titleSmall, + ) + } + } + val nodeId by remember { viewModel.ldkNodeId } InfoField( value = nodeId, label = stringResource(R.string.node_id), trailingIcon = { CopyToClipboardButton(nodeId) }, ) - val ldkBalance by remember { viewModel.ldkBalance } - InfoField( - value = ldkBalance, - label = stringResource(R.string.balance), - ) } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "Wallet", - style = MaterialTheme.typography.titleLarge, - ) + val btcBalance by remember { viewModel.btcBalance } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.wallet), + style = MaterialTheme.typography.titleLarge, + ) + Row { + Text( + text = moneyString(btcBalance), + style = MaterialTheme.typography.titleSmall, + ) + } + } + val address by remember { viewModel.btcAddress } InfoField( value = address, @@ -78,42 +96,11 @@ fun WalletScreen( } }, ) - val btcBalance by remember { viewModel.btcBalance } - InfoField( - value = btcBalance, - label = stringResource(R.string.balance), - trailingIcon = { - IconButton(onClick = viewModel::sync) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = stringResource(R.string.sync), - modifier = Modifier.size(16.dp), - ) - } - }, - ) } content() } } -@Composable -internal fun InfoField( - value: String, - label: String, - trailingIcon: @Composable (() -> Unit)? = null, -) { - OutlinedTextField( - label = { Text(label) }, - value = value, - onValueChange = {}, - textStyle = MaterialTheme.typography.labelSmall, - trailingIcon = trailingIcon, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - ) -} - @Composable internal fun CopyToClipboardButton(text: String) { val clipboardManager = LocalClipboardManager.current @@ -121,7 +108,8 @@ internal fun CopyToClipboardButton(text: String) { Icon( imageVector = Icons.Default.ContentCopy, contentDescription = null, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(16.dp), ) } } + 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 5556580a1..39fbcb1d9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/ChannelsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/ChannelsScreen.kt @@ -1,192 +1,35 @@ package to.bitkit.ui.settings -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -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.Link -import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import to.bitkit.R -import to.bitkit.bdk.Channel -import to.bitkit.ext.toHex import to.bitkit.ui.MainViewModel -import to.bitkit.ui.PEER -import to.bitkit.ui.ldkLocalBalance +import to.bitkit.ui.shared.Channels @Composable fun ChannelsScreen( viewModel: MainViewModel, ) { - Spacer(modifier = Modifier.size(48.dp)) Column( verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp), + modifier = Modifier, ) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Button( - onClick = { - Channel.open(PEER) - viewModel.sync() - }, + onClick = { viewModel.openChannel() }, enabled = viewModel.peers.isNotEmpty() ) { Text("Open Channel") } } - Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = "Channels", - style = MaterialTheme.typography.titleMedium, - ) - ConnectPeerIcon(viewModel.peers, viewModel::togglePeerConnection) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "${ldkLocalBalance()} sats", - style = MaterialTheme.typography.titleSmall, - ) - } - - viewModel.channels.forEach { - val isUsable = it._is_usable - val channelId = it._channel_id.toHex() - val outbound = it._outbound_capacity_msat / 1000 - val inbound = it._inbound_capacity_msat / 1000 - Card( - elevation = CardDefaults.cardElevation(2.5.dp), - ) { - Column(modifier = Modifier.padding(16.dp)) { - ChannelItem( - isActive = isUsable, - channelId = channelId, - outbound = outbound.toString(), - inbound = inbound.toString(), - onClose = { - Channel.close(channelId, PEER) - viewModel.sync() - }, - ) - } - } - } - } - } -} - -@Composable -private fun ConnectPeerIcon( - peers: List, - onClick: () -> Unit, -) { - val icon = if (peers.isEmpty()) Icons.Default.LinkOff else Icons.Default.Link - val color = if (peers.isEmpty()) colorScheme.error else colorScheme.secondary - IconButton( - onClick = onClick, - modifier = Modifier - .border(BorderStroke(1.5.dp, color), RoundedCornerShape(16.dp)) - .size(28.dp), - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } -} - -@Composable -fun ChannelItem( - isActive: Boolean, - channelId: String, - outbound: String, - inbound: String, - onClose: () -> Unit, -) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - val color = if (isActive) colorScheme.secondary else colorScheme.error - Text( - text = channelId, - style = MaterialTheme.typography.labelSmall, - ) - Card( - colors = CardDefaults.cardColors(colorScheme.background), - modifier = Modifier.fillMaxWidth(), - ) { - LinearProgressIndicator( - color = color, - trackColor = Color.Transparent, - progress = { - (inbound.toDouble() / (outbound.toDouble() + inbound.toDouble())).toFloat() - }, - modifier = Modifier.height(8.dp), - ) - } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = "$outbound sats", style = MaterialTheme.typography.labelSmall) - Text(text = "$inbound sats", style = MaterialTheme.typography.labelSmall) - } - Row(verticalAlignment = Alignment.CenterVertically) { - val icon = if (isActive) Icons.Default.Cloud else Icons.Default.CloudOff - Icon( - imageVector = icon, - contentDescription = stringResource(R.string.status), - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.weight(1f)) - TextButton( - onClick = onClose, - contentPadding = PaddingValues(0.dp), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.close), - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.size(4.dp)) - Text(text = stringResource(R.string.close)) - } - } + Channels(viewModel.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 6ba04b50e..9dec9365f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/PaymentsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/PaymentsScreen.kt @@ -2,9 +2,7 @@ package to.bitkit.ui.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -19,9 +17,8 @@ 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.InfoField import to.bitkit.ui.MainViewModel -import to.bitkit.ui.payInvoice +import to.bitkit.ui.shared.InfoField @Composable fun PaymentsScreen( @@ -29,28 +26,36 @@ fun PaymentsScreen( ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp), + modifier = Modifier, ) { - var invoiceToPay by remember { mutableStateOf("") } - OutlinedTextField( - label = { Text("Pay invoice") }, - value = invoiceToPay, - onValueChange = { invoiceToPay = it }, - textStyle = MaterialTheme.typography.labelSmall, - minLines = 5, - modifier = Modifier.fillMaxWidth(), - ) - Button(onClick = { payInvoice(invoiceToPay) }) { - Text(text = stringResource(R.string.pay)) - } + PayInvoice(viewModel::payInvoice) val invoiceToSend by remember { mutableStateOf(viewModel.createInvoice()) } InfoField( - label = "Send invoice", value = invoiceToSend, + label = "Send invoice", trailingIcon = { CopyToClipboardButton(invoiceToSend) }, ) } } + +@Composable +internal fun PayInvoice( + onClick: (String) -> Unit, +) { + var invoiceToPay by remember { mutableStateOf("") } + OutlinedTextField( + label = { Text("Pay invoice") }, + value = invoiceToPay, + onValueChange = { invoiceToPay = it }, + textStyle = MaterialTheme.typography.labelSmall, + minLines = 5, + modifier = Modifier.fillMaxWidth(), + ) + Button(onClick = { + onClick(invoiceToPay) + invoiceToPay = "" + }) { + Text(text = stringResource(R.string.pay)) + } +} 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 9edb93478..d49c308fa 100644 --- a/app/src/main/java/to/bitkit/ui/settings/PeersScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/PeersScreen.kt @@ -2,13 +2,8 @@ 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.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -19,12 +14,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.PEER import to.bitkit.R import to.bitkit.ui.MainViewModel -import to.bitkit.ui.PEER -import to.bitkit.ui.PORT +import to.bitkit.ui.shared.InfoField +import to.bitkit.ui.shared.Peers +import to.bitkit.ui.togglePeerConnection @Composable fun PeersScreen( @@ -32,12 +29,11 @@ fun PeersScreen( ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp), + modifier = Modifier, ) { - var pubKey by remember { mutableStateOf(PEER) } - var port by remember { mutableStateOf(PORT) } + 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), @@ -53,6 +49,7 @@ fun PeersScreen( textStyle = MaterialTheme.typography.labelSmall, modifier = Modifier.fillMaxWidth(), ) + InfoField(value = host, label = "Host") OutlinedTextField( label = { Text("Port") }, value = port, @@ -60,44 +57,10 @@ fun PeersScreen( textStyle = MaterialTheme.typography.labelSmall, modifier = Modifier.fillMaxWidth(), ) - Button(onClick = { viewModel.connectPeer(pubKey, port) }) { + Button(onClick = { viewModel.connectPeer(LnPeer(pubKey, host, port)) }) { Text(stringResource(R.string.connect)) } } - ConnectedPeers(viewModel.peers) - } -} - -@Composable -private fun ConnectedPeers(peers: List) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row { - Text( - text = "Connected Peers", - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "${peers.size}", - style = MaterialTheme.typography.titleMedium, - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - peers.sorted().reversed().forEachIndexed { i, it -> - if (i > 0 && peers.size > 1) { - HorizontalDivider() - } - Text( - text = it, - style = MaterialTheme.typography.labelSmall, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - } - } + Peers(viewModel.peers, viewModel::togglePeerConnection) } } diff --git a/app/src/main/java/to/bitkit/ui/shared/Channels.kt b/app/src/main/java/to/bitkit/ui/shared/Channels.kt new file mode 100644 index 000000000..624783299 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/shared/Channels.kt @@ -0,0 +1,141 @@ +package to.bitkit.ui.shared + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.lightningdevkit.ldknode.ChannelDetails +import to.bitkit.R + +@Composable +internal fun Channels( + channels: SnapshotStateList, + onChannelClose: (ChannelDetails) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Channels", + style = MaterialTheme.typography.titleMedium, + ) + } + + channels.forEach { + OutlinedCard( + elevation = CardDefaults.cardElevation(.5.dp), + colors = CardDefaults.outlinedCardColors(colorScheme.background), + ) { + Column(modifier = Modifier.padding(16.dp)) { + val outbound by remember(it) { + mutableStateOf(it.outboundCapacityMsat / 1000u) + } + val inbound by remember(it) { + mutableStateOf(it.inboundCapacityMsat / 1000u) + } + + ChannelItem( + isActive = it.isUsable, + channelId = it.channelId, + outbound = outbound.toInt(), + inbound = inbound.toInt(), + onClose = { onChannelClose(it) }, + ) + } + } + } + } +} + +@Composable +private fun ChannelItem( + isActive: Boolean, + channelId: String, + outbound: Int, + inbound: Int, + onClose: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = channelId, + style = MaterialTheme.typography.labelSmall, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Card { + LinearProgressIndicator( + color = if (isActive) colorScheme.primary else colorScheme.error, + trackColor = colorScheme.surfaceVariant, + progress = (inbound.toDouble() / (outbound + inbound))::toFloat, + modifier = Modifier + .height(8.dp) + .fillMaxWidth(), + ) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "$outbound sats", style = MaterialTheme.typography.labelSmall) + Text(text = "$inbound sats", style = MaterialTheme.typography.labelSmall) + } + Row(verticalAlignment = Alignment.CenterVertically) { + val (icon, color) = Pair( + if (isActive) Icons.Default.Cloud else Icons.Default.CloudOff, + if (isActive) colorScheme.primary else colorScheme.error, + ) + Icon( + imageVector = icon, + contentDescription = stringResource(R.string.status), + tint = color, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.weight(1f)) + TextButton( + onClick = onClose, + contentPadding = PaddingValues(0.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.size(4.dp)) + Text(text = stringResource(R.string.close)) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/shared/InfoField.kt b/app/src/main/java/to/bitkit/ui/shared/InfoField.kt new file mode 100644 index 000000000..7c998dd21 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/shared/InfoField.kt @@ -0,0 +1,38 @@ +package to.bitkit.ui.shared + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import to.bitkit.ui.shared.util.ellipsisVisualTransformation + +@Composable +internal fun InfoField( + value: String, + label: String, + trailingIcon: @Composable (() -> Unit)? = null, + onValueChange: (String) -> Unit = {}, +) { + OutlinedTextField( + label = { Text(label) }, + value = value, + onValueChange = onValueChange, + trailingIcon = trailingIcon, + readOnly = true, + singleLine = true, + colors = MaterialTheme.colorScheme.onBackground.copy(alpha = .6f).let { + OutlinedTextFieldDefaults.colors( + unfocusedTextColor = it, + focusedTextColor = it, + focusedBorderColor = it, + focusedLabelColor = it.copy(alpha = 1f), + ) + }, + textStyle = MaterialTheme.typography.labelSmall, + visualTransformation = ellipsisVisualTransformation(40), + 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 new file mode 100644 index 000000000..4659e8076 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/shared/Peers.kt @@ -0,0 +1,92 @@ +package to.bitkit.ui.shared + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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 org.lightningdevkit.ldknode.PeerDetails +import to.bitkit.R + +@Composable +internal fun Peers( + peers: SnapshotStateList, + onToggle: (PeerDetails) -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row { + Text( + text = stringResource(R.string.peers), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = peers.filter { it.isConnected }.size.toString(), + style = MaterialTheme.typography.titleMedium, + ) + } + Column { + peers.sortedBy { it.isConnected }.forEachIndexed { i, it -> + if (i > 0 && peers.size > 1) { + HorizontalDivider() + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TogglePeerIcon(it.isConnected) { onToggle(it) } + Text( + text = it.nodeId, + style = MaterialTheme.typography.labelSmall, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } + } + } +} + +@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/Text.kt b/app/src/main/java/to/bitkit/ui/shared/Text.kt new file mode 100644 index 000000000..fd42847dc --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/shared/Text.kt @@ -0,0 +1,21 @@ +package to.bitkit.ui.shared + +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import to.bitkit.R + + +@Composable +internal fun moneyString( + ldkBalance: String, + currency: String = stringResource(R.string.sat), +) = buildAnnotatedString { + append("$ldkBalance ") + withStyle(SpanStyle(color = colorScheme.onBackground.copy(0.5f))) { + append(currency) + } +} 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 new file mode 100644 index 000000000..002115db2 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/shared/util/EllipsisVisualTransformation.kt @@ -0,0 +1,21 @@ +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) +} diff --git a/app/src/main/java/to/bitkit/ui/theme/Color.kt b/app/src/main/java/to/bitkit/ui/theme/Color.kt deleted file mode 100644 index 46974ceb3..000000000 --- a/app/src/main/java/to/bitkit/ui/theme/Color.kt +++ /dev/null @@ -1,8 +0,0 @@ -package to.bitkit.ui.theme - -import androidx.compose.ui.graphics.Color - -val Purple200 = Color(0xFFBB86FC) -val Purple500 = Color(0xFF6200EE) -val Purple700 = Color(0xFF3700B3) -val Teal200 = Color(0xFF03DAC5) \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/ui/theme/Shape.kt b/app/src/main/java/to/bitkit/ui/theme/Shape.kt index 7c38be5e6..3e8bd7269 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Shape.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Shape.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.unit.dp val Shapes = Shapes( small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(4.dp), - large = RoundedCornerShape(0.dp) -) \ No newline at end of file + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(32.dp), + extraLarge = RoundedCornerShape(64.dp), +) diff --git a/app/src/main/java/to/bitkit/ui/theme/Theme.kt b/app/src/main/java/to/bitkit/ui/theme/Theme.kt index 67d04164c..453fbaf32 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Theme.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Theme.kt @@ -10,10 +10,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color +val Brand100 = Color(0xFFFFF1EE) +val Brand500 = Color(0xFFEC5428) +val Teal200 = Color(0xFF03DAC5) + private object ColorPalette { @Stable val Light = lightColorScheme( - primary = Purple500, + primary = Brand500, + primaryContainer = Brand100, secondary = Teal200, background = Color.White, /* // Other default colors to override @@ -27,7 +32,7 @@ private object ColorPalette { @Stable val Dark = darkColorScheme( - primary = Purple200, + primary = Brand500, secondary = Teal200, ) } @@ -53,4 +58,4 @@ internal fun AppTheme( shapes = Shapes, content = content, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/to/bitkit/ui/theme/Type.kt b/app/src/main/java/to/bitkit/ui/theme/Type.kt index 3c294fa9b..1311d9470 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Type.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Type.kt @@ -6,23 +6,20 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// Set of Material typography styles to start with val Typography = Typography( - bodyMedium = TextStyle( + bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 16.sp - ) - /* Other default text styles to override - button = TextStyle( + fontSize = 16.sp, + ), + bodyMedium = TextStyle( fontFamily = FontFamily.Default, - fontWeight = FontWeight.W500, - fontSize = 14.sp + fontWeight = FontWeight.Normal, + fontSize = 14.sp, ), - caption = TextStyle( + bodySmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 12.sp - ) - */ -) \ No newline at end of file + fontSize = 12.sp, + ), +) diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index a3af6d433..7b72fac4f 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,16 +1,16 @@ - + - \ No newline at end of file + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127d3..f9f49fc88 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,10 +1,9 @@ - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF - \ No newline at end of file + #FFF1EE + #EF886A + #03DAC5 + #018786 + #000000 + #FFFFFF + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ddbddcb8c..57729faba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,9 +15,11 @@ Mnemonic Node Id Pay - %1$s sat + Peers + sat Settings Status Stop Sync + Wallet diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 30b78396e..862899309 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,15 +2,15 @@ @@ -19,7 +19,7 @@ true -