From 6549838bdcc78e0307438cda6679096d7492309c Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Wed, 3 Dec 2025 23:52:35 +0200 Subject: [PATCH 1/4] Android 15+ Foreground Service Issue and Permission Handling logic --- app/build.gradle.kts | 3 +- app/lint-baseline.xml | 167 ++++++------- .../miclock/receiver/BootCompletedReceiver.kt | 71 +++--- .../github/miclock/service/MicLockService.kt | 18 +- .../github/miclock/tile/MicLockTileService.kt | 20 +- .../java/io/github/miclock/ui/MainActivity.kt | 220 ++++++++++++++---- .../miclock/worker/BootServiceWorker.kt | 44 ++++ app/src/main/res/values/strings.xml | 4 + gradle/libs.versions.toml | 2 + 9 files changed, 376 insertions(+), 173 deletions(-) create mode 100644 app/src/main/java/io/github/miclock/worker/BootServiceWorker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1da2b4a..5d25a40 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { applicationId = "io.github.miclock" minSdk = 24 targetSdk = 36 - versionCode = 6 + versionCode = 8 versionName = "1.1.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -59,6 +59,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) + implementation(libs.androidx.work.runtime.ktx) // Unit testing dependencies testImplementation("org.junit.jupiter:junit-jupiter:5.9.2") diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index f64542f..eb4bdfd 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -217,7 +217,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -228,7 +228,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -283,7 +283,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -294,7 +294,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -349,7 +349,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -360,7 +360,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -371,7 +371,7 @@ errorLine2=" ~~~~"> @@ -404,7 +404,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -415,7 +415,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -426,7 +426,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -437,7 +437,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -448,7 +448,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -459,7 +459,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -470,7 +470,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -481,7 +481,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -492,7 +492,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -503,7 +503,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -514,7 +514,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -573,6 +573,17 @@ column="12"/> + + + + @@ -591,7 +602,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -602,7 +613,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -613,7 +624,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -624,7 +635,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -635,7 +646,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -646,7 +657,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -657,7 +668,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -668,7 +679,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -679,7 +690,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -690,7 +701,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -701,7 +712,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -712,7 +723,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -811,7 +822,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -822,7 +833,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -844,7 +855,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -855,7 +866,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -866,7 +877,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -877,7 +888,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -888,7 +899,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -899,7 +910,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1230,7 +1241,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1241,7 +1252,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1252,7 +1263,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1263,7 +1274,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1274,7 +1285,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1285,7 +1296,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1296,7 +1307,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1307,7 +1318,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1318,7 +1329,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1329,7 +1340,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1340,7 +1351,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1351,7 +1362,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1362,7 +1373,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1373,7 +1384,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1384,7 +1395,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1395,7 +1406,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1406,7 +1417,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1417,7 +1428,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1428,7 +1439,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1439,7 +1450,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1450,7 +1461,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1461,7 +1472,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1472,7 +1483,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1483,7 +1494,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1494,7 +1505,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1505,7 +1516,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1516,7 +1527,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1527,7 +1538,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1538,7 +1549,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1549,7 +1560,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1560,7 +1571,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1571,7 +1582,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1582,7 +1593,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1593,7 +1604,7 @@ errorLine2=" ~~~"> @@ -1604,7 +1615,7 @@ errorLine2=" ~~~~~~"> @@ -1615,7 +1626,7 @@ errorLine2=" ~~"> @@ -1626,7 +1637,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1637,7 +1648,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1648,7 +1659,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/app/src/main/java/io/github/miclock/receiver/BootCompletedReceiver.kt b/app/src/main/java/io/github/miclock/receiver/BootCompletedReceiver.kt index 937be6e..f972e91 100644 --- a/app/src/main/java/io/github/miclock/receiver/BootCompletedReceiver.kt +++ b/app/src/main/java/io/github/miclock/receiver/BootCompletedReceiver.kt @@ -10,18 +10,27 @@ import android.content.pm.PackageManager import android.os.Build import android.util.Log import androidx.core.content.ContextCompat -import io.github.miclock.service.MicLockService -import io.github.miclock.util.ApiGuard +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import io.github.miclock.worker.BootServiceWorker +import java.util.concurrent.TimeUnit +/** + * Receives BOOT_COMPLETED broadcast and schedules a WorkManager task to start the service. + * This approach is required for Android 15+ to avoid ForegroundServiceStartNotAllowedException + * when starting foreground services directly from BOOT_COMPLETED. + */ class BootCompletedReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == Intent.ACTION_BOOT_COMPLETED || intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) { - Log.d("BootCompletedReceiver", "Received ${intent.action}") + Log.d(TAG, "Received ${intent.action}") val micGranted = ContextCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO, ) == PackageManager.PERMISSION_GRANTED + + // Check notification status but don't require it val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notifsGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // API 33+ nm.areNotificationsEnabled() && ContextCompat.checkSelfPermission( @@ -32,44 +41,34 @@ class BootCompletedReceiver : BroadcastReceiver() { nm.areNotificationsEnabled() } - if (micGranted && notifsGranted) { - Log.d("BootCompletedReceiver", "Permissions granted, attempting to start MicLockService.") - val serviceIntent = Intent(context, MicLockService::class.java) + if (!notifsGranted) { + Log.w(TAG, "Notifications not enabled - service will run without notification updates") + } + + // Only require microphone permission + if (micGranted) { + Log.d(TAG, "Microphone permission granted, scheduling service start via WorkManager") + + // Use WorkManager to start the service with a delay + // This avoids Android 15+ restrictions on starting foreground services from BOOT_COMPLETED + val workRequest = OneTimeWorkRequestBuilder() + .setInitialDelay(5, TimeUnit.SECONDS) + .build() - try { - ContextCompat.startForegroundService(context, serviceIntent) - Log.d("BootCompletedReceiver", "MicLockService started as foreground service successfully.") - } catch (e: Exception) { - ApiGuard.onApi31_S( - block = { - if (e.javaClass.simpleName == "ForegroundServiceStartNotAllowedException") { - Log.w( - "BootCompletedReceiver", - "Foreground service start blocked for MicLockService: ${e.message}.", - ) - } else { - Log.e( - "BootCompletedReceiver", - "Unexpected error starting MicLockService on API 31+: ${e.message}", - e, - ) - } - }, - onUnsupported = { - Log.e( - "BootCompletedReceiver", - "Unexpected error starting MicLockService on older API: ${e.message}", - e, - ) - }, - ) - } + WorkManager.getInstance(context) + .enqueue(workRequest) + + Log.d(TAG, "WorkManager task scheduled to start MicLockService") } else { Log.d( - "BootCompletedReceiver", - "Permissions not fully granted. MicLockService will not start automatically.", + TAG, + "Microphone permission not granted. MicLockService will not start automatically.", ) } } } + + companion object { + private const val TAG = "BootCompletedReceiver" + } } diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 8432efb..45c6739 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -606,9 +606,9 @@ class MicLockService : Service(), MicActivationService { private fun handleBootStart() { if (!state.value.isRunning) { - isStartedFromBoot = true + isStartedFromBoot = false // Changed: WorkManager handles the delay, so we can treat this as normal start updateServiceState(running = true) - Log.i(TAG, "Service started from boot - waiting for screen state events") + Log.i(TAG, "Service started from boot via WorkManager - waiting for screen state events") } } @@ -657,12 +657,24 @@ class MicLockService : Service(), MicActivationService { } private fun hasAllRequirements(): Boolean { + // Only microphone permission is required + // Notifications are optional - service can run without them val mic = ContextCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO, ) == PackageManager.PERMISSION_GRANTED + + if (!mic) { + Log.w(TAG, "Microphone permission not granted") + } + + // Log notification status but don't require it val notifs = notifManager.areNotificationsEnabled() - return mic && notifs + if (!notifs) { + Log.w(TAG, "Notifications disabled - service will run without notification updates") + } + + return mic } private fun registerRecordingCallback(cb: AudioManager.AudioRecordingCallback) { diff --git a/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt b/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt index f6c88c9..bbbc5f7 100644 --- a/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt +++ b/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt @@ -131,6 +131,8 @@ class MicLockTileService : TileService() { } private fun hasAllPerms(): Boolean { + // Only microphone permission is required + // Notification permission is optional val micGranted = try { checkSelfPermission(Manifest.permission.RECORD_AUDIO) == @@ -140,6 +142,7 @@ class MicLockTileService : TileService() { false } + // Check notification status but don't require it val notifs = try { val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -155,9 +158,12 @@ class MicLockTileService : TileService() { false } - val hasPerms = micGranted && notifs - Log.d(TAG, "Permission check: mic=$micGranted, notifs=$notifs, hasAll=$hasPerms") - return hasPerms + if (!notifs) { + Log.d(TAG, "Notifications disabled - tile will work but user won't see service notifications") + } + + Log.d(TAG, "Permission check: mic=$micGranted, notifs=$notifs (optional)") + return micGranted } override fun onClick() { @@ -347,12 +353,12 @@ class MicLockTileService : TileService() { when { !hasPerms -> { - // Permissions missing - show unavailable state + // Microphone permission missing - show unavailable state tile.state = Tile.STATE_UNAVAILABLE - tile.label = "No Permission" - tile.contentDescription = "Tap to grant microphone and notification permissions" + tile.label = "No Mic Permission" + tile.contentDescription = "Tap to grant microphone permission" tile.icon = Icon.createWithResource(this, R.drawable.ic_mic_off) - Log.d(TAG, "Tile set to 'No Permission' state") + Log.d(TAG, "Tile set to 'No Mic Permission' state") } state.isDelayedActivationPending -> { // Delayed activation is pending - show activating state diff --git a/app/src/main/java/io/github/miclock/ui/MainActivity.kt b/app/src/main/java/io/github/miclock/ui/MainActivity.kt index d389c0d..492af5e 100644 --- a/app/src/main/java/io/github/miclock/ui/MainActivity.kt +++ b/app/src/main/java/io/github/miclock/ui/MainActivity.kt @@ -68,6 +68,11 @@ open class MainActivity : AppCompatActivity() { private var timerJob: Job? = null + // Track if we're showing permission dialog to prevent loops + private var isShowingPermissionDialog = false + private var hasRequestedNotificationPermission = false + private var isWaitingForPermissionFromSettings = false + private val audioPerms = arrayOf(Manifest.permission.RECORD_AUDIO) private val notifPerms = if (Build.VERSION.SDK_INT >= 33) { arrayOf(Manifest.permission.POST_NOTIFICATIONS) @@ -77,10 +82,85 @@ open class MainActivity : AppCompatActivity() { private val reqPerms = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), - ) { _ -> - updateAllUi() - // Request tile update after permission changes - requestTileUpdate() + ) { results -> + isShowingPermissionDialog = false + + // Check if microphone permission was denied + val micPermissionGranted = results[Manifest.permission.RECORD_AUDIO] ?: hasMicPermission() + + if (!micPermissionGranted) { + // Microphone permission is required - show dialog and exit + showMicPermissionDeniedDialog() + } else { + // Microphone permission granted - update UI + updateAllUi() + // Request tile update after permission changes + requestTileUpdate() + + // Show info if notification permission was denied (optional) - but only once + val notifPermissionGranted = if (Build.VERSION.SDK_INT >= 33) { + results[Manifest.permission.POST_NOTIFICATIONS] ?: hasNotificationPermission() + } else { + true + } + + if (!notifPermissionGranted && Build.VERSION.SDK_INT >= 33 && !hasRequestedNotificationPermission) { + hasRequestedNotificationPermission = true + android.widget.Toast.makeText( + this, + "Notification permission denied. You won't see service status notifications.", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + } + + private fun showMicPermissionDeniedDialog() { + if (isShowingPermissionDialog) return + isShowingPermissionDialog = true + + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("Permission Required") + .setMessage(getString(R.string.mic_permission_denied_message)) + .setPositiveButton("Open Settings") { dialog, _ -> + dialog.dismiss() + isShowingPermissionDialog = false + isWaitingForPermissionFromSettings = true + // Open app settings so user can grant permission manually + openAppSettings() + } + .setNegativeButton("Exit") { _, _ -> + isShowingPermissionDialog = false + isWaitingForPermissionFromSettings = false + finish() + } + .setOnDismissListener { + // Don't auto-exit when dialog is dismissed + // Only exit if user explicitly clicked "Exit" + isShowingPermissionDialog = false + } + .setCancelable(false) + .show() + } + + /** + * Opens the app settings page where the user can manually grant permissions. + */ + private fun openAppSettings() { + try { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:$packageName") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + } catch (e: Exception) { + Log.e("MainActivity", "Failed to open app settings: ${e.message}", e) + android.widget.Toast.makeText( + this, + "Unable to open settings. Please grant microphone permission manually in Settings > Apps > Mic-Lock > Permissions", + android.widget.Toast.LENGTH_LONG, + ).show() + } } /** @@ -152,8 +232,9 @@ open class MainActivity : AppCompatActivity() { } startBtn.setOnClickListener { - if (!hasAllPerms()) { - reqPerms.launch(audioPerms + notifPerms) + if (!hasMicPermission()) { + // Microphone permission is required + showMicrophonePermissionRequiredDialog() } else { handleStartButtonClick() } @@ -163,18 +244,18 @@ open class MainActivity : AppCompatActivity() { // Request battery optimization exemption requestBatteryOptimizationExemption() - // Always enforce permissions on every app start - enforcePermsOrRequest() + // Check permissions on app start (only in onCreate, not onResume to avoid loops) + checkPermissionsOnStart() updateAllUi() // Handle tile-initiated start if (intent.getBooleanExtra(EXTRA_START_SERVICE_FROM_TILE, false)) { Log.d("MainActivity", "Starting service from tile fallback request") - if (hasAllPerms()) { + if (hasMicPermission()) { startMicLockFromTileFallback() } else { - Log.w("MainActivity", "Permissions missing for tile fallback - requesting permissions") - reqPerms.launch(audioPerms + notifPerms) + Log.w("MainActivity", "Microphone permission missing for tile fallback") + showMicrophonePermissionRequiredDialog() } } @@ -186,8 +267,22 @@ open class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() - // Re-check permissions every time activity becomes visible - enforcePermsOrRequest() + + // Check if we're returning from settings with permission granted + if (isWaitingForPermissionFromSettings) { + if (hasMicPermission()) { + // Permission granted! Reset flag and continue + isWaitingForPermissionFromSettings = false + android.widget.Toast.makeText( + this, + "Microphone permission granted. You can now use Mic-Lock.", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + // If permission still not granted, keep waiting (don't exit) + } + + // Only update UI, don't re-request permissions to avoid loops updateAllUi() lifecycleScope.launch { @@ -738,63 +833,92 @@ open class MainActivity : AppCompatActivity() { } } - private fun hasAllPerms(): Boolean { - val micGranted = ContextCompat.checkSelfPermission( + private fun hasMicPermission(): Boolean { + return ContextCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO, ) == PackageManager.PERMISSION_GRANTED + } - var notifGranted = true + private fun hasNotificationPermission(): Boolean { if (ApiGuard.isApi33_Tiramisu_OrAbove()) { val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val postNotificationsGranted = ContextCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED - notifGranted = notificationManager.areNotificationsEnabled() && postNotificationsGranted + return notificationManager.areNotificationsEnabled() && postNotificationsGranted } else { val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notifGranted = notificationManager.areNotificationsEnabled() + return notificationManager.areNotificationsEnabled() } - - return micGranted && notifGranted } - private fun enforcePermsOrRequest() { - if (ApiGuard.isApi28_P_OrAbove()) { - if (!hasAllPerms()) { - val permissionsToRequest = mutableListOf() + private fun hasAllPerms(): Boolean { + // Only microphone permission is required + // Notification permission is optional (nice to have) + return hasMicPermission() + } - if (ContextCompat.checkSelfPermission( - this, - Manifest.permission.RECORD_AUDIO, - ) != PackageManager.PERMISSION_GRANTED - ) { - permissionsToRequest.add(Manifest.permission.RECORD_AUDIO) - } + private fun checkPermissionsOnStart() { + if (!ApiGuard.isApi28_P_OrAbove()) { + Log.d("MainActivity", "Skipping permission check on pre-P device") + return + } - if (ApiGuard.isApi33_Tiramisu_OrAbove()) { - val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS, - ) != PackageManager.PERMISSION_GRANTED || - !notificationManager.areNotificationsEnabled() - ) { - permissionsToRequest.add(Manifest.permission.POST_NOTIFICATIONS) - } - } + // Check if microphone permission is missing (required) + if (!hasMicPermission()) { + showMicrophonePermissionRequiredDialog() + return + } - if (permissionsToRequest.isNotEmpty()) { - reqPerms.launch(permissionsToRequest.toTypedArray()) - } + // Optionally request notification permission if not granted (nice to have) + // Only request once per app session + if (!hasRequestedNotificationPermission && !hasNotificationPermission() && ApiGuard.isApi33_Tiramisu_OrAbove()) { + hasRequestedNotificationPermission = true + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED || + !notificationManager.areNotificationsEnabled() + ) { + // Request notification permission, but don't block app usage if denied + isShowingPermissionDialog = true + reqPerms.launch(arrayOf(Manifest.permission.POST_NOTIFICATIONS)) } - } else { - Log.d("MainActivity", "Skipping enforcePermsOrRequest on pre-P device, standard checks apply.") } } + private fun showMicrophonePermissionRequiredDialog() { + if (isShowingPermissionDialog) return + isShowingPermissionDialog = true + + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("Microphone Permission Required") + .setMessage(getString(R.string.mic_permission_required_message)) + .setPositiveButton("Grant Permission") { dialog, _ -> + dialog.dismiss() + isShowingPermissionDialog = false + isWaitingForPermissionFromSettings = true + // Don't reset flag here - let the permission callback handle it + reqPerms.launch(arrayOf(Manifest.permission.RECORD_AUDIO)) + } + .setNegativeButton("Exit") { _, _ -> + isShowingPermissionDialog = false + isWaitingForPermissionFromSettings = false + finish() + } + .setOnDismissListener { + // Don't auto-exit when dialog is dismissed + // Only exit if user explicitly clicked "Exit" + isShowingPermissionDialog = false + } + .setCancelable(false) + .show() + } + /** * Handles start button click with proper screen-off pause state handling. * Checks service state and routes to appropriate action (start, resume, or request permissions). diff --git a/app/src/main/java/io/github/miclock/worker/BootServiceWorker.kt b/app/src/main/java/io/github/miclock/worker/BootServiceWorker.kt new file mode 100644 index 0000000..a07602c --- /dev/null +++ b/app/src/main/java/io/github/miclock/worker/BootServiceWorker.kt @@ -0,0 +1,44 @@ +package io.github.miclock.worker + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import io.github.miclock.service.MicLockService +import kotlinx.coroutines.delay + +/** + * Worker to start MicLockService after boot with a delay. + * This is required for Android 15+ to avoid ForegroundServiceStartNotAllowedException + * when starting foreground services from BOOT_COMPLETED broadcast. + */ +class BootServiceWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + Log.d(TAG, "BootServiceWorker started - waiting before starting service") + + // Wait a bit to ensure system is ready + delay(5000) // 5 seconds delay + + Log.d(TAG, "Starting MicLockService from worker") + val serviceIntent = Intent(applicationContext, MicLockService::class.java) + applicationContext.startService(serviceIntent) + + Log.d(TAG, "MicLockService started successfully from worker") + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Failed to start MicLockService from worker: ${e.message}", e) + Result.failure() + } + } + + companion object { + private const val TAG = "BootServiceWorker" + const val WORK_NAME = "boot_service_worker" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f082476..6d1166a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,4 +80,8 @@ License MicLock is licensed under the MIT License.\n\nCopyright © 2024 Mic-Lock Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. Back + + + Mic-Lock requires microphone permission to function.\n\nThe app will stay open while you grant permission. If you need to grant it manually:\n\n1. Tap \"Grant Permission\"\n2. Select \"Allow\" in the permission dialog\n\nOr go to Settings > Apps > Mic-Lock > Permissions > Microphone > Allow + Microphone permission was denied. Mic-Lock cannot function without this permission.\n\nTo grant permission manually:\n\n1. Tap \"Open Settings\" below\n2. Select \"Permissions\"\n3. Tap \"Microphone\"\n4. Select \"Allow\"\n5. Return to Mic-Lock\n\nThe app will stay open while you grant permission. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e0e7d87..0417600 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ espressoCore = "3.5.1" appcompat = "1.6.1" material = "1.10.0" rules = "1.7.0" +workManager = "2.9.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -18,6 +19,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-rules = { group = "androidx.test", name = "rules", version.ref = "rules" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 32964d06ae2261c7d37cdf73bd6f996d593e99df Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Wed, 3 Dec 2025 23:53:07 +0200 Subject: [PATCH 2/4] refactored dialog text --- app/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d1166a..affc8f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,6 @@ Back - Mic-Lock requires microphone permission to function.\n\nThe app will stay open while you grant permission. If you need to grant it manually:\n\n1. Tap \"Grant Permission\"\n2. Select \"Allow\" in the permission dialog\n\nOr go to Settings > Apps > Mic-Lock > Permissions > Microphone > Allow - Microphone permission was denied. Mic-Lock cannot function without this permission.\n\nTo grant permission manually:\n\n1. Tap \"Open Settings\" below\n2. Select \"Permissions\"\n3. Tap \"Microphone\"\n4. Select \"Allow\"\n5. Return to Mic-Lock\n\nThe app will stay open while you grant permission. + Mic-Lock requires microphone permission to function.\n\n To grant it manually:\n\n1. Tap \"Grant Permission\"\n2. Select \"Allow\" in the permission dialog\n\nOr go to Settings > Apps > Mic-Lock > Permissions > Microphone > Allow + Microphone permission was denied. Mic-Lock cannot function without this permission.\n\nTo grant permission manually:\n\n1. Tap \"Open Settings\" below\n2. Select \"Permissions\"\n3. Tap \"Microphone\"\n4. Select \"Allow\"\n5. Return to Mic-Lock\n\n From b79afe0de029a19b71ffe2a31ec52db2fe5f700d Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 4 Dec 2025 09:25:41 +0200 Subject: [PATCH 3/4] Prevent MainActivity from calling finish() when reopening from recents after quick tile --- .../main/java/io/github/miclock/ui/MainActivity.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/miclock/ui/MainActivity.kt b/app/src/main/java/io/github/miclock/ui/MainActivity.kt index 492af5e..314e743 100644 --- a/app/src/main/java/io/github/miclock/ui/MainActivity.kt +++ b/app/src/main/java/io/github/miclock/ui/MainActivity.kt @@ -1056,11 +1056,23 @@ open class MainActivity : AppCompatActivity() { private fun startMicLockFromTileFallback() { Log.d("MainActivity", "Starting MicLock service as tile fallback") + + // Check if service is already running before starting + val wasRunning = MicLockService.state.value.isRunning + val intent = Intent(this, MicLockService::class.java).apply { action = MicLockService.ACTION_START_USER_INITIATED } ContextCompat.startForegroundService(this, intent) - finish() + + // Only finish if service wasn't already running (initial tile click) + // If service was already running, user likely reopened from recents - stay open + if (!wasRunning) { + Log.d("MainActivity", "Service started from tile - closing activity") + finish() + } else { + Log.d("MainActivity", "Service already running - keeping activity open (likely reopened from recents)") + } } private fun requestTileUpdate() { From 350f69ee62ec3208ef970043c7b814a768bf6cb3 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 4 Dec 2025 09:33:43 +0200 Subject: [PATCH 4/4] new version code --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5d25a40..6f35869 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { applicationId = "io.github.miclock" minSdk = 24 targetSdk = 36 - versionCode = 8 + versionCode = 9 versionName = "1.1.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"