From 4c86a62522daead180cad00bfde0532da669ac8c Mon Sep 17 00:00:00 2001 From: Dan Oren <94993872+Dan8Oren@users.noreply.github.com> Date: Thu, 4 Dec 2025 03:23:22 +0700 Subject: [PATCH 1/2] Menu Section - Debug Recording | Feedback | About (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ” Menu Section - Debug Recording | Feedback | About ### This release focuses on making it easier to troubleshoot issues and provide feedback. ### ๐Ÿ› Debug Logging Tools 1. **Start Recording**: Tap the menu (โ‹ฎ) โ†’ "Debug Recording" 2. **Reproduce Your Issue**: Use your phone normally while the issue occurs 3. **Stop & Share**: Tap menu again โ†’ "Stop & Share Debug Logs" 4. **Send Logs**: Share via email, GitHub, or any app you prefer * If MicLock crashes while debug recording is active, logs are automatically saved!, the app prompts to share the logs when opened after a crash. #### What Gets Captured - โœ… **App Logs**: Only MicLock's logs (no other app data) - โœ… **System Audio State**: Audio routing, call state, media sessions - โœ… **Device Info**: Manufacturer, model, Android version ``` miclock_debug_logs_YYYYMMDD_HHMMSS.zip โ”œโ”€โ”€ collection_report.txt (what succeeded/failed) โ”œโ”€โ”€ app_logs.txt (MicLock logcat output) โ”œโ”€โ”€ dumpsys_audio.txt (audio routing state) โ”œโ”€โ”€ dumpsys_telecom.txt (call state - critical for call issues) โ”œโ”€โ”€ dumpsys_media_session.txt (media player state) โ”œโ”€โ”€ dumpsys_audio_policy.txt (audio policy decisions) โ”œโ”€โ”€ dumpsys_audio_flinger.txt (low-level audio HAL) โ””โ”€โ”€ device_info.txt (device and app metadata) ``` ### ๐Ÿ“ข Feedback & About #### 1. Send Feedback New "Feedback" menu option makes it easy to: - ๐Ÿž **Report a Bug**: Something not working right? Let us know. - ๐Ÿ’ก **Request a Feature**: Have an idea to make MicLock better? Both options open GitHub with pre-filled templates to make reporting easy! #### 2. About Screen with some information. > [!IMPORTANT] > **Action Required for Update (APK / GitHub Users Only)** > > *Note: If you installed via Google Play, you can ignore this warning. Your app will update automatically.* > > If you have a previous `.apk` version of the app installed manually, you must **delete it** before installing this update. > > Due to a necessary security update in our signing keys, this version cannot be installed directly over the old one. If you try to update normally, the installation may fail with an error. > > ### How to update: > 1. **Uninstall** the current version of the app from your phone. > 2. **Download** the new `.apk` file below. > 3. **Install** as normal. --- .github/ISSUE_TEMPLATE/bug_report.md | 16 +- DEV_SPECS.md | 39 +- app/build.gradle.kts | 4 +- app/lint-baseline.xml | 1160 +++++++++++++---- app/src/main/AndroidManifest.xml | 21 + .../io/github/miclock/MicLockApplication.kt | 33 + .../miclock/data/DebugRecordingState.kt | 209 +++ .../io/github/miclock/ui/AboutActivity.kt | 103 ++ .../java/io/github/miclock/ui/MainActivity.kt | 560 +++++++- .../github/miclock/util/CollectionResult.kt | 53 + .../io/github/miclock/util/CrashHandler.kt | 341 +++++ .../github/miclock/util/DebugLogCollector.kt | 719 ++++++++++ .../github/miclock/util/DebugLogRecorder.kt | 262 ++++ app/src/main/res/drawable/ic_back_arrow.xml | 9 + app/src/main/res/drawable/ic_bug.xml | 9 + app/src/main/res/drawable/ic_lightbulb.xml | 9 + app/src/main/res/drawable/ic_more_vert.xml | 10 + app/src/main/res/layout/activity_about.xml | 257 ++++ app/src/main/res/layout/activity_main.xml | 132 +- .../main/res/layout/bottom_sheet_feedback.xml | 127 ++ app/src/main/res/menu/main_menu.xml | 12 + app/src/main/res/values/strings.xml | 68 + app/src/main/res/xml/file_paths.xml | 5 + 23 files changed, 3842 insertions(+), 316 deletions(-) create mode 100644 app/src/main/java/io/github/miclock/MicLockApplication.kt create mode 100644 app/src/main/java/io/github/miclock/data/DebugRecordingState.kt create mode 100644 app/src/main/java/io/github/miclock/ui/AboutActivity.kt create mode 100644 app/src/main/java/io/github/miclock/util/CollectionResult.kt create mode 100644 app/src/main/java/io/github/miclock/util/CrashHandler.kt create mode 100644 app/src/main/java/io/github/miclock/util/DebugLogCollector.kt create mode 100644 app/src/main/java/io/github/miclock/util/DebugLogRecorder.kt create mode 100644 app/src/main/res/drawable/ic_back_arrow.xml create mode 100644 app/src/main/res/drawable/ic_bug.xml create mode 100644 app/src/main/res/drawable/ic_lightbulb.xml create mode 100644 app/src/main/res/drawable/ic_more_vert.xml create mode 100644 app/src/main/res/layout/activity_about.xml create mode 100644 app/src/main/res/layout/bottom_sheet_feedback.xml create mode 100644 app/src/main/res/menu/main_menu.xml create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9a645df..6872c33 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -34,9 +34,21 @@ A clear and concise description of what actually happened. - [ ] Tried switching between MediaRecorder/AudioRecord modes - [ ] Checked battery optimization settings -**Logs (if available)** -If you can access Android logs (via ADB or developer options), please include any relevant error messages: +**Debug Logs** ๐Ÿ” +To help us diagnose your issue faster, please consider capturing debug logs: +1. **In the app**: Tap the menu (โ‹ฎ) โ†’ "Debug Tools" +2. **Reproduce the issue**: Use the app normally while the bug occurs +3. **Stop recording**: Tap menu (โ‹ฎ) โ†’ "Stop & Share Debug Logs" +4. **Attach the file**: The app will create a zip file in Downloads/miclock_logs folder + +**Why debug logs are helpful:** +- Contains app logs, system audio state, and device information +- Especially useful for device-specific or hard-to-reproduce issues +- Helps identify audio routing problems unique to your device model +- **Privacy-safe**: Only captures MicLock logs (no call audio, contacts, or personal data) + +**If you can't use the in-app tool**, you can also provide logs via ADB: ``` Paste logs here ``` diff --git a/DEV_SPECS.md b/DEV_SPECS.md index 3c323cd..fcd56ed 100644 --- a/DEV_SPECS.md +++ b/DEV_SPECS.md @@ -107,7 +107,44 @@ Mic-Lock must implement configurable delayed activation to optimize battery usag * **Battery Usage Awareness:** Clearly communicate to users that MediaRecorder mode uses more battery but provides better compatibility. * **Battery Optimization Exemption:** Upon first launch, the app prompts the user to grant an exemption from battery optimizations. This is critical to prevent the Android system from terminating the service during long periods of device inactivity, ensuring continuous background operation. -### 2.9 Service Resilience and User Experience +### 2.9 Debug Logging and Diagnostics + +To facilitate troubleshooting of audio routing issues, Mic-Lock provides comprehensive debug logging capabilities: + +* **On-Demand Recording:** Users can start debug log recording through the overflow menu (โ‹ฎ) โ†’ "Debug Tools" +* **Automatic Safety Mechanisms:** + - 30-minute auto-stop to prevent battery drain and storage exhaustion + - Automatic cleanup of temporary files when app closes + - Visual recording indicator with elapsed time counter +* **Comprehensive Data Collection:** + - Application logs (logcat filtered to Mic-Lock process only) + - System audio state (dumpsys audio, telecom, media.session, media.audio_policy, media.audio_flinger) + - Device metadata (manufacturer, model, Android version, app version) + - Service state at time of collection +* **Privacy-Conscious Design:** + - Only captures Mic-Lock app logs (no other app data) + - No call audio content, contacts, phone numbers, or location data + - Explicit user action required to share logs +* **Persistent Storage:** + - Logs saved to Downloads/miclock_logs folder with timestamped filenames + - Files persist after app uninstall for later reference + - Notification with "Share" action for easy log distribution +* **Graceful Error Handling:** + - Continues collection even if some components fail + - Provides context about which failures are relevant to specific issues + - Clear error messages guide users on next steps +* **Crash Detection and Recovery:** + - Automatically saves debug logs if app crashes during recording + - Shows notification with crash log location + - On next launch, offers to report crash on GitHub with pre-filled issue template + - Crash logs include exception details and stack traces +* **User-Friendly Sharing:** + - Standard Android share sheet integration + - Direct GitHub issue creation with pre-filled crash reports + - Feedback mechanism for bug reports and feature requests + - About screen with app information and repository link + +### 2.10 Service Resilience and User Experience To ensure the service remains active and is easy to manage, Mic-Lock implements several resilience features: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3820a2e..1da2b4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "io.github.miclock" minSdk = 24 targetSdk = 36 - versionCode = 5 - versionName = "1.1.1" + versionCode = 6 + versionName = "1.1.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 2aa8fe4..f64542f 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -2,25 +2,355 @@ + id="UnknownIssueId" + message="Unknown issue id "MethodName"" + errorLine1=" <issue id="MethodName" severity="error" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + message="Call requires API level 26 (current min is 24): `java.lang.Process#isAlive`" + errorLine1=" return logcatProcess?.isAlive == true" + errorLine2=" ~~~~~~~"> + + + + + line="448" + column="39"/> + + + + + errorLine1=" testImplementation("androidx.test:core:1.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="75" + column="22"/> + errorLine1=" testImplementation("androidx.test.ext:junit:1.1.5")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="78" + column="22"/> + errorLine1=" testImplementation("androidx.test:runner:1.5.2")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="79" + column="22"/> + errorLine1=" androidTestImplementation("androidx.test:core:1.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="87" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test:runner:1.5.2")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="88" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test:rules:1.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="89" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test.ext:junit-ktx:1.1.5")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="90" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="91" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test.espresso:espresso-intents:3.5.1")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="92" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test:core:1.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="101" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test:core-ktx:1.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="105" + column="29"/> @@ -195,7 +536,7 @@ errorLine2=" ~~~~~~~"> @@ -206,7 +547,7 @@ errorLine2=" ~~~~~~~"> @@ -217,7 +558,7 @@ errorLine2=" ~~~~~~~"> @@ -228,164 +569,208 @@ errorLine2=" ~~~~~~~~"> + message="A newer version of org.junit.jupiter:junit-jupiter than 5.9.2 is available: 6.0.1" + errorLine1=" testImplementation("org.junit.jupiter:junit-jupiter:5.9.2")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="64" + column="22"/> + message="A newer version of org.junit.jupiter:junit-jupiter-engine than 5.9.2 is available: 6.0.1" + errorLine1=" testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.2")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="65" + column="22"/> + errorLine1=" testImplementation("org.mockito:mockito-core:5.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="67" + column="22"/> + message="A newer version of org.mockito.kotlin:mockito-kotlin than 5.1.0 is available: 6.1.0" + errorLine1=" testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="68" + column="22"/> + errorLine1=" testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="69" + column="22"/> + errorLine1=" testImplementation("com.google.truth:truth:1.1.3")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="71" + column="22"/> + errorLine1=" testImplementation("org.robolectric:robolectric:4.11.1")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="74" + column="22"/> + errorLine1=" androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="95" + column="29"/> + errorLine1=" androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="96" + column="29"/> + errorLine1=" androidTestImplementation("org.mockito:mockito-core:5.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="99" + column="29"/> + errorLine1=" androidTestImplementation("org.mockito:mockito-android:5.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="100" + column="29"/> + message="A newer version of org.mockito.kotlin:mockito-kotlin than 5.1.0 is available: 6.1.0" + errorLine1=" androidTestImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="102" + column="29"/> + errorLine1=" androidTestImplementation("com.google.truth:truth:1.1.3")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="107" + column="29"/> + + + + + + + + + + + + + + + + + + + + @@ -426,7 +822,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -437,7 +833,18 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + @@ -448,7 +855,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -459,7 +866,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -470,7 +877,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -481,10 +888,21 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + @@ -523,6 +952,17 @@ column="1"/> + + + + @@ -541,7 +981,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -552,7 +992,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -563,7 +1003,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -574,7 +1014,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -585,7 +1025,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -596,7 +1036,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -607,7 +1047,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -618,7 +1058,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -629,7 +1069,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -662,6 +1102,17 @@ file="src/main/res/mipmap-hdpi/ic_launcher_round.webp"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -702,305 +1285,305 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" testImplementation("org.junit.jupiter:junit-jupiter:5.9.2")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="64" + column="22"/> + errorLine1=" testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.2")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="65" + column="22"/> + errorLine1=" testImplementation("junit:junit:4.13.2")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + line="66" + column="22"/> + errorLine1=" testImplementation("org.mockito:mockito-core:5.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="67" + column="22"/> + errorLine1=" testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="68" + column="22"/> + errorLine1=" testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="69" + column="22"/> + errorLine1=" testImplementation("androidx.arch.core:core-testing:2.2.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="70" + column="22"/> + errorLine1=" testImplementation("com.google.truth:truth:1.1.3")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="71" + column="22"/> + errorLine1=" testImplementation("org.robolectric:robolectric:4.11.1")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="74" + column="22"/> + errorLine1=" testImplementation("androidx.test:core:1.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="75" + column="22"/> + errorLine1=" testImplementation("androidx.test.ext:junit:1.1.5")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="78" + column="22"/> + errorLine1=" testImplementation("androidx.test:runner:1.5.2")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="79" + column="22"/> + errorLine1=" androidTestImplementation("androidx.test:core:1.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="87" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test:runner:1.5.2")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="88" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test:rules:1.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="89" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test.ext:junit-ktx:1.1.5")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="90" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="91" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test.espresso:espresso-intents:3.5.1")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="92" + column="29"/> + errorLine1=" androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="95" + column="29"/> + errorLine1=" androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="96" + column="29"/> + errorLine1=" androidTestImplementation("org.mockito:mockito-core:5.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="99" + column="29"/> + errorLine1=" androidTestImplementation("org.mockito:mockito-android:5.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="100" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test:core:1.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="101" + column="29"/> + errorLine1=" androidTestImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="102" + column="29"/> + errorLine1=" androidTestImplementation("androidx.test:core-ktx:1.5.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="105" + column="29"/> + errorLine1=" androidTestImplementation("androidx.arch.core:core-testing:2.2.0")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="106" + column="29"/> + errorLine1=" androidTestImplementation("com.google.truth:truth:1.1.3")" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="107" + column="29"/> @@ -1021,7 +1604,7 @@ errorLine2=" ~~~~~~"> @@ -1032,7 +1615,7 @@ errorLine2=" ~~"> @@ -1043,7 +1626,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1054,7 +1637,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1065,29 +1648,62 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + + errorLine1=" android:text="Mic-Lock"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> + line="89" + column="25"/> + errorLine1=" android:text="Microphone Route Protection"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + @@ -1098,7 +1714,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1109,7 +1725,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -1120,7 +1736,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1131,7 +1747,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1142,7 +1758,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1153,7 +1769,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1164,7 +1780,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b662f77..a5897a6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + + + + + + + + diff --git a/app/src/main/java/io/github/miclock/MicLockApplication.kt b/app/src/main/java/io/github/miclock/MicLockApplication.kt new file mode 100644 index 0000000..ae99322 --- /dev/null +++ b/app/src/main/java/io/github/miclock/MicLockApplication.kt @@ -0,0 +1,33 @@ +package io.github.miclock + +import android.app.Application +import android.os.Build +import android.util.Log +import io.github.miclock.util.CrashHandler + +/** + * Application class for Mic-Lock. + * Registers the crash handler to intercept uncaught exceptions during debug recording. + */ +class MicLockApplication : Application() { + + companion object { + private const val TAG = "MicLockApplication" + } + + override fun onCreate() { + super.onCreate() + + Log.d(TAG, "Application onCreate") + + // Register crash handler on Android O+ (required for DebugLogCollector) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + val crashHandler = CrashHandler(applicationContext, defaultHandler) + Thread.setDefaultUncaughtExceptionHandler(crashHandler) + Log.d(TAG, "Crash handler registered") + } else { + Log.d(TAG, "Crash handler not registered (requires Android O+)") + } + } +} diff --git a/app/src/main/java/io/github/miclock/data/DebugRecordingState.kt b/app/src/main/java/io/github/miclock/data/DebugRecordingState.kt new file mode 100644 index 0000000..56e3561 --- /dev/null +++ b/app/src/main/java/io/github/miclock/data/DebugRecordingState.kt @@ -0,0 +1,209 @@ +package io.github.miclock.data + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import io.github.miclock.R +import io.github.miclock.ui.MainActivity +import io.github.miclock.util.DebugLogRecorder +import java.io.File +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Represents the current state of debug log recording. + * Used with StateFlow for reactive UI updates. + * * @param isRecording Whether a recording session is currently active + * @param startTime Unix timestamp (milliseconds) when recording started, or 0 if not recording + * @param autoStopScheduled Whether the 30-minute auto-stop timeout is scheduled + */ +data class DebugRecordingState( + val isRecording: Boolean = false, + val startTime: Long = 0L, + val autoStopScheduled: Boolean = false, +) + +/** + * Manages the state of debug log recording with reactive updates. + * Provides a single source of truth for recording state across the application. + * * This manager coordinates with DebugLogRecorder to control the recording lifecycle + * and exposes state changes via StateFlow for UI observation. + */ +object DebugRecordingStateManager { + private const val TAG = "DebugRecordingStateMgr" + private const val DEBUG_CHANNEL_ID = "debug_recording_channel" + private const val AUTO_STOP_NOTIF_ID = 44 + + private val _state = MutableStateFlow(DebugRecordingState()) + + /** + * Observable state flow for debug recording state. + * UI components should collect this flow to react to state changes. + */ + val state: StateFlow = _state.asStateFlow() + + /** + * Starts a debug recording session. + * * This method transitions to the recording state and initiates log capture. + * The state will be updated to reflect the recording status, start time, and + * auto-stop scheduling. + * * @param context Application context for file access and recording operations + * @return true if recording started successfully, false otherwise + * @throws SecurityException if logcat access is denied on the device + */ + fun startRecording(context: Context): Boolean { + // Check if already recording + if (_state.value.isRecording) { + Log.w(TAG, "Already recording") + return false + } + + // Start the actual recording + val success = DebugLogRecorder.startRecording(context) + if (!success) { + Log.e(TAG, "Failed to start DebugLogRecorder") + return false + } + + val startTime = System.currentTimeMillis() + + // Schedule auto-stop with callback to update state + DebugLogRecorder.scheduleAutoStop(context) { handleAutoStop(context) } + + // Update state to recording + _state.value = DebugRecordingState( + isRecording = true, + startTime = startTime, + autoStopScheduled = true, + ) + + Log.d(TAG, "Recording started successfully") + return true + } + + /** + * Stops the current debug recording session. + * * This method terminates log capture and transitions back to the non-recording state. + * The captured log file is returned for further processing (collection and sharing). + * * @return File containing captured logs, or null if no logs were captured or not recording + */ + fun stopRecording(): File? { + // Check if actually recording + if (!_state.value.isRecording) { + Log.w(TAG, "Not recording, cannot stop") + return null + } + + // Cancel auto-stop since we're manually stopping + DebugLogRecorder.cancelAutoStop() + + // Stop the actual recording + val logFile = DebugLogRecorder.stopRecording() + + // Update state to not recording + _state.value = DebugRecordingState( + isRecording = false, + startTime = 0L, + autoStopScheduled = false, + ) + + Log.d(TAG, "Recording stopped") + return logFile + } + + /** + * Resets the recording state to initial values. + * * This method ensures a clean state, typically called on app startup or after + * sharing logs. It stops any active recording and cleans up resources. + * * @param context Application context for cleanup operations + */ + fun reset(context: Context) { + // If recording is active, stop it first + if (_state.value.isRecording) { + stopRecording() + } + + // Clean up any leftover files + DebugLogRecorder.cleanup(context) + + // Cancel any pending auto-stop + DebugLogRecorder.cancelAutoStop() + + // Reset to initial state + _state.value = DebugRecordingState() + + Log.d(TAG, "State reset") + } + + /** + * Handles auto-stop when the 30-minute timeout is reached. + * Shows a notification to inform the user and updates the state. + * * @param context Application context for notifications + */ + private fun handleAutoStop(context: Context) { + Log.d(TAG, "Auto-stop triggered") + + // Stop recording + val logFile = DebugLogRecorder.stopRecording() + + // Update state + _state.value = DebugRecordingState( + isRecording = false, + startTime = 0L, + autoStopScheduled = false, + ) + + // Show notification + showAutoStopNotification(context) + + Log.d(TAG, "Auto-stop completed, log file: ${logFile?.absolutePath}") + } + + /** + * Shows a notification when auto-stop is triggered. + * * @param context Application context + */ + private fun showAutoStopNotification(context: Context) { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Create notification channel on Android O+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + DEBUG_CHANNEL_ID, + "Debug Recording", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Notifications for debug recording events" + setShowBadge(true) + } + notificationManager.createNotificationChannel(channel) + } + + // Create intent to open MainActivity + val openIntent = Intent(context, MainActivity::class.java) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or + (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) + val pendingIntent = PendingIntent.getActivity(context, 0, openIntent, flags) + + // Build notification + val notification = NotificationCompat.Builder(context, DEBUG_CHANNEL_ID) + .setContentTitle(context.getString(R.string.debug_auto_stop_title)) + .setContentText(context.getString(R.string.debug_auto_stop_message)) + .setSmallIcon(R.mipmap.ic_launcher) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + notificationManager.notify(AUTO_STOP_NOTIF_ID, notification) + + Log.d(TAG, "Auto-stop notification shown") + } +} diff --git a/app/src/main/java/io/github/miclock/ui/AboutActivity.kt b/app/src/main/java/io/github/miclock/ui/AboutActivity.kt new file mode 100644 index 0000000..b895661 --- /dev/null +++ b/app/src/main/java/io/github/miclock/ui/AboutActivity.kt @@ -0,0 +1,103 @@ +package io.github.miclock.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.SpannableString +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.view.MenuItem +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import io.github.miclock.R + +/** + * AboutActivity displays information about the Mic-Lock application including + * version, description, open source information, and license details. + */ +class AboutActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_about) + + // Set up custom back button + val backButton = findViewById(R.id.backButton) + backButton.setOnClickListener { + finish() + } + + setupVersionInfo() + setupGitHubLink() + } + + /** + * Sets up the version information display + */ + private fun setupVersionInfo() { + val versionText = findViewById(R.id.versionText) + try { + val packageInfo = packageManager.getPackageInfo(packageName, 0) + + @Suppress("DEPRECATION") + val versionCode = packageInfo.versionCode + versionText.text = getString( + R.string.about_version_format, + packageInfo.versionName, + versionCode, + ) + } catch (e: Exception) { + versionText.text = getString(R.string.about_version_format, "Unknown", 0) + } + } + + /** + * Sets up the clickable GitHub link + */ + private fun setupGitHubLink() { + val githubLinkText = findViewById(R.id.githubLinkText) + val githubUrl = getString(R.string.github_url) + + val spannableString = SpannableString(githubUrl) + val clickableSpan = object : ClickableSpan() { + override fun onClick(widget: View) { + openGitHubUrl(githubUrl) + } + } + + spannableString.setSpan( + clickableSpan, + 0, + githubUrl.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + + githubLinkText.text = spannableString + githubLinkText.movementMethod = LinkMovementMethod.getInstance() + } + + /** + * Opens the GitHub repository URL in a browser + */ + private fun openGitHubUrl(url: String) { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(intent) + } catch (e: Exception) { + // Handle case where no browser is available + e.printStackTrace() + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } +} 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 b18911c..d389c0d 100644 --- a/app/src/main/java/io/github/miclock/ui/MainActivity.kt +++ b/app/src/main/java/io/github/miclock/ui/MainActivity.kt @@ -13,21 +13,32 @@ import android.os.PowerManager import android.provider.Settings import android.service.quicksettings.TileService import android.util.Log +import android.widget.ImageButton import android.widget.TextView -import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.PopupMenu import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButton import com.google.android.material.slider.Slider import com.google.android.material.switchmaterial.SwitchMaterial import io.github.miclock.R +import io.github.miclock.data.DebugRecordingStateManager import io.github.miclock.data.Prefs import io.github.miclock.service.MicLockService import io.github.miclock.tile.EXTRA_START_SERVICE_FROM_TILE import io.github.miclock.tile.MicLockTileService import io.github.miclock.util.ApiGuard +import io.github.miclock.util.CollectionResult +import io.github.miclock.util.DebugLogCollector +import io.github.miclock.util.DebugLogRecorder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * MainActivity provides the user interface for controlling the Mic-Lock service. @@ -39,8 +50,9 @@ import kotlinx.coroutines.launch * * The activity communicates with MicLockService through intents and observes * service state changes via StateFlow to update the UI accordingly. */ -open class MainActivity : ComponentActivity() { +open class MainActivity : AppCompatActivity() { + private lateinit var menuButton: ImageButton private lateinit var statusText: TextView private lateinit var startBtn: MaterialButton private lateinit var stopBtn: MaterialButton @@ -51,6 +63,11 @@ open class MainActivity : ComponentActivity() { private lateinit var screenOnDelaySlider: Slider private lateinit var screenOnDelaySummary: TextView + private lateinit var debugRecordingBanner: android.widget.LinearLayout + private lateinit var debugRecordingTimer: TextView + + private var timerJob: Job? = null + private val audioPerms = arrayOf(Manifest.permission.RECORD_AUDIO) private val notifPerms = if (Build.VERSION.SDK_INT >= 33) { arrayOf(Manifest.permission.POST_NOTIFICATIONS) @@ -75,6 +92,9 @@ open class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + menuButton = findViewById(R.id.menuButton) + menuButton.setOnClickListener { showPopupMenu(it) } + statusText = findViewById(R.id.statusText) startBtn = findViewById(R.id.startBtn) stopBtn = findViewById(R.id.stopBtn) @@ -85,6 +105,16 @@ open class MainActivity : ComponentActivity() { screenOnDelaySlider = findViewById(R.id.screenOnDelaySlider) screenOnDelaySummary = findViewById(R.id.screenOnDelaySummary) + debugRecordingBanner = findViewById(R.id.debugRecordingBanner) + debugRecordingTimer = findViewById(R.id.debugRecordingTimer) + + // Ensure clean state on app start + DebugRecordingStateManager.reset(this) + DebugLogRecorder.cleanup(this) + + // Observe recording state + observeDebugRecordingState() + // Initialize compatibility mode toggle mediaRecorderToggle.isChecked = Prefs.getUseMediaRecorder(this) mediaRecorderToggle.setOnCheckedChangeListener { _, isChecked -> @@ -147,6 +177,11 @@ open class MainActivity : ComponentActivity() { reqPerms.launch(audioPerms + notifPerms) } } + + // Check for pending crash logs and show dialog + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + checkForPendingCrashLogs() + } } override fun onResume() { @@ -168,6 +203,527 @@ open class MainActivity : ComponentActivity() { override fun onDestroy() { super.onDestroy() + + // Clean up recording resources + if (DebugRecordingStateManager.state.value.isRecording) { + DebugRecordingStateManager.stopRecording() + } + DebugLogRecorder.cleanup(this) + DebugLogRecorder.cancelAutoStop() + + stopRecordingTimer() + } + + /** + * Shows a popup menu when the menu button is clicked. + * Dynamically updates the debug tools menu item based on recording state. + */ + private fun showPopupMenu(view: android.view.View) { + val popup = PopupMenu(this, view) + popup.menuInflater.inflate(R.menu.main_menu, popup.menu) + + // Update debug tools menu item text based on recording state + val debugItem = popup.menu.findItem(R.id.menu_debug_tools) + val isRecording = DebugRecordingStateManager.state.value.isRecording + debugItem.title = if (isRecording) { + getString(R.string.menu_stop_debug_recording) + } else { + getString(R.string.menu_debug_tools) + } + + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.menu_feedback -> { + showFeedbackBottomSheet() + true + } + R.id.menu_debug_tools -> { + handleDebugToolsClick() + true + } + R.id.menu_about -> { + launchAboutActivity() + true + } + else -> false + } + } + + popup.show() + } + + /** + * Launches the About activity to display app information. + */ + private fun launchAboutActivity() { + val intent = Intent(this, AboutActivity::class.java) + startActivity(intent) + } + + /** + * Shows a bottom sheet dialog with feedback options. + * Provides two options: Report a Bug and Request a Feature. + * Each option opens the GitHub issues page with appropriate labels. + */ + private fun showFeedbackBottomSheet() { + val bottomSheetDialog = com.google.android.material.bottomsheet.BottomSheetDialog(this) + val view = layoutInflater.inflate(R.layout.bottom_sheet_feedback, null) + bottomSheetDialog.setContentView(view) + + // Set peek height and rounded corners for modern appearance + bottomSheetDialog.behavior.peekHeight = 400 + bottomSheetDialog.behavior.state = com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED + + // Report a Bug card click listener + val reportBugCard = view.findViewById(R.id.reportBugCard) + reportBugCard.setOnClickListener { + val bugReportUrl = "https://github.com/Dan8Oren/MicLock/issues/new?template=bug_report.md" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(bugReportUrl)) + startActivity(intent) + bottomSheetDialog.dismiss() + } + + // Request a Feature card click listener + val requestFeatureCard = view.findViewById( + R.id.requestFeatureCard, + ) + requestFeatureCard.setOnClickListener { + val featureRequestUrl = "https://github.com/Dan8Oren/MicLock/issues/new?template=feature_request.md" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(featureRequestUrl)) + startActivity(intent) + bottomSheetDialog.dismiss() + } + + bottomSheetDialog.show() + } + + /** + * Observes debug recording state changes and updates UI accordingly. + * Collects StateFlow emissions in the lifecycle scope to react to recording state changes. + */ + private fun observeDebugRecordingState() { + lifecycleScope.launch { + DebugRecordingStateManager.state.collect { state -> + updateDebugRecordingUI(state) + } + } + } + + /** + * Updates the debug recording UI based on the current state. + * Shows/hides the recording banner and manages the timer. + * Note: Menu text is updated dynamically when the popup menu is shown. + * + * @param state Current debug recording state + */ + private fun updateDebugRecordingUI(state: io.github.miclock.data.DebugRecordingState) { + debugRecordingBanner.visibility = if (state.isRecording) android.view.View.VISIBLE else android.view.View.GONE + + if (state.isRecording) { + startRecordingTimer(state.startTime) + } else { + stopRecordingTimer() + } + } + + /** + * Starts the recording timer that updates every second. + * Displays elapsed time in MM:SS format. + * + * @param startTime Unix timestamp (milliseconds) when recording started + */ + private fun startRecordingTimer(startTime: Long) { + timerJob?.cancel() + timerJob = lifecycleScope.launch { + while (isActive) { + val elapsed = System.currentTimeMillis() - startTime + val minutes = (elapsed / 60000).toInt() + val seconds = ((elapsed % 60000) / 1000).toInt() + debugRecordingTimer.text = String.format("%02d:%02d", minutes, seconds) + delay(1000) + } + } + } + + /** + * Stops the recording timer and cancels the timer job. + */ + private fun stopRecordingTimer() { + timerJob?.cancel() + timerJob = null + } + + /** + * Handles debug tools menu click by routing to start or stop based on current recording state. + * If recording is active, stops and shares logs. Otherwise, shows warning dialog to start recording. + */ + private fun handleDebugToolsClick() { + if (DebugRecordingStateManager.state.value.isRecording) { + stopAndShareDebugLogs() + } else { + showDebugRecordingWarning() + } + } + + /** + * Shows a warning dialog explaining the debug recording feature before starting. + * Informs the user about battery impact, automatic 30-minute timeout, and cleanup behavior. + * If the user confirms, starts the debug recording session. + */ + private fun showDebugRecordingWarning() { + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle(R.string.debug_warning_title) + .setMessage(R.string.debug_warning_message) + .setPositiveButton(R.string.start_recording) { _, _ -> + startDebugRecording() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + /** + * Starts a debug recording session. + * Calls DebugRecordingStateManager to initiate log capture. + * Handles SecurityException for devices that restrict logcat access. + * Shows error dialog if recording fails to start. + */ + private fun startDebugRecording() { + lifecycleScope.launch { + try { + val success = DebugRecordingStateManager.startRecording(this@MainActivity) + if (!success) { + showDebugRecordingError(getString(R.string.debug_start_failed)) + } + } catch (e: SecurityException) { + Log.e("MainActivity", "SecurityException starting debug recording", e) + showDebugRecordingError(getString(R.string.debug_permission_denied)) + } + } + } + + /** + * Shows an error dialog for debug recording failures. + * + * @param message Error message to display to the user + */ + private fun showDebugRecordingError(message: String) { + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle(R.string.failed_to_collect_logs) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + /** + * Stops the debug recording and shares the collected logs. + * + * This method performs the following steps: + * 1. Stops the debug recording and retrieves the log file + * 2. Collects system state via dumpsys commands + * 3. Creates a diagnostic package (zip file) + * 4. Handles the collection result (success, partial success, or failure) + * 5. Resets the recording state + * + * All I/O operations are performed on the IO dispatcher to avoid blocking the UI thread. + */ + private fun stopAndShareDebugLogs() { + lifecycleScope.launch { + try { + // Show loading state - stopping recording + statusText.text = getString(R.string.stopping_recording) + + // Get recording start time before stopping (needed for filename) + val recordingStartTime = DebugRecordingStateManager.state.value.startTime + + // Step 1: Stop recording on IO dispatcher + val logFile = withContext(Dispatchers.IO) { + DebugLogRecorder.stopRecording() + } + + // Show loading state - collecting system state + statusText.text = getString(R.string.collecting_system_state) + + // Step 2: Collect system state on IO dispatcher + val result = withContext(Dispatchers.IO) { + DebugLogCollector.collectAndShare(this@MainActivity, logFile, recordingStartTime) + } + + // Step 3: Handle result based on type + handleCollectionResult(result) + + // Step 4: Reset state + DebugRecordingStateManager.reset(this@MainActivity) + } catch (e: Exception) { + Log.e("MainActivity", "Failed to collect debug logs", e) + showDebugRecordingError(e.message ?: getString(R.string.unknown_error)) + + // Reset state even on error + DebugRecordingStateManager.reset(this@MainActivity) + } finally { + // Restore normal status display + updateAllUi() + } + } + } + + /** + * Handles the collection result by routing to appropriate dialog or action. + * + * @param result CollectionResult from DebugLogCollector + */ + private fun handleCollectionResult(result: CollectionResult) { + when (result) { + is CollectionResult.Success -> { + // Clean success - just share + startActivity(Intent.createChooser(result.shareIntent, getString(R.string.share_debug_logs))) + android.widget.Toast.makeText(this, R.string.logs_ready_to_share, android.widget.Toast.LENGTH_SHORT).show() + } + is CollectionResult.PartialSuccess -> { + // Some failures - warn user but allow sharing + showPartialSuccessDialog(result) + } + is CollectionResult.Failure -> { + // Critical failure - offer retry + showFailureDialog(result) + } + } + } + + /** + * Shows a dialog for partial success scenarios. + * Allows the user to share the partial logs or start a new recording. + * Includes context information to help users understand if missing data is relevant. + * + * @param result PartialSuccess result containing share intent and failure list + */ + private fun showPartialSuccessDialog(result: CollectionResult.PartialSuccess) { + val failureList = result.failures.joinToString("\n") { failure -> + val context = DebugLogCollector.getFailureContext(failure) + if (context != null) { + "โ€ข ${failure.component}: ${failure.error}\n โ†’ $context" + } else { + "โ€ข ${failure.component}: ${failure.error}" + } + } + + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle(R.string.logs_collected_with_warnings) + .setMessage(getString(R.string.partial_collection_message, failureList)) + .setPositiveButton(R.string.share_anyway) { _, _ -> + startActivity(Intent.createChooser(result.shareIntent, getString(R.string.share_debug_logs))) + } + .setNegativeButton(R.string.retry_recording) { _, _ -> + showDebugRecordingWarning() + } + .setNeutralButton(android.R.string.cancel, null) + .setCancelable(true) + .show() + } + + /** + * Shows a dialog for failure scenarios. + * Offers the user the option to start a new recording. + * + * @param result Failure result containing error message and failure list + */ + private fun showFailureDialog(result: CollectionResult.Failure) { + val failureList = result.failures.joinToString("\n") { + "โ€ข ${it.component}: ${it.error}" + } + + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle(R.string.failed_to_collect_logs) + .setMessage(getString(R.string.collection_failure_message, result.error, failureList)) + .setPositiveButton(R.string.retry_recording) { _, _ -> + showDebugRecordingWarning() + } + .setNegativeButton(android.R.string.cancel, null) + .setCancelable(true) + .show() + } + + /** + * Checks for pending crash logs from a previous crash and shows dialog if found. + * This is called on app startup to allow users to report crashes that occurred + * during debug recording. + */ + @androidx.annotation.RequiresApi(Build.VERSION_CODES.O) + private fun checkForPendingCrashLogs() { + if (!io.github.miclock.util.CrashHandler.hasPendingCrashLogs(this)) { + return + } + + Log.d("MainActivity", "Found pending crash logs, showing dialog") + + // Show dialog with crash report options + showCrashReportDialog() + } + + /** + * Shows a dialog offering options to report or share crash logs. + * Provides three options: + * - Report on GitHub (opens pre-filled issue) + * - Share Logs (opens standard share sheet) + * - Dismiss (clears crash log reference) + */ + @androidx.annotation.RequiresApi(Build.VERSION_CODES.O) + private fun showCrashReportDialog() { + val exceptionType = io.github.miclock.util.CrashHandler.getCrashExceptionType(this) ?: "Unknown" + val exceptionMessage = io.github.miclock.util.CrashHandler.getCrashExceptionMessage(this) ?: "No message" + val filename = io.github.miclock.util.CrashHandler.getCrashFilename(this) ?: "crash logs" + + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("App Crashed During Debug Recording") + .setMessage( + "Debug logs were automatically saved. Would you like to report this crash?\n\nException: $exceptionType\nMessage: $exceptionMessage", + ) + .setPositiveButton("Report on GitHub") { _, _ -> + openGitHubIssue() + } + .setNeutralButton("Share Logs") { _, _ -> + shareCrashLogs() + } + .setNegativeButton("Dismiss") { _, _ -> + io.github.miclock.util.CrashHandler.clearPendingCrashLogs(this) + } + .setCancelable(false) + .show() + } + + /** + * Opens GitHub issue page with pre-filled crash report. + * Generates a URL with crash details and instructions to attach the log file. + */ + @androidx.annotation.RequiresApi(Build.VERSION_CODES.O) + private fun openGitHubIssue() { + try { + val exceptionType = io.github.miclock.util.CrashHandler.getCrashExceptionType(this) ?: "Unknown" + val exceptionMessage = io.github.miclock.util.CrashHandler.getCrashExceptionMessage(this) ?: "No message" + val stackTrace = io.github.miclock.util.CrashHandler.getCrashStackTrace(this) ?: "No stack trace" + val timestamp = io.github.miclock.util.CrashHandler.getCrashTimestamp(this) + val filename = io.github.miclock.util.CrashHandler.getCrashFilename(this) ?: "miclock_debug_logs_crash.zip" + + // Get device info + val manufacturer = Build.MANUFACTURER + val model = Build.MODEL + val androidVersion = Build.VERSION.RELEASE + val sdkInt = Build.VERSION.SDK_INT + + // Get app version + val packageInfo = packageManager.getPackageInfo(packageName, 0) + val versionName = packageInfo.versionName ?: "Unknown" + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + + // Format timestamp + val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US) + val timestampFormatted = dateFormat.format(java.util.Date(timestamp)) + + // Create issue title + val title = "[Crash] $exceptionType: ${exceptionMessage.take(50)}" + + // Create issue body (keep it concise to avoid URL length limits) + val body = buildString { + appendLine("## Crash Report") + appendLine() + appendLine("The app crashed during debug log recording. Debug logs have been automatically collected.") + appendLine() + appendLine("**Exception:** $exceptionType: $exceptionMessage") + appendLine() + appendLine("**Device:** $manufacturer $model (Android $androidVersion, API $sdkInt)") + appendLine() + appendLine("**App Version:** $versionName ($versionCode)") + appendLine() + appendLine("**Timestamp:** $timestampFormatted") + appendLine() + appendLine("**Stack Trace (first 5 lines):**") + appendLine("```") + appendLine(stackTrace) + appendLine("```") + appendLine() + appendLine("## Steps to Reproduce") + appendLine() + appendLine("(Please describe what you were doing when the crash occurred)") + appendLine() + appendLine("## Debug Logs") + appendLine() + appendLine("**Important:** Please attach the crash log file from Downloads/miclock_logs/$filename") + appendLine() + appendLine("The file is located at: Downloads/miclock_logs/$filename") + } + + // Limit body length to avoid URL length issues (max ~2000 chars) + val bodyTruncated = if (body.length > 2000) { + body.take(1900) + "\n\n[Content truncated - see attached log file for full details]" + } else { + body + } + + // Build GitHub issue URL + val githubUrl = Uri.parse("https://github.com/Dan8Oren/MicLock/issues/new").buildUpon() + .appendQueryParameter("labels", "bug,crash") + .appendQueryParameter("title", title) + .appendQueryParameter("body", bodyTruncated) + .build() + + // Open browser + val intent = Intent(Intent.ACTION_VIEW, githubUrl) + startActivity(intent) + + // Clear crash logs after opening GitHub + io.github.miclock.util.CrashHandler.clearPendingCrashLogs(this) + } catch (e: Exception) { + Log.e("MainActivity", "Failed to open GitHub issue", e) + android.widget.Toast.makeText(this, "Failed to open GitHub: ${e.message}", android.widget.Toast.LENGTH_LONG).show() + } + } + + /** + * Opens the share sheet with the crash log file. + * Allows users to share crash logs via email, Drive, etc. + */ + @androidx.annotation.RequiresApi(Build.VERSION_CODES.O) + private fun shareCrashLogs() { + try { + val uriString = io.github.miclock.util.CrashHandler.getCrashLogUri(this) + if (uriString == null) { + android.widget.Toast.makeText(this, "Crash log file not found", android.widget.Toast.LENGTH_SHORT).show() + return + } + + val uri = Uri.parse(uriString) + val filename = io.github.miclock.util.CrashHandler.getCrashFilename(this) ?: "crash logs" + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "Mic-Lock Crash Logs") + putExtra(Intent.EXTRA_TITLE, filename) + putExtra( + Intent.EXTRA_TEXT, + "Crash logs from Mic-Lock app. " + + "The app crashed during debug recording and logs were automatically collected.", + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + clipData = android.content.ClipData.newRawUri(filename, uri) + } + + startActivity(Intent.createChooser(shareIntent, "Share Crash Logs")) + + // Clear crash logs after sharing + io.github.miclock.util.CrashHandler.clearPendingCrashLogs(this) + } catch (e: Exception) { + Log.e("MainActivity", "Failed to share crash logs", e) + android.widget.Toast.makeText( + this, + "Failed to share crash logs: ${e.message}", + android.widget.Toast.LENGTH_LONG, + ).show() + } } private fun requestBatteryOptimizationExemption() { diff --git a/app/src/main/java/io/github/miclock/util/CollectionResult.kt b/app/src/main/java/io/github/miclock/util/CollectionResult.kt new file mode 100644 index 0000000..eda5ec5 --- /dev/null +++ b/app/src/main/java/io/github/miclock/util/CollectionResult.kt @@ -0,0 +1,53 @@ +package io.github.miclock.util + +import android.content.Intent + +/** + * Represents the result of a debug log collection operation. + * Provides detailed information about success, partial success, or failure scenarios. + */ +sealed class CollectionResult { + /** + * All diagnostic data was collected successfully. + * @param shareIntent Intent configured to share the diagnostic package + * @param warnings Optional list of non-critical warnings that occurred during collection + */ + data class Success( + val shareIntent: Intent, + val warnings: List = emptyList(), + ) : CollectionResult() + + /** + * Some diagnostic data was collected, but some components failed. + * @param shareIntent Intent configured to share the partial diagnostic package + * @param failures List of components that failed during collection + */ + data class PartialSuccess( + val shareIntent: Intent, + val failures: List, + ) : CollectionResult() + + /** + * Critical failure occurred and no viable diagnostic data could be collected. + * @param error Human-readable error message describing the failure + * @param failures List of all components that failed during collection + */ + data class Failure( + val error: String, + val failures: List, + ) : CollectionResult() +} + +/** + * Represents a failure that occurred during diagnostic data collection. + * @param component Name of the component that failed (e.g., "dumpsys audio", "logcat") + * @param error Human-readable error message + * @param isCritical Whether this failure prevents sharing any diagnostic data + * @param timestamp Unix timestamp (milliseconds) when the failure occurred + */ +data class CollectionFailure( + val component: String, + val error: String, + val isCritical: Boolean, + val timestamp: Long = System.currentTimeMillis(), +) diff --git a/app/src/main/java/io/github/miclock/util/CrashHandler.kt b/app/src/main/java/io/github/miclock/util/CrashHandler.kt new file mode 100644 index 0000000..1c8d6f1 --- /dev/null +++ b/app/src/main/java/io/github/miclock/util/CrashHandler.kt @@ -0,0 +1,341 @@ +package io.github.miclock.util + +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import io.github.miclock.data.DebugRecordingStateManager +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +/** + * Custom uncaught exception handler that automatically collects debug logs + * when a crash occurs during an active debug recording session. + * * This handler intercepts crashes, saves diagnostic information including + * the exception details, and chains to the default exception handler to + * ensure normal crash handling continues. + */ +class CrashHandler( + private val context: Context, + private val defaultHandler: Thread.UncaughtExceptionHandler?, +) : Thread.UncaughtExceptionHandler { + + companion object { + private const val TAG = "CrashHandler" + private const val COLLECTION_TIMEOUT_MS = 5000L // 5 seconds max + private const val PREFS_NAME = "crash_logs" + private const val KEY_CRASH_LOG_URI = "crash_log_uri" + private const val KEY_CRASH_EXCEPTION_TYPE = "crash_exception_type" + private const val KEY_CRASH_EXCEPTION_MESSAGE = "crash_exception_message" + private const val KEY_CRASH_STACK_TRACE = "crash_stack_trace" + private const val KEY_CRASH_TIMESTAMP = "crash_timestamp" + private const val KEY_CRASH_FILENAME = "crash_filename" + + /** + * Checks if there are pending crash logs from a previous crash. + * @param context Application context + * @return true if crash logs are pending + */ + fun hasPendingCrashLogs(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.contains(KEY_CRASH_LOG_URI) + } + + /** + * Gets the crash log URI from SharedPreferences. + * @param context Application context + * @return Crash log URI string, or null if not available + */ + fun getCrashLogUri(context: Context): String? { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_CRASH_LOG_URI, null) + } + + /** + * Gets the crash exception type from SharedPreferences. + * @param context Application context + * @return Exception type string, or null if not available + */ + fun getCrashExceptionType(context: Context): String? { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_CRASH_EXCEPTION_TYPE, null) + } + + /** + * Gets the crash exception message from SharedPreferences. + * @param context Application context + * @return Exception message string, or null if not available + */ + fun getCrashExceptionMessage(context: Context): String? { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_CRASH_EXCEPTION_MESSAGE, null) + } + + /** + * Gets the crash stack trace summary from SharedPreferences. + * @param context Application context + * @return Stack trace summary string, or null if not available + */ + fun getCrashStackTrace(context: Context): String? { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_CRASH_STACK_TRACE, null) + } + + /** + * Gets the crash timestamp from SharedPreferences. + * @param context Application context + * @return Crash timestamp in milliseconds, or 0 if not available + */ + fun getCrashTimestamp(context: Context): Long { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getLong(KEY_CRASH_TIMESTAMP, 0L) + } + + /** + * Gets the crash log filename from SharedPreferences. + * @param context Application context + * @return Crash log filename, or null if not available + */ + fun getCrashFilename(context: Context): String? { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_CRASH_FILENAME, null) + } + + /** + * Clears pending crash logs from SharedPreferences. + * @param context Application context + */ + fun clearPendingCrashLogs(context: Context) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().clear().apply() + Log.d(TAG, "Cleared pending crash logs") + } + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun uncaughtException(thread: Thread, throwable: Throwable) { + Log.e(TAG, "Uncaught exception on thread ${thread.name}", throwable) + + try { + // Check if debug recording is active + val isRecording = DebugRecordingStateManager.state.value.isRecording + + if (isRecording) { + Log.d(TAG, "Debug recording active during crash, collecting logs...") + + // Collect crash logs with timeout to prevent ANR + try { + collectCrashLogsSync(throwable) + } catch (e: Exception) { + Log.e(TAG, "Failed to collect crash logs", e) + } + } else { + Log.d(TAG, "Debug recording not active, skipping crash log collection") + } + } catch (e: Exception) { + // Catch any errors in crash handling to prevent secondary crashes + Log.e(TAG, "Error in crash handler", e) + } finally { + // Always chain to default handler to ensure normal crash handling + defaultHandler?.uncaughtException(thread, throwable) + } + } + + /** + * Collects crash logs synchronously with timeout protection. + * This runs on the crashing thread and must complete quickly. + */ + @RequiresApi(Build.VERSION_CODES.O) + private fun collectCrashLogsSync(throwable: Throwable) { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // Launch collection with timeout + val job = scope.launch { + try { + withTimeout(COLLECTION_TIMEOUT_MS) { + collectCrashLogs(throwable) + } + } catch (e: Exception) { + Log.e(TAG, "Crash log collection failed or timed out", e) + } + } + + // Block and wait for completion (with timeout) + try { + // Use Thread.sleep to wait for the job to complete + // This is acceptable in crash handler as we're already crashing + val startTime = System.currentTimeMillis() + while (job.isActive && (System.currentTimeMillis() - startTime) < COLLECTION_TIMEOUT_MS) { + Thread.sleep(100) + } + + if (job.isActive) { + Log.w(TAG, "Crash log collection timed out") + job.cancel() + } + } catch (e: InterruptedException) { + Log.w(TAG, "Interrupted while waiting for crash log collection") + job.cancel() + } + } + + /** + * Collects crash logs asynchronously. + * Stops recording, collects system state, creates diagnostic package with crash info. + */ + @RequiresApi(Build.VERSION_CODES.O) + private suspend fun collectCrashLogs(throwable: Throwable) { + try { + val recordingStartTime = DebugRecordingStateManager.state.value.startTime + + // Stop the logcat recording to flush logs to file + Log.d(TAG, "Stopping logcat recording...") + val logFile = DebugLogRecorder.stopRecording() + + if (logFile == null) { + Log.w(TAG, "No log file available from recording") + } + + // Create crash info file + val crashInfo = createCrashInfo(throwable) + val crashInfoFile = saveCrashInfo(crashInfo) + + // Collect system state via dumpsys (same as normal stop flow) + Log.d(TAG, "Collecting system state...") + val result = DebugLogCollector.collectAndShare( + context, + logFile, + recordingStartTime, + isCrashCollection = true, + crashInfoFile = crashInfoFile, + ) + + // Handle result and save crash details + when (result) { + is CollectionResult.Success -> { + Log.d(TAG, "Crash logs collected successfully") + saveCrashDetails(result, throwable, recordingStartTime) + } + is CollectionResult.PartialSuccess -> { + Log.d(TAG, "Crash logs collected with warnings") + saveCrashDetails(result, throwable, recordingStartTime) + } + is CollectionResult.Failure -> { + Log.e(TAG, "Failed to collect crash logs: ${result.error}") + } + } + + // Reset state to not recording + DebugRecordingStateManager.reset(context) + } catch (e: Exception) { + Log.e(TAG, "Error collecting crash logs", e) + } + } + + /** + * Creates crash information text. + */ + private fun createCrashInfo(throwable: Throwable): String { + val stackTrace = StringWriter() + throwable.printStackTrace(PrintWriter(stackTrace)) + + return buildString { + appendLine("=== CRASH INFORMATION ===") + appendLine() + appendLine("Exception Type: ${throwable.javaClass.simpleName}") + appendLine("Exception Message: ${throwable.message ?: "No message"}") + appendLine( + "Timestamp: ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format( + java.util.Date(), + )}", + ) + appendLine() + appendLine("=== STACK TRACE ===") + appendLine(stackTrace.toString()) + } + } + + /** + * Saves crash info to a temporary file. + */ + private fun saveCrashInfo(crashInfo: String): File? { + return try { + val logDir = File(context.cacheDir, "debug_logs") + if (!logDir.exists() && !logDir.mkdirs()) { + Log.e(TAG, "Failed to create log directory") + return null + } + + val crashFile = File(logDir, "crash_info_${System.currentTimeMillis()}.txt") + crashFile.writeText(crashInfo) + Log.d(TAG, "Saved crash info to ${crashFile.absolutePath}") + crashFile + } catch (e: Exception) { + Log.e(TAG, "Failed to save crash info", e) + null + } + } + + /** + * Saves crash details to SharedPreferences for retrieval after app restart. + */ + private fun saveCrashDetails( + result: CollectionResult, + throwable: Throwable, + recordingStartTime: Long, + ) { + try { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // Extract URI from share intent + val shareIntent = when (result) { + is CollectionResult.Success -> result.shareIntent + is CollectionResult.PartialSuccess -> result.shareIntent + else -> return + } + + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + shareIntent.getParcelableExtra(android.content.Intent.EXTRA_STREAM, android.net.Uri::class.java) + } else { + @Suppress("DEPRECATION") + shareIntent.getParcelableExtra(android.content.Intent.EXTRA_STREAM) + } + + if (uri == null) { + Log.w(TAG, "No URI found in share intent") + return + } + + // Get filename from intent + val filename = shareIntent.getStringExtra(android.content.Intent.EXTRA_TITLE) + + // Get stack trace summary (first 5 lines) + val stackTrace = StringWriter() + throwable.printStackTrace(PrintWriter(stackTrace)) + val stackTraceLines = stackTrace.toString().lines() + val stackTraceSummary = stackTraceLines.take(5).joinToString("\n") + + prefs.edit().apply { + putString(KEY_CRASH_LOG_URI, uri.toString()) + putString(KEY_CRASH_EXCEPTION_TYPE, throwable.javaClass.simpleName) + putString(KEY_CRASH_EXCEPTION_MESSAGE, throwable.message ?: "No message") + putString(KEY_CRASH_STACK_TRACE, stackTraceSummary) + putLong(KEY_CRASH_TIMESTAMP, System.currentTimeMillis()) + if (filename != null) { + putString(KEY_CRASH_FILENAME, filename) + } + apply() + } + + Log.d(TAG, "Saved crash details to SharedPreferences") + } catch (e: Exception) { + Log.e(TAG, "Failed to save crash details", e) + } + } +} diff --git a/app/src/main/java/io/github/miclock/util/DebugLogCollector.kt b/app/src/main/java/io/github/miclock/util/DebugLogCollector.kt new file mode 100644 index 0000000..9fcc4f6 --- /dev/null +++ b/app/src/main/java/io/github/miclock/util/DebugLogCollector.kt @@ -0,0 +1,719 @@ +package io.github.miclock.util + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.content.FileProvider +import io.github.miclock.R +import io.github.miclock.service.MicLockService +import java.io.BufferedReader +import java.io.File +import java.io.IOException +import java.io.InputStreamReader +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +/** + * Collects system state snapshots and packages diagnostic data. + * Handles dumpsys command execution, device info collection, and zip file creation. + */ +object DebugLogCollector { + private const val TAG = "DebugLogCollector" + private const val LOG_DIR = "debug_logs" + private const val DUMPSYS_TIMEOUT_SECONDS = 10L + private const val MAX_DUMPSYS_SIZE_BYTES = 100 * 1024 // 100KB + private const val RETRY_ATTEMPTS = 2 + private const val RETRY_DELAY_MS = 500L + private const val DEBUG_LOGS_SAVED_NOTIF_ID = 44 + + // Dumpsys services to collect + private val DUMPSYS_SERVICES = listOf( + "audio", + "telecom", + "media.session", + "media.audio_policy", + "media.audio_flinger", + ) + + // Critical services that require retry logic + private val CRITICAL_SERVICES = setOf("telecom") + + // Context information for each dumpsys service + private val SERVICE_CONTEXT = mapOf( + "audio" to "Relevant for all audio routing issues", + "telecom" to "Relevant when issues occur during phone calls", + "media.session" to "Relevant when media players (Spotify, YouTube, etc.) are involved", + "media.audio_policy" to "Relevant for audio focus and routing policy issues", + "media.audio_flinger" to "Relevant for low-level audio HAL issues", + ) + + /** + * Collects all diagnostic data, saves to Downloads, and creates share intent. + * @param context Application context + * @param logFile Log file from DebugLogRecorder, or null + * @param recordingStartTime Timestamp when recording started (for filename generation) + * @param isCrashCollection Whether this collection is triggered by a crash + * @param crashInfoFile Optional crash info file to include in the diagnostic package + * @return CollectionResult indicating success, partial success, or failure + */ + @RequiresApi(Build.VERSION_CODES.O) + suspend fun collectAndShare( + context: Context, + logFile: File?, + recordingStartTime: Long, + isCrashCollection: Boolean = false, + crashInfoFile: File? = null, + ): CollectionResult = withContext(Dispatchers.IO) { + val failures = mutableListOf() + val dumpsysData = mutableMapOf() + + Log.d(TAG, "Starting diagnostic data collection") + + // Collect dumpsys data for each service + for (service in DUMPSYS_SERVICES) { + val isCritical = service in CRITICAL_SERVICES + val attempts = if (isCritical) RETRY_ATTEMPTS else 1 + + var success = false + var lastError: String? = null + + for (attempt in 1..attempts) { + try { + val output = executeDumpsys(service) + if (output.isNotEmpty()) { + dumpsysData[service] = output + success = true + Log.d(TAG, "Successfully collected dumpsys $service (attempt $attempt/$attempts)") + break + } else { + lastError = "Empty output" + } + } catch (e: Exception) { + lastError = e.message ?: "Unknown error" + Log.w(TAG, "Failed to collect dumpsys $service (attempt $attempt/$attempts): $lastError") + } + + // Delay before retry + if (attempt < attempts) { + delay(RETRY_DELAY_MS) + } + } + + // Add failure if all attempts failed + if (!success) { + failures.add( + CollectionFailure( + component = "dumpsys $service", + error = lastError ?: "Failed to collect", + isCritical = isCritical, + ), + ) + } + } + + // Collect device info + val recordingDuration = if (logFile != null) { + // Estimate duration from file timestamp if available + val timestamp = logFile.name.removePrefix("recording_").removeSuffix(".log").toLongOrNull() ?: 0L + if (timestamp > 0) { + System.currentTimeMillis() - timestamp + } else { + 0L + } + } else { + 0L + } + + val deviceInfo = collectDeviceInfo(context, recordingDuration) + + // Check minimum viable data + val hasLogFile = logFile?.exists() == true && logFile.length() > 0 + val hasTelecomDumpsys = dumpsysData.containsKey("telecom") + + if (!hasLogFile && !hasTelecomDumpsys) { + Log.e(TAG, "No viable data collected") + return@withContext CollectionResult.Failure( + error = "No diagnostic data could be collected. App logs and critical system state are unavailable.", + failures = failures, + ) + } + + // Create zip file with all collected data + val zipFile = try { + createZipFile(context, logFile, dumpsysData, deviceInfo, failures, isCrashCollection, crashInfoFile) + } catch (e: Exception) { + Log.e(TAG, "Failed to create zip file", e) + return@withContext CollectionResult.Failure( + error = "Failed to package diagnostic data: ${e.message}", + failures = failures, + ) + } + + if (zipFile == null) { + return@withContext CollectionResult.Failure( + error = "Failed to create diagnostic package", + failures = failures, + ) + } + + // Save zip file to Downloads/miclock_logs folder + val savedUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveToDownloads(context, zipFile, recordingStartTime) + } else { + // For Android 9 and below, use legacy external storage + // For now, we'll just use the cache file and skip Downloads save + null + } + + // Handle save to Downloads failure + if (savedUri == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Log.e(TAG, "Failed to save to Downloads") + failures.add( + CollectionFailure( + component = "Save to Downloads", + error = "Failed to save file to Downloads/miclock_logs folder", + isCritical = false, + ), + ) + + // Offer to share from cache as fallback + val cacheShareIntent = createShareIntent(context, zipFile) + return@withContext CollectionResult.PartialSuccess(cacheShareIntent, failures) + } + + // Generate filename for notification (with _crash suffix if crash collection) + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date(recordingStartTime)) + val crashSuffix = if (isCrashCollection) "_crash" else "" + val fileName = "miclock_debug_logs_${timestamp}$crashSuffix.zip" + + // Show notification with file location and share action + if (savedUri != null) { + showDebugLogsSavedNotification(context, savedUri, fileName, isCrashCollection) + } + + // Create share intent with Downloads Uri (or cache file for Android 9-) + val shareIntent = if (savedUri != null) { + createShareIntentFromUri(context, savedUri, fileName) + } else { + // Android 9 and below: share from cache + createShareIntent(context, zipFile) + } + + // Determine result type based on failures + return@withContext if (failures.isEmpty()) { + Log.d(TAG, "Collection completed successfully") + CollectionResult.Success(shareIntent) + } else { + Log.d(TAG, "Collection completed with ${failures.size} failure(s)") + CollectionResult.PartialSuccess(shareIntent, failures) + } + } + + /** + * Executes a dumpsys command and returns output. + * @param service Service name (e.g., "audio", "telecom") + * @return Command output as string + * @throws Exception if command fails or times out + */ + @RequiresApi(Build.VERSION_CODES.O) + private suspend fun executeDumpsys(service: String): String = withContext(Dispatchers.IO) { + val command = arrayOf("dumpsys", service) + val process = Runtime.getRuntime().exec(command) + + try { + // Wait for process with timeout + val completed = process.waitFor(DUMPSYS_TIMEOUT_SECONDS, TimeUnit.SECONDS) + + if (!completed) { + process.destroyForcibly() + throw Exception("Timeout after $DUMPSYS_TIMEOUT_SECONDS seconds") + } + + // Check exit code + val exitCode = process.exitValue() + if (exitCode != 0) { + val errorOutput = process.errorStream.bufferedReader().use { it.readText() } + throw Exception("Command failed with exit code $exitCode: $errorOutput") + } + + // Read output with size limit + val output = StringBuilder() + val reader = BufferedReader(InputStreamReader(process.inputStream)) + var totalBytes = 0 + var line: String? + + while (reader.readLine().also { line = it } != null) { + val lineBytes = line!!.toByteArray().size + 1 // +1 for newline + if (totalBytes + lineBytes > MAX_DUMPSYS_SIZE_BYTES) { + output.append("\n[Output truncated at ${MAX_DUMPSYS_SIZE_BYTES / 1024}KB limit]") + break + } + output.append(line).append("\n") + totalBytes += lineBytes + } + + return@withContext output.toString() + } finally { + process.destroy() + } + } + + /** + * Collects device and app metadata. + * @param context Application context + * @param recordingDuration Duration of recording session in milliseconds + * @return Formatted device info string + */ + private fun collectDeviceInfo(context: Context, recordingDuration: Long): String { + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + val timestamp = dateFormat.format(Date()) + + // Format recording duration as HH:MM:SS + val hours = TimeUnit.MILLISECONDS.toHours(recordingDuration) + val minutes = TimeUnit.MILLISECONDS.toMinutes(recordingDuration) % 60 + val seconds = TimeUnit.MILLISECONDS.toSeconds(recordingDuration) % 60 + val durationFormatted = String.format("%02d:%02d:%02d", hours, minutes, seconds) + + // Get app version from package manager + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val versionName = packageInfo.versionName ?: "Unknown" + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + + // Get service state + val serviceState = MicLockService.state.value + + return buildString { + appendLine("Mic-Lock Debug Information") + appendLine("==========================") + appendLine() + appendLine("Device Information:") + appendLine("- Manufacturer: ${Build.MANUFACTURER}") + appendLine("- Model: ${Build.MODEL}") + appendLine("- Device: ${Build.DEVICE}") + appendLine("- Android Version: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})") + appendLine() + appendLine("App Information:") + appendLine("- Version: $versionName ($versionCode)") + appendLine("- Package: ${context.packageName}") + appendLine() + appendLine("Recording Information:") + appendLine("- Timestamp: $timestamp") + appendLine("- Duration: $durationFormatted") + appendLine() + appendLine("Service State:") + appendLine("- Running: ${serviceState.isRunning}") + appendLine("- Paused by Silence: ${serviceState.isPausedBySilence}") + appendLine("- Paused by Screen Off: ${serviceState.isPausedByScreenOff}") + appendLine("- Current Device Address: ${serviceState.currentDeviceAddress ?: "None"}") + appendLine("- Delayed Activation Pending: ${serviceState.isDelayedActivationPending}") + if (serviceState.isDelayedActivationPending) { + appendLine("- Delayed Activation Remaining: ${serviceState.delayedActivationRemainingMs}ms") + } + } + } + + /** + * Creates zip file with all diagnostic data. + * @param context Application context + * @param logFile App log file (optional) + * @param dumpsysData Map of service name to dumpsys output + * @param deviceInfo Device metadata string + * @param failures List of collection failures + * @param isCrashCollection Whether this is a crash collection + * @param crashInfoFile Optional crash info file to include + * @return Zip file, or null on failure + */ + private suspend fun createZipFile( + context: Context, + logFile: File?, + dumpsysData: Map, + deviceInfo: String, + failures: List, + isCrashCollection: Boolean = false, + crashInfoFile: File? = null, + ): File? = withContext(Dispatchers.IO) { + try { + // Create log directory + val logDir = File(context.cacheDir, LOG_DIR) + if (!logDir.exists() && !logDir.mkdirs()) { + Log.e(TAG, "Failed to create log directory") + return@withContext null + } + + // Create zip file with timestamp + val timestamp = System.currentTimeMillis() + val zipFile = File(logDir, "debug_package_$timestamp.zip") + + java.util.zip.ZipOutputStream(zipFile.outputStream().buffered()).use { zip -> + // Add collection report + val report = createCollectionReport(logFile, dumpsysData, failures, isCrashCollection) + zip.putNextEntry(java.util.zip.ZipEntry("collection_report.txt")) + zip.write(report.toByteArray()) + zip.closeEntry() + + // Add crash info if this is a crash collection + if (isCrashCollection && crashInfoFile?.exists() == true) { + zip.putNextEntry(java.util.zip.ZipEntry("crash_info.txt")) + crashInfoFile.inputStream().use { input -> + input.copyTo(zip) + } + zip.closeEntry() + } + + // Add app logs if available + if (logFile?.exists() == true) { + zip.putNextEntry(java.util.zip.ZipEntry("app_logs.txt")) + logFile.inputStream().use { input -> + input.copyTo(zip) + } + zip.closeEntry() + } + + // Add dumpsys outputs + for ((service, output) in dumpsysData) { + val filename = "dumpsys_${service.replace(".", "_")}.txt" + zip.putNextEntry(java.util.zip.ZipEntry(filename)) + zip.write(output.toByteArray()) + zip.closeEntry() + } + + // Add device info + zip.putNextEntry(java.util.zip.ZipEntry("device_info.txt")) + zip.write(deviceInfo.toByteArray()) + zip.closeEntry() + } + + Log.d(TAG, "Created zip file: ${zipFile.absolutePath} (${zipFile.length()} bytes)") + return@withContext zipFile + } catch (e: Exception) { + Log.e(TAG, "Error creating zip file", e) + return@withContext null + } + } + + /** + * Creates a collection report documenting what succeeded and failed. + * Includes context information for each failed component to help users understand relevance. + * @param logFile App log file (optional) + * @param dumpsysData Map of service name to dumpsys output + * @param failures List of collection failures + * @param isCrashCollection Whether this is a crash collection + * @return Formatted report string + */ + private fun createCollectionReport( + logFile: File?, + dumpsysData: Map, + failures: List, + isCrashCollection: Boolean = false, + ): String { + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + val timestamp = dateFormat.format(Date()) + + return buildString { + appendLine("=== Debug Log Collection Report ===") + if (isCrashCollection) { + appendLine("โš ๏ธ CRASH DETECTED - Logs automatically collected during crash") + } + appendLine("Timestamp: $timestamp") + appendLine() + + if (failures.isNotEmpty()) { + appendLine("=== Collection Status ===") + appendLine("โš  ${failures.size} component(s) failed:") + for (failure in failures) { + val severity = if (failure.isCritical) "CRITICAL" else "WARNING" + appendLine(" โœ— [$severity] ${failure.component}") + appendLine(" Error: ${failure.error}") + + // Add context information for dumpsys services + val serviceName = failure.component.removePrefix("dumpsys ") + val context = SERVICE_CONTEXT[serviceName] + if (context != null) { + appendLine(" Context: $context") + } + } + appendLine() + } + + appendLine("=== Collected Data ===") + + // App logs status + if (logFile?.exists() == true) { + appendLine("App Logcat: โœ“ (${logFile.length()} bytes)") + } else { + appendLine("App Logcat: โœ— (not available)") + } + + // Dumpsys status + for (service in DUMPSYS_SERVICES) { + if (dumpsysData.containsKey(service)) { + appendLine("dumpsys $service: โœ“") + } else { + appendLine("dumpsys $service: โœ—") + } + } + + if (failures.isNotEmpty()) { + appendLine() + appendLine("=== Notes ===") + appendLine("โš  Some system state data missing. Logs may be incomplete.") + appendLine() + appendLine("=== Context Information ===") + appendLine("Understanding what's missing:") + for (failure in failures) { + val serviceName = failure.component.removePrefix("dumpsys ") + val context = SERVICE_CONTEXT[serviceName] + if (context != null) { + appendLine("โ€ข $serviceName: $context") + } + } + } + } + } + + /** + * Saves zip file to Downloads/miclock_logs folder using MediaStore API with timestamped filename. + * @param context Application context + * @param zipFile Temporary zip file from cache + * @param recordingStartTime Timestamp when recording started (for filename generation) + * @return Uri of saved file in Downloads, or null on failure + */ + @RequiresApi(Build.VERSION_CODES.Q) + private suspend fun saveToDownloads( + context: Context, + zipFile: File, + recordingStartTime: Long, + ): Uri? = withContext(Dispatchers.IO) { + try { + // Generate filename with timestamp from recording start time + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date(recordingStartTime)) + val fileName = "miclock_debug_logs_$timestamp.zip" + + Log.d(TAG, "Saving debug logs to Downloads/miclock_logs/$fileName") + + val resolver = context.contentResolver + val contentValues = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, "application/zip") + put(MediaStore.Downloads.RELATIVE_PATH, "Download/miclock_logs") + } + + // Insert the file entry + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + + if (uri == null) { + Log.e(TAG, "Failed to create MediaStore entry") + return@withContext null + } + + // Copy zip file from cache to Downloads + resolver.openOutputStream(uri)?.use { output -> + zipFile.inputStream().use { input -> + input.copyTo(output) + } + } ?: run { + Log.e(TAG, "Failed to open output stream for $uri") + resolver.delete(uri, null, null) + return@withContext null + } + + contentValues.clear() + contentValues.put(MediaStore.Downloads.IS_PENDING, 0) + resolver.update(uri, contentValues, null, null) + + Log.d(TAG, "Successfully saved debug logs to Downloads: $uri") + + // Delete temporary cache zip file after successful save + if (zipFile.delete()) { + Log.d(TAG, "Deleted temporary cache file: ${zipFile.absolutePath}") + } else { + Log.w(TAG, "Failed to delete temporary cache file: ${zipFile.absolutePath}") + } + + return@withContext uri + } catch (e: IOException) { + Log.e(TAG, "IOException while saving to Downloads", e) + return@withContext null + } catch (e: Exception) { + Log.e(TAG, "Unexpected error while saving to Downloads", e) + return@withContext null + } + } + + /** + * Creates a share intent for the diagnostic package from a file. + * @param context Application context + * @param zipFile Zip file to share + * @return Share intent configured with FileProvider URI + */ + private fun createShareIntent(context: Context, zipFile: File): Intent { + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + zipFile, + ) + + return createShareIntentFromUri(context, uri) + } + + /** + * Creates a share intent for the diagnostic package from a Uri. + * @param context Application context + * @param uri Uri of the zip file to share + * @param fileName Optional filename to display in share sheet + * @return Share intent configured with the provided URI + */ + private fun createShareIntentFromUri(context: Context, uri: Uri, fileName: String? = null): Intent { + return Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "Mic-Lock Debug Logs") + if (fileName != null) { + putExtra(Intent.EXTRA_TITLE, fileName) + // Add clip data with label for better filename display + clipData = android.content.ClipData.newRawUri(fileName, uri) + } + putExtra( + Intent.EXTRA_TEXT, + "Debug logs from Mic-Lock app. " + + "This package contains application logs and system audio state information.", + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + /** + * Gets context information for a collection failure. + * Helps users understand when the missing data is relevant to their issue. + * @param failure The collection failure + * @return Context string explaining when this data is relevant, or null if no context available + */ + fun getFailureContext(failure: CollectionFailure): String? { + val serviceName = failure.component.removePrefix("dumpsys ") + return SERVICE_CONTEXT[serviceName] + } + + /** + * Shows a notification that debug logs have been saved to Downloads. + * Includes a "Share" action button to open the share sheet with the saved file. + * + * @param context Application context + * @param savedUri Uri of the saved file in Downloads + * @param fileName Name of the saved file + * @param isCrashCollection Whether this is a crash collection (shows high-priority notification) + */ + fun showDebugLogsSavedNotification( + context: Context, + savedUri: Uri, + fileName: String, + isCrashCollection: Boolean = false, + ) { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Create intent to open the file directly + val openFileIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(savedUri, "application/zip") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + val contentPendingIntent = PendingIntent.getActivity( + context, + 101, + openFileIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + // Create share intent with the saved file Uri and proper filename + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, savedUri) + putExtra(Intent.EXTRA_SUBJECT, "Mic-Lock Debug Logs") + putExtra(Intent.EXTRA_TITLE, fileName) // Add filename for better display + putExtra( + Intent.EXTRA_TEXT, + "Debug logs from Mic-Lock app. " + + "This package contains application logs and system audio state information.", + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + // Add clip data with label for better filename display + clipData = android.content.ClipData.newRawUri(fileName, savedUri) + } + + // Wrap in chooser and create PendingIntent for share action + val chooserIntent = Intent.createChooser(shareIntent, context.getString(R.string.share_debug_logs)) + + val sharePendingIntent = PendingIntent.getActivity( + context, + 100, + chooserIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + // Build notification with BigTextStyle + val title = if (isCrashCollection) { + "Crash Logs Saved" + } else { + context.getString(R.string.debug_logs_saved_title) + } + + val text = if (isCrashCollection) { + "App crashed during debug recording. Logs saved to Downloads/miclock_logs" + } else { + context.getString(R.string.logs_saved_to_downloads) + } + + val bigText = if (isCrashCollection) { + "The app crashed during debug recording. Crash logs have been automatically saved to Downloads/miclock_logs folder.\nTap 'Share' to send them now, or find them in Downloads/miclock_logs later." + } else { + context.getString(R.string.debug_logs_saved_big_text) + } + + val actionText = if (isCrashCollection) { + "Share Crash Logs" + } else { + context.getString(R.string.share) + } + + val notification = NotificationCompat.Builder(context, MicLockService.RESTART_CHANNEL_ID) + .setContentTitle(title) + .setContentText(text) + .setSmallIcon(R.drawable.ic_mic_tile) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(bigText), + ) + .setContentIntent(contentPendingIntent) + .addAction( + 0, // No icon for action + actionText, + sharePendingIntent, + ) + .setAutoCancel(true) + .setPriority(if (isCrashCollection) NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH) + .build() + + // Show notification with unique ID + notificationManager.notify(DEBUG_LOGS_SAVED_NOTIF_ID, notification) + + Log.d(TAG, "Debug logs saved notification shown for file: $fileName") + } +} diff --git a/app/src/main/java/io/github/miclock/util/DebugLogRecorder.kt b/app/src/main/java/io/github/miclock/util/DebugLogRecorder.kt new file mode 100644 index 0000000..0333253 --- /dev/null +++ b/app/src/main/java/io/github/miclock/util/DebugLogRecorder.kt @@ -0,0 +1,262 @@ +package io.github.miclock.util + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.util.Log +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.io.InputStreamReader +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** + * Manages logcat recording process lifecycle and log file buffering. + * Thread-safe singleton for capturing application logs. + */ +object DebugLogRecorder { + private const val TAG = "DebugLogRecorder" + private const val LOG_DIR = "debug_logs" + private const val AUTO_STOP_DELAY_MS = 30 * 60 * 1000L // 30 minutes + private const val FILESYSTEM_SYNC_DELAY_MS = 100L + + private val lock = ReentrantLock() + + @Volatile + private var logcatProcess: java.lang.Process? = null + + @Volatile + private var recordingThread: Thread? = null + + @Volatile + private var currentLogFile: File? = null + + @Volatile + private var recordingStartTime: Long = 0L + + @Volatile + private var writer: BufferedWriter? = null + + private val autoStopHandler = Handler(Looper.getMainLooper()) + private var autoStopRunnable: Runnable? = null + private var autoStopCallback: (() -> Unit)? = null + + /** + * Starts logcat recording process. + * @param context Application context for file access + * @return true if recording started successfully, false otherwise + * @throws SecurityException if logcat access is denied + */ + fun startRecording(context: Context): Boolean = lock.withLock { + if (isRecording()) { + Log.w(TAG, "Recording already active") + return false + } + + try { + // Create log directory + val logDir = File(context.cacheDir, LOG_DIR) + if (!logDir.exists() && !logDir.mkdirs()) { + Log.e(TAG, "Failed to create log directory") + return false + } + + // Create log file with timestamp + val timestamp = System.currentTimeMillis() + currentLogFile = File(logDir, "recording_$timestamp.log") + + // Start logcat process with PID filter + val pid = Process.myPid() + val command = arrayOf("logcat", "-v", "threadtime", "--pid=$pid") + logcatProcess = Runtime.getRuntime().exec(command) + + recordingStartTime = timestamp + + // Create background thread to pipe logcat output to file + recordingThread = Thread { + try { + writer = BufferedWriter(FileWriter(currentLogFile, true)) + val reader = BufferedReader(InputStreamReader(logcatProcess?.inputStream)) + + var line: String? + while (reader.readLine().also { line = it } != null) { + writer?.write(line) + writer?.newLine() + + // Flush periodically to ensure data is written + writer?.flush() + } + } catch (e: IOException) { + if (logcatProcess?.isAlive == true) { + Log.e(TAG, "Error writing to log file", e) + } + // If process is dead, this is expected during shutdown + } finally { + try { + writer?.close() + } catch (e: IOException) { + Log.e(TAG, "Error closing writer", e) + } + } + }.apply { + name = "LogcatRecorder" + isDaemon = true + start() + } + + Log.d(TAG, "Recording started successfully") + return true + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException: Logcat access denied", e) + cleanup(context) + throw e + } catch (e: IOException) { + Log.e(TAG, "IOException: Failed to start recording", e) + cleanup(context) + return false + } catch (e: Exception) { + Log.e(TAG, "Unexpected error starting recording", e) + cleanup(context) + return false + } + } + + /** + * Stops logcat recording and returns the log file. + * @return File containing captured logs, or null if no logs captured + */ + fun stopRecording(): File? = lock.withLock { + if (!isRecording()) { + Log.w(TAG, "No active recording to stop") + return null + } + + try { + Log.d(TAG, "Stopping recording...") + + // Destroy the logcat process + logcatProcess?.destroy() + + // Wait for process to terminate (with timeout) + val terminated = try { + logcatProcess?.waitFor() + true + } catch (e: InterruptedException) { + Log.w(TAG, "Interrupted while waiting for process termination") + logcatProcess?.destroyForcibly() + false + } + + // Wait for recording thread to finish + recordingThread?.join(2000) // 2 second timeout + + // Explicit flush and close + try { + writer?.flush() + writer?.close() + } catch (e: IOException) { + Log.e(TAG, "Error flushing/closing writer", e) + } + + // Filesystem sync delay + Thread.sleep(FILESYSTEM_SYNC_DELAY_MS) + + val logFile = currentLogFile + + // Verify file exists and has content + if (logFile?.exists() == true && logFile.length() > 0) { + Log.d(TAG, "Recording stopped successfully. Log file size: ${logFile.length()} bytes") + return logFile + } else { + Log.w(TAG, "Log file is empty or doesn't exist") + return null + } + } catch (e: Exception) { + Log.e(TAG, "Error stopping recording", e) + return null + } finally { + // Clean up references + logcatProcess = null + recordingThread = null + writer = null + currentLogFile = null + recordingStartTime = 0L + } + } + + /** + * Checks if recording is currently active. + * @return true if logcat process is running + */ + fun isRecording(): Boolean { + return logcatProcess?.isAlive == true + } + + /** + * Gets the duration of current recording session. + * @return Duration in milliseconds, or 0 if not recording + */ + fun getRecordingDuration(): Long { + if (!isRecording() || recordingStartTime == 0L) { + return 0L + } + return System.currentTimeMillis() - recordingStartTime + } + + /** + * Cleans up all temporary log files. + * Called on app destroy or after sharing. + */ + fun cleanup(context: Context) { + try { + val logDir = File(context.cacheDir, LOG_DIR) + if (logDir.exists() && logDir.isDirectory) { + logDir.listFiles()?.forEach { file -> + if (file.isFile && file.name.startsWith("recording_")) { + val deleted = file.delete() + Log.d(TAG, "Cleanup: ${file.name} - ${if (deleted) "deleted" else "failed to delete"}") + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error during cleanup", e) + } + } + + /** + * Schedules automatic stop after 30 minutes. + * @param context Application context for notifications + * @param callback Callback to invoke when auto-stop is triggered + */ + fun scheduleAutoStop(context: Context, callback: () -> Unit) { + cancelAutoStop() // Cancel any existing scheduled stop + + autoStopCallback = callback + + autoStopRunnable = Runnable { + Log.d(TAG, "Auto-stop triggered after 30 minutes") + // Invoke callback to let StateManager handle the stop and notification + autoStopCallback?.invoke() + }.also { + autoStopHandler.postDelayed(it, AUTO_STOP_DELAY_MS) + } + + Log.d(TAG, "Auto-stop scheduled for 30 minutes") + } + + /** + * Cancels scheduled auto-stop. + */ + fun cancelAutoStop() { + autoStopRunnable?.let { + autoStopHandler.removeCallbacks(it) + autoStopRunnable = null + autoStopCallback = null + Log.d(TAG, "Auto-stop cancelled") + } + } +} diff --git a/app/src/main/res/drawable/ic_back_arrow.xml b/app/src/main/res/drawable/ic_back_arrow.xml new file mode 100644 index 0000000..ff242f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_back_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bug.xml b/app/src/main/res/drawable/ic_bug.xml new file mode 100644 index 0000000..1d36bd7 --- /dev/null +++ b/app/src/main/res/drawable/ic_bug.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_lightbulb.xml b/app/src/main/res/drawable/ic_lightbulb.xml new file mode 100644 index 0000000..eb55308 --- /dev/null +++ b/app/src/main/res/drawable/ic_lightbulb.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..33120d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml new file mode 100644 index 0000000..9876420 --- /dev/null +++ b/app/src/main/res/layout/activity_about.xml @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 445e9ec..88f1c07 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,22 +1,62 @@ - + + android:background="@color/error_red" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:paddingTop="40dp" + android:paddingBottom="12dp" + android:orientation="horizontal" + android:gravity="center_vertical" + android:visibility="gone"> + + + + + + + + + - + android:layout_height="wrap_content"> - - - + android:orientation="vertical" + android:padding="24dp" + android:gravity="center"> + + - + - + + + + + + + @@ -341,4 +396,7 @@ - + + + + diff --git a/app/src/main/res/layout/bottom_sheet_feedback.xml b/app/src/main/res/layout/bottom_sheet_feedback.xml new file mode 100644 index 0000000..be969c1 --- /dev/null +++ b/app/src/main/res/layout/bottom_sheet_feedback.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml new file mode 100644 index 0000000..3fc81cd --- /dev/null +++ b/app/src/main/res/menu/main_menu.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf30bb1..f082476 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,4 +12,72 @@ Delayed Reactivation (0โ€“5s) Stays-Off Always-On + + + Debug Recording Stopped + Recording stopped after 30 minutes to prevent excessive battery drain. + + + Send Feedback + Debug Recording + Stop & Share Debug Logs + About + + + Send Feedback + Report a Bug + Something not working right? Let us know. + Request a Feature + Have an idea to make MicLock better? + + + Start Debug Recording? + This will record app logs and system audio state to help diagnose issues.\n\nโ€ข Recording will stop automatically after 30 minutes\nโ€ข Logs are stored temporarily and cleaned up when you close the app\nโ€ข Battery usage will increase during recording\n\nYou can stop and share logs at any time from the menu. + Start Recording + + + ๐Ÿ”ด Debug Recording Active + Stopping recordingโ€ฆ + Collecting system stateโ€ฆ + + + Failed to start debug recording. Please try again. + Cannot access app logs on this device. Debug recording is not available. + An unknown error occurred + + + Logs saved to Downloads/miclock_logs + Share Debug Logs + + + Logs Collected with Warnings + Some data could not be collected:\n\n%1$s\n\nLogs have been saved to Downloads/miclock_logs. You can share them now or find them later in your Downloads folder.\n\n + Share Now + Start New Recording + + + Failed to Collect Logs + Unable to collect debug logs:\n\n%1$s\n\nFailures:\n%2$s\n\nYou can start a new recording to try again. + + + Debug Logs Saved + Saved to Downloads/miclock_logs/%1$s + Debug logs saved to Downloads/miclock_logs folder.\nTap \'Share\' to send them now, or find them in Downloads/miclock_logs later. + Logs saved to Downloads/miclock_logs + Share + + + Mic-Lock + Version %1$s (%2$d) + What is MicLock? + MicLock is a microphone protection app that prevents audio routing issues during phone calls. It ensures your microphone stays active and properly routed, even when other apps try to take control of the audio system. Perfect for users experiencing issues where their microphone only works on speakerphone. + How It Works + MicLock runs as a foreground service that continuously holds the microphone resource. By maintaining an active audio recording session, it prevents the Android system from routing your microphone incorrectly during calls. The app uses minimal resources and includes intelligent features like automatic pausing when the screen is off and polite yielding to other apps when needed. + Open Source + MicLock is free and open source software. This means the code is publicly available for anyone to inspect, modify, and contribute to. Open source ensures transparency, security, and community-driven development. You can trust that the app does exactly what it saysโ€”no hidden features, no data collection, no surprises. + GitHub Repository: + https://github.com/Dan8Oren/MicLock + 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 diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..80df825 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + From 5cdf0aabf2d71cb8fc1ad6c7bae5041aadc2b27d Mon Sep 17 00:00:00 2001 From: Dan Oren <94993872+Dan8Oren@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:51:56 +0700 Subject: [PATCH 2/2] Bug Fixes & Android 15+ Compliance (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ› Android 15+ Compliance & Permission Handling #### 1. Bug Fix: Android 15+ Foreground Service Crash ๐Ÿšจ Bug: App crashed on Android 15+ when starting from boot (blocked Google Play publication) Fix: Implemented WorkManager with 5-second delay to comply with foreground service restrictions Files: Added BootServiceWorker.kt, updated BootCompletedReceiver.kt and MicLockService.kt #### 2. Bug Fix + New Feature: Permission Handling Bug: App was stuck in a loop and unfunctional when no permission was given (both notifications or microphone) Fix: - removed the notification permission checks as it's optional and not necessary for core logic. - Added state flags (isShowingPermissionDialog, hasRequestedNotificationPermission) - moved permission checks to onCreate() only - App now shows a dialog when permission is missing prompt the user to grant it and preventing the usage of the app. Files: MainActivity.kt #### 3. Modification due to changes: Quick Settings Tile Requires Notifications ๐Ÿ”” Bug: Tile unusable without notification permission (should be optional) Fix: Updated hasAllPerms() to only require microphone permission, tile now works without notifications Files: MicLockTileService.kt #### 4. Bug Fix: MainActivity exits from recents after tile start Bug: Reopening the app from recent apps caused an immediate exit after it was started via the Quick Settings tile Fix: Updated startMicLockFromTileFallback to keep the activity open if the service is already running (not call `finish()`). #### Implementation Details - WorkManager: Added androidx.work:work-runtime-ktx:2.9.0 dependency - Permission Separation: Microphone (required) vs Notifications (optional) - Improved UX: Clear dialogs, "Try Again" options, Settings deep-linking - State Management: Proper lifecycle handling to prevent loops and leaks --- 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 | 234 ++++++++++++++---- .../miclock/worker/BootServiceWorker.kt | 44 ++++ app/src/main/res/values/strings.xml | 4 + gradle/libs.versions.toml | 2 + 9 files changed, 389 insertions(+), 174 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..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" }