request schedule_exact_alarm permissions

This commit is contained in:
Hunter Kehoe 2025-03-20 22:17:33 -06:00
parent 6f907a28cd
commit 5ecd84855f
9 changed files with 198 additions and 3 deletions

View file

@ -110,6 +110,7 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"/>
</intent-filter>
</receiver>

View file

@ -342,6 +342,16 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
.apply()
}
fun getWebSocketReconnectRemindTime(): Long {
return sharedPrefs.getLong(SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME, WEBSOCKET_RECONNECT_REMIND_TIME_ALWAYS)
}
fun setWebSocketReconnectRemindTime(timeMillis: Long) {
sharedPrefs.edit()
.putLong(SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME, timeMillis)
.apply()
}
fun getDefaultBaseUrl(): String? {
return sharedPrefs.getString(SHARED_PREFS_DEFAULT_BASE_URL, null) ?:
sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null) // Fall back to UP URL, removed when default is set!
@ -492,6 +502,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs"
const val SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME = "BatteryOptimizationsRemindTime"
const val SHARED_PREFS_WEBSOCKET_REMIND_TIME = "JsonStreamRemindTime" // "Use WebSocket" banner (used to be JSON stream deprecation banner)
const val SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME = "WebSocketReconnectRemindTime"
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" // Legacy key required for migration to DefaultBaseURL
const val SHARED_PREFS_DEFAULT_BASE_URL = "DefaultBaseURL"
const val SHARED_PREFS_LAST_TOPICS = "LastTopics"
@ -532,6 +543,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
const val WEBSOCKET_REMIND_TIME_ALWAYS = 1L
const val WEBSOCKET_REMIND_TIME_NEVER = Long.MAX_VALUE
const val WEBSOCKET_RECONNECT_REMIND_TIME_ALWAYS = 1L
const val WEBSOCKET_RECONNECT_REMIND_TIME_NEVER = Long.MAX_VALUE
private const val TAG = "NtfyRepository"
private var instance: Repository? = null

View file

@ -114,7 +114,27 @@ class WsConnection(
Log.d(TAG,"$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via alarm manager)")
val reconnectTime = Calendar.getInstance()
reconnectTime.add(Calendar.SECOND, seconds)
alarmManager.setExact(AlarmManager.RTC_WAKEUP, reconnectTime.timeInMillis, RECONNECT_TAG, { start() }, null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
reconnectTime.timeInMillis,
RECONNECT_TAG,
{ start() },
null
)
} else {
Log.d(TAG, "SCHEDULE_EXACT_ALARM permission denied: Failed to reschedule websocket connection")
}
} else {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
reconnectTime.timeInMillis,
RECONNECT_TAG,
{ start() },
null
)
}
} else {
Log.d(TAG, "$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via handler)")
val handler = Handler(Looper.getMainLooper())

View file

@ -3,6 +3,7 @@ package io.heckel.ntfy.ui
import android.Manifest
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.AlarmManager
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Intent
@ -11,6 +12,7 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.text.method.LinkMovementMethod
import android.view.ActionMode
import android.view.Menu
@ -125,9 +127,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
Log.addScrubTerm(s.topic)
}
// Update banner + WebSocket banner
// Update battery banner + WebSocket banner + websocket reconnect banner
showHideBatteryBanner(subscriptions)
showHideWebSocketBanner(subscriptions)
showHideWebSocketReconnectBanner(subscriptions)
}
}
@ -194,6 +197,38 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
repository.setConnectionProtocol(Repository.CONNECTION_PROTOCOL_WS)
SubscriberServiceManager(this).restart()
wsBanner.visibility = View.GONE
// maybe show WebSocketReconnectBanner
viewModel.list().observe(this) {
it?.let { subscriptions ->
showHideWebSocketReconnectBanner(subscriptions)
}
}
}
// WebSocket Reconnect banner
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val wsReconnectBanner = findViewById<View>(R.id.main_banner_websocket_reconnect)
val wsReconnectText = findViewById<TextView>(R.id.main_banner_websocket_reconnect_text)
val wsReconnectDismissButton =
findViewById<Button>(R.id.main_banner_websocket_reconnect_dontaskagain)
val wsReconnectRemindButton =
findViewById<Button>(R.id.main_banner_websocket_reconnect_remind_later)
val wsReconnectEnableButton =
findViewById<Button>(R.id.main_banner_websocket_reconnect_enable)
wsReconnectText.movementMethod =
LinkMovementMethod.getInstance() // Make links clickable
wsReconnectDismissButton.setOnClickListener {
wsReconnectBanner.visibility = View.GONE
repository.setWebSocketReconnectRemindTime(Repository.WEBSOCKET_RECONNECT_REMIND_TIME_NEVER)
}
wsReconnectRemindButton.setOnClickListener {
wsReconnectBanner.visibility = View.GONE
repository.setWebSocketReconnectRemindTime(System.currentTimeMillis() + ONE_DAY_MILLIS)
}
wsReconnectEnableButton.setOnClickListener {
startActivity(Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
}
// Create notification channels right away, so we can configure them immediately after installing the app
@ -248,6 +283,24 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
wsBanner.visibility = if (showBanner) View.VISIBLE else View.GONE
}
private fun showHideWebSocketReconnectBanner(subscriptions: List<Subscription>) {
val wsReconnectBanner = findViewById<View>(R.id.main_banner_websocket_reconnect)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val hasSelfHostedSubscriptions = subscriptions.count { it.baseUrl != appBaseUrl } > 0
val usingWebSockets =
repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS
val wsReconnectRemindTimeReached =
repository.getWebSocketReconnectRemindTime() < System.currentTimeMillis()
val canScheduleExactAlarms =
(getSystemService(ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms()
val showBanner = hasSelfHostedSubscriptions && wsReconnectRemindTimeReached && usingWebSockets && !canScheduleExactAlarms
Log.d(TAG, "hasSelfHostedSubscriptions: ${hasSelfHostedSubscriptions}, wsReconnectRemindTimeReached: ${wsReconnectRemindTimeReached}, usingWebSockets: ${usingWebSockets}, canScheduleExactAlarms: ${canScheduleExactAlarms}")
wsReconnectBanner.visibility = if (showBanner) View.VISIBLE else View.GONE
} else {
wsReconnectBanner.visibility = View.GONE
}
}
private fun schedulePeriodicPollWorker() {
val workerVersion = repository.getPollWorkerVersion()
val workPolicy = if (workerVersion == PollWorker.VERSION) {

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.ui
import android.Manifest
import android.app.AlarmManager
import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
@ -10,6 +11,7 @@ import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.text.TextUtils
import android.widget.Button
import android.widget.Toast
@ -119,6 +121,11 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
return true
}
override fun onResume() {
super.onResume()
settingsFragment.updateExactAlarmsPref()
}
class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var repository: Repository
private lateinit var serviceManager: SubscriberServiceManager
@ -545,6 +552,9 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
}
}
// CanScheduleExactAlarms
updateExactAlarmsPref()
// Version
val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return
val versionPref: Preference? = findPreference(versionPrefId)
@ -678,6 +688,23 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
}
}
fun updateExactAlarmsPref() {
val exactAlarmsPrefId = context?.getString(R.string.settings_advanced_exact_alarms_key) ?: return
val exactAlarmsPref: Preference? = findPreference(exactAlarmsPrefId)
val canScheduleExactAlarms = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
(activity?.getSystemService(ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms()
} else {
true
}
exactAlarmsPref?.summary = if (canScheduleExactAlarms) getString(R.string.settings_advanced_exact_alarms_true) else getString(R.string.settings_advanced_exact_alarms_false)
exactAlarmsPref?.onPreferenceClickListener = OnPreferenceClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !canScheduleExactAlarms) {
startActivity(Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
true
}
}
@Keep
data class NopasteResponse(val url: String)
}

View file

@ -148,6 +148,73 @@
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:shapeAppearance="?shapeAppearanceLargeComponent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/main_banner_websocket"
android:id="@+id/main_banner_websocket_reconnect" android:visibility="visible">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/main_banner_websocket_reconnect_constraint" android:elevation="5dp">
<ImageView
android:layout_width="28dp"
android:layout_height="28dp" app:srcCompat="@drawable/ic_announcement_orange_24dp"
android:id="@+id/main_banner_websocket_reconnect_image"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/main_banner_websocket_reconnect_text"
app:layout_constraintEnd_toStartOf="@id/main_banner_websocket_reconnect_text"
app:layout_constraintBottom_toBottomOf="@+id/main_banner_websocket_reconnect_text"
android:layout_marginStart="15dp"/>
<TextView
android:id="@+id/main_banner_websocket_reconnect_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/main_banner_websocket_reconnect_text"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginEnd="15dp" android:layout_marginTop="15dp"
app:layout_constraintStart_toEndOf="@+id/main_banner_websocket_reconnect_image"
android:layout_marginStart="10dp"
/>
<androidx.constraintlayout.helper.widget.Flow
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:constraint_referenced_ids="main_banner_websocket_reconnect_remind_later,main_banner_websocket_reconnect_dontaskagain,main_banner_websocket_reconnect_enable" app:layout_constraintTop_toBottomOf="@id/main_banner_websocket_reconnect_text" app:flow_horizontalAlign="end" app:flow_wrapMode="chain" app:flow_horizontalStyle="packed" android:layout_marginEnd="15dp" android:id="@+id/flow_reconnect" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="15dp" app:flow_horizontalBias="1"
app:flow_verticalGap="0dp" app:flow_horizontalGap="0dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/main_banner_websocket_reconnect_remind_later"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/main_banner_websocket_reconnect_button_remind_later"
tools:layout_editor_absoluteX="86dp" tools:layout_editor_absoluteY="83dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/main_banner_websocket_reconnect_dontaskagain"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/main_banner_websocket_reconnect_button_dismiss"
tools:layout_editor_absoluteX="260dp" tools:layout_editor_absoluteY="83dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/main_banner_websocket_reconnect_enable"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/main_banner_websocket_reconnect_button_enable_now"
tools:layout_editor_absoluteX="253dp" tools:layout_editor_absoluteY="131dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/main_subscriptions_list_container"
android:layout_width="match_parent"
@ -155,7 +222,7 @@
android:visibility="visible"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_banner_websocket">
app:layout_constraintTop_toBottomOf="@id/main_banner_websocket_reconnect">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/main_subscriptions_list"
android:layout_width="match_parent"

View file

@ -82,6 +82,12 @@
<string name="main_banner_websocket_button_dismiss">Dismiss</string>
<string name="main_banner_websocket_button_enable_now">Enable now</string>
<!-- Main activity: WebSocket Reconnect banner -->
<string name="main_banner_websocket_reconnect_text">To ensure WebSockets reconnect in the background, grant the "Alarm &amp; Reminders" permission to ntfy</string>
<string name="main_banner_websocket_reconnect_button_remind_later">Ask later</string>
<string name="main_banner_websocket_reconnect_button_dismiss">Dismiss</string>
<string name="main_banner_websocket_reconnect_button_enable_now">Grant now</string>
<!-- Add dialog -->
<string name="add_dialog_title">Subscribe to topic</string>
<string name="add_dialog_description_below">
@ -347,6 +353,9 @@
<string name="settings_advanced_connection_protocol_summary_ws">Use WebSockets to connect to the server. This is the recommended method, but may require additional config in your proxy.</string>
<string name="settings_advanced_connection_protocol_entry_jsonhttp">JSON stream over HTTP</string>
<string name="settings_advanced_connection_protocol_entry_ws">WebSockets</string>
<string name="settings_advanced_exact_alarms_title">Exact Alarms</string>
<string name="settings_advanced_exact_alarms_true">ntfy can schedule exact alarms (used to reconnect WebSockets in the background)</string>
<string name="settings_advanced_exact_alarms_false">ntfy CANNOT schedule exact alarms. Click to grant the permission now</string>
<string name="settings_about_header">About</string>
<string name="settings_about_version_title">Version</string>
<string name="settings_about_version_format">ntfy %1$s (%2$s)</string>

View file

@ -30,6 +30,7 @@
<string name="settings_advanced_export_logs_key" translatable="false">ExportLogs</string>
<string name="settings_advanced_clear_logs_key" translatable="false">ClearLogs</string>
<string name="settings_advanced_connection_protocol_key" translatable="false">ConnectionProtocol</string>
<string name="settings_advanced_exact_alarms_key" translatable="false">ExactAlarms</string>
<string name="settings_about_version_key" translatable="false">Version</string>
<!-- Detail settings constants -->

View file

@ -72,6 +72,9 @@
app:entries="@array/settings_advanced_connection_protocol_entries"
app:entryValues="@array/settings_advanced_connection_protocol_values"
app:defaultValue="jsonhttp"/>
<Preference
app:key="@string/settings_advanced_exact_alarms_key"
app:title="@string/settings_advanced_exact_alarms_title"/>
<SwitchPreference
app:key="@string/settings_advanced_broadcast_key"
app:title="@string/settings_advanced_broadcast_title"