From 487d8d1feb7c9785a0c196fc3aad8038fc42594a Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:24:50 +0200 Subject: [PATCH] fix(lifecycle): preserve connection across activity recreation --- .../com/m57/hermescontrol/MainActivity.kt | 11 ++- .../MainActivityLifecycleTest.kt | 75 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/MainActivityLifecycleTest.kt 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 + } + } +}