Prevent quit notification from reappearing (#530)

* shutdown sequence

* Prevent quit notification from reappearing

* Restrict force-finish broadcast

* Cancel quit shutdown on relaunch
This commit is contained in:
callebtc
2026-01-02 16:52:06 +07:00
committed by GitHub
parent ab555f25f5
commit 903a4584a8
5 changed files with 92 additions and 2 deletions
+6
View File
@@ -19,6 +19,8 @@
<!-- Notification permissions --> <!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Signature permission for internal UI shutdown broadcasts -->
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
<!-- Foreground service and boot permissions for long-running background mesh --> <!-- Foreground service and boot permissions for long-running background mesh -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Connected device foreground service type for BLE operations (API 34+) --> <!-- Connected device foreground service type for BLE operations (API 34+) -->
@@ -46,6 +48,10 @@
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" /> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true" /> <uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<permission
android:name="com.bitchat.android.permission.FORCE_FINISH"
android:protectionLevel="signature" />
<application <application
android:name=".BitchatApplication" android:name=".BitchatApplication"
android:allowBackup="false" android:allowBackup="false"
@@ -63,15 +63,46 @@ class MainActivity : OrientationAwareActivity() {
} }
} }
private val forceFinishReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) {
android.util.Log.i("MainActivity", "Received force finish broadcast, closing UI")
finishAffinity()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Register receiver for force finish signal from shutdown coordinator
val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
if (android.os.Build.VERSION.SDK_INT >= 33) {
registerReceiver(
forceFinishReceiver,
filter,
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
null,
android.content.Context.RECEIVER_NOT_EXPORTED
)
} else {
@Suppress("DEPRECATION")
registerReceiver(
forceFinishReceiver,
filter,
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
null
)
}
// Check if this is a quit request from the notification // Check if this is a quit request from the notification
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity") android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity")
finish() finish()
return return
} }
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
// Enable edge-to-edge display for modern Android look // Enable edge-to-edge display for modern Android look
enableEdgeToEdge() enableEdgeToEdge()
@@ -646,6 +677,8 @@ class MainActivity : OrientationAwareActivity() {
finish() finish()
return return
} }
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
// Handle notification intents when app is already running // Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
@@ -759,6 +792,8 @@ class MainActivity : OrientationAwareActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
// Cleanup location status manager // Cleanup location status manager
try { try {
locationStatusManager.cleanup() locationStatusManager.cleanup()
@@ -9,10 +9,13 @@ import com.bitchat.android.net.TorMode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicLong
/** /**
* Coordinates a full application shutdown: * Coordinates a full application shutdown:
@@ -24,6 +27,15 @@ import kotlinx.coroutines.withTimeoutOrNull
*/ */
object AppShutdownCoordinator { object AppShutdownCoordinator {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val shutdownToken = AtomicLong(0L)
@Volatile
private var shutdownJob: Job? = null
fun cancelPendingShutdown() {
shutdownToken.incrementAndGet()
shutdownJob?.cancel()
shutdownJob = null
}
fun requestFullShutdownAndKill( fun requestFullShutdownAndKill(
app: Application, app: Application,
@@ -32,7 +44,16 @@ object AppShutdownCoordinator {
stopForeground: () -> Unit, stopForeground: () -> Unit,
stopService: () -> Unit stopService: () -> Unit
) { ) {
scope.launch { val token = shutdownToken.incrementAndGet()
shutdownJob?.cancel()
val job = scope.launch {
// Signal UI to finish gracefully before we kill the process
try {
val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
.setPackage(app.packageName)
app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH)
} catch (_: Exception) { }
// Stop mesh (best-effort) // Stop mesh (best-effort)
try { mesh?.stopServices() } catch (_: Exception) { } try { mesh?.stopServices() } catch (_: Exception) { }
@@ -56,12 +77,19 @@ object AppShutdownCoordinator {
} }
// Stop the service itself // Stop the service itself
if (!isActive || shutdownToken.get() != token) return@launch
try { stopService() } catch (_: Exception) { } try { stopService() } catch (_: Exception) { }
// Hard kill the app process // Hard kill the app process
if (!isActive || shutdownToken.get() != token) return@launch
try { Process.killProcess(Process.myPid()) } catch (_: Exception) { } try { Process.killProcess(Process.myPid()) } catch (_: Exception) { }
try { System.exit(0) } catch (_: Exception) { } try { System.exit(0) } catch (_: Exception) { }
} }
shutdownJob = job
job.invokeOnCompletion {
if (shutdownJob === job) {
shutdownJob = null
}
}
} }
} }
@@ -115,6 +115,7 @@ class MeshForegroundService : Service() {
private val serviceJob = Job() private val serviceJob = Job()
private val scope = CoroutineScope(Dispatchers.Default + serviceJob) private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
private var isInForeground: Boolean = false private var isInForeground: Boolean = false
private var isShuttingDown: Boolean = false
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -133,6 +134,13 @@ class MeshForegroundService : Service() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (isShuttingDown && intent?.action == ACTION_START) {
AppShutdownCoordinator.cancelPendingShutdown()
isShuttingDown = false
}
if (isShuttingDown && intent?.action != ACTION_QUIT) {
return START_NOT_STICKY
}
when (intent?.action) { when (intent?.action) {
ACTION_STOP -> { ACTION_STOP -> {
// Stop FGS and mesh cleanly // Stop FGS and mesh cleanly
@@ -145,6 +153,12 @@ class MeshForegroundService : Service() {
return START_NOT_STICKY return START_NOT_STICKY
} }
ACTION_QUIT -> { ACTION_QUIT -> {
isShuttingDown = true
updateJob?.cancel()
updateJob = null
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
isInForeground = false
// Fully stop all background activity, stop Tor (without changing setting), then kill the app // Fully stop all background activity, stop Tor (without changing setting), then kill the app
AppShutdownCoordinator.requestFullShutdownAndKill( AppShutdownCoordinator.requestFullShutdownAndKill(
app = application, app = application,
@@ -208,6 +222,7 @@ class MeshForegroundService : Service() {
} }
private fun ensureMeshStarted() { private fun ensureMeshStarted() {
if (isShuttingDown) return
if (!hasBluetoothPermissions()) return if (!hasBluetoothPermissions()) return
try { try {
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
@@ -218,6 +233,10 @@ class MeshForegroundService : Service() {
} }
private fun updateNotification(force: Boolean) { private fun updateNotification(force: Boolean) {
if (isShuttingDown) {
notificationManager.cancel(NOTIFICATION_ID)
return
}
val count = meshService?.getActivePeerCount() ?: 0 val count = meshService?.getActivePeerCount() ?: 0
val notification = buildNotification(count) val notification = buildNotification(count)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
@@ -115,6 +115,8 @@ object AppConstants {
const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L
const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L
const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L
const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH"
const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH"
} }
object Media { object Media {