diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..1e5d56713 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.iml +.gradle +.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties + +# Secrets +google-services.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..f2d7a0fe2 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Bitkit Android +Bitkit Android Native app. + +## Prerequisites +1. Download `google-services.json` to `./app` from FCM Console +2. Run Polar with the default initial network + +## Dev Tips +- To communicate from host to emulator use: + `127.0.0.1` and setup port forwarding via ADB, eg. `adb forward tcp:9777 tcp:9777` +- To communicate from emulator to host use: + `10.0.2.2` instead of `localhost` diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 000000000..e936726d5 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,139 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.devtools.ksp") + id("com.google.dagger.hilt.android") + id("com.google.gms.google-services") +} + +android { + namespace = "to.bitkit" + compileSdk = 34 + ndkVersion = "26.1.10909125" + + defaultConfig { + applicationId = "to.bitkit" + minSdk = 28 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + vectorDrawables { + useSupportLibrary = true + } + } + signingConfigs { + getByName("debug") { + storeFile = file("debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + buildTypes { + debug { + signingConfig = signingConfigs.getByName("debug") + // applicationIdSuffix = ".dev" + } + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // 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("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.activity:activity-compose:1.9.0") + + // Firebase + implementation(platform("com.google.firebase:firebase-bom:33.1.2")) + implementation("com.google.firebase:firebase-messaging") + implementation("com.google.firebase:firebase-analytics") + + // Lifecycle + val lifecycleVersion = "2.8.3" + // 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") + + val composeNavigationVersion = "2.7.7" + implementation("androidx.navigation:navigation-compose:$composeNavigationVersion") + androidTestImplementation("androidx.navigation:navigation-testing:$composeNavigationVersion") + + // Compose Tooling for Android Studio Preview + val composeToolingVersion = "1.6.8" + implementation("androidx.compose.ui:ui-tooling-preview:$composeToolingVersion") + debugImplementation("androidx.compose.ui:ui-tooling:$composeToolingVersion") + + // Dagger-Hilt + val hiltVersion = "2.51.1" + implementation("com.google.dagger:hilt-android:$hiltVersion") + ksp("com.google.dagger:hilt-android-compiler:$hiltVersion") + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + + // 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") + + // Ktor + val ktorVersion = "2.3.8" + implementation("io.ktor:ktor-client-core:$ktorVersion") + implementation("io.ktor:ktor-client-okhttp:$ktorVersion") + implementation("io.ktor:ktor-client-cio:$ktorVersion") + implementation("io.ktor:ktor-client-logging:$ktorVersion") + implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") + implementation("ch.qos.logback:logback-classic:1.2.11") + + // Testing + debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.8") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.6.8") + + // Other + implementation("com.google.guava:guava:31.1-android") // for ByteArray.toHex()+ +} \ No newline at end of file diff --git a/app/debug.keystore b/app/debug.keystore new file mode 100644 index 000000000..364e105ed Binary files /dev/null and b/app/debug.keystore differ diff --git a/app/libs/LDK-release.aar b/app/libs/LDK-release.aar new file mode 100644 index 000000000..7ca5e10d2 Binary files /dev/null and b/app/libs/LDK-release.aar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 000000000..ff59496d8 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/to/bitkit/ExampleInstrumentedTest.kt b/app/src/androidTest/java/to/bitkit/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..d269b3e82 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package to.bitkit + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals(BuildConfig.APPLICATION_ID, appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..12d610c5d --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e24626283 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt new file mode 100644 index 000000000..1691392f7 --- /dev/null +++ b/app/src/main/java/to/bitkit/App.kt @@ -0,0 +1,62 @@ +package to.bitkit + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Application +import android.os.Bundle +import dagger.hilt.android.HiltAndroidApp +import kotlin.reflect.typeOf + +@HiltAndroidApp +internal class App : Application() { + override fun onCreate() { + super.onCreate() + currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } + } + + override fun onTerminate() { + super.onTerminate() + unregisterActivityLifecycleCallbacks(currentActivity).also { currentActivity = null } + } + + companion object { + @SuppressLint("StaticFieldLeak") // Should be safe given its manual memory management + internal var currentActivity: CurrentActivity? = null + } + + inner class CurrentActivity : ActivityLifecycleCallbacks { + var value: Activity? = null + private set + + override fun onActivityCreated(activity: Activity, bundle: Bundle?) { + this.value = activity + } + + override fun onActivityStarted(activity: Activity) { + this.value = activity + } + + override fun onActivityResumed(activity: Activity) { + this.value = activity + } + + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + } +} + +/** + * Returns the current activity of the application. + * + * **NEVER** store the result to a variable, further calls to such reference can lead to memory leaks. + * + * **ALWAYS** retrieve the current activity functionally, processing on the result of this function. + * */ +internal inline fun currentActivity(): T { + when (val activity = App.currentActivity?.value) { + is T -> return activity + else -> throw IllegalArgumentException("Current Activity is not '${typeOf()}'") + } +} diff --git a/app/src/main/java/to/bitkit/Constants.kt b/app/src/main/java/to/bitkit/Constants.kt new file mode 100644 index 000000000..626315b9a --- /dev/null +++ b/app/src/main/java/to/bitkit/Constants.kt @@ -0,0 +1,10 @@ +package to.bitkit + +import org.bitcoindevkit.Network + +internal const val _DEV = "_DEV" +internal const val _FCM = "_FCM" +internal const val _LDK = "_LDK" +internal const val _BDK = "_BDK" + +internal val NETWORK = Network.REGTEST diff --git a/app/src/main/java/to/bitkit/LauncherActivity.kt b/app/src/main/java/to/bitkit/LauncherActivity.kt new file mode 100644 index 000000000..f59e415e9 --- /dev/null +++ b/app/src/main/java/to/bitkit/LauncherActivity.kt @@ -0,0 +1,79 @@ +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.ui.MainActivity +import java.io.File + +class LauncherActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + warmupNode(filesDir.absolutePath) + 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 new file mode 100644 index 000000000..861b8727d --- /dev/null +++ b/app/src/main/java/to/bitkit/bdk/Bdk.kt @@ -0,0 +1,219 @@ +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.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(NETWORK, Mnemonic.fromString(mnemonic), null) + + wallet = Wallet( + Descriptor.newBip84(key, KeychainKind.INTERNAL, NETWORK), + Descriptor.newBip84(key, KeychainKind.EXTERNAL, NETWORK), + 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 = 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) + mnemonicFile.writeText(mnemonic.asString()) + mnemonic.asString() + } + } +} + +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/data/Models.kt b/app/src/main/java/to/bitkit/data/Models.kt new file mode 100644 index 000000000..c52d64dac --- /dev/null +++ b/app/src/main/java/to/bitkit/data/Models.kt @@ -0,0 +1,47 @@ +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, + val status: TxStatus, +) + +@Serializable +data class TxStatus( + @SerialName("confirmed") + val isConfirmed: Boolean, + @SerialName("block_height") + val blockHeight: Int? = null, + @SerialName("block_hash") + val blockHash: String? = null, +) + +class ConfirmedTx( + val tx: ByteArray, + val blockHeight: Int, + val blockHeader: String, + val merkleProofPos: Int, +) + +@Serializable +data class OutputSpent( + val spent: Boolean, +) + +@Serializable +data class MerkleProof( + @SerialName("block_height") + val blockHeight: Int, + @Suppress("ArrayInDataClass") + val merkle: Array, + val pos: Int, +) \ 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 new file mode 100644 index 000000000..aa4d19b62 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/RestApi.kt @@ -0,0 +1,129 @@ +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.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 { + suspend fun getLatestBlockHash(): String + suspend fun getLatestBlockHeight(): Int + suspend fun broadcastTx(tx: ByteArray): String + suspend fun getTx(txid: String): Tx + suspend fun getTxHex(txid: String): String + suspend fun getHeader(hash: String): String + suspend fun getMerkleProof(txid: String): MerkleProof + suspend fun getOutputSpent(txid: String, outputIndex: Int): OutputSpent + suspend fun connectPeer( + pubKeyHex: String = PEER, + hostname: String = HOST, + port: Int = PORT.toInt(), + ): Boolean + + suspend fun disconnectPeer(pubKeyHex: String): Boolean +} + +class EsploraApi @Inject constructor( + private val client: HttpClient, +) : RestApi { + override suspend fun getLatestBlockHash(): String { + val httpResponse: HttpResponse = client.get("$REST/blocks/tip/hash") + return httpResponse.body() + } + + override suspend fun getLatestBlockHeight(): Int { + val httpResponse: HttpResponse = client.get("$REST/blocks/tip/height") + return httpResponse.body().toInt() + } + + override suspend fun broadcastTx(tx: ByteArray): String { + val response: HttpResponse = client.post("$REST/tx") { + setBody(tx.toHex()) + } + + return response.body() + } + + override suspend fun getTx(txid: String): Tx { + return client.get("$REST/tx/${txid}").body() + } + + override suspend fun getTxHex(txid: String): String { + return client.get("$REST/tx/${txid}/hex").body() + } + + override suspend fun getHeader(hash: String): String { + return client.get("$REST/block/${hash}/header").body() + } + + override suspend fun getMerkleProof(txid: String): MerkleProof { + return client.get("$REST/tx/${txid}/merkle-proof").body() + } + + override suspend fun getOutputSpent(txid: String, outputIndex: Int): OutputSpent { + return client.get("$REST/tx/${txid}/outspend/${outputIndex}").body() + } + + 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 new file mode 100644 index 000000000..8a46c8cde --- /dev/null +++ b/app/src/main/java/to/bitkit/data/Syncer.kt @@ -0,0 +1,169 @@ +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/DispatchersModule.kt b/app/src/main/java/to/bitkit/di/DispatchersModule.kt new file mode 100644 index 000000000..f64f8e874 --- /dev/null +++ b/app/src/main/java/to/bitkit/di/DispatchersModule.kt @@ -0,0 +1,44 @@ +package to.bitkit.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class UiDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class BgDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +@Module +@InstallIn(SingletonComponent::class) +object DispatchersModule { + + @UiDispatcher + @Provides + fun provideUiDispatcher(): CoroutineDispatcher { + return Dispatchers.Main + } + + @BgDispatcher + @Provides + fun provideBgDispatcher(): CoroutineDispatcher { + return Dispatchers.Default + } + + @IoDispatcher + @Provides + fun provideIoDispatcher(): CoroutineDispatcher { + return Dispatchers.IO + } +} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/di/HttpModule.kt b/app/src/main/java/to/bitkit/di/HttpModule.kt new file mode 100644 index 000000000..8c5ae6c5e --- /dev/null +++ b/app/src/main/java/to/bitkit/di/HttpModule.kt @@ -0,0 +1,51 @@ +package to.bitkit.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.DEFAULT +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import to.bitkit.data.EsploraApi +import to.bitkit.data.RestApi +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object HttpModule { + @Provides + @Singleton + fun provideJson(): Json { + return Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + } + } + + @Provides + @Singleton + fun provideHttpClient(json: Json): HttpClient { + return HttpClient { + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.BODY + } + install(ContentNegotiation) { + json(json = json) + } + } + } + + @Provides + @Singleton + fun provideRestApi(esploraApi: EsploraApi): RestApi { + return esploraApi + } +} \ 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/LdkModule.kt new file mode 100644 index 000000000..9cd6aa229 --- /dev/null +++ b/app/src/main/java/to/bitkit/di/LdkModule.kt @@ -0,0 +1,16 @@ +package to.bitkit.di + +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 + +@Suppress("unused") +@Module +@InstallIn(SingletonComponent::class) +abstract class LdkModule { + @Binds + abstract fun bindSyncer(ldkSyncer: LdkSyncer): Syncer +} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/ext/ByteArray.kt b/app/src/main/java/to/bitkit/ext/ByteArray.kt new file mode 100644 index 000000000..ed401e5a0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/ByteArray.kt @@ -0,0 +1,21 @@ +package to.bitkit.ext + +import com.google.common.io.BaseEncoding +import java.io.ByteArrayOutputStream +import java.io.ObjectOutputStream + +fun ByteArray.toHex(): String { + return BaseEncoding.base16().encode(this).lowercase() +} + +fun String.toByteArray(): ByteArray { + return BaseEncoding.base16().decode(this.uppercase()) +} + +fun convertToByteArray(obj: Any): ByteArray { + val bos = ByteArrayOutputStream() + val oos = ObjectOutputStream(bos) + oos.writeObject(obj) + oos.flush() + return bos.toByteArray() +} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt new file mode 100644 index 000000000..56d88b22b --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/Context.kt @@ -0,0 +1,35 @@ +package to.bitkit.ext + +import android.app.NotificationManager +import android.content.Context +import android.content.Context.NOTIFICATION_SERVICE +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.widget.Toast +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import to.bitkit.currentActivity +import to.bitkit.ui.MainActivity + +// System Services + +val Context.notificationManager: NotificationManager + get() = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + +val Context.notificationManagerCompat: NotificationManagerCompat + get() = NotificationManagerCompat.from(this) + +// Permissions + +fun Context.requiresPermission(permission: String): Boolean = + ContextCompat.checkSelfPermission(this, permission) != PERMISSION_GRANTED + +// In-App Notifications + +internal fun toast( + text: String, + duration: Int = Toast.LENGTH_SHORT, +) { + with(currentActivity()) { + Toast.makeText(this, text, duration).show() + } +} diff --git a/app/src/main/java/to/bitkit/fcm/MessagingService.kt b/app/src/main/java/to/bitkit/fcm/MessagingService.kt new file mode 100644 index 000000000..0db98ddbc --- /dev/null +++ b/app/src/main/java/to/bitkit/fcm/MessagingService.kt @@ -0,0 +1,72 @@ +package to.bitkit.fcm + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import to.bitkit._FCM +import to.bitkit.ui.payInvoice +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() + } else { + handleNow(message.data) + } + } + Log.d(_FCM, "--- end FCM ---") + } + + /** + * TODO 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") + } + + /** + * TODO Schedule async work using WorkManager for long-running tasks (10 seconds or more) + */ + private fun scheduleJob() { + TODO("Not yet implemented: scheduleJob") + } + + private fun RemoteMessage.needsScheduling(): Boolean { + // return notification == null && data.isNotEmpty() + return false + } + + override fun onNewToken(token: String) { + this.token = token + Log.d(_FCM, "onNewToken: $token") + } +} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/ldk/Ldk.kt b/app/src/main/java/to/bitkit/ldk/Ldk.kt new file mode 100644 index 000000000..10cbfe4d5 --- /dev/null +++ b/app/src/main/java/to/bitkit/ldk/Ldk.kt @@ -0,0 +1,240 @@ +package to.bitkit.ldk + +import android.util.Log +import org.ldk.batteries.ChannelManagerConstructor +import org.ldk.batteries.NioPeerHandler +import org.ldk.enums.Network +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 +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 = 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 { + if (serializedChannelManager?.isNotEmpty() == true) { + // Restore from disk + val constructor = ChannelManagerConstructor( + serializedChannelManager, + serializedChannelMonitors, + userConfig, + keysManager.inner.as_EntropySource(), + keysManager.inner.as_NodeSigner(), + keysManager.inner.as_SignerProvider(), + feeEstimator, + chainMonitor, + filter, + networkGraph.write(), + ProbabilisticScoringDecayParameters.with_default(), + ProbabilisticScoringFeeParameters.with_default(), + scorer.write(), + null, + txBroadcaster, + logger, + ) + + channelManagerConstructor = constructor + channelManager = constructor.channel_manager + nioPeerHandler = constructor.nio_peer_handler + peerManager = constructor.peer_manager + networkGraph = constructor.net_graph + + constructor.chain_sync_completed( + LdkEventHandler, + true + ) + + constructor.nio_peer_handler.bind_listener( + InetSocketAddress( + "127.0.0.1", + 9777 + ) + ) + + } else { + // Start from scratch + val constructor = ChannelManagerConstructor( + Network.LDKNetwork_Regtest, + userConfig, + latestBlockHash.toByteArray(), + latestBlockHeight, + keysManager.inner.as_EntropySource(), + keysManager.inner.as_NodeSigner(), + keysManager.inner.as_SignerProvider(), + feeEstimator, + chainMonitor, + networkGraph, + ProbabilisticScoringDecayParameters.with_default(), + ProbabilisticScoringFeeParameters.with_default(), + 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) + constructor.nio_peer_handler.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 startTimeSecs = System.currentTimeMillis() / 1000 + val startTimeNano = (System.currentTimeMillis() * 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()) { + Log.d(_LDK, "Network graph found and loaded from disk.") + (NetworkGraph.read( + graphFile.readBytes(), logger, + ) as? Result_NetworkGraphDecodeErrorZ.Result_NetworkGraphDecodeErrorZ_OK)?.let { res -> + Ldk.networkGraph = res.res + } + } else { + Log.d(_LDK, "Network graph not found on disk, syncing from scratch.") + Ldk.networkGraph = NetworkGraph.of(Network.LDKNetwork_Regtest, logger) + } +} + +private fun initProbabilisticScorer(logger: Logger) { + val scorerFile = File("$ldkDir/scorer.bin") + if (scorerFile.exists()) { + val scorerReaderResult = ProbabilisticScorer.read( + scorerFile.readBytes(), ProbabilisticScoringDecayParameters.with_default(), + Ldk.networkGraph, logger + ) + if (scorerReaderResult.is_ok) { + val probabilisticScorer = + (scorerReaderResult as Result_ProbabilisticScorerDecodeErrorZ.Result_ProbabilisticScorerDecodeErrorZ_OK).res + Ldk.scorer = MultiThreadedLockableScore.of(probabilisticScorer.as_Score()) + Log.d(_LDK, "Probabilistic Scorer found and loaded from on disk.") + } else { + Log.d(_LDK, "Error loading Probabilistic Scorer.") + val decayParams = ProbabilisticScoringDecayParameters.with_default() + val probabilisticScorer = ProbabilisticScorer.of( + decayParams, + Ldk.networkGraph, logger + ) + Ldk.scorer = MultiThreadedLockableScore.of(probabilisticScorer.as_Score()) + Log.d(_LDK, "Probabilistic Scorer not found on disk, started from scratch.") + } + } else { + val decayParams = ProbabilisticScoringDecayParameters.with_default() + val probabilisticScorer = ProbabilisticScorer.of(decayParams, Ldk.networkGraph, logger) + Ldk.scorer = MultiThreadedLockableScore.of(probabilisticScorer.as_Score()) + } +} diff --git a/app/src/main/java/to/bitkit/ldk/LdkBroadcaster.kt b/app/src/main/java/to/bitkit/ldk/LdkBroadcaster.kt new file mode 100644 index 000000000..d9104535a --- /dev/null +++ b/app/src/main/java/to/bitkit/ldk/LdkBroadcaster.kt @@ -0,0 +1,28 @@ +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 new file mode 100644 index 000000000..227cc57dc --- /dev/null +++ b/app/src/main/java/to/bitkit/ldk/LdkEventHandler.kt @@ -0,0 +1,220 @@ +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") + 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) { + if (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}") + } + + } + } + + if (event 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") + } + } + } + + if (event 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() + } + + if (event 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") + } + + if (event 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") + } + + if (event is Event.PaymentSent) { + Log.d(_LDK, "event: PaymentSent") + } + + if (event is Event.PaymentFailed) { + Log.d(_LDK, "event: PaymentFailed") + } + + if (event is Event.PaymentPathFailed) { + Log.d(_LDK, "event: PaymentPathFailed${event.failure}") + } + + if (event is Event.PendingHTLCsForwardable) { + Log.d(_LDK, "event: PendingHTLCsForwardable") + channelManager.process_pending_htlc_forwards() + } + + if (event 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}") + } + + } + + if (event is Event.PaymentClaimable) { + Log.d(_LDK, "event: PaymentClaimable") + if (event.payment_hash != null) { + channelManager.claim_funds(event.payment_hash) + } + } + + if (event 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 new file mode 100644 index 000000000..bcc5f5d01 --- /dev/null +++ b/app/src/main/java/to/bitkit/ldk/LdkFeeEstimator.kt @@ -0,0 +1,27 @@ +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 new file mode 100644 index 000000000..abd4ad455 --- /dev/null +++ b/app/src/main/java/to/bitkit/ldk/LdkFilter.kt @@ -0,0 +1,53 @@ +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 new file mode 100644 index 000000000..880c4e3a9 --- /dev/null +++ b/app/src/main/java/to/bitkit/ldk/LdkKeysManager.kt @@ -0,0 +1,95 @@ +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 new file mode 100644 index 000000000..12b423d93 --- /dev/null +++ b/app/src/main/java/to/bitkit/ldk/LdkLogger.kt @@ -0,0 +1,23 @@ +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 new file mode 100644 index 000000000..e4dbf2e9e --- /dev/null +++ b/app/src/main/java/to/bitkit/ldk/LdkPersister.kt @@ -0,0 +1,89 @@ +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/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt new file mode 100644 index 000000000..5ccd3bb41 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -0,0 +1,210 @@ +package to.bitkit.ui + +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +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 +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import dagger.hilt.android.AndroidEntryPoint +import to.bitkit.R +import to.bitkit._FCM +import to.bitkit.ext.requiresPermission +import to.bitkit.ext.toast +import to.bitkit.ui.theme.AppThemeSurface + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initNotificationChannel() + + // Logs FCM registration token + fun logFcmToken() { + FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> + if (!task.isSuccessful) { + Log.w(_FCM, "FCM registration token failed", task.exception) + return@OnCompleteListener + } + val token = task.result + Log.d(_FCM, "FCM registration token: $token") + }) + } + logFcmToken() + + setContent { + 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") + } + } + } + } + } + } + } +} + +private val notificationPermission + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + android.Manifest.permission.POST_NOTIFICATIONS + } else { + TODO("Cant request 'POST_NOTIFICATIONS' permissions on SDK < 33") + } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainScreen( + viewModel: MainViewModel = hiltViewModel(), + startContent: @Composable () -> Unit = {}, +) { + Surface { + val navController = rememberNavController() + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val isBackButtonVisible by remember(currentBackStackEntry) { + derivedStateOf { + navController.previousBackStackEntry?.destination?.route == Routes.Settings.destination + } + } + Scaffold( + topBar = { + CenterAlignedTopAppBar( + navigationIcon = { + if (isBackButtonVisible) { + IconButton(onClick = navController::popBackStack) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + modifier = Modifier.size(24.dp) + ) + } + } + }, + title = { + Text(stringResource(R.string.app_name)) + }, + actions = { + IconButton(viewModel::sync) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.sync), + modifier = Modifier, + ) + } + } + ) + }, + bottomBar = { + NavigationBar(tonalElevation = 5.dp) { + var selected by remember { mutableIntStateOf(0) } + navItems.forEachIndexed { i, it -> + NavigationBarItem( + icon = { + val icon = if (selected != i) it.icon.first else it.icon.second + Icon( + imageVector = icon, + contentDescription = stringResource(it.title), + ) + }, + label = { Text(stringResource(it.title)) }, + selected = selected == i, + onClick = { + selected = i + navController.navigate(it.route.destination) { + navController.graph.startDestinationRoute?.let { popUpTo(it) } + launchSingleTop = true + } + }, + ) + } + } + }, + ) { padding -> + Box(Modifier.padding(padding)) { + AppNavHost( + navController = navController, + viewModel = viewModel, + startDestination = Routes.Wallet.destination, + startContent = startContent, + ) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/MainViewModel.kt b/app/src/main/java/to/bitkit/ui/MainViewModel.kt new file mode 100644 index 000000000..d19fe76c0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/MainViewModel.kt @@ -0,0 +1,220 @@ +package to.bitkit.ui + +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import 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 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, +) : ViewModel() { + val btcAddress = mutableStateOf(btcAddress()) + val ldkNodeId = mutableStateOf(ldkNodeId()) + val ldkBalance = mutableStateOf(ldkLocalBalance()) + val btcBalance = mutableStateOf("Loading…") + val channels = mutableStateListOf(*ldkUsableChannels()) + val peers = mutableStateListOf() + val mnemonic = mutableStateOf(mnemonicPhrase()) + + init { + sync() + } + + fun sync() { + btcBalance.value = "Syncing…" + viewModelScope.launch(ioDispatcher) { + delay(500) + syncer.sync() + delay(1500) + syncPeers() + syncChannels() + syncBalance() + } + } + + private fun syncBalance() { + btcBalance.value = btcBalance() + ldkBalance.value = ldkLocalBalance() + } + + fun getNewAddress() { + btcAddress.value = 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() + } + } + } + + private fun disconnectPeer() = with(viewModelScope) { + launch(ioDispatcher) { + val didDisconnect = restApi.disconnectPeer(PEER) + if (didDisconnect) { + delay(250) + syncPeers() + } + } + } + + fun togglePeerConnection() { + if (peers.contains(PEER)) { + disconnectPeer() + } else { + connectPeer() + } + syncChannels() + } + + private fun syncPeers() { + peers.clear() + peers.addAll(getPeers()) + syncChannels() + } + + private fun getPeers(): List { + val peerManager = Ldk.channelManagerConstructor.peer_manager + return peerManager?._peer_node_ids?.map { it._a.toHex() }.orEmpty() + } + + 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() + } +} + +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) { + 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") + } + + 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") + } + + val sendingError = error?.err as? PaymentError.Sending + if (sendingError != null) { + when (val failure = sendingError.sending) { + RetryableSendFailure.LDKRetryableSendFailure_DuplicatePayment -> { + Log.d(_LDK, "Payment failed: DuplicatePayment") + } + + RetryableSendFailure.LDKRetryableSendFailure_PaymentExpired -> { + Log.d(_LDK, "Payment failed: PaymentExpired") + } + + RetryableSendFailure.LDKRetryableSendFailure_RouteNotFound -> { + Log.d(_LDK, "Payment failed: RouteNotFound") + } + + else -> { + Log.d(_LDK, "Payment failed with unknown error: $failure") + } + } + } +} + +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 diff --git a/app/src/main/java/to/bitkit/ui/Nav.kt b/app/src/main/java/to/bitkit/ui/Nav.kt new file mode 100644 index 000000000..8117b5bde --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/Nav.kt @@ -0,0 +1,81 @@ +package to.bitkit.ui + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Home +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.graphics.vector.ImageVector +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import to.bitkit.R +import to.bitkit.ui.settings.ChannelsScreen +import to.bitkit.ui.settings.PaymentsScreen +import to.bitkit.ui.settings.PeersScreen + +@Composable +fun AppNavHost( + navController: NavHostController, + viewModel: MainViewModel = hiltViewModel(), + startDestination: String = "", + startContent: @Composable () -> Unit = {}, +) { + // val screenViewModel = viewModel() + with(Routes) { + NavHost( + navController = navController, + startDestination = startDestination, + ) { + composable(Wallet.destination) { startContent() } + composable(Settings.destination) { SettingsScreen(navController, viewModel) } + composable(Peers.destination) { PeersScreen(viewModel) } + composable(Channels.destination) { ChannelsScreen(viewModel) } + composable(Payments.destination) { PaymentsScreen(viewModel) } + } + } +} + +@Stable +@Immutable +@JvmInline +value class Route(val destination: String) + +@Stable +object Routes { + val Wallet = Route("wallet") + val Settings = Route("settings") + val Peers = Route("peers") + val Channels = Route("channels") + val Payments = Route("payments") +} + +@Stable +sealed class NavItem( + @StringRes val title: Int, + val icon: Pair, + val route: Route, +) { + data object Wallet : NavItem( + title = R.string.home, + icon = Icons.Outlined.Home to Icons.Filled.Home, + route = Routes.Wallet, + ) + + data object Settings : NavItem( + title = R.string.settings, + icon = Icons.Outlined.Settings to Icons.Filled.Settings, + route = Routes.Settings, + ) +} + +@Stable +val navItems = listOf( + NavItem.Wallet, + NavItem.Settings, +) diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt new file mode 100644 index 000000000..b5f4dcfab --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -0,0 +1,79 @@ +package to.bitkit.ui + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import to.bitkit.R +import to.bitkit.currentActivity +import to.bitkit.ext.notificationManager +import to.bitkit.ext.notificationManagerCompat +import to.bitkit.ext.requiresPermission +import kotlin.random.Random + +val Context.CHANNEL_MAIN get() = getString(R.string.app_notifications_channel_id) + +fun Context.initNotificationChannel( + id: String = CHANNEL_MAIN, + name: String = getString(R.string.app_notifications_channel_name), + desc: String = getString(R.string.app_notifications_channel_desc), + importance: Int = NotificationManager.IMPORTANCE_DEFAULT, +) { + val channel = NotificationChannel(id, name, importance).apply { description = desc } + notificationManager.createNotificationChannel(channel) +} + +internal fun Context.notificationBuilder( + channelId: String = CHANNEL_MAIN, +): NotificationCompat.Builder { + val activityIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent = PendingIntent.getActivity(this, 0, activityIntent, FLAG_IMMUTABLE) + + return NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.ic_notification) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) // fired on tap + .setAutoCancel(true) // remove on tap +} + +internal fun pushNotification( + title: String, + text: String, + bigText: String, +): Int { + val id = Random.nextInt() + with(currentActivity()) { + pushNotification( + title = title, + text = text, + bigText = bigText, + id = id, + ) + } + return id +} + +@SuppressLint("MissingPermission") // Handled by custom guard +internal fun Activity.pushNotification( + title: String, + text: String, + bigText: String, + id: Int, +) { + if (requiresPermission(Manifest.permission.POST_NOTIFICATIONS)) return + + val builder = notificationBuilder() + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) + + notificationManagerCompat.notify(id, builder.build()) +} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/ui/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/SettingsScreen.kt new file mode 100644 index 000000000..4bc6f4357 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/SettingsScreen.kt @@ -0,0 +1,110 @@ +package to.bitkit.ui + +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 +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import to.bitkit.R + +@Composable +fun SettingsScreen( + navController: NavController, + viewModel: MainViewModel, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = "Settings", + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.size(16.dp)) + Text( + text = "Lightning Node", + style = MaterialTheme.typography.titleMedium, + ) + SettingButton( + label = "Peers", + onClick = { navController.navigate(Routes.Peers.destination) } + ) + SettingButton( + label = "Channels", + onClick = { navController.navigate(Routes.Channels.destination) } + ) + SettingButton( + label = "Payments", + onClick = { navController.navigate(Routes.Payments.destination) } + ) + + Spacer(modifier = Modifier.size(16.dp)) + Text( + text = "Bitcoin Wallet", + style = MaterialTheme.typography.titleMedium, + ) + Mnemonic(viewModel.mnemonic.value) + } +} + +@Composable +private fun SettingButton(label: String, onClick: () -> Unit) { + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + shape = MaterialTheme.shapes.small, + contentPadding = PaddingValues(16.dp, 8.dp), + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .padding(top = 4.dp) + ) { + Text(text = label) + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowForwardIos, + contentDescription = null, + modifier = Modifier.size(12.dp), + ) + } +} + +@Composable +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) }, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/WalletScreen.kt b/app/src/main/java/to/bitkit/ui/WalletScreen.kt new file mode 100644 index 000000000..e9d1fcadd --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/WalletScreen.kt @@ -0,0 +1,127 @@ +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.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 + +@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), + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.lightning), + style = MaterialTheme.typography.titleLarge, + ) + 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 address by remember { viewModel.btcAddress } + InfoField( + value = address, + label = stringResource(R.string.address), + trailingIcon = { + Row { + IconButton(onClick = viewModel::getNewAddress) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add), + modifier = Modifier.size(16.dp), + ) + } + CopyToClipboardButton(address) + } + }, + ) + 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 + IconButton(onClick = { clipboardManager.setText(AnnotatedString((text))) }) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + 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 new file mode 100644 index 000000000..5556580a1 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/ChannelsScreen.kt @@ -0,0 +1,192 @@ +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 + +@Composable +fun ChannelsScreen( + viewModel: MainViewModel, +) { + Spacer(modifier = Modifier.size(48.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = { + Channel.open(PEER) + viewModel.sync() + }, + 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)) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/MnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/MnemonicScreen.kt new file mode 100644 index 000000000..afa77838d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/MnemonicScreen.kt @@ -0,0 +1,2 @@ +package to.bitkit.ui.settings + diff --git a/app/src/main/java/to/bitkit/ui/settings/PaymentsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/PaymentsScreen.kt new file mode 100644 index 000000000..6ba04b50e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/PaymentsScreen.kt @@ -0,0 +1,56 @@ +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 +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.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 + +@Composable +fun PaymentsScreen( + viewModel: MainViewModel, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + ) { + 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)) + } + + val invoiceToSend by remember { mutableStateOf(viewModel.createInvoice()) } + InfoField( + label = "Send invoice", + value = invoiceToSend, + trailingIcon = { CopyToClipboardButton(invoiceToSend) }, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/PeersScreen.kt b/app/src/main/java/to/bitkit/ui/settings/PeersScreen.kt new file mode 100644 index 000000000..9edb93478 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/PeersScreen.kt @@ -0,0 +1,103 @@ +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 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.R +import to.bitkit.ui.MainViewModel +import to.bitkit.ui.PEER +import to.bitkit.ui.PORT + +@Composable +fun PeersScreen( + viewModel: MainViewModel, +) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + ) { + var pubKey by remember { mutableStateOf(PEER) } + var port by remember { mutableStateOf(PORT) } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Connect to a Peer", + style = MaterialTheme.typography.titleMedium, + ) + OutlinedTextField( + label = { Text("Pubkey") }, + value = pubKey, + onValueChange = { pubKey = it }, + textStyle = MaterialTheme.typography.labelSmall, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + label = { Text("Port") }, + value = port, + onValueChange = { port = it }, + textStyle = MaterialTheme.typography.labelSmall, + modifier = Modifier.fillMaxWidth(), + ) + Button(onClick = { viewModel.connectPeer(pubKey, 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, + ) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/theme/Color.kt b/app/src/main/java/to/bitkit/ui/theme/Color.kt new file mode 100644 index 000000000..46974ceb3 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/theme/Color.kt @@ -0,0 +1,8 @@ +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 new file mode 100644 index 000000000..7c38be5e6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/theme/Shape.kt @@ -0,0 +1,11 @@ +package to.bitkit.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +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 diff --git a/app/src/main/java/to/bitkit/ui/theme/Theme.kt b/app/src/main/java/to/bitkit/ui/theme/Theme.kt new file mode 100644 index 000000000..67d04164c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/theme/Theme.kt @@ -0,0 +1,56 @@ +package to.bitkit.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color + +private object ColorPalette { + @Stable + val Light = lightColorScheme( + primary = Purple500, + secondary = Teal200, + background = Color.White, + /* // Other default colors to override + surface = Color.White, + onPrimary = Color.White, + onSecondary = Color.Black, + onBackground = Color.Black, + onSurface = Color.Black, + */ + ) + + @Stable + val Dark = darkColorScheme( + primary = Purple200, + secondary = Teal200, + ) +} + +@Composable +internal fun AppThemeSurface( + content: @Composable () -> Unit, +) { + AppTheme { + Surface(content = content) + } +} + +@Composable +internal fun AppTheme( + inDarkTheme: Boolean = isSystemInDarkTheme(), + colorScheme: ColorScheme = if (inDarkTheme) ColorPalette.Dark else ColorPalette.Light, + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + 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 new file mode 100644 index 000000000..3c294fa9b --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/theme/Type.kt @@ -0,0 +1,28 @@ +package to.bitkit.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +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( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ) + /* Other default text styles to override + button = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + fontSize = 14.sp + ), + caption = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..8c6292124 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..4189ab9ba --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 000000000..dda18eefc --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..eca70cfe5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..eca70cfe5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..c209e78ec Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b2dfe3d1b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..4f0f1d64e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..62b611da0 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..948a3070f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b9a6956b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..28d4b77f9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9287f5083 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa7d6427e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9126ae37c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 000000000..a3af6d433 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +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 new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..ddbddcb8c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,23 @@ + + Add + Address + Bitkit + Main Notification Channel for generic notifications. + channel_app + App Notifications + Back + Balance + Close + Connect + Home + Invoice + Lightning + Mnemonic + Node Id + Pay + %1$s sat + Settings + Status + Stop + Sync + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..30b78396e --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,25 @@ + + + + + + +