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..e0d1d00 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) @@ -114,8 +120,6 @@ class Application : android.app.Application() { appWidgetHost = AppWidgetHost(this.applicationContext, APP_WIDGET_HOST_ID) appWidgetManager = AppWidgetManager.getInstance(this.applicationContext) - appWidgetHost.startListening() - val preferences = PreferenceManager.getDefaultSharedPreferences(this) LauncherPreferences.init(preferences, this.resources) @@ -157,6 +161,8 @@ class Application : android.app.Application() { removeUnusedShortcuts(this) } loadApps() + + createNotificationChannels(this) } fun getCustomAppNames(): HashMap { @@ -170,10 +176,4 @@ class Application : android.app.Application() { apps.postValue(getApps(packageManager, applicationContext)) } } - - override fun onTerminate() { - appWidgetHost.stopListening() - super.onTerminate() - - } } 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/actions/WidgetPanelAction.kt b/app/src/main/java/de/jrpie/android/launcher/actions/WidgetPanelAction.kt index d7829a6..84c4179 100644 --- a/app/src/main/java/de/jrpie/android/launcher/actions/WidgetPanelAction.kt +++ b/app/src/main/java/de/jrpie/android/launcher/actions/WidgetPanelAction.kt @@ -1,5 +1,6 @@ package de.jrpie.android.launcher.actions +import android.app.Activity import android.content.Context import android.content.Intent import android.graphics.Rect @@ -25,11 +26,18 @@ class WidgetPanelAction(val widgetPanelId: Int) : Action { override fun invoke(context: Context, rect: Rect?): Boolean { - if (WidgetPanel.byId(widgetPanelId) == null) { + if (context is WidgetPanelActivity) { + if (context.widgetPanelId == widgetPanelId) { + context.finish() + return true + } + } + + if (WidgetPanel.byId(this.widgetPanelId) == null) { Toast.makeText(context, R.string.alert_widget_panel_not_found, Toast.LENGTH_LONG).show() } else { context.startActivity(Intent(context, WidgetPanelActivity::class.java).also { - it.putExtra(EXTRA_PANEL_ID, widgetPanelId) + it.putExtra(EXTRA_PANEL_ID, this.widgetPanelId) }) } return true 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..a4b1f43 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 @@ -2,7 +2,6 @@ package de.jrpie.android.launcher.preferences import android.content.Context import android.util.Log -import de.jrpie.android.launcher.Application import de.jrpie.android.launcher.BuildConfig import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.apps.AbstractAppInfo @@ -10,22 +9,25 @@ import de.jrpie.android.launcher.apps.AbstractAppInfo.Companion.INVALID_USER import de.jrpie.android.launcher.apps.AppInfo import de.jrpie.android.launcher.apps.DetailedAppInfo import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion1 +import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion100 import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion2 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 import de.jrpie.android.launcher.widgets.WidgetPanel import de.jrpie.android.launcher.widgets.WidgetPosition -import de.jrpie.android.launcher.widgets.deleteAllWidgets +import de.jrpie.android.launcher.widgets.generateInternalId +import de.jrpie.android.launcher.widgets.getAppWidgetHost /* Current version of the structure of preferences. * Increase when breaking changes are introduced and write an appropriate case in * `migratePreferencesToNewVersion` */ -const val PREFERENCE_VERSION = 100 +const val PREFERENCE_VERSION = 101 const val UNKNOWN_PREFERENCE_VERSION = -1 private const val TAG = "Launcher - Preferences" @@ -64,6 +66,10 @@ fun migratePreferencesToNewVersion(context: Context) { migratePreferencesFromVersion4(context) Log.i(TAG, "migration of preferences complete (4 -> ${PREFERENCE_VERSION}).") } + 100 -> { + migratePreferencesFromVersion100(context) + Log.i(TAG, "migration of preferences complete (100 -> ${PREFERENCE_VERSION}).") + } else -> { Log.w( @@ -76,6 +82,7 @@ fun migratePreferencesToNewVersion(context: Context) { } } catch (e: Exception) { Log.e(TAG, "Unable to restore preferences:\n${e.stackTrace}") + sendCrashNotification(context, e) resetPreferences(context) } } @@ -84,12 +91,12 @@ fun resetPreferences(context: Context) { Log.i(TAG, "Resetting preferences") LauncherPreferences.clear() LauncherPreferences.internal().versionCode(PREFERENCE_VERSION) - deleteAllWidgets(context) + context.getAppWidgetHost().deleteHost() LauncherPreferences.widgets().widgets( setOf( ClockWidget( - (context.applicationContext as Application).appWidgetHost.allocateAppWidgetId(), + generateInternalId(), WidgetPosition(1, 3, 10, 4), WidgetPanel.HOME.id ) @@ -101,7 +108,7 @@ fun resetPreferences(context: Context) { LauncherPreferences.widgets().widgets().also { it.add( DebugInfoWidget( - (context.applicationContext as Application).appWidgetHost.allocateAppWidgetId(), + generateInternalId(), WidgetPosition(1, 1, 10, 4), WidgetPanel.HOME.id ) diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version100.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version100.kt new file mode 100644 index 0000000..43e4bc7 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version100.kt @@ -0,0 +1,39 @@ +package de.jrpie.android.launcher.preferences.legacy + +import android.content.Context +import de.jrpie.android.launcher.Application +import de.jrpie.android.launcher.preferences.LauncherPreferences +import de.jrpie.android.launcher.preferences.PREFERENCE_VERSION +import de.jrpie.android.launcher.widgets.ClockWidget +import de.jrpie.android.launcher.widgets.DebugInfoWidget +import de.jrpie.android.launcher.widgets.generateInternalId +import de.jrpie.android.launcher.widgets.updateWidget + +fun migratePreferencesFromVersion100(context: Context) { + assert(PREFERENCE_VERSION == 101) + assert(LauncherPreferences.internal().versionCode() == 100) + + val widgets = LauncherPreferences.widgets().widgets() ?: setOf() + widgets.forEach { widget -> + when (widget) { + is ClockWidget -> { + val id = widget.id + val newId = generateInternalId() + (context.applicationContext as Application).appWidgetHost.deleteAppWidgetId(id) + widget.delete(context) + widget.id = newId + updateWidget(widget) + } + is DebugInfoWidget -> { + val id = widget.id + val newId = generateInternalId() + (context.applicationContext as Application).appWidgetHost.deleteAppWidgetId(id) + widget.delete(context) + widget.id = newId + updateWidget(widget) + } + else -> {} + } + } + LauncherPreferences.internal().versionCode(101) +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version4.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version4.kt index a9ab3a1..b13978b 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version4.kt +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version4.kt @@ -7,19 +7,20 @@ import de.jrpie.android.launcher.preferences.PREFERENCE_VERSION import de.jrpie.android.launcher.widgets.ClockWidget import de.jrpie.android.launcher.widgets.WidgetPanel import de.jrpie.android.launcher.widgets.WidgetPosition +import de.jrpie.android.launcher.widgets.generateInternalId fun migratePreferencesFromVersion4(context: Context) { - assert(PREFERENCE_VERSION == 100) assert(LauncherPreferences.internal().versionCode() < 100) LauncherPreferences.widgets().widgets( setOf( ClockWidget( - (context.applicationContext as Application).appWidgetHost.allocateAppWidgetId(), + generateInternalId(), WidgetPosition(1, 3, 10, 4), WidgetPanel.HOME.id ) ) ) LauncherPreferences.internal().versionCode(100) + migratePreferencesFromVersion100(context) } \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt index f501107..76bf443 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt @@ -1,16 +1,9 @@ package de.jrpie.android.launcher.ui -import android.annotation.SuppressLint -import android.app.Activity import android.content.SharedPreferences -import android.content.res.Configuration import android.content.res.Resources -import android.os.Build import android.os.Bundle -import android.view.KeyEvent -import android.view.MotionEvent import android.view.View -import android.window.OnBackInvokedDispatcher import de.jrpie.android.launcher.Application import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.Gesture @@ -19,6 +12,7 @@ import de.jrpie.android.launcher.databinding.ActivityHomeBinding import de.jrpie.android.launcher.openTutorial import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.ui.tutorial.TutorialActivity +import de.jrpie.android.launcher.ui.util.LauncherGestureActivity /** * [HomeActivity] is the actual application Launcher, @@ -32,10 +26,9 @@ import de.jrpie.android.launcher.ui.tutorial.TutorialActivity * - Setting global variables (preferences etc.) * - Opening the [TutorialActivity] on new installations */ -class HomeActivity : UIObject, Activity() { +class HomeActivity : UIObject, LauncherGestureActivity() { private lateinit var binding: ActivityHomeBinding - private var touchGestureDetector: TouchGestureDetector? = null private var sharedPreferencesListener = SharedPreferences.OnSharedPreferenceChangeListener { _, prefKey -> @@ -54,35 +47,21 @@ class HomeActivity : UIObject, Activity() { } override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + super.onCreate(savedInstanceState) super.onCreate() - // Initialise layout binding = ActivityHomeBinding.inflate(layoutInflater) setContentView(binding.root) - // Handle back key / gesture on Android 13+, cf. onKeyDown() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_OVERLAY - ) { - handleBack() - } - } binding.buttonFallbackSettings.setOnClickListener { LauncherAction.SETTINGS.invoke(this) } } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - touchGestureDetector?.updateScreenSize(windowManager) - } - override fun onStart() { - super.onStart() + super.onStart() super.onStart() // If the tutorial was not finished, start it @@ -93,15 +72,6 @@ class HomeActivity : UIObject, Activity() { LauncherPreferences.getSharedPreferences() .registerOnSharedPreferenceChangeListener(sharedPreferencesListener) - (application as Application).appWidgetHost.startListening() - - } - - - - override fun onStop() { - (application as Application).appWidgetHost.stopListening() - super.onStop() } override fun onWindowFocusChanged(hasFocus: Boolean) { @@ -112,7 +82,6 @@ class HomeActivity : UIObject, Activity() { } } - private fun updateSettingsFallbackButtonVisibility() { // If µLauncher settings can not be reached from any action bound to an enabled gesture, // show the fallback button. @@ -131,81 +100,42 @@ class HomeActivity : UIObject, Activity() { return modifyTheme(super.getTheme()) } + override fun onPause() { + try { + (application as Application).appWidgetHost.stopListening() + } catch (e: Exception) { + // Throws a NullPointerException on Android 12 an earlier, see #172 + e.printStackTrace() + } + super.onPause() + } + override fun onResume() { super.onResume() - - /* This should be initialized in onCreate() - However on some devices there seems to be a bug where the touchGestureDetector - is not working properly after resuming the app. - Reinitializing the touchGestureDetector every time the app is resumed might help to fix that. - (see issue #138) - */ - touchGestureDetector = TouchGestureDetector( - this, 0, 0, - LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f - ).also { - it.updateScreenSize(windowManager) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - binding.root.setOnApplyWindowInsetsListener { _, windowInsets -> - @Suppress("deprecation") // required to support API 29 - val insets = windowInsets.systemGestureInsets - touchGestureDetector?.setSystemGestureInsets(insets) - - windowInsets - } - } updateSettingsFallbackButtonVisibility() binding.homeWidgetContainer.updateWidgets(this@HomeActivity, LauncherPreferences.widgets().widgets() ) + + (application as Application).appWidgetHost.startListening() } + override fun onDestroy() { LauncherPreferences.getSharedPreferences() .unregisterOnSharedPreferenceChangeListener(sharedPreferencesListener) super.onDestroy() } - @SuppressLint("GestureBackNavigation") - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - when (keyCode) { - KeyEvent.KEYCODE_BACK -> { - // Only used pre Android 13, cf. onBackInvokedDispatcher - handleBack() - } - - KeyEvent.KEYCODE_VOLUME_UP -> { - if (Action.forGesture(Gesture.VOLUME_UP) == LauncherAction.VOLUME_UP) { - // Let the OS handle the key event. This works better with some custom ROMs - // and apps like Samsung Sound Assistant. - return false - } - Gesture.VOLUME_UP(this) - } - - KeyEvent.KEYCODE_VOLUME_DOWN -> { - if (Action.forGesture(Gesture.VOLUME_DOWN) == LauncherAction.VOLUME_DOWN) { - // see above - return false - } - Gesture.VOLUME_DOWN(this) - } - } - return true - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - touchGestureDetector?.onTouchEvent(event) - return true - } - - private fun handleBack() { + override fun handleBack() { Gesture.BACK(this) } + override fun getRootView(): View { + return binding.root + } + override fun isHomeScreen(): Boolean { return true } 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/settings/meta/SettingsFragmentMeta.kt b/app/src/main/java/de/jrpie/android/launcher/ui/settings/meta/SettingsFragmentMeta.kt index e066da2..1a0e802 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/settings/meta/SettingsFragmentMeta.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/settings/meta/SettingsFragmentMeta.kt @@ -1,39 +1,18 @@ package de.jrpie.android.launcher.ui.settings.meta -import android.content.Context +import android.app.AlertDialog import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties +import android.widget.Button +import android.widget.TextView import androidx.fragment.app.Fragment import de.jrpie.android.launcher.BuildConfig import de.jrpie.android.launcher.R import de.jrpie.android.launcher.copyToClipboard +import de.jrpie.android.launcher.databinding.SettingsMetaBinding import de.jrpie.android.launcher.getDeviceInfo import de.jrpie.android.launcher.openInBrowser import de.jrpie.android.launcher.openTutorial @@ -51,21 +30,13 @@ import de.jrpie.android.launcher.ui.UIObject */ class SettingsFragmentMeta : Fragment(), UIObject { + private lateinit var binding: SettingsMetaBinding override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - return ComposeView(requireContext()).apply { - setContent { - MaterialTheme { - SettingsMetaScreen( - context = requireContext(), - onResetConfirmed = { requireActivity().finish() } - ) - } - } - } + binding = SettingsMetaBinding.inflate(inflater, container, false) + return binding.root } override fun onStart() { @@ -74,215 +45,102 @@ class SettingsFragmentMeta : Fragment(), UIObject { } override fun setOnClicks() { - // No longer needed as click handlers are defined in Compose - } -} -// Data class to represent a settings action -private data class SettingsAction( - val textResId: Int, - val onClick: (Context) -> Unit -) - -// Composable for the settings meta screen -@Composable -fun SettingsMetaScreen( - context: Context, - onResetConfirmed: () -> Unit -) { - val openAlertDialog = remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - SettingsButtonList(context, openAlertDialog) - if (openAlertDialog.value) { - AlertDialogResetSettings( - onDismissRequest = { openAlertDialog.value = false }, - onConfirmation = { - openAlertDialog.value = false - resetPreferences(context) - onResetConfirmed() - }, - dialogTitle = stringResource(R.string.settings_meta_reset), - dialogText = stringResource(R.string.settings_meta_reset_confirm), - icon = Icons.Default.Warning - ) - } - } -} - -@Preview -@Composable -fun SettingsMetaScreenPreview() { - SettingsMetaScreen( - context = LocalContext.current, - onResetConfirmed = {} - ) -} - -// Composable for the scrollable button list and version number -@Composable -private fun SettingsButtonList( - context: Context, - openAlertDialog: MutableState -) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - val actions = listOf( - SettingsAction(R.string.settings_meta_show_tutorial) { openTutorial(it) }, - SettingsAction(R.string.settings_meta_reset) { openAlertDialog.value = true }, - SettingsAction(R.string.settings_meta_view_code) { - openInBrowser(it.getString(R.string.settings_meta_link_github), it) - }, - SettingsAction(R.string.settings_meta_report_bug) { - openInBrowser(it.getString(R.string.settings_meta_report_bug_link), it) - }, - SettingsAction(R.string.settings_meta_join_chat) { - openInBrowser(it.getString(R.string.settings_meta_chat_url), it) - }, - SettingsAction(R.string.settings_meta_fork_contact) { - openInBrowser(it.getString(R.string.settings_meta_fork_contact_url), it) - }, - SettingsAction(R.string.settings_meta_donate) { - openInBrowser(it.getString(R.string.settings_meta_donate_url), it) - }, - SettingsAction(R.string.settings_meta_privacy) { - openInBrowser(it.getString(R.string.settings_meta_privacy_url), it) - }, - SettingsAction(R.string.settings_meta_licenses) { - it.startActivity(Intent(it, LegalInfoActivity::class.java)) - } - ) - - actions.forEachIndexed { index, action -> - SettingsButton( - text = stringResource(action.textResId), - onClick = { action.onClick(context) } - ) - if (index == 1 || index == 3 || index == 6) { - SettingsButtonSpacer() + fun bindURL(view: View, urlRes: Int) { + view.setOnClickListener { + openInBrowser( + getString(urlRes), + requireContext() + ) } } - // Version number at the bottom of buttons - Text( - text = BuildConfig.VERSION_NAME, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.End, - color = colorResource(R.color.finnmglasTheme_text_color), - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp, end = 8.dp) - .clickable { - val deviceInfo = getDeviceInfo() - copyToClipboard(context, deviceInfo) + + binding.settingsMetaButtonViewTutorial.setOnClickListener { + openTutorial(requireContext()) + } + + // prompting for settings-reset confirmation + binding.settingsMetaButtonResetSettings.setOnClickListener { + AlertDialog.Builder(this.requireContext(), R.style.AlertDialogCustom) + .setTitle(getString(R.string.settings_meta_reset)) + .setMessage(getString(R.string.settings_meta_reset_confirm)) + .setPositiveButton( + android.R.string.ok + ) { _, _ -> + resetPreferences(this.requireContext()) + requireActivity().finish() } - ) - Spacer(modifier = Modifier.height(48.dp)) + .setNegativeButton(android.R.string.cancel, null) + .setIcon(android.R.drawable.ic_dialog_alert) + .show() + } + + + // view code + bindURL(binding.settingsMetaButtonViewCode, R.string.settings_meta_link_github) + + // view documentation + bindURL(binding.settingsMetaButtonViewDocs, R.string.settings_meta_link_docs) + + // report a bug + binding.settingsMetaButtonReportBug.setOnClickListener { + val deviceInfo = getDeviceInfo() + AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { + setView(R.layout.dialog_report_bug) + setTitle(R.string.dialog_report_bug_title) + setPositiveButton(R.string.dialog_report_bug_create_report) { _, _ -> + openInBrowser( + getString(R.string.settings_meta_report_bug_link), + requireContext() + ) + } + setNegativeButton(R.string.dialog_cancel) { _, _ -> } + }.create().also { it.show() }.apply { + val info = findViewById(R.id.dialog_report_bug_device_info) + val buttonClipboard = findViewById