Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
81a5c37
refactor: remove self variable
jvsena42 Dec 15, 2025
2a77012
refactor: remove repeated method
jvsena42 Dec 15, 2025
05b2a37
chore: add tag parameter to logs
jvsena42 Dec 15, 2025
7b1019c
chore: add tag parameter to logs
jvsena42 Dec 15, 2025
4982d50
refactor: replace coreService.blocktank.open() with blocktankRepo.ope…
jvsena42 Dec 15, 2025
c7425ee
fix: Add foreground check before stopping node in deliver()
jvsena42 Dec 15, 2025
d026b47
chore: lint
jvsena42 Dec 15, 2025
cfe17fb
Merge branch 'chore/update-deps' into fix/wake-node-polish
jvsena42 Dec 15, 2025
9ef80ca
Merge branch 'master' into fix/wake-node-polish
jvsena42 Dec 15, 2025
e68e22d
Merge branch 'master' into fix/wake-node-polish
ovitrif Dec 17, 2025
2db3e7a
Merge branch 'master' into fix/wake-node-polish
jvsena42 Dec 17, 2025
4a08297
chore: add context tag
jvsena42 Dec 17, 2025
51c9326
chore: clean log
jvsena42 Dec 17, 2025
17a57bd
fix: clean FCM token on wallet wipe
jvsena42 Dec 17, 2025
50c8b2f
fix: add empty token validation
jvsena42 Dec 17, 2025
34b10db
fix: isProduction flag
jvsena42 Dec 17, 2025
9ffee54
fix: set work as important to the user
jvsena42 Dec 17, 2025
da64e09
fix: close activities gracefully without force-stopping the app
jvsena42 Dec 17, 2025
9e20060
Merge branch 'master' into fix/wake-node-polish
ovitrif Dec 17, 2025
2ebce23
Merge branch 'master' into fix/wake-node-polish
ovitrif Dec 18, 2025
a38eb45
chore: log
jvsena42 Dec 18, 2025
2230870
Merge branch 'master' into fix/wake-node-polish
jvsena42 Dec 18, 2025
9dcf16f
fix: wait for node run before open order
jvsena42 Dec 19, 2025
fcb5564
test: update test
jvsena42 Dec 19, 2025
81c6bbd
chore: remove wait for not running, because openChannel is already ca…
jvsena42 Dec 19, 2025
b6a864d
Merge branch 'master' into fix/wake-node-polish
jvsena42 Dec 19, 2025
430a9b4
Merge remote-tracking branch 'origin/fix/wake-node-polish' into fix/w…
jvsena42 Dec 19, 2025
fd11394
chore: lint
jvsena42 Dec 19, 2025
d98f7eb
Merge branch 'master' into fix/wake-node-polish
jvsena42 Dec 19, 2025
13dc30f
fix:race condition when click in the push notification
jvsena42 Dec 19, 2025
0401694
fix: increase delay
jvsena42 Dec 19, 2025
06ebba8
chore: improve token error log
jvsena42 Dec 19, 2025
9544954
chore: add context to logs
jvsena42 Dec 19, 2025
1f35397
Merge branch 'master' into fix/wake-node-polish
ovitrif Dec 19, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ class LightningNodeService : Service() {
when (intent?.action) {
ACTION_STOP_SERVICE_AND_APP -> {
Logger.debug("ACTION_STOP_SERVICE_AND_APP detected", context = TAG)
// Close all activities
App.currentActivity?.value?.finishAndRemoveTask()
// Close activities gracefully without force-stopping the app
App.currentActivity?.value?.finishAffinity()
// Stop the service
stopSelf()
return START_NOT_STICKY
Expand Down
28 changes: 15 additions & 13 deletions app/src/main/java/to/bitkit/fcm/FcmService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package to.bitkit.fcm
import android.os.Bundle
import androidx.core.os.toPersistableBundle
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.google.firebase.messaging.FirebaseMessagingService
Expand Down Expand Up @@ -46,24 +47,24 @@ class FcmService : FirebaseMessagingService() {
* Act on received messages. [Debug](https://goo.gl/39bRNJ)
*/
override fun onMessageReceived(message: RemoteMessage) {
Logger.debug("New FCM at: ${Date(message.sentTime)}")
Logger.debug("New FCM at: ${Date(message.sentTime)}", context = TAG)

message.notification?.run {
Logger.debug("FCM title: $title")
Logger.debug("FCM body: $body")
Logger.debug("FCM title: $title", context = TAG)
Logger.debug("FCM body: $body", context = TAG)
sendNotification(title, body, Bundle(message.data.toPersistableBundle()))
}

if (message.data.isNotEmpty()) {
Logger.debug("FCM data: ${message.data}")
Logger.debug("FCM data: ${message.data}", context = TAG)

val shouldSchedule = runCatching {
val isEncryptedNotification = message.data.tryAs<EncryptedNotification> {
decryptPayload(it)
}
isEncryptedNotification
}.getOrElse {
Logger.error("Failed to read encrypted notification payload", it)
Logger.error("Failed to read encrypted notification payload", it, context = TAG)
// Let the node to spin up and handle incoming events
true
}
Expand All @@ -83,28 +84,29 @@ class FcmService : FirebaseMessagingService() {
"payload" to notificationPayload?.toString(),
)
)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(this)
.beginWith(work)
.enqueue()
}

private fun handleNow(data: Map<String, String>) {
Logger.warn("FCM handler not implemented for: $data")
Logger.warn("FCM handler not implemented for: $data", context = TAG)
}

private fun decryptPayload(response: EncryptedNotification) {
val ciphertext = runCatching { response.cipher.fromBase64() }.getOrElse {
Logger.error("Failed to decode cipher", it)
Logger.error("Failed to decode cipher", it, context = TAG)
return
}
val privateKey = runCatching { keychain.load(Keychain.Key.PUSH_NOTIFICATION_PRIVATE_KEY.name)!! }.getOrElse {
Logger.error("Missing PUSH_NOTIFICATION_PRIVATE_KEY", it)
Logger.error("Missing PUSH_NOTIFICATION_PRIVATE_KEY", it, context = TAG)
return
}
val password =
runCatching { crypto.generateSharedSecret(privateKey, response.publicKey, DERIVATION_NAME) }.getOrElse {
Logger.error("Failed to generate shared secret", it)
Logger.error("Failed to generate shared secret", it, context = TAG)
return
}

Expand All @@ -114,20 +116,20 @@ class FcmService : FirebaseMessagingService() {
)

val decoded = decrypted.decodeToString()
Logger.debug("Decrypted payload: $decoded")
Logger.debug("Decrypted payload: $decoded", context = TAG)

val (payload, type) = runCatching { json.decodeFromString<DecryptedNotification>(decoded) }.getOrElse {
Logger.error("Failed to decode decrypted data", it)
Logger.error("Failed to decode decrypted data", it, context = TAG)
return
}

if (payload == null) {
Logger.error("Missing payload")
Logger.error("Missing payload", context = TAG)
return
}

if (type == null) {
Logger.error("Missing type")
Logger.error("Missing type", context = TAG)
return
}

Expand Down
85 changes: 48 additions & 37 deletions app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import androidx.work.workDataOf
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import org.lightningdevkit.ldknode.Event
import to.bitkit.App
import to.bitkit.R
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
Expand All @@ -34,26 +36,23 @@ import to.bitkit.models.NotificationDetails
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.BlocktankRepo
import to.bitkit.repositories.LightningRepo
import to.bitkit.services.CoreService
import to.bitkit.ui.pushNotification
import to.bitkit.utils.Logger
import to.bitkit.utils.measured
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

@Suppress("LongParameterList")
@HiltWorker
class WakeNodeWorker @AssistedInject constructor(
@Assisted private val appContext: Context,
@Assisted private val workerParams: WorkerParameters,
private val coreService: CoreService,
private val lightningRepo: LightningRepo,
private val blocktankRepo: BlocktankRepo,
private val activityRepo: ActivityRepo,
private val settingsStore: SettingsStore,
private val cacheStore: CacheStore,
) : CoroutineWorker(appContext, workerParams) {
private val self = this

private var bestAttemptContent: NotificationDetails? = null

private var notificationType: BlocktankNotificationType? = null
Expand All @@ -63,15 +62,19 @@ class WakeNodeWorker @AssistedInject constructor(
private val deliverSignal = CompletableDeferred<Unit>()

override suspend fun doWork(): Result {
Logger.debug("Node wakeup from notification…")
Logger.debug("Node wakeup from notification…", context = TAG)

notificationType = workerParams.inputData.getString("type")?.let { BlocktankNotificationType.valueOf(it) }
notificationPayload = workerParams.inputData.getString("payload")?.let {
runCatching { json.parseToJsonElement(it).jsonObject }.getOrNull()
}

Logger.debug("${this::class.simpleName} notification type: $notificationType")
Logger.debug("${this::class.simpleName} notification payload: $notificationPayload")
Logger.debug("notification type: $notificationType", context = TAG)
Logger.debug("notification payload: $notificationPayload", context = TAG)

if (notificationType == null) {
Logger.warn("Notification type is null, proceeding with node wake", context = TAG)
}

try {
measured(TAG) {
Expand All @@ -80,25 +83,22 @@ class WakeNodeWorker @AssistedInject constructor(
timeout = timeout,
eventHandler = { event -> handleLdkEvent(event) }
)
lightningRepo.connectToTrustedPeers()

// Once node is started, handle the manual channel opening if needed
if (self.notificationType == orderPaymentConfirmed) {
if (notificationType == orderPaymentConfirmed) {
val orderId = (notificationPayload?.get("orderId") as? JsonPrimitive)?.contentOrNull

if (orderId == null) {
Logger.error("Missing orderId")
Logger.error("Missing orderId", context = TAG)
} else {
try {
Logger.info("Open channel request for order $orderId")
coreService.blocktank.open(orderId = orderId)
} catch (e: Exception) {
Logger.error("failed to open channel", e)
self.bestAttemptContent = NotificationDetails(
Logger.info("Open channel request for order $orderId", context = TAG)
blocktankRepo.openChannel(orderId).onFailure { e ->
Logger.error("Failed to open channel", e, context = TAG)
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_channel_open_failed_title),
body = e.message ?: appContext.getString(R.string.notification_unknown_error),
)
self.deliver()
deliver()
}
}
}
Expand All @@ -108,12 +108,12 @@ class WakeNodeWorker @AssistedInject constructor(
} catch (e: Exception) {
val reason = e.message ?: appContext.getString(R.string.notification_unknown_error)

self.bestAttemptContent = NotificationDetails(
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_lightning_error_title),
body = reason,
)
Logger.error("Lightning error", e)
self.deliver()
Logger.error("Lightning error", e, context = TAG)
deliver()

return Result.failure(workDataOf("Reason" to reason))
}
Expand All @@ -130,7 +130,7 @@ class WakeNodeWorker @AssistedInject constructor(
is Event.PaymentReceived -> onPaymentReceived(event, showDetails, hiddenBody)

is Event.ChannelPending -> {
self.bestAttemptContent = NotificationDetails(
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_channel_opened_title),
body = appContext.getString(R.string.notification_channel_pending_body),
)
Expand All @@ -141,13 +141,13 @@ class WakeNodeWorker @AssistedInject constructor(
is Event.ChannelClosed -> onChannelClosed(event)

is Event.PaymentFailed -> {
self.bestAttemptContent = NotificationDetails(
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_payment_failed_title),
body = "⚡ ${event.reason}",
)

if (self.notificationType == wakeToTimeout) {
self.deliver()
if (notificationType == wakeToTimeout) {
deliver()
}
}

Expand All @@ -156,7 +156,7 @@ class WakeNodeWorker @AssistedInject constructor(
}

private suspend fun onChannelClosed(event: Event.ChannelClosed) {
self.bestAttemptContent = when (self.notificationType) {
bestAttemptContent = when (notificationType) {
mutualClose -> NotificationDetails(
title = appContext.getString(R.string.notification_channel_closed_title),
body = appContext.getString(R.string.notification_channel_closed_mutual_body),
Expand All @@ -173,7 +173,7 @@ class WakeNodeWorker @AssistedInject constructor(
)
}

self.deliver()
deliver()
}

private suspend fun onPaymentReceived(
Expand All @@ -196,8 +196,8 @@ class WakeNodeWorker @AssistedInject constructor(
title = appContext.getString(R.string.notification_received_title),
body = content,
)
if (self.notificationType == incomingHtlc) {
self.deliver()
if (notificationType == incomingHtlc) {
deliver()
}
}

Expand All @@ -207,16 +207,16 @@ class WakeNodeWorker @AssistedInject constructor(
hiddenBody: String,
) {
val viaNewChannel = appContext.getString(R.string.notification_via_new_channel_body)
if (self.notificationType == cjitPaymentArrived) {
self.bestAttemptContent = NotificationDetails(
if (notificationType == cjitPaymentArrived) {
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_received_title),
body = viaNewChannel,
)

lightningRepo.getChannels()?.find { it.channelId == event.channelId }?.let { channel ->
val sats = channel.amountOnClose
val content = if (showDetails) "$BITCOIN_SYMBOL $sats" else hiddenBody
self.bestAttemptContent = NotificationDetails(
bestAttemptContent = NotificationDetails(
title = content,
body = viaNewChannel,
)
Expand All @@ -233,21 +233,32 @@ class WakeNodeWorker @AssistedInject constructor(
activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel)
}
}
} else if (self.notificationType == orderPaymentConfirmed) {
self.bestAttemptContent = NotificationDetails(
} else if (notificationType == orderPaymentConfirmed) {
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_channel_opened_title),
body = appContext.getString(R.string.notification_channel_ready_body),
)
}
self.deliver()
deliver()
}

private suspend fun deliver() {
lightningRepo.stop()

// Send notification first
bestAttemptContent?.run {
appContext.pushNotification(title, body)
Logger.info("Delivered notification")
Logger.info("Delivered notification", context = TAG)
}

// Delay briefly to allow app to come to foreground if user clicked notification
delay(1.seconds)

// Only stop node if app is not in foreground
// LightningNodeService will keep node running in background when notifications are enabled
if (App.currentActivity?.value == null) {
Logger.debug("App in background, stopping node after notification delivery", context = TAG)
lightningRepo.stop()
} else {
Logger.debug("App in foreground, keeping node running", context = TAG)
}

deliverSignal.complete(Unit)
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,8 @@ class LightningRepo @Inject constructor(
val token = token ?: firebaseMessaging.token.await()
val cachedToken = keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name)

require(token.isNotEmpty()) { "FCM token is empty or null" }

if (cachedToken == token) {
Logger.debug("Skipped registering for notifications, current device token already registered")
return@executeWhenNodeRunning Result.success(Unit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class LspNotificationsService @Inject constructor(
isoTimestamp = "$timestamp",
signature = signature,
customUrl = Env.blocktankNotificationApiUrl,
isProduction = null,
isProduction = !Env.isDebug,
)
}

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package to.bitkit.usecases

import com.google.firebase.messaging.FirebaseMessaging
import to.bitkit.data.AppDb
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
Expand Down Expand Up @@ -27,6 +28,7 @@ class WipeWalletUseCase @Inject constructor(
private val blocktankRepo: BlocktankRepo,
private val activityRepo: ActivityRepo,
private val lightningRepo: LightningRepo,
private val firebaseMessaging: FirebaseMessaging,
) {
@Suppress("TooGenericExceptionCaught")
suspend operator fun invoke(
Expand All @@ -39,6 +41,7 @@ class WipeWalletUseCase @Inject constructor(
backupRepo.reset()

keychain.wipe()
firebaseMessaging.deleteToken()

coreService.wipeData()
db.clearAllTables()
Expand Down
Loading
Loading