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())