Remove phoenix code
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-196
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
-119
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
-226
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-165
@@ -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
|
||||
}
|
||||
-14
@@ -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()
|
||||
}
|
||||
-90
@@ -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)
|
||||
}
|
||||
}
|
||||
-23
@@ -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))
|
||||
}
|
||||
-39
@@ -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")
|
||||
}
|
||||
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-44
@@ -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)
|
||||
}
|
||||
-31
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user