diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 18b8637..e71a8f4 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -1,9 +1,9 @@ name: Android checks on: pull_request: - branches: [Play-Dev, Direct-Dev, Play-Distro, Direct-Distro] + branches: [Play-Dev, Play-Distro] push: - branches: [Play-Dev, Direct-Dev, Play-Distro, Direct-Distro] + branches: [Play-Dev, Play-Distro] permissions: contents: read jobs: @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - flavor: [Direct, Play] + flavor: [Play] steps: - uses: actions/checkout@v7 - uses: actions/setup-java@v4 diff --git a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt index 2e4b43d..7ecf186 100644 --- a/app/src/main/java/com/starpilot/galaxy/SentryPush.kt +++ b/app/src/main/java/com/starpilot/galaxy/SentryPush.kt @@ -22,10 +22,22 @@ object SentryPush { val id = p.getString("fcmToken", "").orEmpty().takeIf { it.isNotEmpty() }?.let { java.security.MessageDigest.getInstance("SHA-256").digest(it.toByteArray()).joinToString("") { byte -> "%02x".format(byte) }.take(12) }.orEmpty() - return JSONObject().put("id", id).put("available", FirebaseRegistration.available).put("enabled", p.getBoolean("sentryPushEnabled", false)) - .put("registered", p.getString("fcmRegisteredOrigin", "").orEmpty().isNotEmpty()) - .put("status", p.getString("fcmAcknowledgement", "Enable phone alerts, then enroll the relay in Galaxy → Sentry.")).toString() + val enabled = p.getBoolean("sentryPushEnabled", false) + val registered = enabled && p.getString("fcmRegisteredOrigin", "").orEmpty().isNotEmpty() + val acknowledgement = p.getString("fcmAcknowledgement", "").orEmpty() + val pendingRemoval = p.all.any { (key, value) -> key.startsWith("fcmSubscription:") && value is String && value.isNotEmpty() } + val status = when { + !enabled -> if (pendingRemoval) "Alerts off. Reconnect to comma to remove this phone." else "Alerts off" + !permitted(ctx) -> "Allow notifications in Android Settings to receive Sentry alerts." + !FirebaseRegistration.available -> "Polling only: Firebase is not configured in this build" + acknowledgement.startsWith("Registration failed") -> "Couldn't register this phone. Reconnect to comma." + registered -> acknowledgement.ifEmpty { "Registered" } + else -> "Registering this phone…" + } + return JSONObject().put("id", id).put("available", FirebaseRegistration.available).put("enabled", enabled) + .put("registered", registered).put("status", status).toString() } + fun enable(ctx: Context, on: Boolean): Boolean { if (on && !permitted(ctx)) return false val p = prefs(ctx) @@ -37,9 +49,14 @@ object SentryPush { FirebaseRegistration.stop(ctx) SentryPollJob.cancel(ctx) val token = p.getString("fcmToken", "").orEmpty() - val current = api - p.edit().remove("fcmRegisteredOrigin").apply() - Thread { runCatching { current?.request("/api/sentry/notifications", JSONObject().put("action", "appUnsubscribe").put("token", token)) } }.start() + val registeredOrigin = p.getString("fcmRegisteredOrigin", "").orEmpty() + p.edit().apply { + // Preserve older registrations that predate per-origin subscription tracking. + if (registeredOrigin.isNotEmpty() && token.isNotEmpty() && !p.contains("fcmSubscription:$registeredOrigin")) + putString("fcmSubscription:$registeredOrigin", token) + remove("fcmRegisteredOrigin") + }.apply() + api?.let { registerIfNeeded(ctx, it) } } return true } diff --git a/app/src/test/java/com/starpilot/galaxy/SentryPushStateTest.kt b/app/src/test/java/com/starpilot/galaxy/SentryPushStateTest.kt new file mode 100644 index 0000000..1cc0a65 --- /dev/null +++ b/app/src/test/java/com/starpilot/galaxy/SentryPushStateTest.kt @@ -0,0 +1,91 @@ +package com.starpilot.galaxy + +import android.Manifest +import android.content.Context +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SentryPushStateTest { + private val context get() = RuntimeEnvironment.getApplication() + private val prefs get() = context.getSharedPreferences("companion", Context.MODE_PRIVATE) + private fun state() = JSONObject(SentryPush.state(context)) + @Before fun setup() { + prefs.edit().clear().commit() + SentryPush.api = null + SentryPush.suspended = false + shadowOf(context).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + } + @After fun cleanup() { SentryPush.api = null; prefs.edit().clear().commit() } + + @Test fun disabledStatusTracksOutstandingOriginsInsteadOfTokenPresence() { + prefs.edit().putString("fcmToken", "token").putString("fcmAcknowledgement", "Registered").commit() + assertEquals("Alerts off", state().getString("status")) + prefs.edit().putString("fcmSubscription:https://comma.test", "old-token").commit() + assertTrue(state().getString("status").contains("Reconnect")) + assertFalse(state().getBoolean("registered")) + } + + @Test fun permissionAndRegistrationFailuresDoNotShowStaleSuccess() { + prefs.edit().putBoolean("sentryPushEnabled", true).putString("fcmRegisteredOrigin", "https://comma.test") + .putString("fcmAcknowledgement", "Registered").commit() + shadowOf(context).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + assertTrue(state().getString("status").startsWith("Allow notifications")) + shadowOf(context).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + if (FirebaseRegistration.available) { + for (failure in listOf("Registration failed", "Registration failed; reconnect to retry")) { + prefs.edit().putString("fcmAcknowledgement", failure).commit() + assertTrue(state().getString("status").startsWith("Couldn't register")) + } + prefs.edit().putString("fcmAcknowledgement", "Phone registered; waiting for relay enrollment").commit() + assertEquals("Phone registered; waiting for relay enrollment", state().getString("status")) + } else assertTrue(state().getString("status").startsWith("Polling only")) + } + + @Test fun disablingOfflineMigratesOlderRegistrationForRetry() { + prefs.edit().putBoolean("sentryPushEnabled", true).putString("fcmToken", "token") + .putString("fcmRegisteredOrigin", "https://comma.test").commit() + assertTrue(SentryPush.enable(context, false)) + assertEquals("token", prefs.getString("fcmSubscription:https://comma.test", null)) + assertFalse(prefs.contains("fcmRegisteredOrigin")) + assertTrue(state().getString("status").contains("Reconnect")) + } + + @Test fun failedRemovalRetriesOnlyTheConnectedOrigin() { + val key = "fcmSubscription:https://comma.test" + val other = "fcmSubscription:https://other.test" + prefs.edit().putString(key, "old-token").putString(other, "other-token").commit() + val failed = CountDownLatch(1) + val failure = SentryApi("https://comma.test", "", bridge = { _, _ -> + failed.countDown(); throw IllegalStateException("offline") + }) + SentryPush.registerIfNeeded(context, failure) + assertTrue(failed.await(3, TimeUnit.SECONDS)) + synchronized(SentryPush) { assertEquals("old-token", prefs.getString(key, null)) } + val removed = CountDownLatch(1) + val success = SentryApi("https://comma.test", "", bridge = { path, body -> + assertEquals("/api/sentry/notifications", path) + assertEquals("appUnsubscribe", body!!.getString("action")) + assertEquals("old-token", body.getString("token")) + removed.countDown(); JSONObject() + }) + SentryPush.registerIfNeeded(context, success) + assertTrue(removed.await(3, TimeUnit.SECONDS)) + synchronized(SentryPush) { + assertFalse(prefs.contains(key)) + assertEquals("other-token", prefs.getString(other, null)) + } + assertTrue(state().getString("status").contains("Reconnect")) + } +}