Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
android:exported="false"
android:foregroundServiceType="remoteMessaging" />

<receiver
android:name="app.tauri.notification.EmbeddedPushAlarmReceiver"
android:exported="false" />

<receiver
android:name="app.tauri.notification.EmbeddedPushBootReceiver"
android:exported="false">
Expand All @@ -70,4 +74,5 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SHORT_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
</manifest>
69 changes: 69 additions & 0 deletions android/src/main/java/app/tauri/notification/EmbeddedPushAlarm.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
42 changes: 29 additions & 13 deletions android/src/main/java/app/tauri/notification/EmbeddedPushService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Pair<Subscription, String>>()

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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -126,6 +132,7 @@ class EmbeddedPushService : Service() {
connectivity?.unregisterNetworkCallback(networkCallback)
connectivity = null
cancelReconnect()
releaseWakeLock()
ready = false
socket?.close(NORMAL_CLOSURE, null)
socket = null
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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<Pair<String, JSONObject>>()

override fun onMessage(webSocket: WebSocket, text: String) {
Expand All @@ -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
Expand Down Expand Up @@ -278,7 +291,7 @@ class EmbeddedPushService : Service() {
PushDiagnostics.record(this@EmbeddedPushService, outcome)
socket = null
ready = false
scheduleReconnect(url)
scheduleReconnect()
}
}
}
Expand Down Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions android/src/main/java/app/tauri/notification/NotificationPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading