Skip to content

Commit d550e70

Browse files
authored
Merge pull request #26 from SableClient/fix/embedded-push-diagnostics
fix: record built-in push connection diagnostics
2 parents a885163 + 56e2980 commit d550e70

5 files changed

Lines changed: 73 additions & 3 deletions

File tree

android/src/main/java/app/tauri/notification/EmbeddedPushService.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class EmbeddedPushService : Service() {
5252
}
5353

5454
if (endpoint != nextEndpoint) {
55+
PushDiagnostics.record(this, PushOutcome.EMBEDDED_STARTED)
5556
handler.removeCallbacksAndMessages(null)
5657
socket?.cancel()
5758
socket = null
@@ -110,14 +111,18 @@ class EmbeddedPushService : Service() {
110111
}
111112
if (event == "open") {
112113
Log.i(TAG, "Push gateway subscription ready")
114+
PushDiagnostics.record(this@EmbeddedPushService, PushOutcome.EMBEDDED_READY)
113115
ready = true
114116
retryDelay = BASE_BACKOFF_MS
115117
endpoint?.let { NotificationPlugin.instance?.onEmbeddedPushReady(it) }
116118
return@post
117119
}
120+
if (event != "message") return@post
121+
PushDiagnostics.record(this@EmbeddedPushService, PushOutcome.EMBEDDED_MESSAGE_RECEIVED)
118122
val sealed = EmbeddedPushEndpoint.pushBody(text) ?: return@post
119123
pushExecutor.execute {
120124
val body = decrypt(sealed) ?: return@execute
125+
PushDiagnostics.record(this@EmbeddedPushService, PushOutcome.EMBEDDED_DECRYPTED)
121126
runCatching { UnifiedPushNotifier.showFromPush(this@EmbeddedPushService, body) }
122127
.onFailure { Log.w(TAG, "Could not display the push notification") }
123128
handler.post {
@@ -130,20 +135,21 @@ class EmbeddedPushService : Service() {
130135
}
131136

132137
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
133-
disconnected(webSocket)
138+
disconnected(webSocket, if (response != null) PushOutcome.EMBEDDED_HTTP_REJECTED else PushOutcome.EMBEDDED_SOCKET_FAILED)
134139
}
135140

136141
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
137142
webSocket.close(code, null)
138143
}
139144

140145
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
141-
disconnected(webSocket)
146+
disconnected(webSocket, PushOutcome.EMBEDDED_CLOSED)
142147
}
143148

144-
private fun disconnected(webSocket: WebSocket) {
149+
private fun disconnected(webSocket: WebSocket, outcome: PushOutcome) {
145150
handler.post {
146151
if (closing || socket !== webSocket) return@post
152+
PushDiagnostics.record(this@EmbeddedPushService, outcome)
147153
socket = null
148154
ready = false
149155
scheduleReconnect(url)
@@ -158,6 +164,7 @@ class EmbeddedPushService : Service() {
158164
// A plaintext relay is used for testing; not a failure worth logging.
159165
if (sealed.isNotEmpty() && sealed[0] == '{'.code.toByte()) return String(sealed)
160166

167+
PushDiagnostics.record(this, PushOutcome.EMBEDDED_DECRYPT_FAILED)
161168
Log.w(TAG, "Could not decrypt the push body")
162169
return null
163170
}

android/src/main/java/app/tauri/notification/NotificationPlugin.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,9 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
946946
if (registration.phase == PushRegistrationPhase.UNIFIED_PUSH || registration.phase == PushRegistrationPhase.DISTRIBUTOR) {
947947
retireUnifiedPush(registration.instance)
948948
}
949+
if (registration.phase == PushRegistrationPhase.EMBEDDED) {
950+
PushDiagnostics.record(activity, PushOutcome.EMBEDDED_REGISTRATION_TIMEOUT)
951+
}
949952
finishPushRegistrationError("Timed out registering for push notifications")
950953
}
951954
}

android/src/main/java/app/tauri/notification/PushDiagnostics.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ internal enum class PushOutcome {
1111
NO_NATIVE_LIB,
1212
DECRYPT_FAILED,
1313
EMPTY_BODY,
14+
EMBEDDED_STARTED,
15+
EMBEDDED_READY,
16+
EMBEDDED_SOCKET_FAILED,
17+
EMBEDDED_HTTP_REJECTED,
18+
EMBEDDED_CLOSED,
19+
EMBEDDED_MESSAGE_RECEIVED,
20+
EMBEDDED_DECRYPTED,
21+
EMBEDDED_DECRYPT_FAILED,
22+
EMBEDDED_REGISTRATION_TIMEOUT,
1423
}
1524

1625
internal data class PushDiagnosticsSnapshot(
@@ -25,6 +34,7 @@ internal object PushDiagnostics {
2534
private const val KEY_LAST = "push-outcome-last"
2635
private const val KEY_LAST_AT = "push-outcome-last-at"
2736

37+
@Synchronized
2838
fun record(context: Context, outcome: PushOutcome) {
2939
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
3040
val key = KEY_PREFIX + outcome.name
@@ -35,6 +45,7 @@ internal object PushDiagnostics {
3545
.apply()
3646
}
3747

48+
@Synchronized
3849
fun drain(context: Context): PushDiagnosticsSnapshot {
3950
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
4051
val counts = PushOutcome.entries.associate { outcome ->

android/src/test/java/app/tauri/notification/EmbeddedPushRegistrationTest.kt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
package app.tauri.notification
22

33
import android.app.Activity
4+
import android.content.ComponentName
5+
import android.content.IntentFilter
46
import android.os.Looper
57
import app.tauri.plugin.Invoke
68
import app.tauri.plugin.JSObject
79
import io.mockk.every
810
import io.mockk.just
911
import io.mockk.mockk
12+
import io.mockk.mockkObject
13+
import io.mockk.unmockkObject
1014
import io.mockk.Runs
1115
import io.mockk.spyk
1216
import io.mockk.verify
1317
import java.time.Duration
18+
import kotlin.test.assertFalse
1419
import kotlin.test.assertEquals
1520
import kotlin.test.assertNotEquals
1621
import kotlin.test.assertTrue
@@ -34,9 +39,14 @@ class EmbeddedPushRegistrationTest {
3439
@Before
3540
fun setup() {
3641
assumeTrue(BuildConfig.ENABLE_PUSH_NOTIFICATIONS)
42+
mockkObject(CachedKeyManager.Companion)
43+
every { CachedKeyManager.getInstance(any()) } returns mockk(relaxed = true) {
44+
every { getPublicKeySet(any()) } returns null
45+
}
3746
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
3847
state = UnifiedPushStateStore(activity)
3948
state.useEmbeddedDistributor = true
49+
PushDiagnostics.drain(activity)
4050
plugin = spyk(NotificationPlugin(activity))
4151
every { plugin.trigger(any(), any<JSObject>()) } just Runs
4252
val manager = mockk<TauriNotificationManager>()
@@ -55,6 +65,7 @@ class EmbeddedPushRegistrationTest {
5565
@After
5666
fun teardown() {
5767
if (::plugin.isInitialized) plugin.onDestroy()
68+
unmockkObject(CachedKeyManager.Companion)
5869
}
5970

6071
@Test
@@ -94,10 +105,40 @@ class EmbeddedPushRegistrationTest {
94105
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(30))
95106
plugin.onEmbeddedPushReady(state.endpoint!!)
96107

108+
assertEquals(1, PushDiagnostics.drain(org.robolectric.RuntimeEnvironment.getApplication()).counts["EMBEDDED_REGISTRATION_TIMEOUT"])
97109
verify(exactly = 1) { invoke.reject("Timed out registering for push notifications") }
98110
verify(exactly = 0) { invoke.resolve(any<JSObject>()) }
99111
}
100112

113+
@Test
114+
fun switchesFromBuiltInToExternalDistributor() {
115+
val application = org.robolectric.RuntimeEnvironment.getApplication()
116+
val component = ComponentName("io.heckel.ntfy", "PushReceiver")
117+
val packages = shadowOf(application.packageManager)
118+
packages.addReceiverIfNotPresent(component).exported = true
119+
packages.addIntentFilterForReceiver(
120+
component, IntentFilter("org.unifiedpush.android.distributor.REGISTER"),
121+
)
122+
assertEquals(listOf("io.heckel.ntfy"), org.unifiedpush.android.connector.UnifiedPush.getDistributors(application))
123+
plugin.registerForPushNotifications(invoke)
124+
plugin.onEmbeddedPushReady(state.endpoint!!)
125+
val selection = mockk<Invoke>(relaxed = true)
126+
every { selection.parseArgs(DistributorArgs::class.java) } returns DistributorArgs().apply {
127+
distributor = "io.heckel.ntfy"
128+
}
129+
plugin.setDistributor(selection)
130+
assertFalse(state.useEmbeddedDistributor)
131+
plugin.registerForPushNotifications(invoke)
132+
verify(exactly = 0) { invoke.reject(any<String>()) }
133+
val externalEndpoint = "https://ntfy.sh/up0123456789ab?up=1"
134+
plugin.onUnifiedPushNewEndpoint(externalEndpoint, "external-key", "external-auth", UnifiedPushStateStore.INSTANCE)
135+
136+
assertEquals("unifiedpush", state.activeProvider)
137+
assertEquals("io.heckel.ntfy", state.distributor)
138+
assertEquals(externalEndpoint, state.endpoint)
139+
verify { invoke.resolve(match<JSObject> { it.optString("deviceToken") == externalEndpoint }) }
140+
}
141+
101142
@Test
102143
fun reusesCompatibleTopic() {
103144
state.embeddedTopic = "up0123456789ab"

android/src/test/java/app/tauri/notification/EmbeddedPushServiceTest.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class EmbeddedPushServiceTest {
4242
state.endpoint = endpoint
4343
plugin = mockk(relaxed = true)
4444
NotificationPlugin.instance = plugin
45+
PushDiagnostics.drain(service)
4546
val client = mockk<OkHttpClient>(relaxed = true)
4647
every { client.newWebSocket(any(), any()) } answers {
4748
val socket = mockk<WebSocket>(relaxed = true)
@@ -78,11 +79,13 @@ class EmbeddedPushServiceTest {
7879
listener.onOpen(socket, mockk())
7980
shadowOf(Looper.getMainLooper()).idle()
8081
verify(exactly = 0) { plugin.onEmbeddedPushReady(any()) }
82+
assertEquals(mapOf("EMBEDDED_STARTED" to 1), PushDiagnostics.drain(service).counts)
8183

8284
frame(0, """{"event":"open"}""")
8385

8486
verify(exactly = 1) { plugin.onEmbeddedPushReady(endpoint) }
8587
assertEquals(listOf("https://ntfy.sh/up0123456789ab/ws"), urls)
88+
assertEquals(mapOf("EMBEDDED_READY" to 1), PushDiagnostics.drain(service).counts)
8689
}
8790

8891
@Test
@@ -107,6 +110,9 @@ class EmbeddedPushServiceTest {
107110
executor.submit {}.get(5, TimeUnit.SECONDS)
108111
shadowOf(Looper.getMainLooper()).idle()
109112
verify(exactly = 1) { plugin.onUnifiedPushMessage(body, UnifiedPushStateStore.INSTANCE) }
113+
val counts = PushDiagnostics.drain(service).counts
114+
assertEquals(1, counts["EMBEDDED_MESSAGE_RECEIVED"])
115+
assertEquals(1, counts["EMBEDDED_DECRYPTED"])
110116
}
111117

112118
@Test
@@ -145,6 +151,8 @@ class EmbeddedPushServiceTest {
145151
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(1))
146152

147153
assertEquals(2, connections.size)
154+
assertEquals(1, PushDiagnostics.drain(service).counts["EMBEDDED_SOCKET_FAILED"])
155+
assertEquals(emptyMap(), PushDiagnostics.drain(service).counts)
148156
}
149157

150158
@Test

0 commit comments

Comments
 (0)