diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index 1c73dc76..794a8ee2 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -89,11 +89,20 @@ class MainActivity : ComponentActivity() { override fun onPause() { ExternalActivityLifecycleGuard.onHostPaused() - NotificationHelper.setAppForeground(this, false) + // Prepare the FGS while still eligible to start it. A pause alone does + // not mean background: rotation also pauses and recreates this activity. NotificationHelper.start(this) super.onPause() } + override fun onStop() { + // isChangingConfigurations is reliable here, not during onPause. + // Keep the shared socket alive across recreation, but still release an + // idle connection when the user really leaves the app or locks the phone. + if (!isChangingConfigurations) NotificationHelper.setAppForeground(this, false) + super.onStop() + } + private fun consumeNotificationIntent(intent: Intent?) { // Issue #832: MainActivity is exported (launcher requirement) — only // honor intents stamped with our own notification action. diff --git a/app/src/test/java/com/m57/hermescontrol/MainActivityLifecycleTest.kt b/app/src/test/java/com/m57/hermescontrol/MainActivityLifecycleTest.kt new file mode 100644 index 00000000..12fbc1ad --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/MainActivityLifecycleTest.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol + +import com.m57.hermescontrol.notification.NotificationHelper +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Test +import java.lang.reflect.InvocationTargetException + +class MainActivityLifecycleTest { + @After + fun tearDown() { + unmockkAll() + ExternalActivityLifecycleGuard.resetForTest() + } + + @Test + fun `pause prepares notifications without declaring the app backgrounded`() { + mockkObject(NotificationHelper) + every { NotificationHelper.start(any()) } returns Unit + every { NotificationHelper.setAppForeground(any(), any()) } returns Unit + val activity = mockk(relaxed = true) + every { activity invoke "onPause" withArguments emptyList() } answers { callOriginal() } + + invokeLifecycle(activity, "onPause") + + verify(exactly = 1) { NotificationHelper.start(activity) } + verify(exactly = 0) { NotificationHelper.setAppForeground(activity, false) } + } + + @Test + fun `configuration stop keeps the shared connection foregrounded`() { + mockkObject(NotificationHelper) + every { NotificationHelper.setAppForeground(any(), any()) } returns Unit + val activity = mockk(relaxed = true) + every { activity.isChangingConfigurations } returns true + every { activity invoke "onStop" withArguments emptyList() } answers { callOriginal() } + + invokeLifecycle(activity, "onStop") + + verify(exactly = 0) { NotificationHelper.setAppForeground(activity, false) } + } + + @Test + fun `real background stop still enables idle socket cleanup`() { + mockkObject(NotificationHelper) + every { NotificationHelper.setAppForeground(any(), any()) } returns Unit + val activity = mockk(relaxed = true) + every { activity.isChangingConfigurations } returns false + every { activity invoke "onStop" withArguments emptyList() } answers { callOriginal() } + + invokeLifecycle(activity, "onStop") + + verify(exactly = 1) { NotificationHelper.setAppForeground(activity, false) } + } + + private fun invokeLifecycle( + activity: MainActivity, + method: String, + ) { + try { + MainActivity::class.java + .getDeclaredMethod(method) + .apply { isAccessible = true } + .invoke(activity) + } catch (error: InvocationTargetException) { + // Execute real app code; only the trailing Android framework stub is unavailable on the JVM. + val expected = "Method $method in android.app.Activity not mocked." + if (error.targetException.message?.startsWith(expected) != true) throw error + } + } +}