diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 2af729cc3e..b6271fcc75 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -4,10 +4,12 @@ import android.app.Notification import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher @@ -59,13 +61,16 @@ class LightningNodeService : Service() { @Inject lateinit var cacheStore: CacheStore + private var hasStartedNode = false + override fun onCreate() { super.onCreate() - startForeground(ID_NOTIFICATION_NODE, createNotification()) - setupService() } private fun setupService() { + if (hasStartedNode) return + hasStartedNode = true + serviceScope.launch { lightningRepo.start( eventHandler = { event -> @@ -149,19 +154,49 @@ class LightningNodeService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Logger.debug("onStartCommand", context = TAG) - when (intent?.action) { + val action = intent?.action + Logger.debug("Received start command action '$action'", context = TAG) + when (action) { + ACTION_START_SERVICE -> { + if (promoteToForeground(startId)) setupService() + } ACTION_STOP_SERVICE_AND_APP -> { - Logger.debug("ACTION_STOP_SERVICE_AND_APP detected", context = TAG) - serviceScope.launch { - lightningRepo.stop() - activityManager.appTasks.forEach { it.finishAndRemoveTask() } - stopSelf() - } - return START_NOT_STICKY + Logger.debug("Received stop service action", context = TAG) + stopForegroundService(startId) + activityManager.appTasks.forEach { it.finishAndRemoveTask() } + serviceScope.launch { lightningRepo.stop() } + } + else -> { + Logger.warn("Stopped service for unsupported action '$action'", context = TAG) + stopSelf(startId) } } - return START_STICKY + return START_NOT_STICKY + } + + private fun promoteToForeground(startId: Int): Boolean { + return runCatching { + ServiceCompat.startForeground( + this, + ID_NOTIFICATION_NODE, + createNotification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + }.fold( + onSuccess = { true }, + onFailure = { + if (it !is RuntimeException) throw it + + Logger.error("Failed to promote foreground service", it, context = TAG) + stopSelf(startId) + false + } + ) + } + + private fun stopForegroundService(startId: Int) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf(startId) } override fun onDestroy() { @@ -173,11 +208,9 @@ class LightningNodeService : Service() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) override fun onTimeout(startId: Int, fgsType: Int) { - Logger.warn("Foreground service timeout reached", context = TAG) - serviceScope.launch { - lightningRepo.stop() - stopSelf() - } + Logger.warn("Reached foreground service timeout for type '$fgsType'", context = TAG) + stopForegroundService(startId) + serviceScope.launch { lightningRepo.stop() } super.onTimeout(startId, fgsType) } @@ -186,6 +219,7 @@ class LightningNodeService : Service() { companion object { const val CHANNEL_ID_NODE = "bitkit_notification_channel_node" const val TAG = "LightningNodeService" + const val ACTION_START_SERVICE = "to.bitkit.androidServices.action.START_SERVICE" const val ACTION_STOP_SERVICE_AND_APP = "to.bitkit.androidServices.action.STOP_SERVICE_AND_APP" } } diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 4511072fda..41bfcae26c 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.R import to.bitkit.androidServices.LightningNodeService +import to.bitkit.androidServices.LightningNodeService.Companion.ACTION_START_SERVICE import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.SamRockSetupRequest @@ -231,7 +232,11 @@ class MainActivity : FragmentActivity() { private fun tryStartForegroundService() { runCatching { Logger.debug("Attempting to start LightningNodeService", context = "MainActivity") - startForegroundService(Intent(this, LightningNodeService::class.java)) + startForegroundService( + Intent(this, LightningNodeService::class.java).apply { + action = ACTION_START_SERVICE + }, + ) }.onFailure { error -> Logger.error("Failed to start LightningNodeService", error, context = "MainActivity") } diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 0e7bbafc31..9297b69602 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -4,7 +4,10 @@ import android.Manifest import android.app.Activity import android.app.Application import android.app.Notification +import android.app.Service import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo import androidx.test.core.app.ApplicationProvider import com.google.firebase.messaging.FirebaseMessaging import dagger.hilt.android.testing.BindValue @@ -12,6 +15,7 @@ import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking @@ -24,9 +28,11 @@ import org.lightningdevkit.ldknode.Event import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.stub +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Robolectric @@ -58,12 +64,18 @@ import to.bitkit.ui.shared.toast.ToastQueueManager import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue @HiltAndroidTest @UninstallModules(DispatchersModule::class, DbModule::class, ViewModelModule::class) @Config(application = HiltTestApplication::class, sdk = [34]) // Pin Robolectric to an SDK that supports Java 17 @RunWith(RobolectricTestRunner::class) class LightningNodeServiceTest : BaseUnitTest() { + companion object { + private const val START_ID = 1 + private const val STOP_START_ID = 2 + private const val TIMEOUT_START_ID = 3 + } @get:Rule(order = 1) var hiltRule = HiltAndroidRule(this) @@ -145,10 +157,111 @@ class LightningNodeServiceTest : BaseUnitTest() { App.currentActivity = null } + @Test + fun `start action promotes data sync foreground service and starts node`() = test { + val service = createService() + val result = service.onStartCommand(serviceIntent(), 0, START_ID) + testScheduler.advanceUntilIdle() + + assertEquals(Service.START_NOT_STICKY, result) + assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, service.foregroundServiceType) + assertNotNull(Shadows.shadowOf(service).lastForegroundNotification) + assertNotNull(capturedHandler, "Event handler should be captured") + } + + @Test + fun `start action only starts node once`() = test { + val service = createService() + service.onStartCommand(serviceIntent(), 0, START_ID) + service.onStartCommand(serviceIntent(), 0, START_ID + 1) + testScheduler.advanceUntilIdle() + + verifyLightningStart(count = 1) + } + + @Test + fun `null intent stops service without starting node`() = test { + val service = createService() + val result = service.onStartCommand(null, 0, START_ID) + + assertEquals(Service.START_NOT_STICKY, result) + assertEquals(START_ID, Shadows.shadowOf(service).stopSelfId) + assertNull(capturedHandler) + verifyLightningNeverStarted() + } + + @Test + fun `unsupported action stops service without starting node`() = test { + val service = createService() + val result = service.onStartCommand(serviceIntent("unsupported"), 0, START_ID) + + assertEquals(Service.START_NOT_STICKY, result) + assertEquals(START_ID, Shadows.shadowOf(service).stopSelfId) + assertNull(capturedHandler) + verifyLightningNeverStarted() + } + + @Test + fun `foreground promotion failure stops service without starting node`() = test { + val service = createService() + val shadowService = Shadows.shadowOf(service) + shadowService.setThrowInStartForeground(RuntimeException("blocked")) + + val result = service.onStartCommand(serviceIntent(), 0, START_ID) + + assertEquals(Service.START_NOT_STICKY, result) + assertEquals(START_ID, shadowService.stopSelfId) + assertNull(capturedHandler) + verifyLightningNeverStarted() + } + + @Test + fun `stop action stops service before node stop completes`() = test { + val stopResult = CompletableDeferred>() + whenever { lightningRepo.stop() }.doSuspendableAnswer { stopResult.await() } + + val service = startService() + testScheduler.advanceUntilIdle() + val shadowService = Shadows.shadowOf(service) + + val result = service.onStartCommand( + serviceIntent(LightningNodeService.ACTION_STOP_SERVICE_AND_APP), + 0, + STOP_START_ID, + ) + + assertEquals(Service.START_NOT_STICKY, result) + assertTrue(shadowService.isStoppedBySelf) + assertTrue(shadowService.isForegroundStopped) + assertEquals(STOP_START_ID, shadowService.stopSelfId) + + stopResult.complete(Result.success(Unit)) + testScheduler.advanceUntilIdle() + } + + @Test + @Config(sdk = [35]) + fun `foreground service timeout stops service before node stop completes`() = test { + val stopResult = CompletableDeferred>() + whenever { lightningRepo.stop() }.doSuspendableAnswer { stopResult.await() } + + val service = startService() + testScheduler.advanceUntilIdle() + val shadowService = Shadows.shadowOf(service) + + service.onTimeout(TIMEOUT_START_ID, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + + assertTrue(shadowService.isStoppedBySelf) + assertTrue(shadowService.isForegroundStopped) + assertEquals(TIMEOUT_START_ID, shadowService.stopSelfId) + + stopResult.complete(Result.success(Unit)) + testScheduler.advanceUntilIdle() + } + @Test fun `payment received in background shows notification`() = test { - val controller = Robolectric.buildService(LightningNodeService::class.java) - controller.create().startCommand(0, 0) + startService() testScheduler.advanceUntilIdle() assertNotNull(capturedHandler, "Event handler should be captured") @@ -186,8 +299,7 @@ class LightningNodeServiceTest : BaseUnitTest() { val mockActivity: Activity = mock() App.currentActivity?.onActivityStarted(mockActivity) - val controller = Robolectric.buildService(LightningNodeService::class.java) - controller.create().startCommand(0, 0) + startService() testScheduler.advanceUntilIdle() val event = Event.PaymentReceived( @@ -214,8 +326,7 @@ class LightningNodeServiceTest : BaseUnitTest() { @Test fun `notification uses content from use case result`() = test { - val controller = Robolectric.buildService(LightningNodeService::class.java) - controller.create().startCommand(0, 0) + startService() testScheduler.advanceUntilIdle() val event = Event.PaymentReceived( @@ -252,8 +363,7 @@ class LightningNodeServiceTest : BaseUnitTest() { ) ) - val controller = Robolectric.buildService(LightningNodeService::class.java) - controller.create().startCommand(0, 0) + startService() testScheduler.advanceUntilIdle() val event = Event.PaymentSuccessful( @@ -288,8 +398,7 @@ class LightningNodeServiceTest : BaseUnitTest() { ) ) - val controller = Robolectric.buildService(LightningNodeService::class.java) - controller.create().startCommand(0, 0) + startService() testScheduler.advanceUntilIdle() val event = Event.PaymentFailed( @@ -325,8 +434,7 @@ class LightningNodeServiceTest : BaseUnitTest() { ) ) - val controller = Robolectric.buildService(LightningNodeService::class.java) - controller.create().startCommand(0, 0) + startService() testScheduler.advanceUntilIdle() val event = Event.PaymentSuccessful( @@ -354,8 +462,7 @@ class LightningNodeServiceTest : BaseUnitTest() { Result.success(NotifyPendingPaymentResolved.Result.Skip) ) - val controller = Robolectric.buildService(LightningNodeService::class.java) - controller.create().startCommand(0, 0) + startService() testScheduler.advanceUntilIdle() val event = Event.PaymentSuccessful( @@ -377,4 +484,48 @@ class LightningNodeServiceTest : BaseUnitTest() { } assertNull(notification, "Non-pending payment should NOT trigger notification") } + + private fun createService(): LightningNodeService { + return Robolectric.buildService(LightningNodeService::class.java) + .create() + .get() + } + + private fun startService(): LightningNodeService { + val service = createService() + service.onStartCommand(serviceIntent(), 0, START_ID) + return service + } + + private fun serviceIntent(action: String = LightningNodeService.ACTION_START_SERVICE): Intent { + return Intent(context, LightningNodeService::class.java).apply { + this.action = action + } + } + + private suspend fun verifyLightningStart(count: Int) { + verify(lightningRepo, times(count)).start( + any(), + anyOrNull(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + any(), + ) + } + + private suspend fun verifyLightningNeverStarted() { + verify(lightningRepo, never()).start( + any(), + anyOrNull(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + any(), + ) + } } diff --git a/changelog.d/next/987.fixed.md b/changelog.d/next/987.fixed.md new file mode 100644 index 0000000000..f3063aec31 --- /dev/null +++ b/changelog.d/next/987.fixed.md @@ -0,0 +1 @@ +Bitkit no longer crashes when Android stops the background Lightning node service.