diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..8787b28b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "security" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "security" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cdfc2550..7060d5f1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -129,6 +129,13 @@ + + + diff --git a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt index 3f4975e4..381065e8 100644 --- a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt +++ b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt @@ -78,6 +78,25 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact super.onCreate() instance = this + // Setup Global Error Boundary + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, exception -> + AppLogger.e("ArflixApplication", "Uncaught exception crashed the app: ${exception.message}", exception) + + val intent = android.content.Intent(this, com.arflix.tv.ui.activity.CrashActivity::class.java).apply { + putExtra(com.arflix.tv.ui.activity.CrashActivity.EXTRA_ERROR_DETAILS, android.util.Log.getStackTraceString(exception)) + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + startActivity(intent) + + // Allow default crash reporters (Sentry/Crashlytics) to still process it if they wrapped it + defaultHandler?.uncaughtException(thread, exception) + + // We exit immediately to prevent the OS from showing the standard "App has stopped" dialog + // since we've already launched our isolated CrashActivity in a different process. + kotlin.system.exitProcess(1) + } + // OkHttpProvider.init(context) just stashes the app context; it does // not build the OkHttpClient. Safe to keep on the main thread — it's // a single volatile assignment. diff --git a/app/src/main/kotlin/com/arflix/tv/ui/activity/CrashActivity.kt b/app/src/main/kotlin/com/arflix/tv/ui/activity/CrashActivity.kt new file mode 100644 index 00000000..6183176b --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/activity/CrashActivity.kt @@ -0,0 +1,121 @@ +package com.arflix.tv.ui.activity + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import kotlin.system.exitProcess + +class CrashActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val errorDetails = intent.getStringExtra(EXTRA_ERROR_DETAILS) ?: "Unknown Error" + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + CrashScreen( + errorDetails = errorDetails, + onRestart = { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + if (launchIntent != null) { + launchIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(launchIntent) + } + finish() + exitProcess(0) + }, + onCopy = { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Crash Log", errorDetails) + clipboard.setPrimaryClip(clip) + Toast.makeText(this, "Crash log copied to clipboard", Toast.LENGTH_SHORT).show() + } + ) + } + } + } + + companion object { + const val EXTRA_ERROR_DETAILS = "extra_error_details" + } +} + +@Composable +fun CrashScreen(errorDetails: String, onRestart: () -> Unit, onCopy: () -> Unit) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Oops! Something went wrong.", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.error + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "The application encountered an unexpected error and needs to restart. We apologize for the inconvenience.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color(0xFF1E1E1E), shape = MaterialTheme.shapes.medium) + .padding(16.dp) + ) { + Text( + text = errorDetails, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = Color.LightGray, + modifier = Modifier.verticalScroll(rememberScrollState()) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Button(onClick = onCopy) { + Text("Copy Error Log") + } + Button( + onClick = onRestart, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Text("Restart App") + } + } + } + } +}