diff --git a/app-base/src/main/java/xyz/aprildown/timer/app/base/data/PreferenceData.kt b/app-base/src/main/java/xyz/aprildown/timer/app/base/data/PreferenceData.kt index 881679ec..711bd130 100644 --- a/app-base/src/main/java/xyz/aprildown/timer/app/base/data/PreferenceData.kt +++ b/app-base/src/main/java/xyz/aprildown/timer/app/base/data/PreferenceData.kt @@ -445,4 +445,7 @@ object PreferenceData { var SharedPreferences.isTtsBakeryOpen: Boolean get() = getBoolean(PREF_IS_TTS_BAKERY_OPEN, AppConfig.openDebug) set(value) = edit { putBoolean(PREF_IS_TTS_BAKERY_OPEN, value) } + + const val PREF_SUPPRESS_NOTIFICATION_CHECK = "pref_suppress_notification_permission_check" + const val PREF_SUPPRESS_BATTERY_CHECK = "pref_suppress_battery_optimization_check" } diff --git a/app-base/src/main/res/values/strings.xml b/app-base/src/main/res/values/strings.xml index 69e609a4..8a289e0d 100644 --- a/app-base/src/main/res/values/strings.xml +++ b/app-base/src/main/res/values/strings.xml @@ -830,6 +830,18 @@ System setting %d + + Enable Notifications + Timer Machine needs notification permission to show timer countdowns and alerts when timers expire. Without it, timers will run silently. + Disable Battery Optimization + Battery optimization may stop timers from running in the background. Please disable it for Timer Machine to ensure reliable timer operation. + Enable + Not now + Don\'t ask again + Reset permission reminders + Re-enable notification and battery optimization prompts + Permission reminders re-enabled + Sign out? Sign Out diff --git a/app-settings/src/main/java/xyz/aprildown/timer/app/settings/SettingsFragment.kt b/app-settings/src/main/java/xyz/aprildown/timer/app/settings/SettingsFragment.kt index a94e37c3..86315379 100644 --- a/app-settings/src/main/java/xyz/aprildown/timer/app/settings/SettingsFragment.kt +++ b/app-settings/src/main/java/xyz/aprildown/timer/app/settings/SettingsFragment.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Intent import android.content.SharedPreferences import android.os.Build +import androidx.core.content.edit import android.os.Bundle import android.provider.Settings import androidx.activity.result.contract.ActivityResultContracts @@ -12,6 +13,7 @@ import androidx.navigation.fragment.NavHostFragment import androidx.preference.ListPreference import androidx.preference.Preference import com.github.deweyreed.timer.component.tts.TtsBakery +import com.github.deweyreed.tools.anko.snackbar import com.github.deweyreed.tools.helper.IntentHelper import com.github.deweyreed.tools.helper.createChooserIntentIfDead import com.github.deweyreed.tools.helper.hasPermissions @@ -171,6 +173,14 @@ class SettingsFragment : NavHostFragment.findNavController(this) .subLevelNavigate(RBase.id.dest_about) } + KEY_RESET_PERMISSION_REMINDERS -> { + sharedPreferences.edit { + putBoolean(PreferenceData.PREF_SUPPRESS_NOTIFICATION_CHECK, false) + putBoolean(PreferenceData.PREF_SUPPRESS_BATTERY_CHECK, false) + } + (requireActivity() as MainCallback.ActivityCallback).snackbarView + .snackbar(getString(RBase.string.pref_permission_reminders_reset)) + } else -> return false } return true @@ -221,6 +231,7 @@ class SettingsFragment : } findPreference(KEY_NOTIF_SETTING)?.onPreferenceClickListener = this + findPreference(KEY_RESET_PERMISSION_REMINDERS)?.onPreferenceClickListener = this findPreference(KEY_AUDIO_VOLUME)?.run { onPreferenceClickListener = this@SettingsFragment @@ -330,6 +341,7 @@ private const val KEY_PHONE_CALL = PreferenceData.KEY_PHONE_CALL private const val KEY_WEEK_START = PreferenceData.KEY_WEEK_START private const val KEY_NOTIF_SETTING = "key_notif_setting" +private const val KEY_RESET_PERMISSION_REMINDERS = "key_reset_permission_reminders" private const val KEY_AUDIO_VOLUME = "key_audio_volume" diff --git a/app-settings/src/main/res/xml/pref_settings.xml b/app-settings/src/main/res/xml/pref_settings.xml index ffafd2b3..a58b1f7e 100644 --- a/app-settings/src/main/res/xml/pref_settings.xml +++ b/app-settings/src/main/res/xml/pref_settings.xml @@ -136,6 +136,14 @@ app:key="key_media_style_notification" app:title="@string/pref_media_style_notification" /> + + diff --git a/app-timer-list/src/main/java/xyz/aprildown/timer/app/timer/list/TimerFragment.kt b/app-timer-list/src/main/java/xyz/aprildown/timer/app/timer/list/TimerFragment.kt index ed067ca2..fbffad87 100644 --- a/app-timer-list/src/main/java/xyz/aprildown/timer/app/timer/list/TimerFragment.kt +++ b/app-timer-list/src/main/java/xyz/aprildown/timer/app/timer/list/TimerFragment.kt @@ -3,19 +3,25 @@ package xyz.aprildown.timer.app.timer.list import android.Manifest import android.content.ComponentName import android.content.Context +import android.content.Intent import android.content.ServiceConnection import android.graphics.Typeface import android.os.Build import android.os.Bundle import android.os.IBinder +import android.os.PowerManager +import android.provider.Settings import android.text.Spanned import android.text.style.StyleSpan +import android.widget.CheckBox import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.core.content.edit +import androidx.core.net.toUri import androidx.core.text.buildSpannedString import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat @@ -144,16 +150,23 @@ class TimerFragment : } } + private var hasCheckedPermissionsThisResume = false + override fun onResume() { super.onResume() ScreenWakeLock.acquireScreenWakeLock( context = requireActivity(), screenTiming = getString(RBase.string.pref_screen_timing_value_timer) ) + if (!hasCheckedPermissionsThisResume) { + hasCheckedPermissionsThisResume = true + view?.post { checkPermissions() } + } } override fun onPause() { super.onPause() + hasCheckedPermissionsThisResume = false ScreenWakeLock.releaseScreenLock( context = requireActivity(), screenTiming = getString(RBase.string.pref_screen_timing_value_timer) @@ -595,6 +608,94 @@ class TimerFragment : } } + private fun checkPermissions() { + val context = context ?: return + if (viewModel.tips.value != TipManager.TIP_NO_MORE) return + + val prefs = context.safeSharedPreference + + // Check notification permission (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + !context.hasPermissions(Manifest.permission.POST_NOTIFICATIONS) + ) { + if (!prefs.getBoolean(PreferenceData.PREF_SUPPRESS_NOTIFICATION_CHECK, false)) { + if (!shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { + postNotificationsLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + showPermissionDialog( + title = RBase.string.permission_notifications_title, + message = RBase.string.permission_notifications_message, + suppressKey = PreferenceData.PREF_SUPPRESS_NOTIFICATION_CHECK, + onEnable = { + startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + ) + } + ) + } + return + } + } + + // Check battery optimization + val pm = context.getSystemService(PowerManager::class.java) + if (pm != null && !pm.isIgnoringBatteryOptimizations(context.packageName)) { + if (!prefs.getBoolean(PreferenceData.PREF_SUPPRESS_BATTERY_CHECK, false)) { + showPermissionDialog( + title = RBase.string.permission_battery_title, + message = RBase.string.permission_battery_message, + suppressKey = PreferenceData.PREF_SUPPRESS_BATTERY_CHECK, + onEnable = { + startActivity( + @Suppress("BatteryLife") + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + .setData("package:${context.packageName}".toUri()) + ) + } + ) + } + } + } + + private fun showPermissionDialog( + @StringRes title: Int, + @StringRes message: Int, + suppressKey: String, + onEnable: () -> Unit + ) { + val context = context ?: return + val checkBox = CheckBox(context).apply { + setText(RBase.string.permission_dont_ask_again) + } + val container = android.widget.FrameLayout(context).apply { + val horizontalMargin = context.dp(24).toInt() + addView(checkBox, android.widget.FrameLayout.LayoutParams( + android.widget.FrameLayout.LayoutParams.WRAP_CONTENT, + android.widget.FrameLayout.LayoutParams.WRAP_CONTENT + ).apply { + marginStart = horizontalMargin + marginEnd = horizontalMargin + }) + } + MaterialAlertDialogBuilder(context) + .setTitle(title) + .setMessage(message) + .setView(container) + .setPositiveButton(RBase.string.permission_enable) { _, _ -> + if (checkBox.isChecked) { + context.safeSharedPreference.edit { putBoolean(suppressKey, true) } + } + onEnable() + } + .setNegativeButton(RBase.string.permission_not_now) { _, _ -> + if (checkBox.isChecked) { + context.safeSharedPreference.edit { putBoolean(suppressKey, true) } + } + } + .show() + } + private val mConnection: ServiceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { listAdapter?.setPresenter((service as MachineContract.PresenterProvider).getPresenter())