Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 3 additions & 21 deletions app/src/main/java/to/bitkit/App.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
package to.bitkit

import android.annotation.SuppressLint
import android.app.Activity
import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
Expand All @@ -23,27 +20,12 @@ internal open class App : Application(), Configuration.Provider {

override fun onCreate() {
super.onCreate()
currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) }
lifecycle = AppLifecycle().also { registerActivityLifecycleCallbacks(it) }
Env.initAppStoragePath(filesDir.absolutePath)
}

companion object {
@SuppressLint("StaticFieldLeak") // Should be safe given its manual memory management
internal var currentActivity: CurrentActivity? = null
@SuppressLint("StaticFieldLeak") // Should be safe given the manual memory management
internal var lifecycle: AppLifecycle? = null
}
}

class CurrentActivity : ActivityLifecycleCallbacks {
var value: Activity? = null
private set

override fun onActivityCreated(activity: Activity, bundle: Bundle?) = Unit
override fun onActivityStarted(activity: Activity) = run { this.value = activity }
override fun onActivityResumed(activity: Activity) = run { this.value = activity }
override fun onActivityPaused(activity: Activity) = clearIfCurrent(activity)
override fun onActivityStopped(activity: Activity) = clearIfCurrent(activity)
override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = clearIfCurrent(activity)

private fun clearIfCurrent(activity: Activity) = run { if (this.value == activity) this.value = null }
}
53 changes: 53 additions & 0 deletions app/src/main/java/to/bitkit/AppLifecycle.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package to.bitkit

import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle

fun interface AppLifecycleListener {
fun onAppLifecycleChanged(isInForeground: Boolean)
}

class AppLifecycle : ActivityLifecycleCallbacks {
var activity: Activity? = null
private set

private val listeners = mutableListOf<AppLifecycleListener>()

val isInForeground: Boolean get() = activity != null

fun addListener(listener: AppLifecycleListener) {
listeners.add(listener)
}

fun removeListener(listener: AppLifecycleListener) {
listeners.remove(listener)
}

override fun onActivityCreated(activity: Activity, bundle: Bundle?) = Unit

override fun onActivityStarted(activity: Activity) {
val wasInBackground = this.activity == null
this.activity = activity
if (wasInBackground) notifyListeners(isInForeground = true)
}

override fun onActivityResumed(activity: Activity) = run { this.activity = activity }

override fun onActivityPaused(activity: Activity) = clearIfCurrent(activity)

override fun onActivityStopped(activity: Activity) {
val wasInForeground = this.activity != null
clearIfCurrent(activity)
if (wasInForeground && this.activity == null) notifyListeners(isInForeground = false)
}

override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = clearIfCurrent(activity)

private fun clearIfCurrent(activity: Activity) = run { if (this.activity == activity) this.activity = null }

private fun notifyListeners(isInForeground: Boolean) {
listeners.forEach { it.onAppLifecycleChanged(isInForeground) }
}
}
38 changes: 27 additions & 11 deletions app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.lightningdevkit.ldknode.Event
import to.bitkit.App
import to.bitkit.AppLifecycleListener
import to.bitkit.R
import to.bitkit.data.CacheStore
import to.bitkit.di.UiDispatcher
Expand Down Expand Up @@ -54,10 +55,30 @@ class LightningNodeService : Service() {
@Inject
lateinit var cacheStore: CacheStore

private var lifecycleListener: AppLifecycleListener? = null

override fun onCreate() {
super.onCreate()
startForeground(ID_NOTIFICATION_NODE, createNotification())
setupService()
setupLifecycleListener()
}

private fun setupLifecycleListener() {
lifecycleListener = AppLifecycleListener { isInForeground ->
Logger.debug("App lifecycle changed: isInForeground=$isInForeground", context = TAG)
serviceScope.launch {
if (isInForeground) {
lightningRepo.disableBatterySavingMode()
.onSuccess { Logger.debug("Exited battery saving mode", context = TAG) }
.onFailure { Logger.warn("Error exiting battery saving mode", it, context = TAG) }
} else {
lightningRepo.enableBatterySavingMode()
.onSuccess { Logger.debug("Entered battery saving mode", context = TAG) }
.onFailure { Logger.warn("Error entering battery saving mode", it, context = TAG) }
}
}
}.also { App.lifecycle?.addListener(it) }
}

private fun setupService() {
Expand Down Expand Up @@ -90,7 +111,7 @@ class LightningNodeService : Service() {
sheet: NewTransactionSheetDetails,
notification: NotificationDetails,
) {
if (App.currentActivity?.value != null) {
if (App.lifecycle?.activity != null) {
Logger.debug("Skipping payment notification: activity is active", context = TAG)
return
}
Expand All @@ -108,9 +129,7 @@ class LightningNodeService : Service() {
val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)

// Create stop action that will close both service and app
val stopIntent = Intent(this, LightningNodeService::class.java).apply {
action = ACTION_STOP_SERVICE_AND_APP
}
val stopIntent = Intent(this, LightningNodeService::class.java).apply { action = ACTION_STOP_SERVICE_AND_APP }
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)

return NotificationCompat.Builder(this, CHANNEL_ID_NODE)
Expand All @@ -120,11 +139,7 @@ class LightningNodeService : Service() {
.setColor(ContextCompat.getColor(this, R.color.brand))
.setContentIntent(pendingIntent)
.setOngoing(true)
.addAction(
R.drawable.ic_x,
getString(R.string.notification__service__stop),
stopPendingIntent
)
.addAction(R.drawable.ic_x, getString(R.string.notification__service__stop), stopPendingIntent)
.build()
}

Expand All @@ -134,7 +149,7 @@ class LightningNodeService : Service() {
ACTION_STOP_SERVICE_AND_APP -> {
Logger.debug("ACTION_STOP_SERVICE_AND_APP detected", context = TAG)
// Close activities gracefully without force-stopping the app
App.currentActivity?.value?.finishAffinity()
App.lifecycle?.activity?.finishAffinity()
// Stop the service
stopSelf()
return START_NOT_STICKY
Expand All @@ -145,6 +160,7 @@ class LightningNodeService : Service() {

override fun onDestroy() {
Logger.debug("onDestroy", context = TAG)
lifecycleListener?.let { App.lifecycle?.removeListener(it) }
serviceScope.launch {
lightningRepo.stop()
serviceScope.cancel()
Expand All @@ -165,8 +181,8 @@ class LightningNodeService : Service() {
override fun onBind(intent: Intent?): IBinder? = null

companion object {
const val CHANNEL_ID_NODE = "bitkit_notification_channel_node"
const val TAG = "LightningNodeService"
const val CHANNEL_ID_NODE = "bitkit_notification_channel_node"
const val ACTION_STOP_SERVICE_AND_APP = "to.bitkit.androidServices.action.STOP_SERVICE_AND_APP"
}
}
17 changes: 16 additions & 1 deletion app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.os.Build
import org.lightningdevkit.ldknode.LogLevel
import org.lightningdevkit.ldknode.Network
import org.lightningdevkit.ldknode.PeerDetails
import org.lightningdevkit.ldknode.RuntimeSyncIntervals
import to.bitkit.BuildConfig
import to.bitkit.ext.ensureDir
import to.bitkit.ext.of
Expand All @@ -20,12 +21,13 @@ internal object Env {
val e2eBackend = BuildConfig.E2E_BACKEND.lowercase()
val network = Network.valueOf(BuildConfig.NETWORK)
val locales = BuildConfig.LOCALES.split(",")
const val walletSyncIntervalSecs = 10_uL
val platform = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
const val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"

val ldkLogLevel = LogLevel.TRACE

val syncIntervals = if (network == Network.REGTEST) SyncIntervals.FAST else SyncIntervals.DEFAULT

val trustedLnPeers
get() = when (network) {
Network.BITCOIN -> listOf(Peers.lnd1, Peers.lnd3, Peers.lnd4)
Expand Down Expand Up @@ -220,3 +222,16 @@ private object ElectrumServers {

const val TESTNET = "ssl://electrum.blockstream.info:60002"
}

private object SyncIntervals {
val DEFAULT = RuntimeSyncIntervals(
onchainWalletSyncIntervalSecs = 80_uL,
lightningWalletSyncIntervalSecs = 30_uL,
feeRateCacheUpdateIntervalSecs = 600_uL, // 10 min
)
val FAST = RuntimeSyncIntervals(
onchainWalletSyncIntervalSecs = 10_uL,
lightningWalletSyncIntervalSecs = 10_uL,
feeRateCacheUpdateIntervalSecs = 10_uL,
)
}
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/ext/ChannelDetails.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ fun createChannelDetails(): ChannelDetails {
forceCloseSpendDelay = null,
inboundHtlcMinimumMsat = 0u,
inboundHtlcMaximumMsat = null,
claimableOnCloseSats = 0u,
config = ChannelConfig(
forwardingFeeProportionalMillionths = 0u,
forwardingFeeBaseMsat = 0u,
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/to/bitkit/ext/RuntimeSyncIntervals.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package to.bitkit.ext

import org.lightningdevkit.ldknode.BackgroundSyncConfig
import org.lightningdevkit.ldknode.RuntimeSyncIntervals

fun RuntimeSyncIntervals.toBackgroundSyncConfig(): BackgroundSyncConfig = BackgroundSyncConfig(
onchainWalletSyncIntervalSecs = onchainWalletSyncIntervalSecs,
lightningWalletSyncIntervalSecs = lightningWalletSyncIntervalSecs,
feeRateCacheUpdateIntervalSecs = feeRateCacheUpdateIntervalSecs,
)
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ class WakeNodeWorker @AssistedInject constructor(

// Only stop node if app is not in foreground
// LightningNodeService will keep node running in background when notifications are enabled
if (App.currentActivity?.value == null) {
if (App.lifecycle?.activity == null) {
Logger.debug("App in background, stopping node after notification delivery", context = TAG)
lightningRepo.stop()
} else {
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import org.lightningdevkit.ldknode.ChannelDataMigration
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.ClosureReason
import org.lightningdevkit.ldknode.Event
import org.lightningdevkit.ldknode.NodeException
import org.lightningdevkit.ldknode.NodeStatus
import org.lightningdevkit.ldknode.PaymentDetails
import org.lightningdevkit.ldknode.PaymentId
Expand Down Expand Up @@ -439,6 +440,30 @@ class LightningRepo @Inject constructor(
/** Clear pending sync flag. Called when manual pull-to-refresh takes priority. */
fun clearPendingSync() = syncPending.set(false)

suspend fun enableBatterySavingMode(): Result<Unit> = executeWhenNodeRunning("enableBatterySavingMode") {
runCatching {
lightningService.updateSyncIntervals(lightningService.batterySavingIntervals())
}.recoverCatching { e ->
if (e is NodeException.BackgroundSyncNotEnabled) {
Logger.warn("Background sync not enabled, skipping sync intervals update", context = TAG)
} else {
throw e
}
}
}

suspend fun disableBatterySavingMode(): Result<Unit> = executeWhenNodeRunning("disableBatterySavingMode") {
runCatching {
lightningService.updateSyncIntervals(lightningService.defaultSyncIntervals())
}.recoverCatching { e ->
if (e is NodeException.BackgroundSyncNotEnabled) {
Logger.warn("Background sync not enabled, skipping sync intervals update", context = TAG)
} else {
throw e
}
}
}

private suspend fun refreshChannelCache() = withContext(bgDispatcher) {
lightningService.channels?.forEach {
channelCache[it.channelId] = it
Expand Down
22 changes: 16 additions & 6 deletions app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import org.lightningdevkit.ldknode.Address
import org.lightningdevkit.ldknode.AnchorChannelsConfig
import org.lightningdevkit.ldknode.BackgroundSyncConfig
import org.lightningdevkit.ldknode.BalanceDetails
import org.lightningdevkit.ldknode.Bolt11Invoice
import org.lightningdevkit.ldknode.Bolt11InvoiceDescription
Expand All @@ -34,6 +33,7 @@ import org.lightningdevkit.ldknode.NodeStatus
import org.lightningdevkit.ldknode.PaymentDetails
import org.lightningdevkit.ldknode.PaymentId
import org.lightningdevkit.ldknode.PeerDetails
import org.lightningdevkit.ldknode.RuntimeSyncIntervals
import org.lightningdevkit.ldknode.SpendableUtxo
import org.lightningdevkit.ldknode.Txid
import org.lightningdevkit.ldknode.defaultConfig
Expand All @@ -44,6 +44,7 @@ import to.bitkit.data.backup.VssStoreIdProvider
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.toBackgroundSyncConfig
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
import to.bitkit.ext.uByteList
import to.bitkit.ext.uri
Expand Down Expand Up @@ -192,11 +193,7 @@ class LightningService @Inject constructor(
setChainSourceElectrum(
serverUrl = serverUrl,
config = ElectrumSyncConfig(
BackgroundSyncConfig(
onchainWalletSyncIntervalSecs = Env.walletSyncIntervalSecs,
lightningWalletSyncIntervalSecs = Env.walletSyncIntervalSecs,
feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs,
),
backgroundSyncConfig = Env.syncIntervals.toBackgroundSyncConfig(),
),
)
}
Expand Down Expand Up @@ -275,6 +272,19 @@ class LightningService @Inject constructor(
Logger.debug("LDK synced", context = TAG)
}

suspend fun updateSyncIntervals(intervals: RuntimeSyncIntervals) = withContext(bgDispatcher) {
val node = this@LightningService.node ?: throw ServiceError.NodeNotSetup()
ServiceQueue.LDK.background {
node.updateSyncIntervals(intervals)
}
Logger.info("Sync intervals updated", context = TAG)
}

fun defaultSyncIntervals(): RuntimeSyncIntervals = Env.syncIntervals

fun batterySavingIntervals(): RuntimeSyncIntervals =
org.lightningdevkit.ldknode.batterySavingSyncIntervals()

suspend fun sign(message: String): String {
val node = this.node ?: throw ServiceError.NodeNotSetup()
val msg = runCatching { message.uByteList }.getOrNull() ?: throw ServiceError.InvalidNodeSigningMessage()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,7 @@ private fun PreviewAutoMode() {
forceCloseSpendDelay = null,
inboundHtlcMinimumMsat = 0uL,
inboundHtlcMaximumMsat = null,
claimableOnCloseSats = 0uL,
config = org.lightningdevkit.ldknode.ChannelConfig(
forwardingFeeProportionalMillionths = 0u,
forwardingFeeBaseMsat = 0u,
Expand Down Expand Up @@ -764,6 +765,7 @@ private fun PreviewSpendingMode() {
forceCloseSpendDelay = null,
inboundHtlcMinimumMsat = 0uL,
inboundHtlcMaximumMsat = null,
claimableOnCloseSats = 0uL,
config = org.lightningdevkit.ldknode.ChannelConfig(
forwardingFeeProportionalMillionths = 0u,
forwardingFeeBaseMsat = 0u,
Expand Down
Loading
Loading