diff --git a/android/app/src/main/java/com/cBreez/client/BreezForegroundService.kt b/android/app/src/main/java/com/cBreez/client/BreezForegroundService.kt index 90fbef3d1..bfa0b5851 100644 --- a/android/app/src/main/java/com/cBreez/client/BreezForegroundService.kt +++ b/android/app/src/main/java/com/cBreez/client/BreezForegroundService.kt @@ -7,14 +7,29 @@ import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper -import android.os.Parcelable import androidx.annotation.RequiresApi +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import breez_sdk.BlockingBreezServices +import breez_sdk.BreezEvent +import breez_sdk.EventListener +import breez_sdk.PaymentStatus import com.cBreez.client.BreezNotificationHelper.Companion.dismissForegroundServiceNotification import com.cBreez.client.BreezNotificationHelper.Companion.notifyForegroundService +import com.cBreez.client.BreezNotificationHelper.Companion.notifyPaymentFailed +import com.cBreez.client.BreezNotificationHelper.Companion.notifyPaymentReceived import com.cBreez.client.BreezNotificationHelper.Companion.registerNotificationChannels +import com.cBreez.client.BreezSdkConnector.Companion.connectSDK import com.cBreez.client.Constants.NOTIFICATION_ID_FOREGROUND_SERVICE import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.tinylog.kotlin.Logger +import java.util.concurrent.locks.ReentrantLock class BreezForegroundService : Service() { companion object { @@ -25,23 +40,53 @@ class BreezForegroundService : Service() { fun getService(): BreezForegroundService = this@BreezForegroundService } + // SDK events listener + inner class SDKListener : EventListener { + override fun onEvent(e: BreezEvent) { + Logger.tag(TAG).info { "Received event $e" } + if (e is BreezEvent.InvoicePaid) { + val pD = e.details + Logger.tag(TAG).info { + "Received payment. Bolt11:${pD.bolt11}\nPayment Hash:${pD.paymentHash}" + } + val amountSat = (e.details.payment?.amountMsat ?: ULong.MIN_VALUE) / 1000u + if (!paymentReceivedInBackground.contains(e.details.paymentHash)) { + paymentReceivedInBackground.add(e.details.paymentHash) + notifyPaymentReceived(applicationContext, amountSat = amountSat) + } + + // push back shutdown by 120s in case we'll receive more payments + shutdownHandler.removeCallbacksAndMessages(null) + shutdownHandler.postDelayed(shutdownRunnable, 120 * 1000L) + } + } + } + + private var breezSDK: BlockingBreezServices? = null + private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) private val binder = BreezNodeBinder() - /** True if the service is running headless (that is without a GUI). In that case we should show a notification */ - @Volatile - private var isHeadless = true + /** + * State of the wallet, provides access to the business when started. Private so that it's not + * mutated from the outside. + */ + private val _state = MutableLiveData(NodeServiceState.Off) + val state: LiveData + get() = _state + + /** Lock for state updates */ + private val stateLock = ReentrantLock() + + /** List of payments received while the app is in the background */ + private val paymentReceivedInBackground = mutableListOf() override fun onCreate() { super.onCreate() - Logger.tag(TAG).debug("Creating Breez node service...") + Logger.tag(TAG).debug { "Creating Breez node service..." } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { registerNotificationChannels(applicationContext) } - startForeground( - NOTIFICATION_ID_FOREGROUND_SERVICE, - notifyForegroundService(applicationContext) - ) - Logger.tag(TAG).debug("Breez node service created.") + Logger.tag(TAG).debug { "Breez node service created." } } // =========================================================== // @@ -49,33 +94,33 @@ class BreezForegroundService : Service() { // =========================================================== // override fun onBind(intent: Intent?): IBinder { - Logger.tag(TAG).debug("Binding Breez node service from intent=$intent") - // UI is binding to the service. The service is not headless anymore and we can remove the notification. - isHeadless = false + Logger.tag(TAG).debug { "Binding Breez node service from intent=$intent" } + paymentReceivedInBackground.clear() stopForeground(STOP_FOREGROUND_REMOVE) dismissForegroundServiceNotification(applicationContext) return binder } - /** When unbound, the service is running headless. */ override fun onUnbind(intent: Intent?): Boolean { - isHeadless = true return false } private val shutdownHandler = Handler(Looper.getMainLooper()) private val shutdownRunnable: Runnable = Runnable { - if (isHeadless) { - Logger.tag(TAG).debug("Reached scheduled shutdown...") + Logger.tag(TAG).debug { "Reached scheduled shutdown..." } + if (paymentReceivedInBackground.isEmpty()) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { stopForeground(STOP_FOREGROUND_DETACH) - shutdown() } + shutdown() } /** Shutdown the node, close connections and stop the service */ private fun shutdown() { - Logger.tag(TAG).info("Shutting down Breez node service") + Logger.tag(TAG).info { "Shutting down Breez node service" } stopSelf() + _state.postValue(NodeServiceState.Off) } // =========================================================== // @@ -86,47 +131,110 @@ class BreezForegroundService : Service() { @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - Logger.tag(TAG) - .debug("Start Breez node service from intent [ intent=$intent, flag=$flags, startId=$startId ]") - getParcelable( - intent, "remote_message", - RemoteMessage::class.java - )?.let { handleNow(it) } + Logger.tag(TAG).debug { + "Start Breez node service from intent [ intent=$intent, flag=$flags, startId=$startId ]" + } + intent.getRemoteMessage()?.let { + if (it.data["notification_type"] == "payment_received") { + val paymentHash = it.data["payment_hash"] + val clickAction = it.data["click_action"] + paymentHash?.let { + startBusiness(paymentHash, clickAction) + } + } + } shutdownHandler.removeCallbacksAndMessages(null) - shutdownHandler.postDelayed(shutdownRunnable, 60 * 1000L) // push back shutdown by 60s - if (!isHeadless) { - stopForeground(STOP_FOREGROUND_REMOVE) - } + shutdownHandler.postDelayed(shutdownRunnable, 120 * 1000L) // push back shutdown by 120s return START_NOT_STICKY } - private fun handleNow(remoteMessage: RemoteMessage): Boolean { - return if (remoteMessage.data["notification_type"] == "payment_received") { - val paymentHash = remoteMessage.data["payment_hash"] - val clickAction = remoteMessage.data["click_action"] - paymentHash?.let { - JobManager.instance.startPaymentReceivedJob( - applicationContext, - paymentHash, - clickAction - ) + private fun startBusiness( + paymentHash: String, + clickAction: String?, + ) { + val notification = notifyForegroundService(applicationContext) + when { + breezSDK != null && _state.value is NodeServiceState.Running -> { + // NOTE: the notification will NOT be shown if the app is already running + startForeground(NOTIFICATION_ID_FOREGROUND_SERVICE, notification) + serviceScope.launch( + Dispatchers.IO + + CoroutineExceptionHandler { _, e -> + Logger.tag(TAG).error { "Error when polling payment: $e" } + notifyPaymentFailed(applicationContext) + } + ) { + Logger.tag(TAG) + .info { "Using current running node service to poll payment" } + pollPaymentHash(paymentHash, clickAction) + } + } + + else -> { + startForeground(NOTIFICATION_ID_FOREGROUND_SERVICE, notification) + Logger.tag(TAG).debug { "Starting wallet..." } + stateLock.lock() + if (_state.value != NodeServiceState.Off) { + Logger.tag(TAG).warn { + "ignore attempt to start business in state=${_state.value}" + } + return + } else { + _state.postValue(NodeServiceState.Init) + } + stateLock.unlock() + serviceScope.launch( + Dispatchers.IO + + CoroutineExceptionHandler { _, e -> + Logger.tag(TAG).error { "Error when starting node: $e" } + _state.postValue(NodeServiceState.Error(e)) + shutdown() + stopForeground(STOP_FOREGROUND_REMOVE) + } + ) { + Logger.tag(TAG).info { + "Starting node from service state=${_state.value?.name}" + } + breezSDK = connectSDK(applicationContext, SDKListener()) + pollPaymentHash(paymentHash, clickAction) + _state.postValue(NodeServiceState.Running) + } } - true - } else { - false } } - private fun getParcelable( - intent: Intent?, - name: String, - clazz: Class, - ): T? { + private suspend fun pollPaymentHash( + paymentHash: String, + clickAction: String?, + ) { + // Poll for 1 minute + for (i in 1..60) { + val payment = breezSDK!!.paymentByHash(paymentHash) + if (payment?.status == PaymentStatus.COMPLETE) { + if (!paymentReceivedInBackground.contains(paymentHash)) { + paymentReceivedInBackground.add(paymentHash) + notifyPaymentReceived( + applicationContext, + clickAction, + amountSat = payment.amountMsat / 1000u + ) + } + return + } else { + Logger.tag(TAG).info { "Payment w/ paymentHash: $paymentHash not received yet." } + withContext(Dispatchers.IO) { Thread.sleep(1_000) } + } + } + + // If we reach here then we didn't receive for more than 1 minute + throw Exception("Payment not found before timeout") + } + + private fun Intent?.getRemoteMessage(): RemoteMessage? { @Suppress("DEPRECATION") return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - intent?.getParcelableExtra(name, clazz) - else - intent?.getParcelableExtra(name) as T? + this?.getParcelableExtra("remote_message", RemoteMessage::class.java) + else this?.getParcelableExtra("remote_message") } } \ No newline at end of file diff --git a/android/app/src/main/java/com/cBreez/client/BreezNotificationHelper.kt b/android/app/src/main/java/com/cBreez/client/BreezNotificationHelper.kt index a16991c26..1a8d8075c 100644 --- a/android/app/src/main/java/com/cBreez/client/BreezNotificationHelper.kt +++ b/android/app/src/main/java/com/cBreez/client/BreezNotificationHelper.kt @@ -50,7 +50,7 @@ class BreezNotificationHelper { val foregroundServiceNotificationChannel = NotificationChannel( NOTIFICATION_CHANNEL_FOREGROUND_SERVICE, context.getString(R.string.foreground_service_notification_channel_name), - NotificationManager.IMPORTANCE_DEFAULT + NotificationManager.IMPORTANCE_LOW ).apply { description = context.getString(R.string.foreground_service_notification_channel_description) @@ -132,7 +132,7 @@ class BreezNotificationHelper { fun notifyPaymentReceived( context: Context, - clickAction: String? = null, + clickAction: String? = "FLUTTER_NOTIFICATION_CLICK", amountSat: ULong, ): Notification { val notificationID: Int = System.currentTimeMillis().toInt() / 1000 diff --git a/android/app/src/main/java/com/cBreez/client/BreezSdkConnector.kt b/android/app/src/main/java/com/cBreez/client/BreezSdkConnector.kt index f42b152d6..cdd95e620 100644 --- a/android/app/src/main/java/com/cBreez/client/BreezSdkConnector.kt +++ b/android/app/src/main/java/com/cBreez/client/BreezSdkConnector.kt @@ -16,7 +16,10 @@ class BreezSdkConnector { private var ELEMENT_PREFERENCES_KEY_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIHNlY3VyZSBzdG9yYWdlCg" - internal fun connectSDK(applicationContext: Context): BlockingBreezServices { + internal fun connectSDK( + applicationContext: Context, + sdkListener: EventListener, + ): BlockingBreezServices { synchronized(this) { if (breezSDK == null) { Logger.tag(TAG).info { "Connecting to Breez SDK" } @@ -29,7 +32,7 @@ class BreezSdkConnector { val config = defaultConfig(EnvironmentType.PRODUCTION, apiKey, nodeConf) config.workingDir = PathUtils.getDataDirectory(applicationContext) // Connect to the Breez SDK make it ready for use - breezSDK = connect(config, seed, SDKListener()) + breezSDK = connect(config, seed, sdkListener) Logger.tag(TAG).info { "Connected to Breez SDK" } } return breezSDK!! diff --git a/android/app/src/main/java/com/cBreez/client/BreezSdkWorker.kt b/android/app/src/main/java/com/cBreez/client/BreezSdkWorker.kt deleted file mode 100644 index 1848f5721..000000000 --- a/android/app/src/main/java/com/cBreez/client/BreezSdkWorker.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.cBreez.client - -import android.content.Context -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE -import android.os.Build -import androidx.work.ForegroundInfo -import androidx.work.Worker -import androidx.work.WorkerParameters -import breez_sdk.BreezEvent -import breez_sdk.EventListener -import breez_sdk.Payment -import breez_sdk.PaymentStatus -import com.cBreez.client.BreezNotificationHelper.Companion.notifyForegroundService -import com.cBreez.client.BreezNotificationHelper.Companion.notifyPaymentFailed -import com.cBreez.client.BreezNotificationHelper.Companion.notifyPaymentReceived -import com.cBreez.client.Constants.NOTIFICATION_ID_FOREGROUND_SERVICE -import org.tinylog.kotlin.Logger - -// SDK events listener -class SDKListener : EventListener { - companion object { - private const val TAG = "SDKListener" - } - - override fun onEvent(e: BreezEvent) { - Logger.tag(TAG).info { "Received event $e" } - if (e is BreezEvent.InvoicePaid) { - val pD = e.details - Logger.tag(TAG) - .info { "Received payment. Bolt11:${pD.bolt11}\nPayment Hash:${pD.paymentHash}" } - } - } -} - -open class BreezSdkWorker(appContext: Context, workerParams: WorkerParameters) : - Worker(appContext, workerParams) { - companion object { - private const val TAG = "BreezSdkWorker" - } - - override fun getForegroundInfo(): ForegroundInfo { - val notification = notifyForegroundService(applicationContext) - val foregroundInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ForegroundInfo( - NOTIFICATION_ID_FOREGROUND_SERVICE, - notification, - FOREGROUND_SERVICE_TYPE_SHORT_SERVICE - ) - } else { - ForegroundInfo(NOTIFICATION_ID_FOREGROUND_SERVICE, notification) - } - return foregroundInfo - } - - override fun doWork(): Result { - try { - val paymentHash = - inputData.getString("PAYMENT_HASH") ?: throw Exception("Couldn't find payment hash") - val breezSDK = BreezSdkConnector.connectSDK(applicationContext) - - // Poll for 1 minute - for (i in 1..60) { - val payment = breezSDK.paymentByHash(paymentHash) - if (payment?.status == PaymentStatus.COMPLETE) { - this.onPaymentReceived(payment) - return Result.success() - } else { - Logger.tag(TAG) - .info { "Payment w/ paymentHash: $paymentHash not received yet." } - Thread.sleep(1_000) - } - } - - // If we reach here then we didn't receive for more than 1 minute - throw Exception("Payment not found before timeout") - - } catch (e: Exception) { - Logger.tag(TAG).error { "Exception: $e" } - e.printStackTrace() - onPaymentFailed() - return Result.failure() - } - } - - override fun onStopped() { - Logger.tag(TAG).debug { "Stopping BreezSdkWorker" } - onPaymentFailed() - } - - private fun onPaymentFailed() { - notifyPaymentFailed(applicationContext) - } - - private fun onPaymentReceived(payment: Payment) { - notifyPaymentReceived( - applicationContext, - inputData.getString("CLICK_ACTION"), - payment.amountMsat / 1000u - ) - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/cBreez/client/JobManager.kt b/android/app/src/main/java/com/cBreez/client/JobManager.kt deleted file mode 100644 index 7eb8f8cca..000000000 --- a/android/app/src/main/java/com/cBreez/client/JobManager.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.cBreez.client - -import android.content.Context -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequest -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkManager -import androidx.work.WorkRequest -import androidx.work.workDataOf -import org.tinylog.Logger -import java.util.concurrent.TimeUnit - -class JobManager private constructor() { - companion object { - private const val TAG = "JobManager" - - var instance = JobManager() - } - - fun startPaymentReceivedJob( - applicationContext: Context, - paymentHash: String, - clickAction: String?, - ) { - try { - Logger.tag(TAG).info { "Enqueueing work request for $paymentHash" } - // Set Constraints for notification to be handled at all times when device is connected to a network - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .setRequiresBatteryNotLow(false) - .setRequiresCharging(false) - .setRequiresDeviceIdle(false) - .setRequiresStorageNotLow(false) - .build() - - // Create expedited work request - val paymentReceivedWorkRequest: OneTimeWorkRequest = - OneTimeWorkRequestBuilder() - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .setConstraints(constraints) - .setBackoffCriteria( - BackoffPolicy.LINEAR, - WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, - TimeUnit.MILLISECONDS - ) - .addTag("receivePayment") - .setInputData( - workDataOf( - "PAYMENT_HASH" to paymentHash, - "CLICK_ACTION" to clickAction - ) - ) - .build() - - // Enqueue unique work - WorkManager - .getInstance(applicationContext) - .enqueueUniqueWork( - paymentHash, - ExistingWorkPolicy.KEEP, - paymentReceivedWorkRequest - ) - Logger.tag(TAG).info { "Work request for $paymentHash was enqueued successfully" } - } catch (e: Exception) { - Logger.tag(TAG).error { "Failed to enqueue job from notification " + e.message; e } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/cBreez/client/NodeServiceState.kt b/android/app/src/main/java/com/cBreez/client/NodeServiceState.kt new file mode 100644 index 000000000..8355535d0 --- /dev/null +++ b/android/app/src/main/java/com/cBreez/client/NodeServiceState.kt @@ -0,0 +1,22 @@ +package com.cBreez.client + +/** + * This class represent the state of the node service. + * - Off = the service has not started the node + * - Init = the wallet is starting + * - Started = the wallet is unlocked and the node will now try to connect and establish channels + * - Error = the node could not start + */ +sealed class NodeServiceState { + + val name: String by lazy { this.javaClass.simpleName } + + /** Default state, the node is not started. */ + object Off : NodeServiceState() + + /** This is an utility state that is used when the binding between the service holding the state and the consumers of that state is disconnected. */ + object Disconnected : NodeServiceState() + object Init : NodeServiceState() + object Running : NodeServiceState() + data class Error(val cause: Throwable) : NodeServiceState() +}