/* * Copyright 2025 ACINQ SAS * * 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 * * http://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. */ package fr.acinq.phoenix.android import at.torch.android.TorchApplication import at.torch.compose.AppVersion import at.torch.compose.ui.composable.widgets.wallet.WalletAvatars import android.content.Context import android.text.format.DateUtils import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.PaymentEvents import fr.acinq.lightning.utils.Connection import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.BusinessMonitorJobs import fr.acinq.phoenix.BusinessRunning import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.android.services.InflightPaymentsWatcher import fr.acinq.phoenix.data.StartBusinessResult import fr.acinq.phoenix.data.StartupParams import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.data.inFlightPaymentsCount import fr.acinq.phoenix.managers.AppConnectionsDaemon import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.PeerManager import fr.acinq.phoenix.managers.global.CurrencyManager import fr.acinq.phoenix.utils.MnemonicLanguage import fr.acinq.phoenix.utils.SystemNotificationHelper import fr.acinq.phoenix.utils.preferences.InternalPrefs import fr.acinq.phoenix.utils.preferences.UserPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.slf4j.LoggerFactory import kotlin.time.Duration.Companion.hours object BusinessManager { private val log = LoggerFactory.getLogger(this::class.java) private val supervisor = SupervisorJob() private val scope = CoroutineScope(Dispatchers.Default + supervisor) private val startupMutex = Mutex() // No memory leaks because this can only contain the application context. private lateinit var appContext: Context private val application by lazy { appContext as TorchApplication } /** A map of (walletId -> active businesses) */ private val _businessFlow = MutableStateFlow>(emptyMap()) val businessFlow = _businessFlow.asStateFlow() /** Map of jobs monitoring events/payments once business starts */ private val eventsMonitoringJobs = mutableMapOf() //List>() fun initialize(context: Context) { appContext = context.applicationContext // TODO: we should be getting this from phoenixGloba... } /** * This method creates and starts a new business from a given [decryptedMnemonics], and adds it to the flow of started businesses. * * If a business already exists for that seed, the method does nothing. * * @param words bip39 mnemonics * @param isHeadless true if started from a service (e.g. after a FCM notification), false if started from the UI. */ suspend fun startNewBusiness(words: List, isHeadless: Boolean): StartBusinessResult = startupMutex.withLock { val business = PhoenixBusiness(application.phoenixGlobal) val walletInfo = try { log.debug("loading wallet before starting a new business") val seed = business.walletManager.mnemonicsToSeed(words, wordList = MnemonicLanguage.English.wordlist()) business.walletManager.loadWallet(seed) } catch (e: Exception) { log.error("unable to load wallet, likely because of an invalid seed, aborting...") return StartBusinessResult.Failure.LoadWalletError } val walletId = WalletId(walletInfo.nodeIdHash) val nodeId = walletInfo.nodeId.toHex() val globalPrefs = application.globalPrefs val walletMetadata = globalPrefs.getAvailableWalletsMeta.first()[walletId] ?: run { val metadata = UserWalletMetadata( walletId = walletId, name = null, avatar = WalletAvatars.list.random(), createdAt = currentTimestampMillis(), isHidden = false ) globalPrefs.saveAvailableWalletMeta(metadata) metadata } val dataStoreManager = business.dataStoreManager val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId) val internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId) val businessInFlow = businessFlow.value[walletId]?.business if (businessInFlow != null) { log.info("business already exists in flow, ignoring...") return StartBusinessResult.Success(walletInfo, businessInFlow) } return try { log.info("preparing new business with node_id=$nodeId wallet_id=$walletId...") // check last used version to display a patch note val lastVersionUsed = globalPrefs.getLastUsedAppCode.first() if (lastVersionUsed == null) { // lastUsedAppCode was added in version 99, and is set up during the wallet creation. So if it's null, this Phoenix was installed prior v99 and we can show a patch note globalPrefs.saveShowReleaseNoteSinceCode("98") } else if (lastVersionUsed < AppVersion.versionCode) { globalPrefs.saveShowReleaseNoteSinceCode(lastVersionUsed) } // update app configuration with user preferences business.appConfigurationManager.updateElectrumConfig(userPrefs.getElectrumServer.first()) val preferredCurrencies = userPrefs.getFiatCurrencies.first() business.appConfigurationManager.updatePreferredFiatCurrencies(preferredCurrencies) business.phoenixGlobal.currencyManager.startMonitoringCurrencies(walletId = walletId.nodeIdHash, currencies = preferredCurrencies) // setup jobs monitoring the business events eventsMonitoringJobs[walletId] = BusinessMonitorJobs( monitorHeadlessPaymentsJob = if (isHeadless) { scope.launch { monitorPaymentsWhenHeadless(walletId, walletMetadata, business.nodeParamsManager, application.phoenixGlobal.currencyManager, userPrefs) } } else null, monitorNodeEventsJob = scope.launch { monitorNodeEvents(walletId, business.peerManager, business.nodeParamsManager, internalPrefs) }, monitorFcmTokenJob = scope.launch { monitorFcmToken(business) }, monitorInFlightPaymentsJob = scope.launch { monitorInFlightPayments(business.peerManager, internalPrefs) }, ) // startup params depend user's settings: Tor and liquidity policy val startupParams = StartupParams(isTorEnabled = userPrefs.getIsTorEnabled.first(), liquidityPolicy = userPrefs.getLiquidityPolicy.first()) delay(1_000) // actually start the business log.info("starting new business with node_id=$nodeId...") _businessFlow.value += walletId to BusinessRunning(business = business, isHeadless = isHeadless) business.start(startupParams) // the node has been started, so we can now increment the last-used build code globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) // start watching the swap-in wallet scope.launch { business.peerManager.getPeer().startWatchSwapInWallet() } log.info("business initialisation has successfully completed") StartBusinessResult.Success(walletInfo, business) } catch (e: Exception) { log.error("there was an error when initialising new business: ", e) stopBusiness(walletId) StartBusinessResult.Failure.Generic(e) } } /** * Updates the matching business in the map of active businesses with a non-headless flag. Should be called when the UI starts a given wallet. * If called improperly, will not have severe effects ; the app will just show incoming payment notifications. */ fun updateBusinessActiveInUI(walletId: WalletId) { val businessMap = _businessFlow.value.toMutableMap() businessMap[walletId]?.let { businessMap[walletId] = it.copy(isHeadless = false) } eventsMonitoringJobs[walletId]?.monitorHeadlessPaymentsJob?.cancel() _businessFlow.value = businessMap } fun stopAllHeadlessBusinesses() { val headlessBusinesses = businessFlow.value.filter { it.value.isHeadless } log.info("stopping all headless businesses (${headlessBusinesses.size})...") headlessBusinesses.forEach { doStopBusiness(it.key, it.value) } _businessFlow.value = businessFlow.value.minus(headlessBusinesses.keys) } fun stopAllBusinesses() { log.info("stopping all businesses...") businessFlow.value.forEach { doStopBusiness(it.key, it.value) } _businessFlow.value = emptyMap() } fun stopBusiness(walletId: WalletId) { val businessMap = _businessFlow.value.toMutableMap() businessMap[walletId]?.let { doStopBusiness(walletId, it)} businessMap.remove(walletId) _businessFlow.value = businessMap } private fun doStopBusiness(walletId: WalletId, running: BusinessRunning) { running.business.appConnectionsDaemon?.incrementDisconnectCount(AppConnectionsDaemon.ControlTarget.All) running.business.stop() eventsMonitoringJobs.remove(walletId)?.let { it.monitorHeadlessPaymentsJob?.cancel() it.monitorFcmTokenJob.cancel() it.monitorNodeEventsJob.cancel() it.monitorInFlightPaymentsJob.cancel() } application.phoenixGlobal.currencyManager.stopMonitoringForWallet(walletId.nodeIdHash) } private suspend fun monitorFcmToken(business: PhoenixBusiness) { val token = application.globalPrefs.getFcmToken.filterNotNull().first() business.connectionsManager.connections.first { it.peer == Connection.ESTABLISHED } delay(5000) log.info("registering fcm token=$token") business.registerFcmToken(token) } private suspend fun monitorNodeEvents(walletId: WalletId, peerManager: PeerManager, nodeParamsManager: NodeParamsManager, internalPrefs: InternalPrefs) { val monitoringStartedAt = currentTimestampMillis() combine( peerManager.swapInNextTimeout, nodeParamsManager.nodeParams.filterNotNull().first().nodeEvents ) { nextTimeout, nodeEvent -> nextTimeout to nodeEvent }.collect { (nextTimeout, event) -> // TODO: click on notif must deeplink to the notification screen when (event) { is LiquidityEvents.Rejected -> { log.debug("processing liquidity_event={}", event) if (event.source == LiquidityEvents.Source.OnChainWallet) { // Check the last time a rejected on-chain swap notification has been shown. If recent, we do not want to trigger a notification every time. val lastRejectedSwap = internalPrefs.getLastRejectedOnchainSwap.first().takeIf { // However, if the app started < 2 min ago, we always want to display a notification. So we'll ignore this check ^ currentTimestampMillis() - monitoringStartedAt >= 2 * DateUtils.MINUTE_IN_MILLIS } if (lastRejectedSwap != null && lastRejectedSwap.first == event.amount && currentTimestampMillis() - lastRejectedSwap.second <= 2 * DateUtils.HOUR_IN_MILLIS ) { log.debug("ignore this liquidity event as a similar notification was recently displayed") return@collect } else { internalPrefs.saveLastRejectedOnchainSwap(event) } } val walletMetadata = application.globalPrefs.getAvailableWalletsMeta.first().getByWalletIdOrDefault(walletId) when (val reason = event.reason) { is LiquidityEvents.Rejected.Reason.PolicySetToDisabled -> { SystemNotificationHelper.notifyPaymentRejectedPolicyDisabled(appContext, walletId, walletMetadata, event.source, event.amount, nextTimeout?.second) } is LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee -> { SystemNotificationHelper.notifyPaymentRejectedOverAbsolute(appContext, walletId, walletMetadata, event.source, event.amount, event.fee, reason.maxAbsoluteFee, nextTimeout?.second) } is LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee -> { SystemNotificationHelper.notifyPaymentRejectedOverRelative(appContext, walletId, walletMetadata, event.source, event.amount, event.fee, reason.maxRelativeFeeBasisPoints, nextTimeout?.second) } is LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow -> { SystemNotificationHelper.notifyPaymentRejectedAmountTooLow(appContext, walletId, walletMetadata, event.source, event.amount) } // Temporary errors is LiquidityEvents.Rejected.Reason.ChannelFundingInProgress, is LiquidityEvents.Rejected.Reason.NoMatchingFundingRate, is LiquidityEvents.Rejected.Reason.TooManyParts -> { SystemNotificationHelper.notifyPaymentRejectedFundingError(appContext, walletId, walletMetadata, event.source, event.amount) } } } else -> Unit } } } private suspend fun monitorPaymentsWhenHeadless(walletId: WalletId, walletMetadata: UserWalletMetadata, nodeParamsManager: NodeParamsManager, currencyManager: CurrencyManager, userPrefs: UserPrefs) { nodeParamsManager.nodeParams.filterNotNull().first().nodeEvents.collect { event -> when (event) { is PaymentEvents.PaymentReceived -> { SystemNotificationHelper.notifyPaymentsReceived( context = appContext, userPrefs = userPrefs, walletId = walletId, userWalletMetadata = walletMetadata, paymentId = event.payment.id, paymentAmount = event.payment.amountReceived, rates = currencyManager.ratesFlow.value, ) } else -> Unit } } } private suspend fun monitorInFlightPayments(peerManager: PeerManager, internalPrefs: InternalPrefs) { peerManager.channelsFlow.filterNotNull().collect { val inFlightPaymentsCount = it.inFlightPaymentsCount() internalPrefs.saveInFlightPaymentsCount(inFlightPaymentsCount) if (inFlightPaymentsCount == 0) { InflightPaymentsWatcher.cancel(appContext) } else { InflightPaymentsWatcher.scheduleOnce(appContext, delay = 2.hours) } } } fun clear() { supervisor.cancel() } }