diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1b10784..087ec28 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ tools:ignore="QueryAllPackagesPermission" /> + + @@ -80,6 +82,9 @@ + - \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/Application.kt b/app/src/main/java/de/jrpie/android/launcher/Application.kt index 3c2e3bc..cf9e697 100644 --- a/app/src/main/java/de/jrpie/android/launcher/Application.kt +++ b/app/src/main/java/de/jrpie/android/launcher/Application.kt @@ -25,6 +25,7 @@ import de.jrpie.android.launcher.preferences.resetPreferences import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlin.system.exitProcess const val APP_WIDGET_HOST_ID = 42; @@ -106,6 +107,11 @@ class Application : android.app.Application() { // TODO Error: Invalid resource ID 0x00000000. // DynamicColors.applyToActivitiesIfAvailable(this) + Thread.setDefaultUncaughtExceptionHandler { _, throwable -> + sendCrashNotification(this@Application, throwable) + exitProcess(1) + } + if (Build.VERSION.SDK_INT >= VERSION_CODES.M) { torchManager = TorchManager(this) @@ -157,6 +163,8 @@ class Application : android.app.Application() { removeUnusedShortcuts(this) } loadApps() + + createNotificationChannels(this) } fun getCustomAppNames(): HashMap { diff --git a/app/src/main/java/de/jrpie/android/launcher/Functions.kt b/app/src/main/java/de/jrpie/android/launcher/Functions.kt index 9679ae5..b6df30b 100644 --- a/app/src/main/java/de/jrpie/android/launcher/Functions.kt +++ b/app/src/main/java/de/jrpie/android/launcher/Functions.kt @@ -6,9 +6,6 @@ import android.app.role.RoleManager import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.appwidget.AppWidgetProviderInfo import android.content.Context import android.content.Intent import android.content.pm.LauncherApps @@ -227,3 +224,13 @@ fun copyToClipboard(context: Context, text: String) { val clipData = ClipData.newPlainText("Debug Info", text) clipboardManager.setPrimaryClip(clipData) } + +fun writeEmail(context: Context, to: String, subject: String, text: String) { + val intent = Intent(Intent.ACTION_SENDTO) + intent.setData("mailto:".toUri()) + intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) + intent.putExtra(Intent.EXTRA_SUBJECT, subject) + intent.putExtra(Intent.EXTRA_TEXT, text) + context.startActivity(Intent.createChooser(intent, context.getString(R.string.send_email))) +} + diff --git a/app/src/main/java/de/jrpie/android/launcher/Notifications.kt b/app/src/main/java/de/jrpie/android/launcher/Notifications.kt new file mode 100644 index 0000000..0cf0efb --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/Notifications.kt @@ -0,0 +1,87 @@ +package de.jrpie.android.launcher + +import android.app.Activity +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import de.jrpie.android.launcher.ui.EXTRA_CRASH_LOG +import de.jrpie.android.launcher.ui.ReportCrashActivity +import java.io.PrintWriter +import java.io.StringWriter +import kotlin.random.Random + +private val NOTIFICATION_CHANNEL_CRASH = "launcher:crash" + +fun createNotificationChannels(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationChannel = NotificationChannel( + NOTIFICATION_CHANNEL_CRASH, + context.getString(R.string.notification_channel_crash), + NotificationManager.IMPORTANCE_HIGH + ) + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(notificationChannel) + } +} + +fun requestNotificationPermission(activity: Activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return + } + + val permission = + (activity.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) + + if (!permission) { + ActivityCompat.requestPermissions( + activity, + arrayOf( android.Manifest.permission.POST_NOTIFICATIONS ), + 1 + ) + } +} + +fun sendCrashNotification(context: Context, throwable: Throwable) { + val stringWriter = StringWriter() + val printWriter = PrintWriter(stringWriter) + throwable.printStackTrace(printWriter) + + val intent = Intent(context, ReportCrashActivity::class.java) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + intent.putExtra(EXTRA_CRASH_LOG, stringWriter.toString()) + + val pendingIntent = PendingIntent.getActivity( + context, + Random.nextInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_CRASH) + .setSmallIcon(R.drawable.baseline_bug_report_24) + .setContentTitle(context.getString(R.string.notification_crash_title)) + .setContentText(context.getString(R.string.notification_crash_explanation)) + .setContentIntent(pendingIntent) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_HIGH) + + val notificationManager = NotificationManagerCompat.from(context) + try { + notificationManager.notify( + 0, + builder.build() + ) + } catch (e: SecurityException) { + Log.e("Crash Notification", "Could not send notification") + } +} diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/Preferences.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/Preferences.kt index fd86d79..8936675 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/Preferences.kt +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/Preferences.kt @@ -14,6 +14,7 @@ import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersio import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion3 import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion4 import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersionUnknown +import de.jrpie.android.launcher.sendCrashNotification import de.jrpie.android.launcher.ui.HomeActivity import de.jrpie.android.launcher.widgets.ClockWidget import de.jrpie.android.launcher.widgets.DebugInfoWidget @@ -76,6 +77,7 @@ fun migratePreferencesToNewVersion(context: Context) { } } catch (e: Exception) { Log.e(TAG, "Unable to restore preferences:\n${e.stackTrace}") + sendCrashNotification(context, e) resetPreferences(context) } } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/ReportCrashActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/ReportCrashActivity.kt new file mode 100644 index 0000000..5d95769 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/ReportCrashActivity.kt @@ -0,0 +1,57 @@ +package de.jrpie.android.launcher.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.copyToClipboard +import de.jrpie.android.launcher.databinding.ActivityReportCrashBinding +import de.jrpie.android.launcher.getDeviceInfo +import de.jrpie.android.launcher.openInBrowser +import de.jrpie.android.launcher.writeEmail + +const val EXTRA_CRASH_LOG = "crashLog" + +class ReportCrashActivity : AppCompatActivity() { + // We don't know what caused the crash, so this Activity should use as little functionality as possible. + // In particular it is not a UIObject (and hence looks quite ugly) + private lateinit var binding: ActivityReportCrashBinding + private var report: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Initialise layout + binding = ActivityReportCrashBinding.inflate(layoutInflater) + setContentView(binding.root) + + setTitle(R.string.report_crash_title) + setSupportActionBar(binding.reportCrashAppbar) + supportActionBar?.setDisplayHomeAsUpEnabled(false) + + report = intent.getStringExtra(EXTRA_CRASH_LOG) + + binding.reportCrashButtonCopy.setOnClickListener { + copyToClipboard(this, + "Device Info:\n${getDeviceInfo()}\n\nCrash Log:\n${report}") + } + + binding.reportCrashButtonMail.setOnClickListener { + writeEmail( + this, + getString(R.string.settings_meta_report_bug_mail), + "Crash in μLauncher", + "Hi!\nUnfortunately, μLauncher crashed:\n" + + "\nDevice Info\n\n${getDeviceInfo()}\n\n" + + "\nCrash Log\n\n${report}\n" + + "\nAdditional Information\n\n" + + "[Please add additional information: What did you do when the crash happened? Do you know how to trigger it? ... ]" + ) + } + binding.reportCrashButtonReport.setOnClickListener { + openInBrowser( + getString(R.string.settings_meta_report_bug_link), + this + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment5Finish.kt b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment5Finish.kt index 8feaa07..e15cef1 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment5Finish.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment5Finish.kt @@ -8,6 +8,7 @@ import androidx.fragment.app.Fragment import de.jrpie.android.launcher.BuildConfig.VERSION_CODE import de.jrpie.android.launcher.databinding.Tutorial5FinishBinding import de.jrpie.android.launcher.preferences.LauncherPreferences +import de.jrpie.android.launcher.requestNotificationPermission import de.jrpie.android.launcher.setDefaultHomeScreen import de.jrpie.android.launcher.ui.UIObject @@ -31,8 +32,10 @@ class TutorialFragment5Finish : Fragment(), UIObject { override fun onStart() { super.onStart() super.onStart() + requestNotificationPermission(requireActivity()) } + override fun setOnClicks() { super.setOnClicks() binding.tutorialFinishButtonStart.setOnClickListener { finishTutorial() } @@ -44,6 +47,7 @@ class TutorialFragment5Finish : Fragment(), UIObject { LauncherPreferences.internal().startedTime(System.currentTimeMillis() / 1000L) } context?.let { setDefaultHomeScreen(it, checkDefault = true) } + activity?.finish() } } diff --git a/app/src/main/res/drawable/baseline_bug_report_24.xml b/app/src/main/res/drawable/baseline_bug_report_24.xml new file mode 100644 index 0000000..ef399ba --- /dev/null +++ b/app/src/main/res/drawable/baseline_bug_report_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/layout/activity_report_crash.xml b/app/src/main/res/layout/activity_report_crash.xml new file mode 100644 index 0000000..c7f2110 --- /dev/null +++ b/app/src/main/res/layout/activity_report_crash.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + +