From 98c87100cb7351a599e4935bf0544ee9709a61f0 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 19 Jun 2026 18:28:24 +0530 Subject: [PATCH 1/2] remove: delete Sentry crash reporting implementation and dependencies --- app/build.gradle.kts | 1 - app/src/main/AndroidManifest.xml | 5 +- .../java/com/sameerasw/airsync/AirSyncApp.kt | 17 --- .../airsync/data/local/DataStoreManager.kt | 11 -- .../data/repository/AirSyncRepositoryImpl.kt | 6 - .../sameerasw/airsync/domain/model/UiState.kt | 1 - .../domain/repository/AirSyncRepository.kt | 4 +- .../ui/components/SettingsView.kt | 10 -- .../pickers/CrashReportingPicker.kt | 106 ------------------ .../ui/composables/WelcomeScreen.kt | 7 -- .../viewmodel/AirSyncViewModel.kt | 17 +-- app/src/main/res/values/strings.xml | 6 - gradle/libs.versions.toml | 2 - 13 files changed, 4 insertions(+), 189 deletions(-) delete mode 100644 app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 115e6d42..56860ad3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -137,7 +137,6 @@ dependencies { // Google Play Review implementation(libs.play.review) implementation(libs.play.review.ktx) - implementation(libs.sentry.android) // Coil for image and GIF loading implementation("io.coil-kt:coil-compose:2.6.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5cd44fcb..d1627178 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,10 +65,7 @@ android:theme="@style/Theme.AirSync" tools:targetApi="36"> - - + 0 - - private fun initSentry() { - val dataStoreManager = DataStoreManager.getInstance(this) - val isEnabled = runBlocking { dataStoreManager.getSentryReportingEnabled().first() } - - if (!isEnabled) return - - SentryAndroid.init(this) { options -> - options.dsn = - "https://cb9b0ead9e88e0818269e773cb662141@o4510996760887296.ingest.de.sentry.io/4511002261389392" - options.isEnabled = true - } - } } diff --git a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt index 1ffdc453..102ab0ad 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt @@ -90,7 +90,6 @@ class DataStoreManager(private val context: Context) { private val DEVICE_ID = stringPreferencesKey("device_id") private val USE_BLUR = booleanPreferencesKey("use_blur") private val PITCH_BLACK_THEME = booleanPreferencesKey("pitch_black_theme") - private val SENTRY_REPORTING_ENABLED = booleanPreferencesKey("sentry_reporting_enabled") private val QUICK_SHARE_ENABLED = booleanPreferencesKey("quick_share_enabled") private val FILE_ACCESS_ENABLED = booleanPreferencesKey("file_access_enabled") @@ -324,17 +323,7 @@ class DataStoreManager(private val context: Context) { } } - suspend fun setSentryReportingEnabled(enabled: Boolean) { - context.dataStore.edit { preferences -> - preferences[SENTRY_REPORTING_ENABLED] = enabled - } - } - fun getSentryReportingEnabled(): Flow { - return context.dataStore.data.map { preferences -> - preferences[SENTRY_REPORTING_ENABLED] ?: true // Default to enabled - } - } suspend fun setQuickShareEnabled(enabled: Boolean) { context.dataStore.edit { preferences -> diff --git a/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt b/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt index 397217b6..a6b4eec8 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt @@ -224,13 +224,7 @@ class AirSyncRepositoryImpl( return dataStoreManager.getDefaultTab() } - override suspend fun setSentryReportingEnabled(enabled: Boolean) { - dataStoreManager.setSentryReportingEnabled(enabled) - } - override fun getSentryReportingEnabled(): Flow { - return dataStoreManager.getSentryReportingEnabled() - } override suspend fun setWidgetTransparency(alpha: Float) { dataStoreManager.setWidgetTransparency(alpha) diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt index 44bd392e..2925473a 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt @@ -46,7 +46,6 @@ data class UiState( val isPowerSaveMode: Boolean = false, val isPitchBlackThemeEnabled: Boolean = false, val isBlurEnabled: Boolean = true, - val isSentryReportingEnabled: Boolean = true, val isOnboardingCompleted: Boolean = true, val widgetTransparency: Float = 1f, val isQuickShareEnabled: Boolean = false, diff --git a/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt b/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt index ab1420d3..ebd0815d 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt @@ -101,9 +101,7 @@ interface AirSyncRepository { suspend fun setDefaultTab(tab: String) fun getDefaultTab(): Flow - // Sentry reporting settings - suspend fun setSentryReportingEnabled(enabled: Boolean) - fun getSentryReportingEnabled(): Flow + // Widget specific settings suspend fun setWidgetTransparency(alpha: Float) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index 87782df6..9cb780ef 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt @@ -176,16 +176,6 @@ fun SettingsView( viewModel.setPitchBlackThemeEnabled(enabled) } ) - - IconToggleItem( - title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_error_reporting), - description = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_error_reporting), - iconRes = R.drawable.rounded_bug_report_24, - isChecked = uiState.isSentryReportingEnabled, - onCheckedChange = { enabled: Boolean -> - viewModel.setSentryReportingEnabled(enabled) - } - ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt deleted file mode 100644 index 446ed7ef..00000000 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.sameerasw.airsync.presentation.ui.components.pickers - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.ButtonGroupDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ToggleButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.sameerasw.airsync.R - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun CrashReportingPicker( - isReportingEnabled: Boolean, - onReportingChanged: (Boolean) -> Unit, - modifier: Modifier = Modifier, - iconRes: Int = R.drawable.rounded_security_24 -) { - val options = listOf(false, true) - val labels = listOf( - R.string.sentry_mode_off, - R.string.sentry_mode_auto - ) - - Column( - modifier = modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = MaterialTheme.shapes.extraSmall - ) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Spacer(modifier = Modifier.size(2.dp)) - Icon( - painter = painterResource(id = iconRes), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.size(2.dp)) - - Text( - text = stringResource(R.string.sentry_report_mode_title), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.onSurface - ) - } - - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), - ) { - options.forEachIndexed { index, option -> - val isChecked = isReportingEnabled == option - - ToggleButton( - checked = isChecked, - onCheckedChange = { - onReportingChanged(option) - }, - modifier = Modifier - .weight(1f) - .semantics { role = Role.RadioButton }, - shapes = when { - index == 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() - index == options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() - else -> ButtonGroupDefaults.connectedMiddleButtonShapes() - }, - ) { - Text( - text = stringResource(labels[index]), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isChecked) FontWeight.Bold else FontWeight.Normal, - maxLines = 1 - ) - } - } - } - } -} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt index d03651ed..bd6e03a4 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt @@ -689,13 +689,6 @@ fun PreferencesStepContent( onCheckedChange = { viewModel.setUseBlurEnabled(it, context) }, enabled = !isBlurProblematic ) - IconToggleItem( - iconRes = R.drawable.rounded_security_24, - title = stringResource(R.string.label_error_reporting), - description = stringResource(R.string.subtitle_error_reporting), - isChecked = uiState.isSentryReportingEnabled, - onCheckedChange = { viewModel.setSentryReportingEnabled(it) } - ) } Spacer(modifier = Modifier.height(32.dp)) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt index 66ba6ec1..af0db09b 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt @@ -211,12 +211,7 @@ class AirSyncViewModel( } } - // Observe sentry reporting preference - viewModelScope.launch { - repository.getSentryReportingEnabled().collect { enabled -> - _uiState.value = _uiState.value.copy(isSentryReportingEnabled = enabled) - } - } + // Observe widget transparency preference viewModelScope.launch { @@ -368,7 +363,6 @@ class AirSyncViewModel( val isDeviceDiscoveryEnabled = repository.getDeviceDiscoveryEnabled().first() val isBlurEnabledSetting = repository.getUseBlurEnabled().first() val isPitchBlackThemeEnabled = repository.getPitchBlackThemeEnabled().first() - val isSentryReportingEnabled = repository.getSentryReportingEnabled().first() val isFirstRun = repository.getFirstRun().first() val isPowerSaveMode = DeviceInfoUtil.isPowerSaveMode(context) val isBlurProblematic = DeviceInfoUtil.isBlurProblematicDevice() @@ -433,7 +427,6 @@ class AirSyncViewModel( isPowerSaveMode = isPowerSaveMode, isPitchBlackThemeEnabled = isPitchBlackThemeEnabled, isBlurEnabled = isBlurEnabled, - isSentryReportingEnabled = isSentryReportingEnabled, isOnboardingCompleted = !isFirstRun, isQuickShareEnabled = isQuickShareEnabled ) @@ -703,13 +696,7 @@ class AirSyncViewModel( } } - fun setSentryReportingEnabled(enabled: Boolean) { - _uiState.value = _uiState.value.copy(isSentryReportingEnabled = enabled) - viewModelScope.launch { - repository.setSentryReportingEnabled(enabled) - // Note: Changes typically take effect on next launch as Sentry is initialized in Application.onCreate - } - } + fun setPitchBlackThemeEnabled(enabled: Boolean) { _uiState.value = _uiState.value.copy(isPitchBlackThemeEnabled = enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 25f49035..20cc47c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,8 +51,6 @@ Disabled due to compatibility issues with this Samsung device Pitch Black Theme Pure black background in dark mode - Error Reporting - Automatically report crashes when they happen Welcome to AirSync @@ -75,10 +73,6 @@ How to Connect? Back - - Crash reporting - Off - Auto App icon credits: @Syntrop2k2 on Telegram https://t.me/Syntrop2k2 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79dd1811..aad785b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,6 @@ kotlinxCoroutines = "1.7.3" okhttp = "4.11.0" playReview = "2.0.2" ksp = "2.3.5" -sentry = "8.0.0" protobuf = "4.28.2" wire = "6.0.0-alpha03" bouncycastle = "1.78.1" @@ -52,7 +51,6 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt # Google Play Review play-review = { group = "com.google.android.play", name = "review", version.ref = "playReview" } play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "playReview" } -sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentry" } wire-runtime = { group = "com.squareup.wire", name = "wire-runtime", version.ref = "wire" } bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } From bfd9038829f149d12adefc71891102b5acf55b97 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 19 Jun 2026 20:32:26 +0530 Subject: [PATCH 2/2] feat: implement custom crash reporting system with notifications --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 16 ++ .../java/com/sameerasw/airsync/AirSyncApp.kt | 3 + .../com/sameerasw/airsync/MainActivity.kt | 7 + .../sameerasw/airsync/crash/CrashHandler.kt | 29 +++ .../airsync/crash/CrashNotificationHelper.kt | 83 ++++++++ .../crash/CrashNotificationReceiver.kt | 15 ++ .../airsync/crash/CrashReportActivity.kt | 45 +++++ .../airsync/crash/CrashReportBottomSheet.kt | 186 ++++++++++++++++++ .../airsync/data/local/DataStoreManager.kt | 13 ++ .../data/repository/AirSyncRepositoryImpl.kt | 8 + .../sameerasw/airsync/domain/model/UiState.kt | 1 + .../domain/repository/AirSyncRepository.kt | 3 + .../ui/components/SettingsView.kt | 10 + .../viewmodel/AirSyncViewModel.kt | 18 +- app/src/main/res/values/strings.xml | 20 ++ build.gradle.kts | 2 + gradle/libs.versions.toml | 8 + settings.gradle.kts | 3 + 19 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/crash/CrashHandler.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/crash/CrashNotificationHelper.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/crash/CrashNotificationReceiver.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/crash/CrashReportActivity.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/crash/CrashReportBottomSheet.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 56860ad3..a40b55f1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,6 +65,7 @@ android { } dependencies { + implementation(project(":exceptionreport")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d1627178..a6ad76b7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -260,6 +260,22 @@ + + + + + + + + diff --git a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt index 158ba8ac..f82b6e29 100644 --- a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt +++ b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.Application import android.os.Bundle import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.crash.CrashNotificationHelper class AirSyncApp : Application() { private var activityCount = 0 @@ -20,6 +21,8 @@ class AirSyncApp : Application() { super.onCreate() instance = this + CrashNotificationHelper.createChannel(this) + bleConnectionManager = com.sameerasw.airsync.data.ble.BleConnectionManager(this) bleConnectionManager.start() registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index b71c49da..207f5738 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -39,6 +39,9 @@ import com.sameerasw.airsync.utils.PermissionUtil import com.sameerasw.airsync.utils.ShortcutUtil import com.sameerasw.airsync.utils.UDPDiscoveryManager import com.sameerasw.airsync.utils.WebSocketUtil +import com.canerture.exceptionreport.handler.ExceptionReport +import com.sameerasw.airsync.crash.CrashHandler +import com.sameerasw.airsync.crash.CrashReportActivity import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import java.net.URLDecoder @@ -175,6 +178,10 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) + ExceptionReport(this) { deviceInfo, stackTrace -> + CrashHandler.onCrash(applicationContext, deviceInfo, stackTrace) + }.setCustomActivity(CrashReportActivity::class.java) + // Dynamically set the splash screen icon based on last connected device splashScreen.setOnExitAnimationListener { splashScreenViewProvider -> try { diff --git a/app/src/main/java/com/sameerasw/airsync/crash/CrashHandler.kt b/app/src/main/java/com/sameerasw/airsync/crash/CrashHandler.kt new file mode 100644 index 00000000..90b3f2ed --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/crash/CrashHandler.kt @@ -0,0 +1,29 @@ +package com.sameerasw.airsync.crash + +import android.content.Context +import com.sameerasw.airsync.data.local.DataStoreManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import java.io.File + +object CrashHandler { + fun onCrash(context: Context, deviceInfo: String, stackTrace: String) { + try { + // Write report to last_crash.log + val logFile = File(context.filesDir, "last_crash.log") + logFile.writeText("$deviceInfo\n\n$stackTrace") + + // Read preference from DataStore synchronously + val dataStoreManager = DataStoreManager.getInstance(context) + val notifyEnabled = runBlocking { + dataStoreManager.getNotifyOnCrashEnabled().first() + } + + if (notifyEnabled) { + CrashNotificationHelper.postNotification(context) + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/crash/CrashNotificationHelper.kt b/app/src/main/java/com/sameerasw/airsync/crash/CrashNotificationHelper.kt new file mode 100644 index 00000000..a73188bd --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/crash/CrashNotificationHelper.kt @@ -0,0 +1,83 @@ +package com.sameerasw.airsync.crash + +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 androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.sameerasw.airsync.R + +object CrashNotificationHelper { + private const val CHANNEL_ID = "crash_report" + const val NOTIFICATION_ID = 9999 + + fun createChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = context.getString(R.string.crash_notification_title) + val descriptionText = context.getString(R.string.crash_notification_body) + val importance = NotificationManager.IMPORTANCE_LOW + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + setSound(null, null) + enableLights(false) + enableVibration(false) + } + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + fun postNotification(context: Context) { + // Broadcast pending intent for Dismiss + val dismissIntent = Intent(context, CrashNotificationReceiver::class.java).apply { + action = "com.sameerasw.airsync.action.DISMISS_CRASH_NOTIFICATION" + } + val dismissPendingIntent = PendingIntent.getBroadcast( + context, + 1, + dismissIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Activity pending intent for Report + val reportIntent = Intent(context, CrashReportActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra("show_report", true) + } + val reportPendingIntent = PendingIntent.getActivity( + context, + 2, + reportIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.rounded_bug_report_24) + .setContentTitle(context.getString(R.string.crash_notification_title)) + .setContentText(context.getString(R.string.crash_notification_body)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSilent(true) + .setAutoCancel(true) + .setContentIntent(reportPendingIntent) + .addAction( + 0, + context.getString(R.string.crash_action_dismiss), + dismissPendingIntent + ) + .addAction( + 0, + context.getString(R.string.crash_action_report), + reportPendingIntent + ) + + try { + val notificationManager = NotificationManagerCompat.from(context) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } catch (e: SecurityException) { + e.printStackTrace() + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/crash/CrashNotificationReceiver.kt b/app/src/main/java/com/sameerasw/airsync/crash/CrashNotificationReceiver.kt new file mode 100644 index 00000000..832864ca --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/crash/CrashNotificationReceiver.kt @@ -0,0 +1,15 @@ +package com.sameerasw.airsync.crash + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class CrashNotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (context != null && intent?.action == "com.sameerasw.airsync.action.DISMISS_CRASH_NOTIFICATION") { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(CrashNotificationHelper.NOTIFICATION_ID) + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/crash/CrashReportActivity.kt b/app/src/main/java/com/sameerasw/airsync/crash/CrashReportActivity.kt new file mode 100644 index 00000000..452a6da2 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/crash/CrashReportActivity.kt @@ -0,0 +1,45 @@ +package com.sameerasw.airsync.crash + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.sameerasw.airsync.ui.theme.AirSyncTheme +import java.io.File + +class CrashReportActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val showReport = intent.getBooleanExtra("show_report", false) + if (!showReport) { + finish() + return + } + + val reportFile = File(filesDir, "last_crash.log") + val reportText = if (reportFile.exists()) { + reportFile.readText() + } else { + "No crash log found." + } + + setContent { + val viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel = + androidx.lifecycle.viewmodel.compose.viewModel { + com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel.create(this@CrashReportActivity) + } + val uiState by viewModel.uiState.collectAsState() + + AirSyncTheme(pitchBlackTheme = uiState.isPitchBlackThemeEnabled) { + CrashReportBottomSheet( + report = reportText, + onDismiss = { + finish() + } + ) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/crash/CrashReportBottomSheet.kt b/app/src/main/java/com/sameerasw/airsync/crash/CrashReportBottomSheet.kt new file mode 100644 index 00000000..326fadb8 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/crash/CrashReportBottomSheet.kt @@ -0,0 +1,186 @@ +package com.sameerasw.airsync.crash + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R +import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem +import java.io.OutputStream + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportBottomSheet( + report: String, + onDismiss: () -> Unit +) { + val context = LocalContext.current + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text( + text = stringResource(R.string.crash_report_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp, top = 8.dp) + ) + } + + item { + RoundedCardContainer { + // Copy report + IconToggleItem( + title = stringResource(R.string.crash_copy), + description = stringResource(R.string.crash_copy_desc), + iconRes = R.drawable.ic_clipboard_24, + showToggle = false, + onClick = { + copyToClipboard(context, report) + } + ) + + // Save to file + IconToggleItem( + title = stringResource(R.string.crash_save_file), + description = stringResource(R.string.crash_save_file_desc), + iconRes = R.drawable.outline_downloading_24, + showToggle = false, + onClick = { + saveLogToFile(context, report, showToast = true) + } + ) + + // Email report + IconToggleItem( + title = stringResource(R.string.crash_email), + description = stringResource(R.string.crash_email_desc), + iconRes = R.drawable.outline_feedback_24, + showToggle = false, + onClick = { + emailReport(context, report) + } + ) + + // Open GitHub issue + IconToggleItem( + title = stringResource(R.string.crash_github), + description = stringResource(R.string.crash_github_desc), + iconRes = R.drawable.brand_github, + showToggle = false, + onClick = { + openGitHubIssue(context, report) + } + ) + } + } + + item { + Box(modifier = Modifier.padding(bottom = 32.dp)) + } + } + } +} + +private fun copyToClipboard(context: Context, text: String) { + try { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = android.content.ClipData.newPlainText("AirSync Crash Report", text) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Report copied to clipboard", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(context, "Failed to copy report", Toast.LENGTH_SHORT).show() + } +} + +private fun saveLogToFile(context: Context, text: String, showToast: Boolean): Uri? { + try { + val filename = "airsync_crash_${System.currentTimeMillis()}.log" + val resolver = context.contentResolver + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, filename) + put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + } + + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + if (uri != null) { + val outputStream: OutputStream? = resolver.openOutputStream(uri) + outputStream?.use { + it.write(text.toByteArray()) + } + if (showToast) { + Toast.makeText(context, context.getString(R.string.crash_file_saved), Toast.LENGTH_LONG).show() + } + return uri + } + } catch (e: Exception) { + e.printStackTrace() + } + Toast.makeText(context, context.getString(R.string.crash_file_save_failed), Toast.LENGTH_SHORT).show() + return null +} + +private fun emailReport(context: Context, text: String) { + try { + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:mail@sameerasw.com") + putExtra(Intent.EXTRA_SUBJECT, "AirSync Android Crash Report") + val bodyText = if (text.length > 4000) text.substring(0, 4000) + "\n...[truncated]" else text + putExtra(Intent.EXTRA_TEXT, bodyText) + } + context.startActivity(Intent.createChooser(emailIntent, "Send Email")) + } catch (e: Exception) { + Toast.makeText(context, "No email client found", Toast.LENGTH_SHORT).show() + } +} + +private fun openGitHubIssue(context: Context, text: String) { + try { + val uri = saveLogToFile(context, text, showToast = false) + if (uri != null) { + Toast.makeText(context, context.getString(R.string.crash_github_hint), Toast.LENGTH_LONG).show() + } + val url = "https://github.com/sameerasw/airsync-android/issues/new" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + } catch (e: Exception) { + Toast.makeText(context, "Could not open link", Toast.LENGTH_SHORT).show() + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt index 102ab0ad..8fb6fb97 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt @@ -101,6 +101,7 @@ class DataStoreManager(private val context: Context) { private val BLE_SYNC_ENABLED = booleanPreferencesKey("ble_sync_enabled") private val BLE_AUTO_CONNECT_ENABLED = booleanPreferencesKey("ble_auto_connect_enabled") + private val NOTIFY_ON_CRASH = booleanPreferencesKey("notify_on_crash") private const val NETWORK_DEVICES_PREFIX = "network_device_" private const val NETWORK_CONNECTIONS_PREFIX = "network_connections_" @@ -624,6 +625,18 @@ class DataStoreManager(private val context: Context) { } } + suspend fun setNotifyOnCrashEnabled(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[NOTIFY_ON_CRASH] = enabled + } + } + + fun getNotifyOnCrashEnabled(): Flow { + return context.dataStore.data.map { preferences -> + preferences[NOTIFY_ON_CRASH] != false + } + } + // Network-aware device connections suspend fun saveNetworkDeviceConnection( deviceName: String, diff --git a/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt b/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt index a6b4eec8..afb6a3d0 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt @@ -297,4 +297,12 @@ class AirSyncRepositoryImpl( override fun isFileAccessEnabled(): Flow { return dataStoreManager.isFileAccessEnabled() } + + override suspend fun setNotifyOnCrashEnabled(enabled: Boolean) { + dataStoreManager.setNotifyOnCrashEnabled(enabled) + } + + override fun getNotifyOnCrashEnabled(): Flow { + return dataStoreManager.getNotifyOnCrashEnabled() + } } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt index 2925473a..52b7d34e 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt @@ -50,5 +50,6 @@ data class UiState( val widgetTransparency: Float = 1f, val isQuickShareEnabled: Boolean = false, val isFileAccessEnabled: Boolean = true, + val isNotifyOnCrashEnabled: Boolean = true, val bleConnectionState: com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState = com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.DISCONNECTED ) \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt b/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt index ebd0815d..87f23e9c 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt @@ -134,4 +134,7 @@ interface AirSyncRepository { // File Access (WebDAV Server) suspend fun setFileAccessEnabled(enabled: Boolean) fun isFileAccessEnabled(): Flow + + suspend fun setNotifyOnCrashEnabled(enabled: Boolean) + fun getNotifyOnCrashEnabled(): Flow } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index 9cb780ef..dbb89ed1 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt @@ -176,6 +176,16 @@ fun SettingsView( viewModel.setPitchBlackThemeEnabled(enabled) } ) + + IconToggleItem( + title = stringResource(R.string.label_notify_on_crash), + description = stringResource(R.string.subtitle_notify_on_crash), + iconRes = R.drawable.rounded_bug_report_24, + isChecked = uiState.isNotifyOnCrashEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setNotifyOnCrashEnabled(enabled) + } + ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt index af0db09b..ede86d00 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt @@ -241,6 +241,13 @@ class AirSyncViewModel( } } + // Observe Notify on Crash preference + viewModelScope.launch { + repository.getNotifyOnCrashEnabled().collect { enabled -> + _uiState.value = _uiState.value.copy(isNotifyOnCrashEnabled = enabled) + } + } + // Observe BLE connection status viewModelScope.launch { com.sameerasw.airsync.AirSyncApp.getBleConnectionManager()?.connectionState?.collect { state -> @@ -367,6 +374,7 @@ class AirSyncViewModel( val isPowerSaveMode = DeviceInfoUtil.isPowerSaveMode(context) val isBlurProblematic = DeviceInfoUtil.isBlurProblematicDevice() val isQuickShareEnabled = repository.isQuickShareEnabled().first() + val isNotifyOnCrashEnabled = repository.getNotifyOnCrashEnabled().first() // Replicate Essentials logic for initial state val isBlurEnabled = isBlurEnabledSetting && !isPowerSaveMode && !isBlurProblematic @@ -428,7 +436,8 @@ class AirSyncViewModel( isPitchBlackThemeEnabled = isPitchBlackThemeEnabled, isBlurEnabled = isBlurEnabled, isOnboardingCompleted = !isFirstRun, - isQuickShareEnabled = isQuickShareEnabled + isQuickShareEnabled = isQuickShareEnabled, + isNotifyOnCrashEnabled = isNotifyOnCrashEnabled ) updateRatingPromptDisplay() @@ -750,6 +759,13 @@ class AirSyncViewModel( } } + fun setNotifyOnCrashEnabled(enabled: Boolean) { + _uiState.value = _uiState.value.copy(isNotifyOnCrashEnabled = enabled) + viewModelScope.launch { + repository.setNotifyOnCrashEnabled(enabled) + } + } + fun manualSyncAppIcons(context: Context) { _uiState.value = _uiState.value.copy(isIconSyncLoading = true, iconSyncMessage = "") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20cc47c5..ad2cd62e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -101,4 +101,24 @@ Grant Answer Calls Access Select apps To be notified + + + Notify on crash + Send a notification if something goes wrong + AirSync did an oopsie! + Share with the developer to help improve AirSync + Dismiss + Report + Send Crash Report + Copy report + Copy the crash trace to clipboard + Save to file + Save log to your downloads folder + Email report + Email log to mail@sameerasw.com + Open GitHub issue + Submit an issue on our GitHub repository + Log saved to Downloads + Please attach the log from Downloads to the issue + Failed to save log file \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 18318bec..ab7c9fa1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aad785b4..c5e4dbf3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,8 +20,10 @@ protobuf = "4.28.2" wire = "6.0.0-alpha03" bouncycastle = "1.78.1" ktor = "2.3.12" +appcompat = "1.7.1" [libraries] +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -62,12 +64,18 @@ ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } ktor-serialization-gson = { group = "io.ktor", name = "ktor-serialization-gson", version.ref = "ktor" } +[bundles] +androidx = ["androidx-core-ktx", "androidx-activity-compose", "androidx-ui", "androidx-ui-graphics", "androidx-ui-tooling-preview", "androidx-material3"] + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } wire = { id = "com.squareup.wire", version.ref = "wire" } +android-library = { id = "com.android.library", version.ref = "agp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } + diff --git a/settings.gradle.kts b/settings.gradle.kts index 9ff59c83..059df158 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,3 +24,6 @@ dependencyResolutionManagement { rootProject.name = "AirSync" include(":app") +include(":exceptionreport") +project(":exceptionreport").projectDir = File("../ExceptionReport/exceptionreport") +