From 0d40acc13a0d2c1a2e049b27ca5febc532c51794 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 12 May 2026 18:43:55 +0200 Subject: [PATCH 1/4] Call DatabaseProvider.close() in AppCloseAndroidService.onTaskRemoved --- .../app/notesr/service/lifecycle/AppCloseAndroidService.java | 3 +++ 1 file changed, 3 insertions(+) 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..bb525d64 100644 --- a/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java +++ b/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java @@ -19,6 +19,7 @@ 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; @@ -66,6 +67,8 @@ protected AndroidServiceEntry getEntry(String payload, String state) { @Override public void onTaskRemoved(Intent rootIntent) { if (getCurrentRunningServicesCount() == 0) { + DatabaseProvider.close(); + CryptoManager cryptoManager = CryptoManagerProvider.getInstance(getApplicationContext()); cryptoManager.destroySecrets(); From d55f9e0e742fa7bd93ca282e9992ce7b89960773 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 12 May 2026 18:47:00 +0200 Subject: [PATCH 2/4] Move notification id to constant in AppCloseAndroidService --- .../app/notesr/service/lifecycle/AppCloseAndroidService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 bb525d64..9f77cd35 100644 --- a/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java +++ b/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java @@ -26,6 +26,8 @@ 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"; @@ -52,7 +54,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; From c054abcdd72e0eef541c8c48e2028f5f12822452 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Thu, 14 May 2026 08:41:07 +0200 Subject: [PATCH 3/4] Refactor AppCloseAndroidService to extract cleanup and lifecycle logic into helper methods --- .../lifecycle/AppCloseAndroidService.java | 58 +++++++++++++++---- 1 file changed, 46 insertions(+), 12 deletions(-) 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 9f77cd35..68948ae6 100644 --- a/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java +++ b/service/src/main/java/app/notesr/service/lifecycle/AppCloseAndroidService.java @@ -24,6 +24,18 @@ 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; @@ -68,24 +80,34 @@ protected AndroidServiceEntry getEntry(String payload, String state) { @Override public void onTaskRemoved(Intent rootIntent) { - if (getCurrentRunningServicesCount() == 0) { - DatabaseProvider.close(); - - 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() @@ -93,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); + } } From b7f1a4247b63d54823e465eee870a2918179823e Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Thu, 14 May 2026 08:41:20 +0200 Subject: [PATCH 4/4] Implement AppCloseAndroidServiceTest --- .../lifecycle/AppCloseAndroidServiceTest.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 service/src/test/java/app/notesr/service/lifecycle/AppCloseAndroidServiceTest.java 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(); + } +}