diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 115e6d42..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) @@ -137,7 +138,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..a6ad76b7 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"> - - + + + + + + + + + diff --git a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt index 45f8a4fe..f82b6e29 100644 --- a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt +++ b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt @@ -4,9 +4,7 @@ import android.app.Activity import android.app.Application import android.os.Bundle import com.sameerasw.airsync.data.local.DataStoreManager -import io.sentry.android.core.SentryAndroid -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import com.sameerasw.airsync.crash.CrashNotificationHelper class AirSyncApp : Application() { private var activityCount = 0 @@ -22,7 +20,8 @@ class AirSyncApp : Application() { override fun onCreate() { super.onCreate() instance = this - initSentry() + + CrashNotificationHelper.createChannel(this) bleConnectionManager = com.sameerasw.airsync.data.ble.BleConnectionManager(this) bleConnectionManager.start() @@ -44,17 +43,4 @@ class AirSyncApp : Application() { } private fun isForeground(): Boolean = activityCount > 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/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 1ffdc453..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 @@ -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") @@ -102,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_" @@ -324,17 +324,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 -> @@ -635,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 397217b6..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 @@ -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) @@ -303,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 44bd392e..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 @@ -46,10 +46,10 @@ 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, 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 ab1420d3..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 @@ -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) @@ -136,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 87782df6..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 @@ -178,12 +178,12 @@ fun SettingsView( ) 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), + 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.isSentryReportingEnabled, + isChecked = uiState.isNotifyOnCrashEnabled, onCheckedChange = { enabled: Boolean -> - viewModel.setSentryReportingEnabled(enabled) + viewModel.setNotifyOnCrashEnabled(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..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 @@ -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 { @@ -246,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 -> @@ -368,11 +370,11 @@ 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() val isQuickShareEnabled = repository.isQuickShareEnabled().first() + val isNotifyOnCrashEnabled = repository.getNotifyOnCrashEnabled().first() // Replicate Essentials logic for initial state val isBlurEnabled = isBlurEnabledSetting && !isPowerSaveMode && !isBlurProblematic @@ -433,9 +435,9 @@ class AirSyncViewModel( isPowerSaveMode = isPowerSaveMode, isPitchBlackThemeEnabled = isPitchBlackThemeEnabled, isBlurEnabled = isBlurEnabled, - isSentryReportingEnabled = isSentryReportingEnabled, isOnboardingCompleted = !isFirstRun, - isQuickShareEnabled = isQuickShareEnabled + isQuickShareEnabled = isQuickShareEnabled, + isNotifyOnCrashEnabled = isNotifyOnCrashEnabled ) updateRatingPromptDisplay() @@ -703,13 +705,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) @@ -763,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 25f49035..ad2cd62e 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 @@ -107,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 79dd1811..c5e4dbf3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,13 +16,14 @@ 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" 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" } @@ -52,7 +53,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" } @@ -64,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") +