Remove phoenix code

This commit is contained in:
Kgothatso Ngako committed 2026-08-02 18:58:36 +02:00
1 parent b17901973c
commit e9510c4ea5
205 files changed
-22110

No files matched your search

@@ -1,324 +0,0 @@
/*
* 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 android.content.Context
import android.text.format.DateUtils
import press.mantra.android.MantraApplication
import press.mantra.compose.AppVersion
import press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars
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 MantraApplication }
/** 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()
}
}
@@ -1,34 +0,0 @@
/*
* Copyright 2019 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.services
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* This receiver is started when the device has booted, and schedules background jobs.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED == intent.action) {
ChannelsWatcher.schedule(context)
InflightPaymentsWatcher.scheduleOnce(context)
DailyConnect.schedule(context)
}
}
}
@@ -1,196 +0,0 @@
/*
* Copyright 2023 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.services
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.Operation
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import press.mantra.android.BuildConfig
import fr.acinq.lightning.channel.states.Closing
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.android.BusinessManager
import fr.acinq.phoenix.data.ChannelsWatcherOutcome
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.data.WatchTowerOutcome
import fr.acinq.phoenix.managers.AppConnectionsDaemon
import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.managers.NodeParamsManager.Companion.chain
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.PlatformContext
import fr.acinq.phoenix.utils.SystemNotificationHelper
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit
/** Worker that monitors channels in the background. Triggers a user-facing notification when an unexpected spending is detected. */
class ChannelsWatcher(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
log.info("starting $name")
if (BusinessManager.businessFlow.first().isNotEmpty()) {
log.info("there already are active businesses, aborting $name")
return Result.success()
}
val userWallets = SeedManager.loadAndDecryptOrNull(
phoenixGlobal = PhoenixGlobal(
ctx = PlatformContext(
applicationContext
)
)
)
if (userWallets.isNullOrEmpty()) {
log.info("could not load any seed, aborting $name")
return Result.success()
}
val watchResult = userWallets.map { (walletId, wallet) ->
watchWallet(walletId, wallet.words)
}
BusinessManager.stopAllHeadlessBusinesses()
return if (watchResult.all { it }) {
log.info("finished $name, watchers have all terminated successfully")
Result.success()
} else {
log.info("finished $name, one or more watchers encountered an error")
Result.failure()
}
}
/** Watches channels for a given node id ; return false if an error occurred. */
private suspend fun watchWallet(walletId: WalletId, words: List<String>): Boolean {
val res = BusinessManager.startNewBusiness(words = words, isHeadless = true)
val dataStoreManager = DataStoreManager(
ctx = PlatformContext(applicationContext),
chain = chain,
)
val internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId)
if (res is StartBusinessResult.Failure) {
log.info("failed to start business for wallet=$walletId")
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.Unknown(currentTimestampMillis()))
return true
}
val business = BusinessManager.businessFlow.value[walletId]?.business
if (business == null) {
log.info("failed to access business for wallet=$walletId")
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.Unknown(currentTimestampMillis()))
return true
}
val notificationsManager = business.notificationsManager
try {
business.appConnectionsDaemon!!.incrementDisconnectCount(AppConnectionsDaemon.ControlTarget.Peer)
business.appConnectionsDaemon!!.incrementDisconnectCount(AppConnectionsDaemon.ControlTarget.Http)
val peer = withTimeout(5_000) {
business.peerManager.getPeer()
}
val channelsAtBoot = peer.bootChannelsFlow.filterNotNull().first()
if (channelsAtBoot.isEmpty()) {
log.info("no channels found, nothing to watch")
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.Nominal(currentTimestampMillis()))
return true
} else {
log.info("watching ${channelsAtBoot.size} channel(s)")
}
// connect electrum (and only electrum), and wait for the watcher to catch-up
withTimeout(ELECTRUM_TIMEOUT_MILLIS) {
business.electrumWatcher.openUpToDateFlow().first()
}
log.info("electrum watcher is up-to-date")
business.appConnectionsDaemon?.decrementDisconnectCount(AppConnectionsDaemon.ControlTarget.Electrum)
val revokedCommitsBeforeWatching = channelsAtBoot.map { (channelId, state) ->
(state as? Closing)?.revokedCommitPublished?.let { channelId to it }
}.filterNotNull().toMap()
log.info("there were initially ${revokedCommitsBeforeWatching.size} channel(s) with revoked commitments")
log.info("checking for new revoked commitments on ${peer.channels.size} channel(s)")
val unknownRevokedAfterWatching = peer.channels.filter { (channelId, state) ->
state is Closing && state.revokedCommitPublished.any {
val isKnown = revokedCommitsBeforeWatching[channelId]?.contains(it) ?: false
if (!isKnown) {
log.warn("found unknown revoked commit for channel=${channelId.toHex()}, tx=${it.commitTx}")
}
!isKnown
}
}.keys
if (unknownRevokedAfterWatching.isNotEmpty()) {
log.warn("new revoked commits found, notifying user")
notificationsManager.saveWatchTowerOutcome(WatchTowerOutcome.RevokedFound(channels = unknownRevokedAfterWatching))
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.RevokedFound(currentTimestampMillis()))
SystemNotificationHelper.notifyRevokedCommits(applicationContext)
} else {
log.info("no revoked commit found, channels-watcher job completed successfully")
notificationsManager.saveWatchTowerOutcome(WatchTowerOutcome.Nominal(channelsWatchedCount = peer.channels.size))
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.Nominal(currentTimestampMillis()))
}
return true
} catch (e: Exception) {
log.error("failed to run channels-watcher job: ", e)
notificationsManager.saveWatchTowerOutcome(WatchTowerOutcome.Unknown())
internalPrefs.saveChannelsWatcherOutcome(ChannelsWatcherOutcome.Unknown(currentTimestampMillis()))
return false
}
}
companion object {
private val log = LoggerFactory.getLogger(ChannelsWatcher::class.java)
private val name = "channels-watcher-job"
const val TAG = BuildConfig.APPLICATION_ID + ".ChannelsWatcher"
private const val ELECTRUM_TIMEOUT_MILLIS = 5 * 60_000L
fun schedule(context: Context) {
log.info("scheduling channels watcher")
val work = PeriodicWorkRequest.Builder(ChannelsWatcher::class.java, 36, TimeUnit.HOURS, 12, TimeUnit.HOURS).addTag(TAG)
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
}
fun scheduleASAP(context: Context) {
val work = OneTimeWorkRequest.Builder(ChannelsWatcher::class.java).addTag(TAG).build()
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work)
}
fun cancel(context: Context): Operation {
return WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
}
}
}
@@ -1,119 +0,0 @@
/*
* Copyright 2024 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.services
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import press.mantra.android.BuildConfig
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.android.BusinessManager
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.ContactsPhotoHelper
import fr.acinq.phoenix.utils.PlatformContext
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import org.slf4j.LoggerFactory
import java.io.File
import java.util.concurrent.TimeUnit
/** Clean up unused photo files for contacts. */
class ContactsPhotoCleaner(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
private val log = LoggerFactory.getLogger(this::class.java)
override suspend fun doWork(): Result {
log.info("starting $name")
try {
// get the active business map, or create it no business is active
val businessMap = BusinessManager.businessFlow.value.takeIf { it.isNotEmpty() }
?: run {
val userWallets = SeedManager.loadAndDecryptOrNull(
phoenixGlobal = PhoenixGlobal(
ctx = PlatformContext(
applicationContext
)
)
)
if (userWallets.isNullOrEmpty()) {
log.info("no seeds available, terminating $name")
return Result.success()
}
userWallets.map { (walletId, wallet) ->
val res = BusinessManager.startNewBusiness(words = wallet.words, isHeadless = true)
if (res is StartBusinessResult.Failure) {
log.info("failed to start business for wallet=$walletId")
return Result.failure()
}
val business = BusinessManager.businessFlow.value[walletId]
if (business == null) {
log.info("failed to access business for wallet=$walletId")
return Result.success()
}
walletId to business
}.toMap()
}
val contactsPhotoDir = ContactsPhotoHelper.contactsDir(applicationContext)
val toDelete = businessMap.map { (_, running) ->
val contacts = running.business.databaseManager.contactsList.filterNotNull().first()
val contactsPhotoNames = contactsPhotoDir.listFiles()?.map { it.name }?.toSet() ?: emptySet()
contactsPhotoNames.subtract(contacts.map { it.photoUri }.toSet()).filterNotNull()
}.flatten()
log.debug("info ${toDelete.size} unused photo file(s)")
toDelete.forEach { imageName ->
File(contactsPhotoDir, imageName).takeIf { it.exists() && it.isFile && it.canWrite() }?.delete()
}
return Result.success()
} catch (e: Exception) {
log.error("error in $name: ", e)
return Result.failure()
} finally {
log.debug("finished $name")
}
}
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
private const val name = "contacts-photo-cleaner"
private const val TAG = BuildConfig.APPLICATION_ID + ".ContactPhotoCleaner"
/** Schedule [ContactsPhotoCleaner] to run roughly every 2 weeks. */
fun schedule(context: Context) {
val work = PeriodicWorkRequest.Builder(ContactsPhotoCleaner::class.java, 15, TimeUnit.DAYS, 3, TimeUnit.DAYS).addTag(TAG)
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
}
fun scheduleASAP(context: Context) {
log.info("scheduling $name once")
val work = OneTimeWorkRequest.Builder(ContactsPhotoCleaner::class.java).addTag(TAG).build()
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work)
}
}
}
@@ -1,159 +0,0 @@
/*
* Copyright 2024 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.services
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.Operation
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import press.mantra.android.BuildConfig
import fr.acinq.lightning.utils.Connection
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.android.BusinessManager
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.PlatformContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit
/**
* This worker is scheduled to run roughly every day. It simply connects to the LSP, wait for 1 minute,
* then shuts down. The purpose is to settle pending payments that may have been missed by the
* [InflightPaymentsWatcher], to complete closings properly, etc...
*/
class DailyConnect(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
private val log = LoggerFactory.getLogger(this::class.java)
override suspend fun doWork(): Result {
log.info("starting $name")
if (BusinessManager.businessFlow.first().isNotEmpty()) {
log.info("there already are active businesses, aborting $name")
return Result.success()
}
val userWallets = SeedManager.loadAndDecryptOrNull(
phoenixGlobal = PhoenixGlobal(
ctx = PlatformContext(
applicationContext
)
)
)
if (userWallets.isNullOrEmpty()) {
log.info("could not load any seed, aborting $name")
return Result.success()
}
try {
val businessMap = userWallets.map { (walletId, wallet) ->
val res = BusinessManager.startNewBusiness(words = wallet.words, isHeadless = true)
if (res is StartBusinessResult.Failure) {
log.info("failed to start business for wallet=$walletId")
return Result.success()
}
val business = BusinessManager.businessFlow.value[walletId]
if (business == null) {
log.info("failed to access business for wallet=$walletId")
return Result.success()
}
walletId to business
}.toMap()
withContext(Dispatchers.Default) {
val stopJobSignal = MutableStateFlow(false)
val watchers = businessMap.map { (walletId, running) ->
val business = running.business
launch {
business.appConnectionsDaemon?.forceReconnect()
business.connectionsManager.connections.first { it.global is Connection.ESTABLISHED }
log.debug("connections established for wallet={}", walletId)
business.peerManager.channelsFlow.filterNotNull().collect { channels ->
when {
channels.isEmpty() -> {
log.info("no channels found for wallet=$walletId")
stopJobSignal.value = true
}
else -> {
log.info("${channels.size} channel(s) found for wallet=$walletId, waiting 60s...")
delay(60_000)
stopJobSignal.value = true
}
}
}
}.also {
it.invokeOnCompletion { log.debug("completed watching-channels job for wallet={} ({})", walletId, it?.localizedMessage) }
}
}
stopJobSignal.first { it }
log.debug("stop-job signal detected")
watchers.forEach { it.cancelAndJoin() }
}
return Result.success()
} catch (e: Exception) {
log.error("error in $name: ", e)
return Result.failure()
} finally {
BusinessManager.stopAllHeadlessBusinesses()
log.info("finished $name")
}
}
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
private val name = "daily-connect-job"
const val TAG = BuildConfig.APPLICATION_ID + ".DailyConnect"
/** Schedule [DailyConnect] to run roughly every day. */
fun schedule(context: Context) {
log.info("scheduling $name")
val work = PeriodicWorkRequest.Builder(DailyConnect::class.java, 36, TimeUnit.HOURS, 12, TimeUnit.HOURS).addTag(TAG)
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
}
fun scheduleASAP(context: Context) {
log.info("scheduling $name once")
val work = OneTimeWorkRequest.Builder(DailyConnect::class.java).addTag(TAG).build()
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work)
}
/** Cancel all scheduled in-flight payments worker. */
fun cancel(context: Context): Operation {
return WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
}
}
}
@@ -1,198 +0,0 @@
/*
* Copyright 2023 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.services
import android.app.Service
import android.content.Intent
import android.nfc.NdefMessage
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
import com.machankura.compose.ui.composable.widgets.nfc.NfcState
import com.machankura.compose.ui.composable.widgets.nfc.NfcStateRepository
import fr.acinq.bitcoin.byteVector
import fr.acinq.phoenix.utils.nfc.ApduCommands
import fr.acinq.phoenix.utils.nfc.ApduCommands.A_ERROR
import fr.acinq.phoenix.utils.nfc.ApduCommands.A_OKAY
import fr.acinq.phoenix.utils.nfc.ApduCommands.NDEF_FILE
import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_CC
import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_CC_RESPONSE
import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_NDEF_BINARY
import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_NDEF_BINARY_LENGTH
import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_AID
import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_AID_LE
import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_CC_FILE
import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_NDEF_FILE
import fr.acinq.phoenix.utils.nfc.NfcHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
/**
* This service emulates a NDEF type 4 tag. See the NFC Forum specs section 5.4 for the expected
* sequence of messages/responses. We hardcode most of them in [ApduCommands].
*
* Uses [NfcStateRepository] to pass the payment request to emit.
*
* The message will be a Ndef text record with TNF WELL-KNOWN.
*/
class HceService : HostApduService() {
private val log = LoggerFactory.getLogger(this::class.java)
private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private var ndefMessage: HceMessage? = null
override fun onCreate() {
super.onCreate()
log.debug("creating hce service")
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
log.debug("onStartCommand")
serviceScope.launch {
NfcStateRepository.state.collect {
ndefMessage = when (it) {
is NfcState.EmulatingTag -> HceMessage(it.paymentRequest)
else -> null
}
}
}
return START_NOT_STICKY
}
@OptIn(ExperimentalStdlibApi::class)
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
log.debug("processing apdu command={} extras={}", commandApdu?.byteVector()?.toHex(), extras)
if (commandApdu == null) return A_ERROR
val nfcState = NfcStateRepository.state.value
if (nfcState is NfcState.Inactive) {
log.debug("trying to emulate tag in state={}, aborting", nfcState)
return A_ERROR
}
val message = ndefMessage ?: return A_ERROR
return when {
SELECT_AID.contentEquals(commandApdu) or SELECT_AID_LE.contentEquals(commandApdu) -> {
log.debug("selecting ndef tag AID, returning {}", A_OKAY.toHexString())
A_OKAY
}
SELECT_CC_FILE.contentEquals(commandApdu) -> {
log.debug("selecting capability container, returning {}", A_OKAY.toHexString())
A_OKAY
}
READ_CC.contentEquals(commandApdu) -> {
log.debug("selecting capability container file, returning {}", READ_CC_RESPONSE.toHexString())
READ_CC_RESPONSE
}
SELECT_NDEF_FILE.contentEquals(commandApdu) -> {
log.debug("selecting ndef file, returning {}", A_OKAY.toHexString())
A_OKAY
}
READ_NDEF_BINARY_LENGTH.contentEquals(commandApdu) -> {
val response = message.messageLength + A_OKAY
log.debug("reading ndef message length, returning {}", response.toHexString())
response
}
commandApdu.size > 2 && commandApdu.sliceArray(0..1).contentEquals(READ_NDEF_BINARY) -> {
// we may receive several commands in a row if the message is large enough
val response = getBinaryResponse(commandApdu, message)
val expectedResponse = message.message.records[0].payload.toHexString() + A_OKAY.toHexString()
if (expectedResponse.contains(response.toHexString())) {
log.info("done sending payment request via nfc: ${message.paymentRequest}")
NfcStateRepository.updateState(NfcState.Inactive)
}
log.debug("reading ndef message, returning {}", response.toHexString())
response
}
else -> {
log.debug("unhandled apdu command={}", commandApdu)
A_ERROR
}
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun getBinaryResponse(commandApdu: ByteArray, message: HceMessage): ByteArray {
try {
if (commandApdu.size < 5) {
log.error("invalid APDU: too short (${commandApdu.size} bytes)")
return A_ERROR
}
// the full response before applying offset
val response = message.messageLength + message.messageBytes
// the reader may want the data in chunks
val offset = commandApdu.sliceArray(2..3).toHexString().toInt(16)
val chunkSize = commandApdu[4].toInt() and 0xFF
log.debug("offset=$offset length=$chunkSize for full_response=${response.toHexString()} =")
if (offset >= response.size) {
log.error("offset $offset is beyond full response size ${response.size}")
return A_ERROR
}
val responseAfterOffset = response.sliceArray(offset until response.size)
val chunkSizeForOffset = minOf(chunkSize, responseAfterOffset.size)
val chunk = responseAfterOffset.copyOfRange(0, chunkSizeForOffset) + A_OKAY
return chunk
} catch (e: Exception) {
log.error("error when getting binary response: {}", e.localizedMessage)
return A_ERROR
}
}
override fun onDeactivated(reason: Int) {
when (reason) {
DEACTIVATION_DESELECTED -> log.info("deactivation: different AID selected")
DEACTIVATION_LINK_LOSS -> log.info("deactivation: link lost")
else -> log.info("deactivation: code $reason")
}
}
override fun onDestroy() {
super.onDestroy()
log.info("service removed")
if (NfcStateRepository.state.value is NfcState.EmulatingTag) {
NfcStateRepository.updateState(NfcState.Inactive)
}
}
private data class HceMessage(val paymentRequest: String) {
val message by lazy { NdefMessage(NfcHelper.createTextRecord(paymentRequest, NDEF_FILE)) }
val messageBytes: ByteArray by lazy { message.toByteArray() }
val messageLength by lazy {
NfcHelper.fillByteArrayToFixedDimension(messageBytes.size.toBigInteger().toByteArray(), 2)
}
override fun toString(): String {
return "message=$paymentRequest length=$messageLength tnf=${message.records.firstOrNull()?.tnf} type=${message.records.firstOrNull()?.type}"
}
}
}
@@ -1,226 +0,0 @@
/*
* Copyright 2024 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.services
import android.content.Context
import android.os.Build
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import press.mantra.android.BuildConfig
import press.mantra.android.MantraApplication
import fr.acinq.lightning.channel.states.Syncing
import fr.acinq.lightning.utils.Connection
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.android.BusinessManager
import fr.acinq.phoenix.data.LocalChannelInfo
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.data.inFlightPaymentsCount
import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.managers.NodeParamsManager.Companion.chain
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.PlatformContext
import fr.acinq.phoenix.utils.SystemNotificationHelper
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
/**
* This worker starts a node to settle any pending in-flight payments. This will prevent payment timeouts
* (and channels force-close) in case the app is not started regularly and the silent push notifications
* sent by the ACINQ peer are ignored by the device.
*
* Example: devices using GrapheneOS, where FCM is not supported.
*
* This service is scheduled whenever there's a pending htlc in a channel.
* See [LocalChannelInfo.inFlightPaymentsCount].
*/
class InflightPaymentsWatcher(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
private val log = LoggerFactory.getLogger(this::class.java)
override suspend fun doWork(): Result {
log.info("starting $name")
if (BusinessManager.businessFlow.first().isNotEmpty()) {
log.debug("there already are active businesses, aborting $name")
return Result.success()
}
val userWallets = SeedManager.loadAndDecryptOrNull(
phoenixGlobal = PhoenixGlobal(
ctx = PlatformContext(
applicationContext
)
)
)
if (userWallets.isNullOrEmpty()) {
log.debug("could not load any seed, aborting $name")
return Result.success()
}
val watchResult = userWallets.map { (walletId, wallet) ->
val walletMetadata = (applicationContext as MantraApplication).globalPrefs.getAvailableWalletsMeta.first().getByWalletIdOrDefault(walletId)
watchWallet(walletId, walletMetadata, wallet.words)
}
BusinessManager.stopAllHeadlessBusinesses()
return if (watchResult.all { it }) {
log.info("finished $name, watchers have all terminated successfully")
Result.success()
} else {
log.info("finished $name, one or more watchers encountered an error")
Result.failure()
}
}
private suspend fun watchWallet(walletId: WalletId, walletMetadata: UserWalletMetadata, words: List<String>): Boolean {
try {
val dataStoreManager = DataStoreManager(
ctx = PlatformContext(
applicationContext
),
chain = chain,
)
val internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId = walletId)
val inFlightPaymentsCount = internalPrefs.getInFlightPaymentsCount.first()
if (inFlightPaymentsCount == 0) {
log.info("aborting $name: expecting NO in-flight payments")
return true
}
val res = BusinessManager.startNewBusiness(words = words, isHeadless = true)
if (res is StartBusinessResult.Failure) {
log.info("failed to start business for wallet=$walletId")
return false
}
val business = BusinessManager.businessFlow.value[walletId]?.business
if (business == null) {
log.info("failed to access business for wallet=$walletId")
return false
}
withContext(Dispatchers.Default) {
val stopJobs = MutableStateFlow(false)
val jobTimer = launch {
delay(2.minutes)
log.info("stopping $name-$walletId after 2 minutes without resolution - show notification")
scheduleOnce(applicationContext)
SystemNotificationHelper.notifyInFlightHtlc(applicationContext, walletMetadata)
stopJobs.value = true
}
val watcher = launch {
business.appConnectionsDaemon?.forceReconnect()
business.connectionsManager.connections.first { it.global is Connection.ESTABLISHED }
log.debug("watching in-flight payments for wallet={}", walletId)
business.peerManager.channelsFlow.filterNotNull().collectIndexed { index, channels ->
val paymentsCount = channels.inFlightPaymentsCount()
internalPrefs.saveInFlightPaymentsCount(paymentsCount)
when {
channels.isEmpty() -> {
log.info("no channels found, successfully terminating watcher (#$index)")
stopJobs.value = true
}
channels.any { it.value.state is Syncing } -> {
log.debug("channels syncing, pausing 10s before next check (#$index)")
delay(10.seconds)
}
paymentsCount > 0 -> {
log.debug("$paymentsCount payments in-flight, pausing 5s before next check (#$index)...")
delay(5.seconds)
}
else -> {
log.info("$paymentsCount payments in-flight, successfully completing worker (#$index)...")
stopJobs.value = true
}
}
}
}
stopJobs.first { it }
log.debug("stop-job signal detected")
watcher.cancelAndJoin()
jobTimer.cancelAndJoin()
}
return true
} catch (e: Exception) {
log.error("error in $name-$walletId: ", e)
return false
}
}
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
const val name = "inflight-payments-watcher"
const val TAG = BuildConfig.APPLICATION_ID + ".InflightPaymentsWatcher"
/** Schedule a in-flight payments watcher job to start every few hours. */
fun schedulePeriodic(context: Context) {
log.info("scheduling periodic $name")
val work = PeriodicWorkRequest.Builder(InflightPaymentsWatcher::class.java, 2, TimeUnit.HOURS, 3, TimeUnit.HOURS).addTag(TAG)
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
}
/** Schedule an in-flight payments job to run once in [delay] from now (by default, 2 hours). Existing schedules are replaced. */
fun scheduleOnce(context: Context, delay: Duration = 2.hours) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
log.info("scheduling $name in $delay from now")
val work = OneTimeWorkRequestBuilder<InflightPaymentsWatcher>().setInitialDelay(delay.toJavaDuration()).build()
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work)
} else {
log.error("Couldn't schedule $name in $delay from now")
}
}
/** Cancel all scheduled in-flight payments worker. */
fun cancel(context: Context): Operation {
return WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
}
}
}
@@ -1,165 +0,0 @@
package fr.acinq.phoenix.android.services
import android.app.Service
import android.content.Intent
import android.os.Handler
import android.os.Looper
import androidx.core.app.NotificationManagerCompat
import press.mantra.android.BuildConfig
import press.mantra.android.MantraApplication
import fr.acinq.lightning.utils.Connection
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.android.BusinessManager
import fr.acinq.phoenix.data.DecryptSeedResult
import fr.acinq.phoenix.data.StartBusinessResult
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.managers.AppConnectionsDaemon
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.utils.PlatformContext
import fr.acinq.phoenix.utils.SystemNotificationHelper
import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* This foreground service starts a [PhoenixBusiness] upon receiving FCM payment messages from the LSP. Being a foreground
* service, it needs to display a foreground notification visible by the user.
*
* This service allows Phoenix to receive payments (or settle pending payments) even when it's closed or in the background.
*/
class PaymentsForegroundService : Service() {
private val log = LoggerFactory.getLogger(this::class.java)
private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private lateinit var notificationManager: NotificationManagerCompat
override fun onCreate() {
super.onCreate()
log.debug("creating node service...")
notificationManager = NotificationManagerCompat.from(this)
log.debug("service created")
}
override fun onBind(intent: Intent?) = null
private val shutdownHandler = Handler(Looper.getMainLooper())
private val shutdownRunnable: Runnable = Runnable {
log.info("reached scheduled shutdown while headless")
stopForeground(STOP_FOREGROUND_REMOVE)
BusinessManager.stopAllHeadlessBusinesses()
stopSelf()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
log.info("start service from intent [ intent=$intent, flag=$flags, startId=$startId ]")
val reason = intent?.getStringExtra(EXTRA_REASON)
val walletId = intent?.getStringExtra(EXTRA_NODE_ID_HASH)?.let { WalletId(it) }
val businessMap = BusinessManager.businessFlow.value
// the notification for the foreground service depends on whether the business is started or not.
val shouldWeWaitLong: Boolean = when {
walletId != null && businessMap[walletId] != null -> {
log.info("active business found for wallet=$walletId, ignoring background message (reason=$reason)")
businessMap[walletId]?.business?.let {
if (it.connectionsManager.connections.value.peer !is Connection.ESTABLISHED) {
it.appConnectionsDaemon?.forceReconnect(AppConnectionsDaemon.ControlTarget.Peer)
}
}
false
}
walletId == null -> {
log.info("no wallet_id provided, ignoring background message (reason=$reason)")
false
}
else -> {
when (val result = SeedManager.loadAndDecrypt(
phoenixGlobal = PhoenixGlobal(
ctx = PlatformContext(
applicationContext
)
)
)) {
is DecryptSeedResult.Failure.SeedFileNotFound -> {
log.info("seed not found, ignoring background message (reason=$reason)")
false
}
is DecryptSeedResult.Failure -> {
log.info("unable to read seed, ignoring background message (reason=$reason)")
serviceScope.launch {
val walletMetadataMap = (application as MantraApplication).globalPrefs.getAvailableWalletsMeta.first()
val metadata = walletMetadataMap.getByWalletIdOrDefault(walletId)
when (reason) {
"IncomingPayment" -> SystemNotificationHelper.notifyPaymentMissedAppUnavailable(applicationContext, metadata)
"PendingSettlement" -> SystemNotificationHelper.notifyPendingSettlement(applicationContext, metadata)
else -> Unit
}
}
false
}
is DecryptSeedResult.Success -> {
val userWallets = result.userWalletsMap
val wallet = userWallets[walletId]
if (wallet == null) {
log.info("seed not found for node_id=$walletId, ignoring background message (reason=$reason)")
false
} else {
serviceScope.launch(Dispatchers.Default) {
when (val res = BusinessManager.startNewBusiness(wallet.words, isHeadless = true)) {
is StartBusinessResult.Failure -> {
log.error("error when starting wallet=$walletId... from foreground service: $res")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
is StartBusinessResult.Success -> Unit
}
}
true
}
}
}
}
}
// show a notification -- this is mandatory!
SystemNotificationHelper.notifyRunningHeadless(applicationContext)
// startForeground(notif)
// service will automatically shutdown -- delay is short if the message is ignored
shutdownHandler.removeCallbacksAndMessages(null)
shutdownHandler.postDelayed(shutdownRunnable, if (shouldWeWaitLong) 2.minutes.inWholeMilliseconds else 5.seconds.inWholeMilliseconds)
return START_NOT_STICKY
}
// private fun startForeground(notif: Notification) {
// if (Build.VERSION.SDK_INT >= 34) {
// ServiceCompat.startForeground(this, SystemNotificationHelper.HEADLESS_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
// } else {
// startForeground(SystemNotificationHelper.HEADLESS_NOTIF_ID, notif)
// }
// }
override fun onDestroy() {
super.onDestroy()
log.info("foreground service destroyed")
}
companion object {
const val EXTRA_REASON = "${BuildConfig.APPLICATION_ID}.FCM_MESSAGE.REASON"
const val EXTRA_NODE_ID_HASH = "${BuildConfig.APPLICATION_ID}.FCM_MESSAGE.NODE_ID_HASH"
}
}
@@ -1,6 +0,0 @@
package fr.acinq.phoenix.data
import fr.acinq.lightning.io.TcpSocket
import fr.acinq.lightning.utils.ServerAddress
actual fun platformElectrumRegtestConf(): ServerAddress = ServerAddress(host = "10.0.2.2", port = 51002, tls = TcpSocket.TLS.DISABLED)
@@ -1,52 +0,0 @@
package fr.acinq.phoenix.db
import androidx.sqlite.db.SupportSQLiteDatabase
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import fr.acinq.phoenix.db.migrations.v10.AfterVersion10
import fr.acinq.phoenix.db.migrations.v11.AfterVersion11
import fr.acinq.phoenix.db.sqldelight.AppDatabase
import fr.acinq.phoenix.db.sqldelight.ChannelsDatabase
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
import fr.acinq.phoenix.utils.PlatformContext
actual fun createChannelsDbDriver(ctx: PlatformContext, fileName: String): SqlDriver {
return AndroidSqliteDriver(
schema = ChannelsDatabase.Schema,
context = ctx.applicationContext,
name = fileName,
callback = object : AndroidSqliteDriver.Callback(schema = ChannelsDatabase.Schema) {
override fun onConfigure(db: SupportSQLiteDatabase) {
super.onConfigure(db)
db.setForeignKeyConstraintsEnabled(true)
}
}
)
}
actual fun createPaymentsDbDriver(ctx: PlatformContext, fileName: String, onError: (String) -> Unit): SqlDriver {
return AndroidSqliteDriver(
schema = PaymentsDatabase.Schema,
context = ctx.applicationContext,
name = fileName,
callback = object : AndroidSqliteDriver.Callback(
schema = PaymentsDatabase.Schema,
AfterVersion10(onError),
AfterVersion11(onError),
) {
override fun onConfigure(db: SupportSQLiteDatabase) {
super.onConfigure(db)
db.setForeignKeyConstraintsEnabled(true)
}
}
)
}
actual fun createAppDbDriver(ctx: PlatformContext): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, ctx.applicationContext, "appdb.sqlite", callback = object : AndroidSqliteDriver.Callback(schema = AppDatabase.Schema) {
override fun onConfigure(db: SupportSQLiteDatabase) {
super.onConfigure(db)
db.setForeignKeyConstraintsEnabled(true)
}
})
}
@@ -1,16 +0,0 @@
package fr.acinq.phoenix.db
import fr.acinq.lightning.utils.UUID
import fr.acinq.phoenix.db.payments.CloudKitInterface
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
actual fun didSaveWalletPayment(id: UUID, database: PaymentsDatabase) {}
actual fun didDeleteWalletPayment(id: UUID, database: PaymentsDatabase) {}
actual fun didUpdateWalletPaymentMetadata(id: UUID, database: PaymentsDatabase) {}
actual fun didSaveContact(contactId: UUID, database: PaymentsDatabase) {}
actual fun didDeleteContact(contactId: UUID, database: PaymentsDatabase) {}
actual fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? {
return null
}
@@ -1,14 +0,0 @@
package fr.acinq.phoenix.managers
import fr.acinq.phoenix.utils.PlatformContext
import okio.Path
import okio.Path.Companion.toOkioPath
actual fun computePreferencePath(
platformContext: PlatformContext,
dataStoreFileName: String,
): Path {
return platformContext.applicationContext.filesDir.resolve(
relative = dataStoreFileName
).toOkioPath()
}
@@ -1,90 +0,0 @@
package fr.acinq.phoenix.managers.global
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import fr.acinq.lightning.logging.LoggerFactory
import fr.acinq.lightning.logging.debug
import fr.acinq.lightning.logging.info
import fr.acinq.phoenix.utils.PlatformContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.atomic.AtomicBoolean
actual class NetworkMonitor actual constructor(loggerFactory: LoggerFactory, val ctx: PlatformContext) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
val logger = loggerFactory.newLogger(this::class)
private val isCallbackRegistered = AtomicBoolean(false)
private val _networkState = MutableStateFlow(NetworkState.NotAvailable)
actual val networkState: StateFlow<NetworkState> = _networkState
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
logger.debug { "network is now $network" }
_networkState.value = NetworkState.Available
}
override fun onBlockedStatusChanged(network: Network, blocked: Boolean) {
super.onBlockedStatusChanged(network, blocked)
logger.debug { "block status change to $blocked for network=$network"}
}
override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) {
logger.debug { "default network changed capabilities to $networkCapabilities" }
}
override fun onLinkPropertiesChanged(network : Network, linkProperties : LinkProperties) {
logger.debug { "default network changed link properties to $linkProperties" }
}
override fun onLosing(network: Network, maxMsToLive: Int) {
super.onLosing(network, maxMsToLive)
logger.debug { "losing network in ${maxMsToLive}ms..." }
}
override fun onLost(network: Network) {
super.onLost(network)
logger.info { "network has been lost" }
_networkState.value = NetworkState.NotAvailable
}
override fun onUnavailable() {
super.onUnavailable()
logger.info { "network is unavailable" }
_networkState.value = NetworkState.NotAvailable
}
}
actual fun enable() {
}
actual fun disable() {
}
actual fun start() {
val connectivityManager = ctx.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (isCallbackRegistered.compareAndSet(false, true)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityManager.registerDefaultNetworkCallback(networkCallback)
} else {
// TODO: Implement logic to register connectivity monitor
}
}
}
actual fun stop() {
val connectivityManager = ctx.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.unregisterNetworkCallback(networkCallback)
isCallbackRegistered.set(false)
}
}
@@ -1,23 +0,0 @@
package fr.acinq.phoenix.security
import co.touchlab.kermit.Logger
import fr.acinq.phoenix.utils.extensions.tryWith
import java.security.GeneralSecurityException
actual fun keyStoreDecryption(
keyName: String,
iv: ByteArray,
ciphertext: ByteArray
): ByteArray {
Logger.withTag("KeyStoreFunctions").i("IV length: ${iv.size}")
return KeystoreHelper.getDecryptionCipher(keyName, iv).doFinal(ciphertext)
}
actual fun keyStoreEncryption(keyName: String, plainText: ByteArray): Pair<ByteArray, ByteArray> = tryWith(GeneralSecurityException()) {
val cipher = KeystoreHelper.getEncryptionCipher(keyName)
Logger.withTag("KeyStoreFunctions").i("IV for encryption: ${cipher.iv.size}")
return Pair(
cipher.iv,
cipher.doFinal(plainText)
)
}
@@ -1,116 +0,0 @@
/*
* Copyright 2020 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.security
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import co.touchlab.kermit.Logger
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
object KeystoreHelper {
private val log = Logger.withTag("KeystoreHelper")
private val ENC_ALGO = KeyProperties.KEY_ALGORITHM_AES
private val ENC_BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
private val ENC_PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
private val keyStore by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
}
private fun getOrCreateKeyNoAuthRequired(): SecretKey {
keyStore.getKey(KeyStoreNames.KEY_NO_AUTH, null)?.let { return it as SecretKey }
val spec = KeyGenParameterSpec.Builder(KeyStoreNames.KEY_NO_AUTH, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).apply {
setBlockModes(ENC_BLOCK_MODE)
setEncryptionPaddings(ENC_PADDING)
setRandomizedEncryptionRequired(true)
setKeySize(256)
setUserAuthenticationRequired(false)
setInvalidatedByBiometricEnrollment(false)
}
return generateKeyWithSpec(spec)
}
/** Generate key from key gen specs. If possible, store the key in strongbox. */
private fun generateKeyWithSpec(spec: KeyGenParameterSpec.Builder): SecretKey {
val keygen = KeyGenerator.getInstance(ENC_ALGO, keyStore.provider)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// try to use strongbox on Android 9+, which is only supported by a few devices
try {
spec.setIsStrongBoxBacked(true)
keygen.init(spec.build())
keygen.generateKey()
} catch (e: Exception) {
log.w("failed to generate key with strongbox enabled: ${e.javaClass.simpleName}: ${e.localizedMessage}, trying again without strongbox")
spec.setIsStrongBoxBacked(false)
keygen.init(spec.build())
keygen.generateKey()
}
} else {
keygen.init(spec.build())
keygen.generateKey()
}
}
private fun getKeyForName(keyName: String): SecretKey = when (keyName) {
KeyStoreNames.KEY_NO_AUTH -> getOrCreateKeyNoAuthRequired()
KeyStoreNames.KEY_FOR_PINCODE_V1 -> getOrCreateKeyNoAuthRequired()
else -> throw IllegalArgumentException("unhandled key=$keyName")
}
/**
* Get the encryption cipher for the key. If it fails once, delete the key, and try again. This should only be used by the seed fallback to check if the
* keystore is accessible. This seems to (rarely) happen after an OS update that fails to upgrade the key (raising a `upgrade_keyblob_if_required_with`
* error), and might be related to the secure element option?
*/
fun checkEncryptionCipherOrReset(keyName: String) = when (keyName) {
KeyStoreNames.KEY_NO_AUTH -> {
try {
getEncryptionCipher(keyName)
} catch (e: Exception) {
log.e("could not get encryption cipher: ${e.localizedMessage}")
try {
log.e("deleting key=$keyName from keystore")
keyStore.deleteEntry(keyName)
getEncryptionCipher(keyName)
} catch (e: Exception) {
log.e("cannot delete $keyName entry from keystore: ${e.localizedMessage}")
throw e
}
}
}
else -> {
throw IllegalArgumentException("unhandled key_name=$keyName")
}
}
/** Get encryption Cipher for given key. */
internal fun getEncryptionCipher(keyName: String): Cipher = Cipher.getInstance("$ENC_ALGO/$ENC_BLOCK_MODE/$ENC_PADDING").apply {
init(Cipher.ENCRYPT_MODE, getKeyForName(keyName), parameters)
}
/** Get decryption Cipher for given key. */
internal fun getDecryptionCipher(keyName: String, iv: ByteArray): Cipher = Cipher.getInstance("$ENC_ALGO/$ENC_BLOCK_MODE/$ENC_PADDING").apply {
init(Cipher.DECRYPT_MODE, getKeyForName(keyName), IvParameterSpec(iv))
}
}
@@ -1,91 +0,0 @@
package fr.acinq.phoenix.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.content.FileProvider
import press.mantra.android.BuildConfig
import fr.acinq.lightning.utils.currentTimestampMillis
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileOutputStream
object ContactsPhotoHelper {
private val log = LoggerFactory.getLogger(this::class.java)
fun contactsDir(context: Context) = File(context.filesDir, "contacts")
/** Creates a temporary file where the camera activity will store the picture in. File is deleted on app exit. */
fun createTempContactPictureUri(
context: Context,
): Uri? {
val cacheContactsDir = File(context.cacheDir, "contacts") // using cacheDir !
if (!cacheContactsDir.exists()) cacheContactsDir.mkdir()
return try {
val tempFile = File.createTempFile("contact_", ".png", cacheContactsDir)
tempFile.deleteOnExit()
FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tempFile)
} catch (e: Exception) {
log.error("failed to write temporary file for contact: {}", e.localizedMessage)
null
}
}
/** Creates the final picture from the temporary picture URI returned by the camera activity. The picture is resized and compressed. Returns the file name (in filesDir/contacts). */
fun createPermaContactPicture(
context: Context,
tempFileUri: Uri,
): String? {
val contactsDir = contactsDir(context)
if (!contactsDir.exists()) contactsDir.mkdir()
return try {
val orientation = context.contentResolver.openInputStream(tempFileUri)?.use {
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
} ?: ExifInterface.ORIENTATION_UNDEFINED
val bitmap = context.contentResolver.openInputStream(tempFileUri)?.use {
BitmapFactory.decodeStream(it)
} ?: return null
val scale = (480f / bitmap.width.coerceAtLeast(bitmap.height)).coerceAtMost(1f)
val matrix = Matrix().apply {
postScale(scale, scale)
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> postRotate(270f)
}
}
val scaledBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, false)
val photoFile = File(contactsDir, "contact_${currentTimestampMillis()}.jpg")
FileOutputStream(photoFile).use { scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 50, it) }
photoFile.name
} catch (e: Exception) {
log.error("failed to write contact photo to disk: {}", e.localizedMessage)
null
}
}
fun getPhotoForFile(
context: Context,
fileName: String
): ImageBitmap? {
val contactsDir = contactsDir(context)
if (!contactsDir.exists() || !contactsDir.canRead()) return null
return try {
val photoFile = File(contactsDir, fileName)
val content = photoFile.readBytes()
BitmapFactory.decodeByteArray(content, 0, content.size).asImageBitmap()
} catch (e: Exception) {
log.info("could not read contact photo=$fileName: ", e.localizedMessage)
null
}
}
}
@@ -1,62 +0,0 @@
/*
* Copyright 2019 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.utils
import android.content.Context
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
object Logging {
private const val LOGS_DIR = "logs"
private const val CURRENT_LOG_FILE = "phoenix.log"
private const val ARCHIVED_LOG_FILE = "phoenix.archive-%i.log"
fun exportLogFile(context: Context): File {
val export = File(File(context.filesDir, LOGS_DIR), "phoenix_export.log")
val exportOutputStream = FileOutputStream(export)
val exportChannel = exportOutputStream.channel
// write archive-1 to export file, if available
File(File(context.filesDir, LOGS_DIR), "phoenix.archive-1.log").takeIf {
it.exists() && it.isFile && it.canRead()
}?.let {
FileInputStream(it)
}?.also {
val channel = it.channel
channel.transferTo(0, channel.size(), exportChannel)
channel.close()
}?.close()
// write current log file to export file, if available
File(File(context.filesDir, LOGS_DIR), CURRENT_LOG_FILE).takeIf {
it.exists() && it.isFile && it.canRead()
}?.let {
FileInputStream(it)
}?.also {
val channel = it.channel
channel.transferTo(0, channel.size(), exportChannel)
channel.close()
}?.close()
exportChannel.close()
exportOutputStream.close()
return export
}
}
@@ -1,16 +0,0 @@
package fr.acinq.phoenix.utils
import android.content.Context
actual class PlatformContext(val applicationContext: Context)
actual fun getApplicationFilesDirectoryPath(ctx: PlatformContext): String =
ctx.applicationContext.filesDir.absolutePath
actual fun getDatabaseFilesDirectoryPath(ctx: PlatformContext): String? = null
actual fun getApplicationCacheDirectoryPath(ctx: PlatformContext): String =
ctx.applicationContext.cacheDir.absolutePath
actual fun getTemporaryDirectoryPath(ctx: PlatformContext): String =
ctx.applicationContext.cacheDir.absolutePath
@@ -1,335 +0,0 @@
/*
* Copyright 2021 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.utils
import android.Manifest
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.text.format.DateUtils
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.net.toUri
import press.mantra.android.BuildConfig
import press.mantra.android.MainActivity
import press.mantra.android.R
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.LiquidityEvents
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.data.BitcoinUnit
import fr.acinq.phoenix.data.ExchangeRate
import fr.acinq.phoenix.data.FiatCurrency
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.utils.converters.AmountFormatter.toPrettyString
import fr.acinq.phoenix.utils.converters.DateFormatter.toAbsoluteDateString
import fr.acinq.phoenix.utils.preferences.UserPrefs
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import kotlinx.coroutines.flow.first
import org.slf4j.LoggerFactory
import java.text.DecimalFormat
object SystemNotificationHelper {
private const val PAYMENT_FAILED_NOTIF_ID = 354319
private const val PAYMENT_FAILED_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.PAYMENT_FAILED_NOTIF"
private const val PAYMENT_RECEIVED_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.PAYMENT_RECEIVED_NOTIF"
private const val SETTLEMENT_PENDING_NOTIF_ID = 354322
private const val SETTLEMENT_PENDING_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.SETTLEMENT_PENDING_NOTIF"
const val HEADLESS_NOTIF_ID = 354321
const val HEADLESS_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.BACKGROUND_PROCESSING"
private const val CHANNELS_WATCHER_ALERT_ID = 354324
private const val CHANNELS_WATCHER_ALERT_CHANNEL = "${BuildConfig.APPLICATION_ID}.CHANNELS_WATCHER"
private const val SWAP_TIMEOUT_ID = 354325
private const val SWAP_TIMEOUT_CHANNEL = "${BuildConfig.APPLICATION_ID}.SWAP_TIMEOUT"
private val log = LoggerFactory.getLogger(this::class.java)
/** If the remaining blocks count before a swap timeout is lower than this, we should mention it in the notification. */
private const val SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS = 144 * 30 * 2 // ~2 months
fun registerNotificationChannels(context: Context) {
// notification channels (android 8+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.getSystemService(NotificationManager::class.java)?.createNotificationChannels(
listOf(
// TODO: Setup notifications...
// NotificationChannel(HEADLESS_NOTIF_CHANNEL, context.getString(R.string.notification_headless_title), NotificationManager.IMPORTANCE_DEFAULT).apply {
// description = context.getString(R.string.notification_headless_desc)
// },
// NotificationChannel(CHANNELS_WATCHER_ALERT_CHANNEL, context.getString(R.string.notification_channels_watcher_title), NotificationManager.IMPORTANCE_HIGH).apply {
// description = context.getString(R.string.notification_channels_watcher_desc)
// },
// NotificationChannel(SETTLEMENT_PENDING_NOTIF_CHANNEL, context.getString(R.string.notification_pending_settlement_title), NotificationManager.IMPORTANCE_HIGH).apply {
// description = context.getString(R.string.notification_pending_settlement_desc)
// },
// NotificationChannel(PAYMENT_RECEIVED_NOTIF_CHANNEL, context.getString(R.string.notification_received_payment_title), NotificationManager.IMPORTANCE_LOW).apply {
// description = context.getString(R.string.notification_received_payment_desc)
// },
// NotificationChannel(PAYMENT_FAILED_NOTIF_CHANNEL, context.getString(R.string.notification_missed_payment_title), NotificationManager.IMPORTANCE_DEFAULT).apply {
// description = context.getString(R.string.notification_missed_payment_desc)
// },
// NotificationChannel(SWAP_TIMEOUT_CHANNEL, context.getString(R.string.notification_swap_timeout_title), NotificationManager.IMPORTANCE_HIGH).apply {
// description = context.getString(R.string.notification_swap_timeout_desc)
// },
)
)
}
}
fun notifyRunningHeadless(context: Context): Notification {
return NotificationCompat.Builder(context, HEADLESS_NOTIF_CHANNEL).apply {
// TODO: setContentTitle(context.getString(Res.string.notif_headless_title_default))
setContentTitle("Running Headless")
// TODO: setSmallIcon(R.drawable.ic_phoenix_outline)
}.build().also {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(HEADLESS_NOTIF_ID, it)
}
}
}
private fun notifyPaymentFailed(context: Context, walletMetadata: UserWalletMetadata, title: String, message: String, deepLink: String?): Notification {
return NotificationCompat.Builder(context, PAYMENT_FAILED_NOTIF_CHANNEL).apply {
setContentTitle(getTitleForWallet(walletMetadata, title))
setContentText(message)
setStyle(NotificationCompat.BigTextStyle().bigText(message))
// TODO: setSmallIcon(R.drawable.ic_phoenix_outline)
val intent = deepLink?.let {
Intent(Intent.ACTION_VIEW, it.toUri(), context, MainActivity::class.java)
} ?: Intent(context, MainActivity::class.java)
setContentIntent(
TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
}
)
setAutoCancel(true)
}.build().also {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(PAYMENT_FAILED_NOTIF_ID, it)
}
}
}
private fun getTitleForWallet(walletMetadata: UserWalletMetadata, title: String): String {
return "${walletMetadata.avatar} $title"
}
fun notifyPaymentRejectedPolicyDisabled(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, nextTimeoutRemainingBlocks: Int?): Notification {
return notifyPaymentFailed(
context = context,
title = "Payment rejected policy disabled",
// TODO: title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title,
// amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)),
message = when {
source == LiquidityEvents.Source.OnChainWallet && nextTimeoutRemainingBlocks != null && nextTimeoutRemainingBlocks < SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS -> {
nextTimeoutRemainingBlocks * 10 * DateUtils.MINUTE_IN_MILLIS
// TODO: context.getString(R.string.notif_rejected_policy_disabled_timeout, (currentTimestampMillis() + remainingTimeMillis).toAbsoluteDateString())
"Rejected policy disabled timeout"
}
else -> {
// TODO: context.getString(R.string.notif_rejected_policy_disabled)
"Rejected policy disabled"
}
},
walletMetadata = walletMetadata,
deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId",
)
}
fun notifyPaymentRejectedOverAbsolute(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, fee: MilliSatoshi, absoluteMax: Satoshi, nextTimeoutRemainingBlocks: Int?): Notification {
return notifyPaymentFailed(
context = context,
title = getTitleForWallet(walletMetadata, context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title,
amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true))),
message = when {
source == LiquidityEvents.Source.OnChainWallet && nextTimeoutRemainingBlocks != null && nextTimeoutRemainingBlocks < SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS -> {
val remainingTimeMillis = nextTimeoutRemainingBlocks * 10 * DateUtils.MINUTE_IN_MILLIS
context.getString(
R.string.notif_rejected_over_absolute_timeout, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true),
absoluteMax.toPrettyString(BitcoinUnit.Sat, withUnit = true), (currentTimestampMillis() + remainingTimeMillis).toAbsoluteDateString()
)
}
else -> {
context.getString(R.string.notif_rejected_over_absolute, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true),
absoluteMax.toPrettyString(BitcoinUnit.Sat, withUnit = true))
}
},
walletMetadata = walletMetadata,
deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId",
)
}
fun notifyPaymentRejectedOverRelative(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, fee: MilliSatoshi, percentMax: Int, nextTimeoutRemainingBlocks: Int?): Notification {
return notifyPaymentFailed(
context = context,
title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title,
amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)),
message = when {
source == LiquidityEvents.Source.OnChainWallet && nextTimeoutRemainingBlocks != null && nextTimeoutRemainingBlocks < SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS -> {
val remainingTimeMillis = nextTimeoutRemainingBlocks * 10 * DateUtils.MINUTE_IN_MILLIS
context.getString(R.string.notif_rejected_over_relative_timeout, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true),
DecimalFormat("0.##").format(percentMax.toDouble() / 100), (currentTimestampMillis() + remainingTimeMillis).toAbsoluteDateString()
)
}
else -> {
context.getString(R.string.notif_rejected_over_relative, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true),
DecimalFormat("0.##").format(percentMax.toDouble() / 100))
}
},
walletMetadata = walletMetadata,
deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId",
)
}
fun notifyPaymentRejectedAmountTooLow(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi): Notification {
return notifyPaymentFailed(
context = context,
title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title,
amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)),
message = context.getString(R.string.notif_rejected_amount_too_low),
walletMetadata = walletMetadata,
deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId",
)
}
fun notifyPaymentRejectedFundingError(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi): Notification {
return notifyPaymentFailed(
context = context,
title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title,
amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)),
message = context.getString(R.string.notif_rejected_generic_error),
walletMetadata = walletMetadata,
deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId",
)
}
fun notifyPaymentMissedAppUnavailable(context: Context, walletMetadata: UserWalletMetadata): Notification {
return notifyPaymentFailed(
context = context,
title = context.getString(R.string.notif_missed_title),
message = context.getString(R.string.notif_missed_unavailable),
walletMetadata = walletMetadata,
deepLink = null
)
}
fun notifyPendingSettlement(context: Context, walletMetadata: UserWalletMetadata): Notification {
return NotificationCompat.Builder(context, SETTLEMENT_PENDING_NOTIF_CHANNEL).apply {
setContentTitle(getTitleForWallet(walletMetadata, context.getString(R.string.notif_pending_settlement_title)))
setContentText(context.getString(R.string.notif_pending_settlement_message))
setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_pending_settlement_message)))
setSmallIcon(R.drawable.ic_launcher_foreground)
setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE))
setAutoCancel(true)
}.build().also {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(SETTLEMENT_PENDING_NOTIF_ID, it)
}
}
}
fun notifyInFlightHtlc(context: Context, walletMetadata: UserWalletMetadata): Notification {
return NotificationCompat.Builder(context, SETTLEMENT_PENDING_NOTIF_CHANNEL).apply {
setContentTitle(getTitleForWallet(walletMetadata, context.getString(R.string.notif_inflight_payment_title)))
setContentText(context.getString(R.string.notif_inflight_payment_message))
setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_inflight_payment_message)))
setSmallIcon(R.drawable.ic_launcher_foreground)
setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE))
setAutoCancel(true)
}.build().also {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(SETTLEMENT_PENDING_NOTIF_ID, it)
}
}
}
suspend fun notifyPaymentsReceived(
context: Context,
userPrefs: UserPrefs,
walletId: WalletId,
userWalletMetadata: UserWalletMetadata,
paymentId: UUID,
paymentAmount: MilliSatoshi,
rates: List<ExchangeRate>,
): Notification {
val isFiat = userPrefs.getIsAmountInFiat.first() && rates.isNotEmpty()
val unit = if (isFiat) {
userPrefs.getFiatCurrencies.first().primary
} else {
userPrefs.getBitcoinUnits.first().primary
}
val rate = if (isFiat) {
when (val rate = rates.find { it.fiatCurrency == unit }) {
is ExchangeRate.BitcoinPriceRate -> rate
is ExchangeRate.UsdPriceRate -> {
(rates.find { it.fiatCurrency == FiatCurrency.USD } as? ExchangeRate.BitcoinPriceRate)?.let { usdRate ->
// create a BTC/Fiat price rate using the USD/BTC rate and the Fiat/USD rate.
ExchangeRate.BitcoinPriceRate(
fiatCurrency = rate.fiatCurrency,
price = rate.price * usdRate.price,
source = rate.source,
timestampMillis = rate.timestampMillis
)
}
}
else -> null
}
} else null
return NotificationCompat.Builder(context, PAYMENT_RECEIVED_NOTIF_CHANNEL).apply {
setContentTitle(context.getString(R.string.notif_headless_received, userWalletMetadata.avatar, paymentAmount.toPrettyString(unit, rate, withUnit = true)))
setSmallIcon(R.drawable.ic_launcher_foreground)
val intent = Intent(Intent.ACTION_VIEW,"phoenix:payments/$walletId/$paymentId".toUri(), context, MainActivity::class.java).apply {
Intent.FLAG_ACTIVITY_SINGLE_TOP
}
setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT))
setAutoCancel(true)
}.build().also {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(currentTimestampMillis().toInt(), it)
}
}
}
fun notifyRevokedCommits(context: Context) {
NotificationCompat.Builder(context, CHANNELS_WATCHER_ALERT_CHANNEL).apply {
setContentTitle(context.getString(R.string.notif_watcher_revoked_commit_title))
setContentText(context.getString(R.string.notif_watcher_revoked_commit_message))
setSmallIcon(R.drawable.ic_launcher_foreground)
setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE))
setAutoCancel(true)
}.let {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(CHANNELS_WATCHER_ALERT_ID, it.build())
}
}
}
}
@@ -1,55 +0,0 @@
/*
* 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.utils.converters
import android.annotation.SuppressLint
import android.text.format.DateUtils
import androidx.compose.runtime.Composable
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import kotlin.math.abs
object DateFormatter {
/** Converts this millis timestamp into a relative string date. */
@Composable
fun Long.toRelativeDateString(): String {
val now = System.currentTimeMillis()
val delay: Long = this - now
return if (abs(delay) < 60 * 1000L) { // less than 1 minute ago
// TODO: stringResource(id = R.string.utils_date_just_now)
"Just now"
} else {
DateUtils.getRelativeTimeSpanString(this, now, delay).toString()
}
}
/** Converts this millis timestamp into a pretty, absolute string date time using the locale format. */
fun Long.toAbsoluteDateTimeString(): String = DateFormat.getDateTimeInstance().format(Date(this))
/** Converts this millis timestamp into a pretty, absolute string date using the locale format. */
fun Long.toAbsoluteDateString(): String = DateFormat.getDateInstance().format(Date(this))
/** Converts this millis timestamp into an year-month-day string. */
@SuppressLint("SimpleDateFormat")
fun Long.toBasicAbsoluteDateString(): String = SimpleDateFormat("yyyy-MM-dd").format(Date(this))
/** Converts this millis timestamp into an year-month-day string. */
@SuppressLint("SimpleDateFormat")
fun Long.toBasicAbsoluteDateTimeString(): String = SimpleDateFormat("yyyyMMdd-HHmmss").format(Date(this))
}
@@ -1,39 +0,0 @@
/*
* Copyright 2024 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.utils.extensions
import android.content.Context
import android.content.ContextWrapper
import press.mantra.android.MainActivity
fun Context.findActivitySafe(): MainActivity? {
var context = this
while (context is ContextWrapper) {
if (context is MainActivity) return context
context = context.baseContext
}
return null
}
fun Context.findActivity(): MainActivity {
var context = this
while (context is ContextWrapper) {
if (context is MainActivity) return context
context = context.baseContext
}
throw IllegalStateException("not in the context of the main Phoenix activity")
}
@@ -1,35 +0,0 @@
package fr.acinq.phoenix.utils.extensions
import co.touchlab.kermit.Logger
import fr.acinq.phoenix.data.DecryptSeedResult
import kotlinx.serialization.SerializationException
import java.security.KeyStoreException
actual inline fun gracefulSingleSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult = try {
action.invoke()
} catch (e: Exception) {
return when (e) {
is KeyStoreException -> DecryptSeedResult.Failure.KeyStoreFailure(e)
else -> DecryptSeedResult.Failure.DecryptionError(e)
}
}
actual inline fun gracefulMultiSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult = try {
action.invoke()
} catch (e: Exception) {
val log = Logger.withTag("gracefulMultiSeedDecryption")
return when (e) {
is SerializationException, is IllegalArgumentException -> {
log.e("failed to decrypt [V2.MultipleSeed]: ${e.javaClass.simpleName}")
DecryptSeedResult.Failure.SerializationError
}
is KeyStoreException -> {
log.e("failed to decrypt [V2.MultipleSeed]: ", e)
DecryptSeedResult.Failure.KeyStoreFailure(e)
}
else -> {
log.e("failed to decrypt [V2.MultipleSeed]: ", e)
DecryptSeedResult.Failure.DecryptionError(e)
}
}
}
@@ -1,44 +0,0 @@
/*
* Copyright 2024 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.utils.extensions
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
/**
* Utility method rebinding any exceptions thrown by a method into another exception, using the origin exception as the root cause.
* Helps with pattern matching.
*/
inline fun <T> tryWith(exception: Exception, action: () -> T): T = try {
action.invoke()
} catch (t: Exception) {
exception.initCause(t)
throw exception
}
inline fun <T1 : Any, T2 : Any, R : Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2) -> R?): R? {
return if (p1 != null && p2 != null) block(p1, p2) else null
}
@OptIn(ExperimentalContracts::class)
inline fun <T, R> T.ifLet(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}
@@ -1,31 +0,0 @@
package fr.acinq.phoenix.utils.logger
import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Severity
import co.touchlab.kermit.Severity.Assert
import co.touchlab.kermit.Severity.Debug
import co.touchlab.kermit.Severity.Info
import co.touchlab.kermit.Severity.Verbose
import co.touchlab.kermit.Severity.Warn
import fr.acinq.phoenix.utils.PlatformContext
import org.slf4j.LoggerFactory
/**
* Use SLF4J writer on Android. Note that writing logs to Logcat is already done in
* phoenix-android SLF4J configuration.
*/
actual fun phoenixLogWriters(ctx: PlatformContext): List<LogWriter> = listOf(Slf4jLogWriter())
class Slf4jLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val logger = LoggerFactory.getLogger(tag)
when (severity) {
Verbose -> logger.trace(message, throwable)
Debug -> logger.debug(message, throwable)
Info -> logger.info(message, throwable)
Warn -> logger.warn(message, throwable)
Severity.Error -> logger.error(message, throwable)
Assert -> logger.error(message, throwable)
}
}
}
@@ -1,48 +0,0 @@
/*
* 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.utils.nfc
import fr.acinq.bitcoin.ByteVector
object ApduCommands {
private const val SELECT_FILE = "00A4000C02"
// structure is CLA-INS-P1-P2 -- NDEF AID -- LE field
// This AID is also the one defined in res/xml/apduservice.xml
val SELECT_AID = ByteVector("00a4040007" + "d2760000850101")
// LE field is optional, we should accept the command if LE is set
val SELECT_AID_LE = SELECT_AID.concat(0x00)
// file id of the CC file is 0xe103
val SELECT_CC_FILE = ByteVector(SELECT_FILE + "E103")
// file id of the ndef file is e104
val SELECT_NDEF_FILE = ByteVector(SELECT_FILE + "E104")
// file identifier = ndef
// maximum ndef file size = 1024b
// read/write access 00
val READ_CC = ByteVector("00B000000F")
val READ_CC_RESPONSE = ByteVector("000F20003B00340406" + "E104" + "4000" + "0000" + "9000").toByteArray()
val READ_NDEF_BINARY_LENGTH = ByteVector("00B0000002")
val READ_NDEF_BINARY = ByteVector("00B0").toByteArray()
val A_OKAY = ByteVector("9000").toByteArray()
val A_ERROR = ByteVector("6A82").toByteArray()
val NDEF_FILE = ByteVector("E104").toByteArray()
}
@@ -1,50 +0,0 @@
/*
* Copyright 2024 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.utils.nfc
import android.nfc.NdefRecord
object NfcHelper {
fun createTextRecord(text: String, id: ByteArray): NdefRecord {
val languageBytes = "en".toByteArray(Charsets.US_ASCII)
val langLength = languageBytes.size and 0x3F
val textBytes = text.toByteArray(Charsets.UTF_8)
val payload = byteArrayOf(langLength.toByte()) + languageBytes.copyOfRange(0, langLength) + textBytes
return NdefRecord(NdefRecord.TNF_WELL_KNOWN, NdefRecord.RTD_TEXT, id, payload)
}
fun fillByteArrayToFixedDimension(array: ByteArray, fixedSize: Int): ByteArray =
if (array.size >= fixedSize) {
array.copyOfRange(0, fixedSize)
} else {
fillByteArrayToFixedDimension(byteArrayOf(0x00) + array, fixedSize)
}
fun intToByteArray(value: Int): ByteArray {
if (value == 0) return byteArrayOf(0)
val bytes = mutableListOf<Byte>()
var temp = value
while (temp != 0) {
bytes.add(0, (temp and 0xFF).toByte()) // prepend to preserve big-endian order
temp = temp ushr 8
}
return bytes.toByteArray()
}
}
@@ -1,72 +0,0 @@
/*
* 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.utils.nfc
import android.nfc.NdefRecord
import org.slf4j.LoggerFactory
object NdefParser {
private val log = LoggerFactory.getLogger(this::class.java)
@OptIn(ExperimentalStdlibApi::class)
fun parseNdefRecord(record: NdefRecord): String? {
val tnf = record.tnf
val type = record.type
val payload = record.payload
log.info("parsing ndef record tnf=$tnf type=${type.toHexString()} payload=${payload.decodeToString()}")
return try {
when (tnf) {
NdefRecord.TNF_WELL_KNOWN -> {
if (type.contentEquals(NdefRecord.RTD_TEXT)) {
parseTextRecord(payload)
} else if (type.contentEquals(NdefRecord.RTD_URI)) {
parseUriRecord(payload)
} else {
log.debug("unhandled well-known record with type={}", type)
null
}
}
NdefRecord.TNF_ABSOLUTE_URI -> {
parseAbsoluteUriRecord(payload)
}
else -> {
log.debug("unhandled tnf={}", tnf)
null
}
}
} catch (e: Exception) {
log.warn("failed to parse ndef record: ${e.message}")
null
}
}
private fun parseTextRecord(payload: ByteArray): String {
val textEncoding = if (((payload[0].toInt() and 0x80) == 0)) "UTF-8" else "UTF-16"
val languageCodeLength = payload[0].toInt() and 0x3F
return String(payload, languageCodeLength + 1, payload.size - languageCodeLength - 1, charset(textEncoding))
}
private fun parseUriRecord(payload: ByteArray): String {
// URI records have a prefix byte followed by the URI
return String(payload, 1, payload.size - 1, Charsets.UTF_8)
}
private fun parseAbsoluteUriRecord(payload: ByteArray): String {
return String(payload, Charsets.UTF_8)
}
}
@@ -1,141 +0,0 @@
/*
* 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.utils.nfc
import android.nfc.NdefMessage
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import com.machankura.compose.ui.composable.widgets.nfc.NfcState
import com.machankura.compose.ui.composable.widgets.nfc.NfcStateRepository
import fr.acinq.lightning.utils.toByteVector
import fr.acinq.phoenix.android.services.HceService
import org.slf4j.LoggerFactory
/**
* This class retrieves the first NDEF message in an NFC tag.
*
* Sends a sequence of APDU commands to the tag, mirroring what's done in [HceService].
*/
class NfcReaderCallback(val onFoundData: (String) -> Unit) : NfcAdapter.ReaderCallback {
private val log = LoggerFactory.getLogger(this::class.java)
@OptIn(ExperimentalStdlibApi::class)
override fun onTagDiscovered(tag: Tag?) {
log.info("discovered tag=$tag")
val isoDep = IsoDep.get(tag)
if (isoDep == null) {
log.info("aborting tag discovery: tag does not support iso-dep")
return
}
if (!NfcStateRepository.isReading()) {
log.info("aborting tag discovery: nfc_state=${NfcStateRepository.state.value}")
isoDep.close()
return
}
try {
log.debug("connecting to tag={}", tag)
isoDep.connect()
log.info("nfc reader connected, starting communication...")
val selectCommand = ApduCommands.SELECT_AID
log.debug("SEND select aid: ${selectCommand.toByteArray().toHexString()}")
val responseSelect = isoDep.transceive(selectCommand.toByteArray())
log.debug("RECV select aid: ${responseSelect.toByteVector().toHex()}")
if (!responseSelect.contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for select aid: ${responseSelect.toHexString()}")
}
log.debug("SEND select cc: ${ApduCommands.SELECT_CC_FILE.toByteArray().toHexString()}")
val selectCC = isoDep.transceive(ApduCommands.SELECT_CC_FILE.toByteArray())
log.debug("RECV select cc: ${selectCC.toByteVector().toHex()}")
if (!selectCC.contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for select cc: ${selectCC.toHexString()}")
}
log.debug("SEND read cc: ${ApduCommands.READ_CC.toByteArray().toHexString()}")
val responseReadCC = isoDep.transceive(ApduCommands.READ_CC.toByteArray())
log.debug("RECV read cc: ${responseReadCC.toByteVector().toHex()}")
if (!responseReadCC.takeLast(2).toByteArray().contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for read cc: ${responseReadCC.toByteVector().toHex()}")
}
log.debug("SEND select ndef: ${ApduCommands.SELECT_NDEF_FILE.toByteArray().toHexString()}")
val selectNdefFile = isoDep.transceive(ApduCommands.SELECT_NDEF_FILE.toByteArray())
log.debug("RECV select ndef: ${selectNdefFile.toByteVector().toHex()}")
if (!selectNdefFile.contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for select ndef: ${selectNdefFile.toByteVector().toHex()}")
}
log.debug("SEND select bin len: ${ApduCommands.READ_NDEF_BINARY_LENGTH.toByteArray().toHexString()}")
val readBinaryLengthResponse = isoDep.transceive(ApduCommands.READ_NDEF_BINARY_LENGTH.toByteArray())
log.debug("RECV select bin len: ${readBinaryLengthResponse.toByteVector().toHex()}")
val (c,s) = readBinaryLengthResponse.dropLast(2) to readBinaryLengthResponse.takeLast(2)
if (!s.toByteArray().contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for bin length: ${s.toByteArray().toHexString()}")
}
val messageLength = c.toByteArray().toHexString().toInt(16)
log.debug("binary ndef message length=$messageLength")
// depending on the length of the message contained in the tag, we send several commands to retrieve the
// data in chunks
var offset = 2
val chunkSize = 59
var result = byteArrayOf()
while (offset < messageLength) {
val size = if (offset + chunkSize > messageLength) {
messageLength - offset + 2
} else {
chunkSize
}
val b = NfcHelper.intToByteArray(offset) + NfcHelper.intToByteArray(size)
val padded = ApduCommands.READ_NDEF_BINARY + NfcHelper.fillByteArrayToFixedDimension(b, 3)
log.debug("SEND read bin (o=$offset, len=$size): ${padded.toHexString()}")
val r = isoDep.transceive(padded)
log.debug("RECV read bin: ${r.toHexString()}")
if (!r.takeLast(2).toByteArray().contentEquals(ApduCommands.A_OKAY)) {
throw IllegalArgumentException("invalid response for bin read (o=$offset len=$size): ${r.toHexString()}")
}
result += r.dropLast(2) // must drop A_OKAY suffix
offset += chunkSize
}
val m = NdefMessage(result)
log.info("successfully read tag, found ndef_message=$m")
val d = m.records.mapNotNull {
NdefParser.parseNdefRecord(it)
}
d.firstOrNull()?.let {
onFoundData(it)
}
} catch (e: Exception) {
log.error("failed to read tag: ", e)
} finally {
isoDep.close()
if (NfcStateRepository.isReading()) {
NfcStateRepository.updateState(NfcState.Inactive)
}
log.info("terminated nfc reader callback")
}
}
}