diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index dff0dcdf..b74ace65 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -55,6 +55,10 @@ android:exported="false" android:foregroundServiceType="remoteMessaging" /> + + @@ -70,4 +74,5 @@ + \ No newline at end of file diff --git a/android/src/main/java/app/tauri/notification/EmbeddedPushAlarm.kt b/android/src/main/java/app/tauri/notification/EmbeddedPushAlarm.kt new file mode 100644 index 00000000..1d6cefed --- /dev/null +++ b/android/src/main/java/app/tauri/notification/EmbeddedPushAlarm.kt @@ -0,0 +1,69 @@ +package app.tauri.notification + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.SystemClock +import android.util.Log +import androidx.core.app.AlarmManagerCompat + +internal object EmbeddedPushAlarm { + private const val TAG = "EmbeddedPushAlarm" + private const val REQUEST_CODE = 0x5AB1F + + fun schedule(context: Context, delayMs: Long) { + val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val triggerAt = SystemClock.elapsedRealtime() + delayMs + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || manager.canScheduleExactAlarms()) { + AlarmManagerCompat.setExactAndAllowWhileIdle( + manager, + AlarmManager.ELAPSED_REALTIME_WAKEUP, + triggerAt, + pendingIntent(context), + ) + return + } + + manager.setAndAllowWhileIdle( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + triggerAt, + pendingIntent(context), + ) + } + + fun cancel(context: Context) { + val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + manager.cancel(pendingIntent(context)) + } + + private fun pendingIntent(context: Context): PendingIntent = + PendingIntent.getBroadcast( + context, + REQUEST_CODE, + Intent(context, EmbeddedPushAlarmReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + fun onAlarm(context: Context) { + val state = UnifiedPushStateStore(context) + if (state.activeProvider != "embedded") return + if (EmbeddedPushEndpoint.webSocketUrlForEndpoint(state.endpoint) == null) return + + try { + EmbeddedPushService.start(context) + } catch (error: Exception) { + Log.w(TAG, "Could not restart the push service from an alarm: ${error.message}") + PushDiagnostics.record(context, PushOutcome.EMBEDDED_SOCKET_FAILED) + } + } +} + +internal class EmbeddedPushAlarmReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + EmbeddedPushAlarm.onAlarm(context) + } +} diff --git a/android/src/main/java/app/tauri/notification/EmbeddedPushService.kt b/android/src/main/java/app/tauri/notification/EmbeddedPushService.kt index 5c642dc2..627003a6 100644 --- a/android/src/main/java/app/tauri/notification/EmbeddedPushService.kt +++ b/android/src/main/java/app/tauri/notification/EmbeddedPushService.kt @@ -12,6 +12,7 @@ import android.net.Network import android.os.Build import android.os.Handler import android.os.IBinder +import android.os.PowerManager import android.os.Looper import android.util.Log import java.util.concurrent.Executors @@ -37,11 +38,16 @@ class EmbeddedPushService : Service() { private var activeSubscription: Subscription? = null private var ready = false private val pushExecutor = Executors.newSingleThreadExecutor() - private var reconnect: Runnable? = null private var currentNetwork: Network? = null private var connectivity: ConnectivityManager? = null private val inFlight = mutableSetOf>() + private val wakeLock: PowerManager.WakeLock by lazy { + (getSystemService(POWER_SERVICE) as PowerManager) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG) + .apply { setReferenceCounted(false) } + } + private data class Subscription(val endpoint: String, val userId: String?, val deviceId: String?) private val networkCallback = object : ConnectivityManager.NetworkCallback() { @@ -75,8 +81,7 @@ class EmbeddedPushService : Service() { } private fun cancelReconnect() { - reconnect?.let { handler.removeCallbacks(it) } - reconnect = null + EmbeddedPushAlarm.cancel(this) } override fun onBind(intent: Intent?): IBinder? = null @@ -115,8 +120,9 @@ class EmbeddedPushService : Service() { if (socket == null) { cancelReconnect() connect(url) - } else if (ready) { - endpoint?.let { NotificationPlugin.instance?.onEmbeddedPushReady(it) } + } else { + EmbeddedPushAlarm.schedule(this, HEARTBEAT_MS) + if (ready) endpoint?.let { NotificationPlugin.instance?.onEmbeddedPushReady(it) } } return START_STICKY } @@ -126,6 +132,7 @@ class EmbeddedPushService : Service() { connectivity?.unregisterNetworkCallback(networkCallback) connectivity = null cancelReconnect() + releaseWakeLock() ready = false socket?.close(NORMAL_CLOSURE, null) socket = null @@ -136,6 +143,8 @@ class EmbeddedPushService : Service() { } private fun connect(url: String) { + if (!wakeLock.isHeld) wakeLock.acquire(CONNECT_WAKELOCK_MS) + val http = client ?: OkHttpClient.Builder() // The gateway sends its own keepalives; this catches a half-open socket. .pingInterval(PING_SECONDS, TimeUnit.SECONDS) @@ -147,19 +156,21 @@ class EmbeddedPushService : Service() { if (state.activeProvider != "embedded" || state.endpoint != endpoint) return val owner = Subscription(state.endpoint ?: return, state.pushUserId, state.pushDeviceId) val requestUrl = Request.Builder().url(url).build().url.newBuilder().addQueryParameter("since", "12h").build() - socket = http.newWebSocket(Request.Builder().url(requestUrl).build(), Listener(url, owner)) + socket = http.newWebSocket(Request.Builder().url(requestUrl).build(), Listener(owner)) } - private fun scheduleReconnect(url: String) { + private fun scheduleReconnect() { if (closing) return val delay = retryDelay retryDelay = (retryDelay * 2).coerceAtMost(MAX_BACKOFF_MS) Log.i(TAG, "Reconnecting to the push gateway in ${delay}ms") cancelReconnect() - reconnect = Runnable { - reconnect = null - if (!closing && socket == null) connect(url) - }.also { handler.postDelayed(it, delay) } + releaseWakeLock() + EmbeddedPushAlarm.schedule(this, delay) + } + + private fun releaseWakeLock() { + if (wakeLock.isHeld) wakeLock.release() } private fun owns(owner: Subscription): Boolean { @@ -224,7 +235,7 @@ class EmbeddedPushService : Service() { } } - private inner class Listener(private val url: String, private val owner: Subscription) : WebSocketListener() { + private inner class Listener(private val owner: Subscription) : WebSocketListener() { private val pending = mutableListOf>() override fun onMessage(webSocket: WebSocket, text: String) { @@ -243,6 +254,8 @@ class EmbeddedPushService : Service() { return@ready } Log.i(TAG, "Push gateway subscription ready") + releaseWakeLock() + EmbeddedPushAlarm.schedule(this@EmbeddedPushService, HEARTBEAT_MS) PushDiagnostics.record(this@EmbeddedPushService, PushOutcome.EMBEDDED_READY) ready = true retryDelay = BASE_BACKOFF_MS @@ -278,7 +291,7 @@ class EmbeddedPushService : Service() { PushDiagnostics.record(this@EmbeddedPushService, outcome) socket = null ready = false - scheduleReconnect(url) + scheduleReconnect() } } } @@ -333,6 +346,9 @@ class EmbeddedPushService : Service() { private const val FOREGROUND_ID = 0x5AB1E private const val NORMAL_CLOSURE = 1000 private const val PING_SECONDS = 30L + private const val WAKELOCK_TAG = "SableEmbeddedPush:WakeLock" + private const val CONNECT_WAKELOCK_MS = 30_000L + private const val HEARTBEAT_MS = 900_000L private const val BASE_BACKOFF_MS = 1_000L private const val MAX_BACKOFF_MS = 120_000L diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index efc2c328..29e1607a 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -6,9 +6,12 @@ import android.app.Activity import android.app.NotificationManager import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper +import android.os.PowerManager +import android.provider.Settings import android.webkit.WebView import app.tauri.PermissionState import app.tauri.annotation.Command @@ -1110,6 +1113,27 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { invoke.resolve() } + @Command + fun isIgnoringBatteryOptimizations(invoke: Invoke) { + val manager = activity.getSystemService(Context.POWER_SERVICE) as PowerManager + val result = JSObject() + result.put("ignoring", manager.isIgnoringBatteryOptimizations(activity.packageName)) + invoke.resolve(result) + } + + @Command + fun requestIgnoreBatteryOptimizations(invoke: Invoke) { + try { + activity.startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + .setData(Uri.parse("package:${activity.packageName}")), + ) + invoke.resolve() + } catch (error: Exception) { + invoke.reject(error.message ?: "Could not open the battery optimization prompt") + } + } + @Command fun takePushDiagnostics(invoke: Invoke) { val snapshot = PushDiagnostics.drain(activity.applicationContext) diff --git a/android/src/test/java/app/tauri/notification/EmbeddedPushAlarmTest.kt b/android/src/test/java/app/tauri/notification/EmbeddedPushAlarmTest.kt new file mode 100644 index 00000000..002f586c --- /dev/null +++ b/android/src/test/java/app/tauri/notification/EmbeddedPushAlarmTest.kt @@ -0,0 +1,83 @@ +package app.tauri.notification + +import android.app.AlarmManager +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class EmbeddedPushAlarmTest { + + private val context get() = RuntimeEnvironment.getApplication() + + private val alarms + get() = shadowOf(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager) + + @Test + fun schedulesAWakeupAlarmThatSurvivesDoze() { + EmbeddedPushAlarm.schedule(context, 60_000L) + + val alarm = alarms.nextScheduledAlarm + assertNotNull(alarm) + assertEquals(AlarmManager.ELAPSED_REALTIME_WAKEUP, alarm!!.type) + } + + @Test + fun cancelDropsThePendingAlarm() { + EmbeddedPushAlarm.schedule(context, 60_000L) + EmbeddedPushAlarm.cancel(context) + + assertNull(alarms.nextScheduledAlarm) + } + + @Test + fun rescheduleReplacesTheAlarmInsteadOfStacking() { + EmbeddedPushAlarm.schedule(context, 60_000L) + EmbeddedPushAlarm.schedule(context, 120_000L) + + assertEquals(1, alarms.scheduledAlarms.size) + } + + @Test + fun anAlarmForAnotherDistributorDoesNotStartTheService() { + val state = UnifiedPushStateStore(context) + state.activeProvider = "unifiedpush" + state.endpoint = ENDPOINT + + EmbeddedPushAlarm.onAlarm(context) + + assertNull(shadowOf(context).nextStartedService) + } + + @Test + fun anAlarmWithoutAnEndpointDoesNotStartTheService() { + UnifiedPushStateStore(context).activeProvider = "embedded" + + EmbeddedPushAlarm.onAlarm(context) + + assertNull(shadowOf(context).nextStartedService) + } + + @Test + fun anAlarmForTheEmbeddedDistributorRestartsTheService() { + val state = UnifiedPushStateStore(context) + state.activeProvider = "embedded" + state.endpoint = ENDPOINT + + EmbeddedPushAlarm.onAlarm(context) + + val started = shadowOf(context).nextStartedService + assertNotNull(started) + assertEquals(EmbeddedPushService::class.java.name, started.component?.className) + } + + private companion object { + const val ENDPOINT = "https://ntfy.sh/up0123456789ab?up=1" + } +} diff --git a/android/src/test/java/app/tauri/notification/EmbeddedPushServiceTest.kt b/android/src/test/java/app/tauri/notification/EmbeddedPushServiceTest.kt index 44797acb..0120ad67 100644 --- a/android/src/test/java/app/tauri/notification/EmbeddedPushServiceTest.kt +++ b/android/src/test/java/app/tauri/notification/EmbeddedPushServiceTest.kt @@ -1,8 +1,11 @@ package app.tauri.notification +import android.app.AlarmManager +import android.content.Context import android.net.ConnectivityManager import android.app.NotificationManager import android.os.Looper +import android.os.SystemClock import android.util.Base64 import com.google.crypto.tink.apps.fixed_webpush.WebPushHybridEncrypt import io.mockk.every @@ -77,6 +80,16 @@ class EmbeddedPushServiceTest { service.onStartCommand(null, 0, 1) } + private fun nextAlarmDelayMs(): Long { + val alarms = shadowOf(service.getSystemService(Context.ALARM_SERVICE) as AlarmManager) + val alarm = alarms.nextScheduledAlarm + assertNotNull(alarm) + return alarm.triggerAtTime - SystemClock.elapsedRealtime() + } + + private fun scheduledAlarmCount(): Int = + shadowOf(service.getSystemService(Context.ALARM_SERVICE) as AlarmManager).scheduledAlarms.size + private fun frame(index: Int, text: String) { val (socket, listener) = connections[index] listener.onMessage(socket, text) @@ -201,7 +214,8 @@ class EmbeddedPushServiceTest { finishWork() val (socket, listener) = connections[0] listener.onFailure(socket, IOException("offline"), null) - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(1)) + shadowOf(Looper.getMainLooper()).idle() + start() frame(1, """{"event":"open","time":300}""") finishWork() frame(1, message("A")) @@ -524,7 +538,10 @@ class EmbeddedPushServiceTest { val (socket, listener) = connections.single() listener.onFailure(socket, IOException("disconnected"), null) listener.onClosed(socket, 1000, "closed") - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(1)) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, scheduledAlarmCount()) + start() assertEquals(2, connections.size) assertEquals(1, PushDiagnostics.drain(service).counts["EMBEDDED_SOCKET_FAILED"]) @@ -537,9 +554,11 @@ class EmbeddedPushServiceTest { val (socket, listener) = connections.single() listener.onFailure(socket, IOException("disconnected"), null) shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, scheduledAlarmCount()) + service.onDestroy() - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMinutes(5)) + assertEquals(0, scheduledAlarmCount()) assertEquals(1, connections.size) } @@ -550,10 +569,12 @@ class EmbeddedPushServiceTest { val (socket, listener) = connections.last() listener.onFailure(socket, IOException("offline"), null) shadowOf(Looper.getMainLooper()).idle() + + val delay = nextAlarmDelayMs() + assertTrue(delay in 1..120_000) + val beforeRetry = connections.size - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(999)) - assertEquals(beforeRetry, connections.size) - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(119_001)) + start() assertEquals(beforeRetry + 1, connections.size) } } diff --git a/build.rs b/build.rs index 97769319..e09c9575 100644 --- a/build.rs +++ b/build.rs @@ -26,6 +26,10 @@ const COMMANDS: &[&str] = &[ "set_click_listener_active", "set_action_listener_active", "set_push_message_listener_active", + "set_encrypted_content_allowed", + "take_push_diagnostics", + "is_ignoring_battery_optimizations", + "request_ignore_battery_optimizations", "list_distributors", "set_distributor", "set_token", diff --git a/permissions/autogenerated/commands/is_ignoring_battery_optimizations.toml b/permissions/autogenerated/commands/is_ignoring_battery_optimizations.toml new file mode 100644 index 00000000..a5e08bd7 --- /dev/null +++ b/permissions/autogenerated/commands/is_ignoring_battery_optimizations.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-is-ignoring-battery-optimizations" +description = "Enables the is_ignoring_battery_optimizations command without any pre-configured scope." +commands.allow = ["is_ignoring_battery_optimizations"] + +[[permission]] +identifier = "deny-is-ignoring-battery-optimizations" +description = "Denies the is_ignoring_battery_optimizations command without any pre-configured scope." +commands.deny = ["is_ignoring_battery_optimizations"] diff --git a/permissions/autogenerated/commands/request_ignore_battery_optimizations.toml b/permissions/autogenerated/commands/request_ignore_battery_optimizations.toml new file mode 100644 index 00000000..9145ebe6 --- /dev/null +++ b/permissions/autogenerated/commands/request_ignore_battery_optimizations.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-request-ignore-battery-optimizations" +description = "Enables the request_ignore_battery_optimizations command without any pre-configured scope." +commands.allow = ["request_ignore_battery_optimizations"] + +[[permission]] +identifier = "deny-request-ignore-battery-optimizations" +description = "Denies the request_ignore_battery_optimizations command without any pre-configured scope." +commands.deny = ["request_ignore_battery_optimizations"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index cce79c7b..e4a6d948 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -35,6 +35,8 @@ It allows all notification related features. - `allow-set-push-message-listener-active` - `allow-set-encrypted-content-allowed` - `allow-take-push-diagnostics` +- `allow-is-ignoring-battery-optimizations` +- `allow-request-ignore-battery-optimizations` - `allow-list-distributors` - `allow-set-distributor` - `allow-set-token` @@ -259,6 +261,32 @@ Denies the get_pending command without any pre-configured scope. +`notifications:allow-is-ignoring-battery-optimizations` + + + + +Enables the is_ignoring_battery_optimizations command without any pre-configured scope. + + + + + + + +`notifications:deny-is-ignoring-battery-optimizations` + + + + +Denies the is_ignoring_battery_optimizations command without any pre-configured scope. + + + + + + + `notifications:allow-is-permission-granted` @@ -545,6 +573,32 @@ Denies the remove_listener command without any pre-configured scope. +`notifications:allow-request-ignore-battery-optimizations` + + + + +Enables the request_ignore_battery_optimizations command without any pre-configured scope. + + + + + + + +`notifications:deny-request-ignore-battery-optimizations` + + + + +Denies the request_ignore_battery_optimizations command without any pre-configured scope. + + + + + + + `notifications:allow-request-permission` diff --git a/permissions/default.toml b/permissions/default.toml index b31883ce..ee3826b6 100644 --- a/permissions/default.toml +++ b/permissions/default.toml @@ -37,6 +37,8 @@ permissions = [ "allow-set-push-message-listener-active", "allow-set-encrypted-content-allowed", "allow-take-push-diagnostics", + "allow-is-ignoring-battery-optimizations", + "allow-request-ignore-battery-optimizations", "allow-list-distributors", "allow-set-distributor", "allow-set-token", diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index 021e46a1..4c5480d9 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -390,6 +390,18 @@ "const": "deny-get-pending", "markdownDescription": "Denies the get_pending command without any pre-configured scope." }, + { + "description": "Enables the is_ignoring_battery_optimizations command without any pre-configured scope.", + "type": "string", + "const": "allow-is-ignoring-battery-optimizations", + "markdownDescription": "Enables the is_ignoring_battery_optimizations command without any pre-configured scope." + }, + { + "description": "Denies the is_ignoring_battery_optimizations command without any pre-configured scope.", + "type": "string", + "const": "deny-is-ignoring-battery-optimizations", + "markdownDescription": "Denies the is_ignoring_battery_optimizations command without any pre-configured scope." + }, { "description": "Enables the is_permission_granted command without any pre-configured scope.", "type": "string", @@ -522,6 +534,18 @@ "const": "deny-remove-listener", "markdownDescription": "Denies the remove_listener command without any pre-configured scope." }, + { + "description": "Enables the request_ignore_battery_optimizations command without any pre-configured scope.", + "type": "string", + "const": "allow-request-ignore-battery-optimizations", + "markdownDescription": "Enables the request_ignore_battery_optimizations command without any pre-configured scope." + }, + { + "description": "Denies the request_ignore_battery_optimizations command without any pre-configured scope.", + "type": "string", + "const": "deny-request-ignore-battery-optimizations", + "markdownDescription": "Denies the request_ignore_battery_optimizations command without any pre-configured scope." + }, { "description": "Enables the request_permission command without any pre-configured scope.", "type": "string", @@ -643,10 +667,10 @@ "markdownDescription": "Denies the unregister_for_push_notifications command without any pre-configured scope." }, { - "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-cancel-all`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-remove-all`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`\n- `allow-set-action-listener-active`\n- `allow-set-push-message-listener-active`\n- `allow-set-encrypted-content-allowed`\n- `allow-take-push-diagnostics`\n- `allow-list-distributors`\n- `allow-set-distributor`\n- `allow-set-token`", + "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-cancel-all`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-remove-all`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`\n- `allow-set-action-listener-active`\n- `allow-set-push-message-listener-active`\n- `allow-set-encrypted-content-allowed`\n- `allow-take-push-diagnostics`\n- `allow-is-ignoring-battery-optimizations`\n- `allow-request-ignore-battery-optimizations`\n- `allow-list-distributors`\n- `allow-set-distributor`\n- `allow-set-token`", "type": "string", "const": "default", - "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-cancel-all`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-remove-all`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`\n- `allow-set-action-listener-active`\n- `allow-set-push-message-listener-active`\n- `allow-set-encrypted-content-allowed`\n- `allow-take-push-diagnostics`\n- `allow-list-distributors`\n- `allow-set-distributor`\n- `allow-set-token`" + "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-cancel-all`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-remove-all`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`\n- `allow-set-action-listener-active`\n- `allow-set-push-message-listener-active`\n- `allow-set-encrypted-content-allowed`\n- `allow-take-push-diagnostics`\n- `allow-is-ignoring-battery-optimizations`\n- `allow-request-ignore-battery-optimizations`\n- `allow-list-distributors`\n- `allow-set-distributor`\n- `allow-set-token`" } ] } diff --git a/src/commands.rs b/src/commands.rs index 54ff6ba8..b66977bd 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -203,6 +203,28 @@ pub async fn set_encrypted_content_allowed( return notification.set_encrypted_content_allowed(allowed); } +#[command] +pub async fn is_ignoring_battery_optimizations( + _app: AppHandle, + notification: State<'_, Notifications>, +) -> Result { + #[cfg(mobile)] + return notification.is_ignoring_battery_optimizations().await; + #[cfg(desktop)] + return notification.is_ignoring_battery_optimizations(); +} + +#[command] +pub async fn request_ignore_battery_optimizations( + _app: AppHandle, + notification: State<'_, Notifications>, +) -> Result<()> { + #[cfg(mobile)] + return notification.request_ignore_battery_optimizations().await; + #[cfg(desktop)] + return notification.request_ignore_battery_optimizations(); +} + #[command] pub async fn take_push_diagnostics( _app: AppHandle, diff --git a/src/desktop.rs b/src/desktop.rs index 5295be3e..50084476 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -402,6 +402,14 @@ impl Notifications { } #[allow(clippy::unused_self)] + pub fn is_ignoring_battery_optimizations(&self) -> crate::Result { + Ok(true) + } + + pub fn request_ignore_battery_optimizations(&self) -> crate::Result<()> { + Ok(()) + } + pub fn take_push_diagnostics(&self) -> crate::Result { Ok(crate::models::PushDiagnostics::default()) } diff --git a/src/lib.rs b/src/lib.rs index 80948df4..31ee1d37 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -331,6 +331,8 @@ pub fn init() -> TauriPlugin> { commands::set_push_message_listener_active, commands::set_encrypted_content_allowed, commands::take_push_diagnostics, + commands::is_ignoring_battery_optimizations, + commands::request_ignore_battery_optimizations, commands::remove_active, commands::remove_all, commands::cancel, diff --git a/src/macos.rs b/src/macos.rs index 19959ac9..f13484ed 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -337,6 +337,14 @@ impl Notifications { } #[allow(clippy::unused_self)] + pub fn is_ignoring_battery_optimizations(&self) -> crate::Result { + Ok(true) + } + + pub fn request_ignore_battery_optimizations(&self) -> crate::Result<()> { + Ok(()) + } + pub fn take_push_diagnostics(&self) -> crate::Result { Ok(crate::models::PushDiagnostics::default()) } diff --git a/src/mobile.rs b/src/mobile.rs index c75a3521..5f48513a 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -321,6 +321,38 @@ impl Notifications { } } + pub async fn is_ignoring_battery_optimizations(&self) -> crate::Result { + #[cfg(all(target_os = "android", feature = "push-notifications"))] + { + self.0 + .run_mobile_plugin_async::( + "isIgnoringBatteryOptimizations", + (), + ) + .await + .map(|r| r.ignoring) + .map_err(Into::into) + } + #[cfg(not(all(target_os = "android", feature = "push-notifications")))] + { + Ok(true) + } + } + + pub async fn request_ignore_battery_optimizations(&self) -> crate::Result<()> { + #[cfg(all(target_os = "android", feature = "push-notifications"))] + { + self.0 + .run_mobile_plugin_async::<()>("requestIgnoreBatteryOptimizations", ()) + .await + .map_err(Into::into) + } + #[cfg(not(all(target_os = "android", feature = "push-notifications")))] + { + Ok(()) + } + } + pub async fn take_push_diagnostics(&self) -> crate::Result { #[cfg(target_os = "android")] { diff --git a/src/models.rs b/src/models.rs index 46d5b762..37783617 100644 --- a/src/models.rs +++ b/src/models.rs @@ -41,6 +41,12 @@ pub struct DistributorsResponse { pub distributors: Vec, } +#[cfg(all(target_os = "android", feature = "push-notifications"))] +#[derive(Debug, Deserialize)] +pub struct BatteryOptimizationResponse { + pub ignoring: bool, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PushDiagnostics { diff --git a/src/windows.rs b/src/windows.rs index 3f81a857..56cf573e 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -1071,6 +1071,14 @@ impl Notifications { } #[allow(clippy::unused_self)] + pub fn is_ignoring_battery_optimizations(&self) -> crate::Result { + Ok(true) + } + + pub fn request_ignore_battery_optimizations(&self) -> crate::Result<()> { + Ok(()) + } + pub fn take_push_diagnostics(&self) -> crate::Result { Ok(crate::models::PushDiagnostics::default()) }