diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1da2b4a..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 = 6
+ versionCode = 9
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..314e743 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).
@@ -932,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() {
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..affc8f0 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\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
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" }