Skip to content

Commit

Permalink
(android) Add support for Bolt12 offers (#575)
Browse files Browse the repository at this point in the history
The offer displayed is the default offer as computed in
NodeParams.defaultOffer. An helper method has been added
in NodeParamsManager to easily get that offer.

Added several screen to attach an offer to a contact, manage
the contacts, and to list contacts. This list of contacts can be 
reached from the scanner view and the settings menu.

Contacts are stored locally in `appdb.sqlite`. A new manager
`ContactsManager` has been added.

Parsing offers is done through the ScanDataController, but
paying it is done with `Peer.payOffer`. A message can be
attached when paying an offer.

Added a new setting in the payment options to use a random 
payer key.
  • Loading branch information
dpad85 authored Jul 2, 2024
1 parent 4d8f310 commit 757c1c4
Show file tree
Hide file tree
Showing 105 changed files with 3,949 additions and 491 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/testnet-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name: Build
on:
workflow_dispatch:
push:
branches: [ master, test-ci-build ]
branches: [ master, test-ci-build, offer ]
paths:
- 'phoenix-legacy/**'
- 'phoenix-android/**'
Expand Down
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/Versions.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
object Versions {
const val lightningKmp = "1.6.3"
const val lightningKmp = "1.7.0"
const val secp256k1 = "0.14.0"
const val torMobile = "0.2.0"

Expand Down
1 change: 1 addition & 0 deletions phoenix-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ dependencies {

// firebase cloud messaging
implementation("com.google.firebase:firebase-messaging:${Versions.Android.fcm}")
implementation("com.google.android.gms:play-services-base:18.5.0")

implementation("com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.firebase.messaging.FirebaseMessaging
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.android.components.Button
Expand Down Expand Up @@ -89,6 +90,7 @@ import fr.acinq.phoenix.android.settings.walletinfo.SwapInWallet
import fr.acinq.phoenix.android.settings.walletinfo.WalletInfoView
import fr.acinq.phoenix.android.startup.LegacySwitcherView
import fr.acinq.phoenix.android.startup.StartupView
import fr.acinq.phoenix.android.utils.SystemNotificationHelper
import fr.acinq.phoenix.android.utils.appBackground
import fr.acinq.phoenix.android.utils.logger
import fr.acinq.phoenix.data.BitcoinUnit
Expand Down Expand Up @@ -149,7 +151,6 @@ fun AppView(
factory = NoticesViewModel.Factory(
appConfigurationManager = business.appConfigurationManager,
peerManager = business.peerManager,
internalDataRepository = internalData
)
)
MonitorNotices(vm = noticesViewModel)
Expand Down Expand Up @@ -248,7 +249,11 @@ fun AppView(
)
}
composable(
Screen.ScanData.route, deepLinks = listOf(
route = "${Screen.ScanData.route}?input={input}",
arguments = listOf(
navArgument("input") { type = NavType.StringType ; nullable = true },
),
deepLinks = listOf(
navDeepLink { uriPattern = "lightning:{data}" },
navDeepLink { uriPattern = "bitcoin:{data}" },
navDeepLink { uriPattern = "lnurl:{data}" },
Expand All @@ -260,6 +265,7 @@ fun AppView(
navDeepLink { uriPattern = "scanview:{data}" },
)
) {
log.info("input arg=${it.arguments?.getString("input")}")
val intent = try {
it.arguments?.getParcelable<Intent>(NavController.KEY_DEEP_LINK_INTENT)
} catch (e: Exception) {
Expand All @@ -270,12 +276,13 @@ fun AppView(
// prevents forwarding an internal deeplink intent coming from androidx-navigation framework.
// TODO properly parse deeplinks following f0ae90444a23cc17d6d7407dfe43c0c8d20e62fc
!it.contains("androidx.navigation")
}
} ?: it.arguments?.getString("input")
ScanDataView(
input = input,
onBackClick = { popToHome(navController) },
onBackClick = { navController.popBackStack() },
onAuthSchemeInfoClick = { navController.navigate("${Screen.PaymentSettings.route}/true") },
onFeeManagementClick = { navController.navigate(Screen.LiquidityPolicy.route) },
onProcessingFinished = { popToHome(navController) },
)
}
}
Expand Down Expand Up @@ -303,8 +310,10 @@ fun AppView(
paymentId = paymentId,
onBackClick = {
val previousNav = navController.previousBackStackEntry
if (!navController.popBackStack() || (fromEvent && previousNav?.destination?.route == Screen.ScanData.route)) {
if (fromEvent && previousNav?.destination?.route == Screen.ScanData.route) {
popToHome(navController)
} else {
navController.popBackStack()
}
},
fromEvent = fromEvent
Expand Down Expand Up @@ -464,17 +473,27 @@ fun AppView(
)
}
}
composable(Screen.Contacts.route) {
SettingsContactsView(onBackClick = { navController.popBackStack() })
}
}
}
}

val isDataMigrationExpected by LegacyPrefsDatastore.getDataMigrationExpected(context).collectAsState(initial = null)
val lastCompletedPayment by business.paymentsManager.lastCompletedPayment.collectAsState()
lastCompletedPayment?.let {
// log.debug { "completed payment=${lastCompletedPayment?.id()} with data-migration=$isDataMigrationExpected" }
LaunchedEffect(key1 = it.walletPaymentId()) {
val userPrefs = userPrefs
val exchangeRates = fiatRates
lastCompletedPayment?.let { payment ->
LaunchedEffect(key1 = payment.walletPaymentId()) {
if (isDataMigrationExpected == false) {
navigateToPaymentDetails(navController, id = it.walletPaymentId(), isFromEvent = true)
if (payment is IncomingPayment && payment.origin is IncomingPayment.Origin.Offer) {
SystemNotificationHelper.notifyPaymentsReceived(
context, userPrefs, paymentHash = payment.paymentHash, amount = payment.amount, rates = exchangeRates, isHeadless = false
)
} else {
navigateToPaymentDetails(navController, id = payment.walletPaymentId(), isFromEvent = true)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ sealed class Screen(val route: String) {
data object LiquidityRequest: Screen("settings/requestliquidity")
data object AdvancedLiquidityPolicy: Screen("settings/advancedliquiditypolicy")
data object Notifications: Screen("notifications")
data object Contacts: Screen("settings/contacts")
data object ResetWallet: Screen("settings/resetwallet")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,19 @@

package fr.acinq.phoenix.android

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.PowerManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository
import fr.acinq.phoenix.data.WalletNotice
import fr.acinq.phoenix.managers.AppConfigurationManager
Expand All @@ -33,29 +42,52 @@ sealed class Notice() {
abstract val priority: Int
sealed class ShowInHome(override val priority: Int) : Notice()

object MigrationFromLegacy : ShowInHome(1)
data object MigrationFromLegacy : ShowInHome(1)
data class RemoteMessage(val notice: WalletNotice) : ShowInHome(1)
object CriticalUpdateAvailable : ShowInHome(2)
object SwapInCloseToTimeout : ShowInHome(3)
object BackupSeedReminder : ShowInHome(5)
object MempoolFull : ShowInHome(10)
object UpdateAvailable : ShowInHome(20)
object NotificationPermission : ShowInHome(30)
data object CriticalUpdateAvailable : ShowInHome(2)
data object SwapInCloseToTimeout : ShowInHome(3)
data object BackupSeedReminder : ShowInHome(5)
data object MempoolFull : ShowInHome(10)
data object UpdateAvailable : ShowInHome(20)
data object NotificationPermission : ShowInHome(30)

// less important notices
sealed class DoNotShowInHome(override val priority: Int = 999) : Notice()
object WatchTowerLate : DoNotShowInHome()
data object WatchTowerLate : DoNotShowInHome()
}

class NoticesViewModel(val appConfigurationManager: AppConfigurationManager, val peerManager: PeerManager, val internalDataRepository: InternalDataRepository) : ViewModel() {
class NoticesViewModel(
val appConfigurationManager: AppConfigurationManager,
val peerManager: PeerManager,
val internalDataRepository: InternalDataRepository,
val context: Context

) : ViewModel() {
private val log = LoggerFactory.getLogger(this::class.java)

val notices = mutableStateListOf<Notice>()
var isPowerSaverModeOn by mutableStateOf(false)

private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
log.info("power_saver=${powerManager.isPowerSaveMode}")
isPowerSaverModeOn = powerManager.isPowerSaveMode
}
}

init {
viewModelScope.launch { monitorWalletContext() }
viewModelScope.launch { monitorSwapInCloseToTimeout() }
viewModelScope.launch { monitorWalletNotice() }
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
isPowerSaverModeOn = powerManager.isPowerSaveMode
context.registerReceiver(receiver, IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED))
}

override fun onCleared() {
super.onCleared()
context.unregisterReceiver(receiver)
}

fun addNotice(notice: Notice) {
Expand All @@ -70,7 +102,7 @@ class NoticesViewModel(val appConfigurationManager: AppConfigurationManager, val

private suspend fun monitorWalletContext() {
appConfigurationManager.walletContext.collect {
log.debug("collecting wallet-context=$it")
log.debug("collecting wallet-context={}", it)
val isMempoolFull = it?.isMempoolFull ?: false
val isUpdateAvailable = it?.androidLatestVersion?.let { it > BuildConfig.VERSION_CODE } ?: false
val isCriticalUpdateAvailable = it?.androidLatestCriticalVersion?.let { it > BuildConfig.VERSION_CODE } ?: false
Expand Down Expand Up @@ -98,7 +130,7 @@ class NoticesViewModel(val appConfigurationManager: AppConfigurationManager, val
combine(appConfigurationManager.walletNotice, internalDataRepository.getLastReadWalletNoticeIndex) { notice, lastReadIndex ->
notice to lastReadIndex
}.collect { (notice, lastReadIndex) ->
log.debug("collecting wallet-notice=$notice")
log.debug("collecting wallet-notice={}", notice)
if (notice != null && notice.index > lastReadIndex) {
addNotice(Notice.RemoteMessage(notice))
} else {
Expand All @@ -119,11 +151,15 @@ class NoticesViewModel(val appConfigurationManager: AppConfigurationManager, val
class Factory(
private val appConfigurationManager: AppConfigurationManager,
private val peerManager: PeerManager,
private val internalDataRepository: InternalDataRepository,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as? PhoenixApplication)
@Suppress("UNCHECKED_CAST")
return NoticesViewModel(appConfigurationManager, peerManager, internalDataRepository) as T
return NoticesViewModel(
appConfigurationManager, peerManager,
internalDataRepository = application.internalDataRepository,
application.applicationContext
) as T
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ fun BorderButton(
enabled: Boolean = true,
enabledEffect: Boolean = true,
space: Dp = 12.dp,
maxLines: Int = Int.MAX_VALUE,
textStyle: TextStyle = MaterialTheme.typography.button,
padding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
Expand All @@ -85,6 +86,7 @@ fun BorderButton(
border = BorderStroke(ButtonDefaults.OutlinedBorderSize, if (enabled) borderColor else borderColor.copy(alpha = 0.4f)),
textStyle = textStyle,
padding = padding,
maxLines = maxLines,
interactionSource = interactionSource,
modifier = modifier
)
Expand All @@ -99,6 +101,7 @@ fun FilledButton(
iconTint: Color = MaterialTheme.colors.onPrimary,
maxLines: Int = Int.MAX_VALUE,
enabled: Boolean = true,
enabledEffect: Boolean = true,
space: Dp = 12.dp,
shape: Shape = CircleShape,
textStyle: TextStyle = MaterialTheme.typography.button.copy(color = MaterialTheme.colors.onPrimary),
Expand All @@ -112,6 +115,7 @@ fun FilledButton(
iconTint = iconTint,
maxLines = maxLines,
enabled = enabled,
enabledEffect = enabledEffect,
space = space,
onClick = onClick,
shape = shape,
Expand Down Expand Up @@ -298,7 +302,7 @@ fun Button(
Row(
Modifier
.defaultMinSize(
minWidth = 42.dp,
minWidth = 0.dp,
minHeight = 0.dp
)
.indication(interactionSource, LocalIndication.current)
Expand All @@ -323,7 +327,7 @@ fun Button(
} else if (text != null) {
Text(text = text, maxLines = maxLines, overflow = TextOverflow.Ellipsis)
} else if (icon != null) {
PhoenixIcon(resourceId = icon, tint = iconTint, modifier = Modifier.padding(vertical = 1.dp))
PhoenixIcon(resourceId = icon, tint = iconTint)
}
}
)
Expand Down
Loading

0 comments on commit 757c1c4

Please sign in to comment.