diff --git a/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java b/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java index e2fbaaed..68948ae6 100644 --- a/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java +++ b/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java @@ -19,12 +19,27 @@ import app.notesr.core.security.crypto.CryptoManager; import app.notesr.core.security.crypto.CryptoManagerProvider; +import app.notesr.data.DatabaseProvider; import app.notesr.service.AndroidService; import app.notesr.service.AndroidServiceEntry; import app.notesr.service.AndroidServiceRegistry; +/** + * A foreground {@link app.notesr.service.AndroidService} responsible for handling the application's + * lifecycle cleanup when the task is removed + * (e.g., when the app is swiped away from the recent apps list). + * + *

This service ensures that sensitive data is cleared and database connections are + * properly closed to maintain data integrity and security. If no other application services + * are running, it performs a full cleanup and terminates the process.

+ * + *

The service operates as a foreground service to ensure the system grants it sufficient + * time to execute cleanup logic during the task removal phase.

+ */ public final class AppCloseAndroidService extends AndroidService { + private static final int NOTIFICATION_ID = 1005; + private static final String CHANNEL_ID = "app_close_service_channel"; private static final String CHANNEL_NAME = "App Close Service Channel"; @@ -51,7 +66,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC; } - startForeground(1005, notification, type); + startForeground(NOTIFICATION_ID, notification, type); register(null, null); return START_NOT_STICKY; @@ -65,22 +80,34 @@ protected AndroidServiceEntry getEntry(String payload, String state) { @Override public void onTaskRemoved(Intent rootIntent) { - if (getCurrentRunningServicesCount() == 0) { - CryptoManager cryptoManager = CryptoManagerProvider.getInstance(getApplicationContext()); - cryptoManager.destroySecrets(); + if (getOtherRunningServicesCount() == 0) { + closeDatabase(); + destroySecrets(); - stopForeground(true); - stopSelf(); + stopForegroundService(); + stopService(); - super.onTaskRemoved(rootIntent); - System.exit(0); + callSuperOnTaskRemoved(rootIntent); + exitProcess(); } else { - stopForeground(true); - stopSelf(); + stopForegroundService(); + stopService(); } } - private long getCurrentRunningServicesCount() { + void callSuperOnTaskRemoved(Intent rootIntent) { + super.onTaskRemoved(rootIntent); + } + + void stopForegroundService() { + stopForeground(true); + } + + void stopService() { + stopSelf(); + } + + long getOtherRunningServicesCount() { return AndroidServiceRegistry.getInstance(getApplicationContext()) .getSet() .stream() @@ -88,4 +115,16 @@ private long getCurrentRunningServicesCount() { serviceEntry.getServiceClass() != getClass()) .count(); } + + void closeDatabase() { + DatabaseProvider.close(); + } + + void destroySecrets() { + CryptoManagerProvider.getInstance(getApplicationContext()).destroySecrets(); + } + + void exitProcess() { + System.exit(0); + } } diff --git a/service/src/test/java/app/notesr/service/lifecycle/AppCloseAndroidServiceTest.java b/service/src/test/java/app/notesr/service/lifecycle/AppCloseAndroidServiceTest.java new file mode 100644 index 00000000..86d68379 --- /dev/null +++ b/service/src/test/java/app/notesr/service/lifecycle/AppCloseAndroidServiceTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 zHd4 + * SPDX-License-Identifier: MIT + */ + +package app.notesr.service.lifecycle; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.description; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import android.content.Intent; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AppCloseAndroidServiceTest { + + private AppCloseAndroidService service; + + @BeforeEach + void setUp() { + service = spy(new AppCloseAndroidService()); + } + + @Test + void testOnTaskRemovedWhenNoOtherServicesRunningClosesEverythingAndExits() { + doReturn(0L).when(service).getOtherRunningServicesCount(); + doNothing().when(service).closeDatabase(); + doNothing().when(service).destroySecrets(); + doNothing().when(service).stopForegroundService(); + doNothing().when(service).stopService(); + doNothing().when(service).callSuperOnTaskRemoved(any(Intent.class)); + doNothing().when(service).exitProcess(); + + Intent intent = new Intent(); + + service.onTaskRemoved(intent); + + verify(service, description("Database should be closed" + + " when no other services are running")) + .closeDatabase(); + verify(service, description("Secrets should be destroyed" + + " when no other services are running")) + .destroySecrets(); + verify(service, description("Foreground notification should be stopped" + + " when no other services are running")) + .stopForegroundService(); + verify(service, description("Service should stop itself" + + " when no other services are running")) + .stopService(); + verify(service, description("Super.onTaskRemoved should be called" + + " when no other services are running")) + .callSuperOnTaskRemoved(intent); + verify(service, description("Process should exit" + + " when no other services are running")) + .exitProcess(); + } + + @Test + void testOnTaskRemovedWhenOtherServicesRunningOnlyStopsSelf() { + doReturn(1L).when(service).getOtherRunningServicesCount(); + doNothing().when(service).stopForegroundService(); + doNothing().when(service).stopService(); + + Intent intent = new Intent(); + + service.onTaskRemoved(intent); + + verify(service, never().description("Database should NOT be closed" + + " when other services are running")) + .closeDatabase(); + verify(service, never().description("Secrets should NOT be destroyed" + + " when other services are running")) + .destroySecrets(); + verify(service, never().description("Super.onTaskRemoved should NOT be called" + + " when other services are running")) + .callSuperOnTaskRemoved(any(Intent.class)); + verify(service, never().description("Process should NOT exit" + + " when other services are running")) + .exitProcess(); + verify(service, description("Foreground notification should be stopped" + + " even if other services are running")) + .stopForegroundService(); + verify(service, description("Service should stop itself" + + " even if other services are running")) + .stopService(); + } +}