diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 6fdae7255..ad77c1f50 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -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 diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index 4992e1758..a9ab62d45 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -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 @@ -46,16 +47,16 @@ 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 { @@ -63,7 +64,7 @@ class FcmService : FirebaseMessagingService() { } 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 } @@ -83,6 +84,7 @@ class FcmService : FirebaseMessagingService() { "payload" to notificationPayload?.toString(), ) ) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() WorkManager.getInstance(this) .beginWith(work) @@ -90,21 +92,21 @@ class FcmService : FirebaseMessagingService() { } private fun handleNow(data: Map) { - 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 } @@ -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(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 } diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index f6916a032..77aedd670 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -8,6 +8,7 @@ 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 @@ -15,6 +16,7 @@ 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 @@ -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 @@ -63,15 +62,19 @@ class WakeNodeWorker @AssistedInject constructor( private val deliverSignal = CompletableDeferred() 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) { @@ -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() } } } @@ -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)) } @@ -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), ) @@ -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() } } @@ -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), @@ -173,7 +173,7 @@ class WakeNodeWorker @AssistedInject constructor( ) } - self.deliver() + deliver() } private suspend fun onPaymentReceived( @@ -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() } } @@ -207,8 +207,8 @@ 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, ) @@ -216,7 +216,7 @@ class WakeNodeWorker @AssistedInject constructor( 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, ) @@ -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) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c846a3316..5b5140a38 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -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) diff --git a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt index 7100389b4..d29e8b95b 100644 --- a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt +++ b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt @@ -52,7 +52,7 @@ class LspNotificationsService @Inject constructor( isoTimestamp = "$timestamp", signature = signature, customUrl = Env.blocktankNotificationApiUrl, - isProduction = null, + isProduction = !Env.isDebug, ) } diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index a836be49b..12025db7b 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -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 @@ -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( @@ -39,6 +41,7 @@ class WipeWalletUseCase @Inject constructor( backupRepo.reset() keychain.wipe() + firebaseMessaging.deleteToken() coreService.wipeData() db.clearAllTables() diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index cb6c8dfab..d7bc27af8 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -1,5 +1,6 @@ package to.bitkit.usecases +import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -33,6 +34,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val blocktankRepo = mock() private val activityRepo = mock() private val lightningRepo = mock() + private val firebaseMessaging = mock() private lateinit var sut: WipeWalletUseCase @@ -56,6 +58,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { blocktankRepo = blocktankRepo, activityRepo = activityRepo, lightningRepo = lightningRepo, + firebaseMessaging = firebaseMessaging, ) }