325 lines
16 KiB
Kotlin
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()
|
|
}
|
|
}
|