Files
mantra-kmp/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt
2026-06-17 18:58:00 +03:00

325 lines
16 KiB
Kotlin

/*
* 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<Map<WalletId, BusinessRunning>>(emptyMap())
val businessFlow = _businessFlow.asStateFlow()
/** Map of jobs monitoring events/payments once business starts */
private val eventsMonitoringJobs = mutableMapOf<WalletId, BusinessMonitorJobs>() //List<Job>>()
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<String>, 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()
}
}