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 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/to/bitkit/ExampleUnitTest.kt b/app/src/test/java/to/bitkit/ExampleUnitTest.kt
new file mode 100644
index 000000000..3ee8b816e
--- /dev/null
+++ b/app/src/test/java/to/bitkit/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package to.bitkit
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 000000000..0ea7d8f6d
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,16 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ val kotlinVersion = "1.9.24"
+ val hiltVersion = "2.51.1"
+ id("com.android.application") version "8.5.1" apply false
+ id("org.jetbrains.kotlin.android") version kotlinVersion apply false
+ // id("org.jetbrains.kotlin.jvm") version kotlinVersion apply false // for room db
+ id("org.jetbrains.kotlin.plugin.serialization") version kotlinVersion apply false
+ id("com.google.gms.google-services") version "4.4.2" apply false
+
+ // https://github.com/google/ksp/releases
+ id("com.google.devtools.ksp") version "$kotlinVersion-1.0.20" apply false
+
+ // https://github.com/google/dagger/releases/
+ id("com.google.dagger.hilt.android") version hiltVersion apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 000000000..7edc7334c
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,24 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+android.nonFinalResIds=false
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..e708b1c02
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..a5e0a70bd
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Mon Mar 21 13:35:39 GMT 2022
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
new file mode 100755
index 000000000..4f906e0c8
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 000000000..107acd32c
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 000000000..dfd757ba4
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots/")
+ }
+}
+rootProject.name = "bitkit-android"
+include(":app")