diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 27b3a7c17..187b6296a 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -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 @@ -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 } -} diff --git a/app/src/main/java/to/bitkit/AppLifecycle.kt b/app/src/main/java/to/bitkit/AppLifecycle.kt new file mode 100644 index 000000000..560e46d4f --- /dev/null +++ b/app/src/main/java/to/bitkit/AppLifecycle.kt @@ -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() + + 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) } + } +} diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 084093db6..ab282af87 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -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 @@ -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() { @@ -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 } @@ -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) @@ -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() } @@ -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 @@ -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() @@ -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" } } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 1bd38d2c5..147faf472 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -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 @@ -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) @@ -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, + ) +} diff --git a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt index 00441336b..5a5a39cf5 100644 --- a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt +++ b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt @@ -78,6 +78,7 @@ fun createChannelDetails(): ChannelDetails { forceCloseSpendDelay = null, inboundHtlcMinimumMsat = 0u, inboundHtlcMaximumMsat = null, + claimableOnCloseSats = 0u, config = ChannelConfig( forwardingFeeProportionalMillionths = 0u, forwardingFeeBaseMsat = 0u, diff --git a/app/src/main/java/to/bitkit/ext/RuntimeSyncIntervals.kt b/app/src/main/java/to/bitkit/ext/RuntimeSyncIntervals.kt new file mode 100644 index 000000000..68d932940 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/RuntimeSyncIntervals.kt @@ -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, +) diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 7702122f1..2cda1dd4c 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -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 { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index aacbd5c29..e6c90a342 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -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 @@ -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 = 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 = 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 diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 84fe835cb..32c45eecc 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -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 @@ -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 @@ -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 @@ -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(), ), ) } @@ -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() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index d4101feb0..59bbaa6c0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -694,6 +694,7 @@ private fun PreviewAutoMode() { forceCloseSpendDelay = null, inboundHtlcMinimumMsat = 0uL, inboundHtlcMaximumMsat = null, + claimableOnCloseSats = 0uL, config = org.lightningdevkit.ldknode.ChannelConfig( forwardingFeeProportionalMillionths = 0u, forwardingFeeBaseMsat = 0u, @@ -764,6 +765,7 @@ private fun PreviewSpendingMode() { forceCloseSpendDelay = null, inboundHtlcMinimumMsat = 0uL, inboundHtlcMaximumMsat = null, + claimableOnCloseSats = 0uL, config = org.lightningdevkit.ldknode.ChannelConfig( forwardingFeeProportionalMillionths = 0u, forwardingFeeBaseMsat = 0u, diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 200705b97..ad51794f7 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -36,7 +36,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows import org.robolectric.annotation.Config import to.bitkit.App -import to.bitkit.CurrentActivity +import to.bitkit.AppLifecycle import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore @@ -115,13 +115,13 @@ class LightningNodeServiceTest : BaseUnitTest() { val app = context as Application Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) - // Reset App.currentActivity to simulate background state - App.currentActivity = CurrentActivity() + // Reset App.lifecycle to simulate background state + App.lifecycle = AppLifecycle() } @After fun tearDown() { - App.currentActivity = null + App.lifecycle = null } @Test @@ -162,9 +162,9 @@ class LightningNodeServiceTest : BaseUnitTest() { @Test fun `payment received in foreground does nothing`() = test { - // Simulate foreground by setting App.currentActivity.value via lifecycle callback + // Simulate foreground by setting App.lifecycle.value via lifecycle callback val mockActivity: Activity = mock() - App.currentActivity?.onActivityStarted(mockActivity) + App.lifecycle?.onActivityStarted(mockActivity) val controller = Robolectric.buildService(LightningNodeService::class.java) controller.create().startCommand(0, 0) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd7612931..acf4aa9ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,7 +58,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "v0.7.0-rc.8" } # fork | local: remove `v` +ldk-node-android = { module = "com.github.synonymdev.ldk-node:ldk-node-android", version = "v0.7.0-rc.17" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }