Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@

This repository contains a **new native Android app** which is **not ready for production**.

## Prerequisites

1. Download `google-services.json` to `./app` from FCM Console

## Development

**Prerequisites**
1. Download `google-services.json` to `app/` from FCM Console.

### References

- For LNURL dev testing see [bitkit-docker](https://github.com/ovitrif/bitkit-docker)
Expand All @@ -28,8 +27,7 @@ Recommended Android Studio plugins:
- EditorConfig
- Detekt

#### Commands

**Commands**
```sh
./gradlew detekt # run analysis + formatting check
./gradlew detekt --auto-correct # auto-fix formatting issues
Expand All @@ -49,11 +47,12 @@ The build config supports building 3 different apps for the 3 bitcoin networks (

### Build for Release

**Prerequisite**: setup the signing config:
- Add the keystore file to root dir (i.e. `./release.keystore`)
**Prerequisites**
Setup the signing config:
- Add the keystore file to root dir (i.e. `release.keystore`)
- Setup `keystore.properties` file in root dir (`cp keystore.properties.template keystore.properties`)

#### Routine:
**Routine**

Increment `versionCode` and `versionName` in `app/build.gradle.kts`, then run:
```sh
Expand All @@ -63,3 +62,8 @@ Increment `versionCode` and `versionName` in `app/build.gradle.kts`, then run:

APK is generated in `app/build/outputs/apk/_flavor_/release`. (`_flavor_` can be any of 'dev', 'mainnet', 'tnet').
Example for dev: `app/build/outputs/apk/dev/release`

## License

This project is licensed under the MIT License.
See the [LICENSE](./LICENSE) file for more details.
25 changes: 22 additions & 3 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import com.synonym.bitkitcore.LightningInvoice
import com.synonym.bitkitcore.Scanner
import com.synonym.bitkitcore.createWithdrawCallbackUrl
import com.synonym.bitkitcore.decode
import com.synonym.bitkitcore.getLnurlInvoice
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -41,6 +40,8 @@ import to.bitkit.services.CoreService
import to.bitkit.services.LdkNodeEventBus
import to.bitkit.services.LightningService
import to.bitkit.services.LnUrlWithdrawResponse
import to.bitkit.services.LnurlChannelInfoResponse
import to.bitkit.services.LnurlChannelResponse
import to.bitkit.services.LnurlService
import to.bitkit.services.NodeEventHandler
import to.bitkit.utils.Logger
Expand Down Expand Up @@ -378,6 +379,12 @@ class LightningRepo @Inject constructor(
Result.success(Unit)
}

suspend fun connectPeer(peer: LnPeer): Result<Unit> = executeWhenNodeRunning("connectPeer") {
lightningService.connectPeer(peer)
syncState()
Result.success(Unit)
}

suspend fun disconnectPeer(peer: LnPeer): Result<Unit> = executeWhenNodeRunning("Disconnect peer") {
lightningService.disconnectPeer(peer)
syncState()
Expand Down Expand Up @@ -410,15 +417,15 @@ class LightningRepo @Inject constructor(
): Result<LightningInvoice> {
return runCatching {
// TODO use bitkit-core getLnurlInvoice if it works with callbackUrl
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).pr
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).getOrThrow().pr
val decoded = (decode(bolt11) as Scanner.Lightning).invoice
return@runCatching decoded
}.onFailure {
Logger.error("Error fetching lnurl invoice, url: $callbackUrl, amount: $amountSats, comment: $comment", it)
}
}

suspend fun handleLnUrlWithdraw(
suspend fun handleLnurlWithdraw(
k1: String,
callback: String,
paymentRequest: String,
Expand All @@ -428,6 +435,18 @@ class LightningRepo @Inject constructor(
lnurlService.fetchWithdrawInfo(callbackUrl)
}

suspend fun fetchLnurlChannelInfo(url: String): Result<LnurlChannelInfoResponse> =
lnurlService.fetchLnurlChannelInfo(url)

suspend fun handleLnurlChannel(
k1: String,
callback: String,
nodeId: String,
): Result<LnurlChannelResponse> = executeWhenNodeRunning("handleLnurlChannel") {
// TODO use bitkit-core createChannelRequestUrl after it is fixed to prevent k1 duplicating
lnurlService.handleLnurlChannel(k1 = k1, callback = callback, nodeId = nodeId)
}

suspend fun payInvoice(bolt11: String, sats: ULong? = null): Result<PaymentId> =
executeWhenNodeRunning("Pay invoice") {
val paymentId = lightningService.send(bolt11 = bolt11, sats = sats)
Expand Down
77 changes: 72 additions & 5 deletions app/src/main/java/to/bitkit/services/LnurlService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class LnurlService @Inject constructor(
) {

suspend fun fetchWithdrawInfo(callbackUrl: String): Result<LnUrlWithdrawResponse> = runCatching {
Logger.debug("Fetching LNURL withdraw info from: $callbackUrl")

val response: HttpResponse = client.get(callbackUrl)
Logger.debug("Http call: $response")

Expand All @@ -29,6 +31,7 @@ class LnurlService @Inject constructor(
withdrawResponse.status == "ERROR" -> {
throw Exception("LNURL error: ${withdrawResponse.reason}")
}

else -> withdrawResponse
}
}.onFailure {
Expand All @@ -39,11 +42,15 @@ class LnurlService @Inject constructor(
callbackUrl: String,
amountSats: ULong,
comment: String? = null,
): LnurlPayResponse {
): Result<LnurlPayResponse> = runCatching {
Logger.debug("Fetching LNURL pay invoice info from: $callbackUrl")

val response = client.get(callbackUrl) {
url {
parameters.append("amount", "${amountSats * 1000u}") // convert to msat
comment?.takeIf { it.isNotBlank() }?.let { parameters.append("comment", it) }
parameters["amount"] = "${amountSats * 1000u}" // convert to msat
comment?.takeIf { it.isNotBlank() }?.let {
parameters["comment"] = it
}
}
}
Logger.debug("Http call: $response")
Expand All @@ -52,7 +59,53 @@ class LnurlService @Inject constructor(
throw Exception("HTTP error: ${response.status}")
}

return response.body<LnurlPayResponse>()
return@runCatching response.body<LnurlPayResponse>()
}

suspend fun fetchLnurlChannelInfo(url: String): Result<LnurlChannelInfoResponse> = runCatching {
Logger.debug("Fetching LNURL channel info from: $url")

val response: HttpResponse = client.get(url)
Logger.debug("Http call: $response")

if (!response.status.isSuccess()) {
throw Exception("HTTP error: ${response.status}")
}

return@runCatching response.body<LnurlChannelInfoResponse>()
}.onFailure {
Logger.warn(msg = "Failed to fetch channel info", e = it, context = TAG)
}

suspend fun handleLnurlChannel(
k1: String,
callback: String,
nodeId: String,
): Result<LnurlChannelResponse> = runCatching {
Logger.debug("Handling LNURL channel request to: $callback")

val response = client.get(callback) {
url {
parameters["k1"] = k1
parameters["remoteid"] = nodeId
parameters["private"] = "1" // Private channel
}
}
Logger.debug("Http call: $response")

if (!response.status.isSuccess()) throw Exception("HTTP error: ${response.status}")

val parsedResponse = response.body<LnurlChannelResponse>()

when {
parsedResponse.status == "ERROR" -> {
throw Exception("LNURL channel error: ${parsedResponse.reason}")
}

else -> parsedResponse
}
}.onFailure {
Logger.warn(msg = "Failed to handle LNURL channel", e = it, context = TAG)
}

companion object {
Expand All @@ -70,11 +123,25 @@ data class LnUrlWithdrawResponse(
val defaultDescription: String? = null,
val minWithdrawable: Long? = null,
val maxWithdrawable: Long? = null,
val balanceCheck: String? = null
val balanceCheck: String? = null,
)

@Serializable
data class LnurlPayResponse(
val pr: String,
val routes: List<String>,
)

@Serializable
data class LnurlChannelResponse(
val status: String? = null,
val reason: String? = null,
)

@Serializable
data class LnurlChannelInfoResponse(
val uri: String,
val tag: String,
val callback: String,
val k1: String,
)
49 changes: 30 additions & 19 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,10 @@ import to.bitkit.viewmodels.BlocktankViewModel
import to.bitkit.viewmodels.CurrencyViewModel
import to.bitkit.viewmodels.MainScreenEffect
import to.bitkit.viewmodels.RestoreState
import to.bitkit.viewmodels.SendEvent
import to.bitkit.viewmodels.SettingsViewModel
import to.bitkit.viewmodels.TransferViewModel
import to.bitkit.viewmodels.WalletViewModel
import to.bitkit.ui.screens.transfer.external.LnurlChannelScreen

@Composable
fun ContentView(
Expand Down Expand Up @@ -610,6 +610,14 @@ private fun RootNavHost(
onCloseClick = { navController.navigateToHome() },
)
}
composableWithDefaultTransitions<Routes.LnurlChannel> {
LnurlChannelScreen(
route = it.toRoute<Routes.LnurlChannel>(),
onConnected = { navController.navigate(Routes.ExternalSuccess) },
onBack = { navController.popBackStack() },
onClose = { navController.navigateToHome() },
)
}
composableWithDefaultTransitions<Routes.ExternalSuccess> {
ExternalSuccessScreen(
onContinue = { navController.popBackStack<Routes.TransferRoot>(inclusive = true) },
Expand Down Expand Up @@ -728,14 +736,14 @@ private fun NavGraphBuilder.shop(
}
)
}
composableWithDefaultTransitions<Routes.ShopWebView> { navBackEntry ->
composableWithDefaultTransitions<Routes.ShopWebView> {
ShopWebViewScreen (
onClose = { navController.navigateToHome() },
onBack = { navController.popBackStack() },
page = navBackEntry.toRoute<Routes.ShopWebView>().page,
title = navBackEntry.toRoute<Routes.ShopWebView>().title,
page = it.toRoute<Routes.ShopWebView>().page,
title = it.toRoute<Routes.ShopWebView>().title,
onPaymentIntent = { data ->
appViewModel.onScanSuccess(data)
appViewModel.onScanSuccess(data)
}
)
}
Expand Down Expand Up @@ -823,8 +831,8 @@ private fun NavGraphBuilder.changePinNew(navController: NavHostController) {
}

private fun NavGraphBuilder.changePinConfirm(navController: NavHostController) {
composableWithDefaultTransitions<Routes.ChangePinConfirm> { navBackEntry ->
val route = navBackEntry.toRoute<Routes.ChangePinConfirm>()
composableWithDefaultTransitions<Routes.ChangePinConfirm> {
val route = it.toRoute<Routes.ChangePinConfirm>()
ChangePinConfirmScreen(
newPin = route.newPin,
navController = navController,
Expand Down Expand Up @@ -887,9 +895,9 @@ private fun NavGraphBuilder.channelOrdersSettings(
private fun NavGraphBuilder.orderDetailSettings(
navController: NavHostController,
) {
composableWithDefaultTransitions<Routes.OrderDetail> { navBackEntry ->
composableWithDefaultTransitions<Routes.OrderDetail> {
OrderDetailScreen(
orderItem = navBackEntry.toRoute(),
orderItem = it.toRoute(),
onBackClick = { navController.popBackStack() },
)
}
Expand All @@ -898,9 +906,9 @@ private fun NavGraphBuilder.orderDetailSettings(
private fun NavGraphBuilder.cjitDetailSettings(
navController: NavHostController,
) {
composableWithDefaultTransitions<Routes.CjitDetail> { navBackEntry ->
composableWithDefaultTransitions<Routes.CjitDetail> {
CJitDetailScreen(
cjitItem = navBackEntry.toRoute(),
cjitItem = it.toRoute(),
onBackClick = { navController.popBackStack() },
)
}
Expand Down Expand Up @@ -940,19 +948,19 @@ private fun NavGraphBuilder.activityItem(
activityListViewModel: ActivityListViewModel,
navController: NavHostController,
) {
composableWithDefaultTransitions<Routes.ActivityDetail> { navBackEntry ->
composableWithDefaultTransitions<Routes.ActivityDetail> {
ActivityDetailScreen(
listViewModel = activityListViewModel,
route = navBackEntry.toRoute(),
route = it.toRoute(),
onExploreClick = { id -> navController.navigateToActivityExplore(id) },
onBackClick = { navController.popBackStack() },
onCloseClick = { navController.navigateToHome() },
)
}
composableWithDefaultTransitions<Routes.ActivityExplore> { navBackEntry ->
composableWithDefaultTransitions<Routes.ActivityExplore> {
ActivityExploreScreen(
listViewModel = activityListViewModel,
route = navBackEntry.toRoute(),
route = it.toRoute(),
onBackClick = { navController.popBackStack() },
onCloseClick = { navController.navigateToHome() },
)
Expand All @@ -979,8 +987,8 @@ private fun NavGraphBuilder.qrScanner(
private fun NavGraphBuilder.authCheck(
navController: NavHostController,
) {
composable<Routes.AuthCheck> { navBackEntry ->
val route = navBackEntry.toRoute<Routes.AuthCheck>()
composable<Routes.AuthCheck> {
val route = it.toRoute<Routes.AuthCheck>()
AuthCheckScreen(
route = route,
navController = navController,
Expand All @@ -994,8 +1002,8 @@ private fun NavGraphBuilder.logs(
composableWithDefaultTransitions<Routes.Logs> {
LogsScreen(navController)
}
composableWithDefaultTransitions<Routes.LogDetail> { navBackEntry ->
val route = navBackEntry.toRoute<Routes.LogDetail>()
composableWithDefaultTransitions<Routes.LogDetail> {
val route = it.toRoute<Routes.LogDetail>()
LogDetailScreen(
navController = navController,
fileName = route.fileName,
Expand Down Expand Up @@ -1565,6 +1573,9 @@ sealed interface Routes {
@Serializable
data object ExternalFeeCustom : Routes

@Serializable
data class LnurlChannel(val uri: String, val callback: String, val k1: String) : Routes

@Serializable
data class ActivityDetail(val id: String) : Routes

Expand Down
Loading
Loading