Skip to content

Commit bfd9038

Browse files
committed
feat: implement custom crash reporting system with notifications
1 parent 98c8710 commit bfd9038

19 files changed

Lines changed: 470 additions & 1 deletion

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ android {
6565
}
6666

6767
dependencies {
68+
implementation(project(":exceptionreport"))
6869

6970
implementation(libs.androidx.core.ktx)
7071
implementation(libs.androidx.lifecycle.runtime.ktx)

app/src/main/AndroidManifest.xml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,22 @@
260260
</intent-filter>
261261
</receiver>
262262

263+
<!-- Crash Reporting System Components -->
264+
<receiver
265+
android:name=".crash.CrashNotificationReceiver"
266+
android:exported="false">
267+
<intent-filter>
268+
<action android:name="com.sameerasw.airsync.action.DISMISS_CRASH_NOTIFICATION" />
269+
</intent-filter>
270+
</receiver>
271+
272+
<activity
273+
android:name=".crash.CrashReportActivity"
274+
android:exported="false"
275+
android:theme="@style/Theme.AirSync.Transparent"
276+
android:launchMode="singleInstance"
277+
android:excludeFromRecents="true"
278+
android:taskAffinity="" />
263279

264280
</application>
265281

app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.app.Activity
44
import android.app.Application
55
import android.os.Bundle
66
import com.sameerasw.airsync.data.local.DataStoreManager
7+
import com.sameerasw.airsync.crash.CrashNotificationHelper
78

89
class AirSyncApp : Application() {
910
private var activityCount = 0
@@ -20,6 +21,8 @@ class AirSyncApp : Application() {
2021
super.onCreate()
2122
instance = this
2223

24+
CrashNotificationHelper.createChannel(this)
25+
2326
bleConnectionManager = com.sameerasw.airsync.data.ble.BleConnectionManager(this)
2427
bleConnectionManager.start()
2528
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {

app/src/main/java/com/sameerasw/airsync/MainActivity.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ import com.sameerasw.airsync.utils.PermissionUtil
3939
import com.sameerasw.airsync.utils.ShortcutUtil
4040
import com.sameerasw.airsync.utils.UDPDiscoveryManager
4141
import com.sameerasw.airsync.utils.WebSocketUtil
42+
import com.canerture.exceptionreport.handler.ExceptionReport
43+
import com.sameerasw.airsync.crash.CrashHandler
44+
import com.sameerasw.airsync.crash.CrashReportActivity
4245
import kotlinx.coroutines.flow.first
4346
import kotlinx.coroutines.runBlocking
4447
import java.net.URLDecoder
@@ -175,6 +178,10 @@ class MainActivity : ComponentActivity() {
175178

176179
super.onCreate(savedInstanceState)
177180

181+
ExceptionReport(this) { deviceInfo, stackTrace ->
182+
CrashHandler.onCrash(applicationContext, deviceInfo, stackTrace)
183+
}.setCustomActivity(CrashReportActivity::class.java)
184+
178185
// Dynamically set the splash screen icon based on last connected device
179186
splashScreen.setOnExitAnimationListener { splashScreenViewProvider ->
180187
try {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.sameerasw.airsync.crash
2+
3+
import android.content.Context
4+
import com.sameerasw.airsync.data.local.DataStoreManager
5+
import kotlinx.coroutines.flow.first
6+
import kotlinx.coroutines.runBlocking
7+
import java.io.File
8+
9+
object CrashHandler {
10+
fun onCrash(context: Context, deviceInfo: String, stackTrace: String) {
11+
try {
12+
// Write report to last_crash.log
13+
val logFile = File(context.filesDir, "last_crash.log")
14+
logFile.writeText("$deviceInfo\n\n$stackTrace")
15+
16+
// Read preference from DataStore synchronously
17+
val dataStoreManager = DataStoreManager.getInstance(context)
18+
val notifyEnabled = runBlocking {
19+
dataStoreManager.getNotifyOnCrashEnabled().first()
20+
}
21+
22+
if (notifyEnabled) {
23+
CrashNotificationHelper.postNotification(context)
24+
}
25+
} catch (e: Exception) {
26+
e.printStackTrace()
27+
}
28+
}
29+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.sameerasw.airsync.crash
2+
3+
import android.app.NotificationChannel
4+
import android.app.NotificationManager
5+
import android.app.PendingIntent
6+
import android.content.Context
7+
import android.content.Intent
8+
import android.os.Build
9+
import androidx.core.app.NotificationCompat
10+
import androidx.core.app.NotificationManagerCompat
11+
import com.sameerasw.airsync.R
12+
13+
object CrashNotificationHelper {
14+
private const val CHANNEL_ID = "crash_report"
15+
const val NOTIFICATION_ID = 9999
16+
17+
fun createChannel(context: Context) {
18+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
19+
val name = context.getString(R.string.crash_notification_title)
20+
val descriptionText = context.getString(R.string.crash_notification_body)
21+
val importance = NotificationManager.IMPORTANCE_LOW
22+
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
23+
description = descriptionText
24+
setSound(null, null)
25+
enableLights(false)
26+
enableVibration(false)
27+
}
28+
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
29+
notificationManager.createNotificationChannel(channel)
30+
}
31+
}
32+
33+
fun postNotification(context: Context) {
34+
// Broadcast pending intent for Dismiss
35+
val dismissIntent = Intent(context, CrashNotificationReceiver::class.java).apply {
36+
action = "com.sameerasw.airsync.action.DISMISS_CRASH_NOTIFICATION"
37+
}
38+
val dismissPendingIntent = PendingIntent.getBroadcast(
39+
context,
40+
1,
41+
dismissIntent,
42+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
43+
)
44+
45+
// Activity pending intent for Report
46+
val reportIntent = Intent(context, CrashReportActivity::class.java).apply {
47+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
48+
putExtra("show_report", true)
49+
}
50+
val reportPendingIntent = PendingIntent.getActivity(
51+
context,
52+
2,
53+
reportIntent,
54+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
55+
)
56+
57+
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
58+
.setSmallIcon(R.drawable.rounded_bug_report_24)
59+
.setContentTitle(context.getString(R.string.crash_notification_title))
60+
.setContentText(context.getString(R.string.crash_notification_body))
61+
.setPriority(NotificationCompat.PRIORITY_LOW)
62+
.setSilent(true)
63+
.setAutoCancel(true)
64+
.setContentIntent(reportPendingIntent)
65+
.addAction(
66+
0,
67+
context.getString(R.string.crash_action_dismiss),
68+
dismissPendingIntent
69+
)
70+
.addAction(
71+
0,
72+
context.getString(R.string.crash_action_report),
73+
reportPendingIntent
74+
)
75+
76+
try {
77+
val notificationManager = NotificationManagerCompat.from(context)
78+
notificationManager.notify(NOTIFICATION_ID, builder.build())
79+
} catch (e: SecurityException) {
80+
e.printStackTrace()
81+
}
82+
}
83+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.sameerasw.airsync.crash
2+
3+
import android.app.NotificationManager
4+
import android.content.BroadcastReceiver
5+
import android.content.Context
6+
import android.content.Intent
7+
8+
class CrashNotificationReceiver : BroadcastReceiver() {
9+
override fun onReceive(context: Context?, intent: Intent?) {
10+
if (context != null && intent?.action == "com.sameerasw.airsync.action.DISMISS_CRASH_NOTIFICATION") {
11+
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
12+
notificationManager.cancel(CrashNotificationHelper.NOTIFICATION_ID)
13+
}
14+
}
15+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.sameerasw.airsync.crash
2+
3+
import android.os.Bundle
4+
import androidx.activity.ComponentActivity
5+
import androidx.activity.compose.setContent
6+
import androidx.compose.runtime.collectAsState
7+
import androidx.compose.runtime.getValue
8+
import com.sameerasw.airsync.ui.theme.AirSyncTheme
9+
import java.io.File
10+
11+
class CrashReportActivity : ComponentActivity() {
12+
override fun onCreate(savedInstanceState: Bundle?) {
13+
super.onCreate(savedInstanceState)
14+
15+
val showReport = intent.getBooleanExtra("show_report", false)
16+
if (!showReport) {
17+
finish()
18+
return
19+
}
20+
21+
val reportFile = File(filesDir, "last_crash.log")
22+
val reportText = if (reportFile.exists()) {
23+
reportFile.readText()
24+
} else {
25+
"No crash log found."
26+
}
27+
28+
setContent {
29+
val viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel =
30+
androidx.lifecycle.viewmodel.compose.viewModel {
31+
com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel.create(this@CrashReportActivity)
32+
}
33+
val uiState by viewModel.uiState.collectAsState()
34+
35+
AirSyncTheme(pitchBlackTheme = uiState.isPitchBlackThemeEnabled) {
36+
CrashReportBottomSheet(
37+
report = reportText,
38+
onDismiss = {
39+
finish()
40+
}
41+
)
42+
}
43+
}
44+
}
45+
}

0 commit comments

Comments
 (0)