diff --git a/README.md b/README.md index 555af4f..7049f6f 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,8 @@ _Plain_ formatter is selected by default, but selecting any other is persisted b ## Crash monitor _Sentinel_ has a built in default uncaught exception handler and ANR observer. If switched on in -settings, it will notify both in a form of a notification. +settings, it will notify both in a form of a notification. Note that from Android 13 you need to give permission +to the app to show notifications. Once tapped on this notification, a screen with details is shown. A complete list of crashes is persisted between sessions and available on demand. Methods to react on these crashes in a graceful way are provided in _Sentinel_. diff --git a/sentinel/src/main/AndroidManifest.xml b/sentinel/src/main/AndroidManifest.xml index 82190de..5d3da56 100644 --- a/sentinel/src/main/AndroidManifest.xml +++ b/sentinel/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + (R.la override val viewModel: SettingsViewModel by viewModels() + private val permissionRequest = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isPermissionGranted: Boolean -> + if (!isPermissionGranted) { + Toast.makeText( + requireContext(), + getString(R.string.sentinel_notification_permission_denied), + Toast.LENGTH_LONG, + ).show() + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -107,10 +129,12 @@ internal class SettingsFragment : BaseChildFragment(R.la binding.airplaneModeTriggerView, trigger ) + else -> throw NotImplementedError() } } } + is SettingsEvent.FormatChanged -> { when (event.value.type) { FormatType.PLAIN -> R.id.plainChip @@ -123,6 +147,7 @@ internal class SettingsFragment : BaseChildFragment(R.la binding.formatGroup.check(it) } } + is SettingsEvent.BundleMonitorChanged -> { binding.bundleMonitorSwitch.setOnCheckedChangeListener(null) binding.bundleMonitorSwitch.isChecked = event.value.notify @@ -167,6 +192,7 @@ internal class SettingsFragment : BaseChildFragment(R.la } binding.limitValueView.text = String.format(FORMAT_BUNDLE_SIZE, event.value.limit) } + is SettingsEvent.CrashMonitorChanged -> { binding.uncaughtExceptionSwitch.setOnCheckedChangeListener(null) binding.uncaughtExceptionSwitch.isChecked = event.value.notifyExceptions @@ -184,6 +210,7 @@ internal class SettingsFragment : BaseChildFragment(R.la viewModel.updateCrashMonitor(event.value.copy(includeAllData = isChecked)) } } + is SettingsEvent.CertificateMonitorChanged -> { binding.runOnStartSwitch.setOnCheckedChangeListener(null) binding.runOnStartSwitch.isChecked = event.value.runOnStart @@ -240,8 +267,48 @@ internal class SettingsFragment : BaseChildFragment(R.la viewModel.updateCertificatesMonitor(event.value.copy(expireInUnit = ChronoUnit.YEARS)) } } + + SettingsEvent.PermissionsCheck -> { + handleNotificationsPermission() + } } + private fun handleNotificationsPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return + } + when { + ContextCompat.checkSelfPermission( + requireContext(), Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED -> { + // We have permission, all good + } + + shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { + Snackbar.make( + binding.root, + getString(R.string.sentinel_notification_permission_denied), + Snackbar.LENGTH_LONG + ).setAction(getString(R.string.sentinel_change)) { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + data = Uri.fromParts( + getString(R.string.sentinel_package_schema), + requireContext().packageName, + null + ) + }.also { intent -> + startActivity(intent) + } + }.show() + } + + else -> { + permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + private fun setupSwitch(switchView: SwitchMaterial, trigger: TriggerEntity) = with(switchView) { setOnCheckedChangeListener(null) diff --git a/sentinel/src/main/kotlin/com/infinum/sentinel/ui/settings/SettingsViewModel.kt b/sentinel/src/main/kotlin/com/infinum/sentinel/ui/settings/SettingsViewModel.kt index 1f2b9f6..e46eb1b 100644 --- a/sentinel/src/main/kotlin/com/infinum/sentinel/ui/settings/SettingsViewModel.kt +++ b/sentinel/src/main/kotlin/com/infinum/sentinel/ui/settings/SettingsViewModel.kt @@ -124,8 +124,14 @@ internal class SettingsViewModel( anrObserver.stop() } } + if (isAtLeastSomeCrashOptionChecked(entity)) { + emitEvent(SettingsEvent.PermissionsCheck) + } } + private fun isAtLeastSomeCrashOptionChecked(entity: CrashMonitorEntity): Boolean = + entity.notifyAnrs || entity.notifyExceptions + fun updateCertificatesMonitor(entity: CertificateMonitorEntity) { launch { io { @@ -141,6 +147,12 @@ internal class SettingsViewModel( workManager.startCertificatesCheck(it) } ?: workManager.stopCertificatesCheck() } + if (isAtLeastSomeCertificateOptionChecked(entity)) { + emitEvent(SettingsEvent.PermissionsCheck) + } } } + + private fun isAtLeastSomeCertificateOptionChecked(entity: CertificateMonitorEntity): Boolean = + entity.runOnStart || entity.runInBackground || entity.notifyInvalidNow || entity.notifyToExpire } diff --git a/sentinel/src/main/kotlin/com/infinum/sentinel/ui/shared/notification/SystemNotificationFactory.kt b/sentinel/src/main/kotlin/com/infinum/sentinel/ui/shared/notification/SystemNotificationFactory.kt index 45d34d6..731296a 100644 --- a/sentinel/src/main/kotlin/com/infinum/sentinel/ui/shared/notification/SystemNotificationFactory.kt +++ b/sentinel/src/main/kotlin/com/infinum/sentinel/ui/shared/notification/SystemNotificationFactory.kt @@ -14,6 +14,7 @@ import com.infinum.sentinel.data.models.local.CrashEntity import com.infinum.sentinel.ui.shared.Constants.NOTIFICATIONS_CHANNEL_ID import me.tatarka.inject.annotations.Inject +@Suppress("TooManyFunctions") @Inject internal class SystemNotificationFactory( private val context: Context, @@ -29,6 +30,14 @@ internal class SystemNotificationFactory( private val notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private fun canShowNotifications(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + notificationManager.areNotificationsEnabled() + } else { + true + } + } + init { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val notificationChannel = NotificationChannel( @@ -117,6 +126,9 @@ internal class SystemNotificationFactory( content: String, intents: Array, ) { + if (!canShowNotifications()) { + return + } val builder = NotificationCompat.Builder(context, NOTIFICATIONS_CHANNEL_ID) .setContentIntent(buildPendingIntent(intents)) .setLocalOnly(true) @@ -139,6 +151,7 @@ internal class SystemNotificationFactory( when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> PendingIntent.FLAG_MUTABLE + else -> PendingIntent.FLAG_CANCEL_CURRENT } ) diff --git a/sentinel/src/main/kotlin/com/infinum/sentinel/ui/tools/AppInfoTool.kt b/sentinel/src/main/kotlin/com/infinum/sentinel/ui/tools/AppInfoTool.kt index a4542ae..103cf04 100644 --- a/sentinel/src/main/kotlin/com/infinum/sentinel/ui/tools/AppInfoTool.kt +++ b/sentinel/src/main/kotlin/com/infinum/sentinel/ui/tools/AppInfoTool.kt @@ -17,16 +17,16 @@ internal data class AppInfoTool( it.context.startActivity( Intent().apply { action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS - data = Uri.fromParts(SCHEME_PACKAGE, it.context.packageName, null) + data = Uri.fromParts( + it.context.getString(R.string.sentinel_package_schema), + it.context.packageName, + null + ) } ) } ) : Sentinel.Tool { - companion object { - private const val SCHEME_PACKAGE = "package" - } - /** * A dedicated name for this tool * diff --git a/sentinel/src/main/res/values/strings.xml b/sentinel/src/main/res/values/strings.xml index 166a058..51a05cf 100644 --- a/sentinel/src/main/res/values/strings.xml +++ b/sentinel/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ Search Show Save + Change Network Memory @@ -151,5 +152,9 @@ Months Years + Notification permission denied. Can\'t show info + unknown + package + \ No newline at end of file diff --git a/sentinel/src/main/res/values/themes.xml b/sentinel/src/main/res/values/themes.xml index 7109386..350ccb7 100644 --- a/sentinel/src/main/res/values/themes.xml +++ b/sentinel/src/main/res/values/themes.xml @@ -28,6 +28,8 @@ @color/sentinel_inverseOnSurface @color/sentinel_inverseSurface @color/sentinel_primaryInverse + @style/Sentinel.Snackbar + @style/Sentinel.Snackbar.TextButton + + + + + + \ No newline at end of file diff --git a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerActivity.kt b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerActivity.kt index d3267f0..fc6a2c9 100644 --- a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerActivity.kt +++ b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerActivity.kt @@ -64,6 +64,7 @@ public class LoggerActivity : AppCompatActivity() { } ) + @Suppress("LongMethod", "CyclomaticComplexMethod") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerAdapter.kt b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerAdapter.kt index 2f49dc1..46d750f 100644 --- a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerAdapter.kt +++ b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerAdapter.kt @@ -33,4 +33,4 @@ internal class LoggerAdapter( currentList: MutableList ) = onListChanged(currentList.isEmpty()) -} \ No newline at end of file +} diff --git a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerViewHolder.kt b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerViewHolder.kt index a938a00..5be2992 100644 --- a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerViewHolder.kt +++ b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logger/LoggerViewHolder.kt @@ -58,4 +58,4 @@ internal class LoggerViewHolder( stackTraceContainer.isVisible = false root.setOnClickListener(null) } -} \ No newline at end of file +} diff --git a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logs/LogsAdapter.kt b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logs/LogsAdapter.kt index ea5b4b9..8913309 100644 --- a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logs/LogsAdapter.kt +++ b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logs/LogsAdapter.kt @@ -36,4 +36,4 @@ internal class LogsAdapter( currentList: MutableList ) = onListChanged(currentList.isEmpty()) -} \ No newline at end of file +} diff --git a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logs/LogsViewHolder.kt b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logs/LogsViewHolder.kt index c9a7acf..61c2d60 100644 --- a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logs/LogsViewHolder.kt +++ b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/logs/LogsViewHolder.kt @@ -33,4 +33,4 @@ internal class LogsViewHolder( deleteButton.setOnClickListener(null) shareButton.setOnClickListener(null) } -} \ No newline at end of file +} diff --git a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/shared/LogFileResolver.kt b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/shared/LogFileResolver.kt index 4f3964e..80144b5 100644 --- a/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/shared/LogFileResolver.kt +++ b/tool-timber/src/main/kotlin/com/infinum/sentinel/ui/shared/LogFileResolver.kt @@ -3,6 +3,7 @@ package com.infinum.sentinel.ui.shared import android.content.Context import java.io.File import java.util.Calendar +import java.util.Locale internal class LogFileResolver( private val context: Context @@ -27,11 +28,12 @@ internal class LogFileResolver( val year = Calendar.getInstance().get(Calendar.YEAR) val filename = buildString { - append(String.format("%02d", day)) + val locale = Locale.getDefault() + append(String.format(locale = locale, format = "%02d", day)) append("-") - append(String.format("%02d", month)) + append(String.format(locale = locale, format = "%02d", month)) append("-") - append(String.format("%04d", year)) + append(String.format(locale = locale, format = "%04d", year)) append(LOG_EXTENSION) } val nowFile = File("${parent.absolutePath}/$filename")