~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1

Push registers with registerPush, falling back on servers without it

Merged chadtmiller.com asked to merge into main from grain.social/grain-android:push/register-push
app/src/main/java/social/grain/data/api/Endpoints.kt+63−13
@@ -3,9 +3,13 @@ package social.grain.data.api
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import social.grain.data.model.GrainComment
import social.grain.data.model.GrainGallery
@@ -671,29 +675,75 @@ suspend fun XrpcClient.createReport(
// MARK: - Push
/** The service that sends the pushes: the server's DID. */
const val PUSH_SERVICE_DID = "did:web:grain.social"
/** The app the pushes are for: the release applicationId. */
const val PUSH_APP_ID = "social.grain"
/**
* The input of `social.grain.notification.registerPush` and `unregisterPush`,
* shaped as Bluesky's `app.bsky.notification.registerPush`.
*/
internal fun pushRegistration(token: String): JsonObject = buildJsonObject {
put("serviceDid", PUSH_SERVICE_DID)
put("token", token)
put("platform", "android")
put("appId", PUSH_APP_ID)
}
/**
* Whether the server answered that it does not serve the method at all, as a
* server without `social.grain.notification.registerPush` does. Only then is
* the older `registerToken` call tried; any other failure, such as a 401 or a
* rejected input, is the answer.
*/
internal fun isMethodMissing(error: Throwable): Boolean {
if (error !is XrpcException.Http) return false
if (error.statusCode == 404 || error.statusCode == 501) return true
val name = runCatching {
Json.parseToJsonElement(error.body.orEmpty()).jsonObject["error"]?.jsonPrimitive?.contentOrNull
}.getOrNull()
return name == "MethodNotImplemented" || name == "MethodNotFound"
}
/**
* Hand this device's FCM token to the server so it can push to it.
*
* Asks with `registerPush` first, and with the older `registerToken` only when
* the server does not serve `registerPush`.
*
* The server upserts on (did, token), so re-registering an unchanged token is
* free and safe to do on every launch.
*/
suspend fun XrpcClient.registerPushToken(token: String, auth: AuthContext) {
procedureVoid(
"dev.hatk.push.registerToken",
buildJsonObject {
put("token", token)
put("platform", "fcm")
},
auth,
)
try {
procedureVoid("social.grain.notification.registerPush", pushRegistration(token), auth)
} catch (e: XrpcException.Http) {
if (!isMethodMissing(e)) throw e
procedureVoid(
"dev.hatk.push.registerToken",
buildJsonObject {
put("token", token)
put("platform", "fcm")
},
auth,
)
}
}
/** Withdraw the token, the same way round as [registerPushToken]. */
suspend fun XrpcClient.unregisterPushToken(token: String, auth: AuthContext) {
procedureVoid(
"dev.hatk.push.unregisterToken",
buildJsonObject { put("token", token) },
auth,
)
try {
procedureVoid("social.grain.notification.unregisterPush", pushRegistration(token), auth)
} catch (e: XrpcException.Http) {
if (!isMethodMissing(e)) throw e
procedureVoid(
"dev.hatk.push.unregisterToken",
buildJsonObject { put("token", token) },
auth,
)
}
}
// MARK: - Moderation lists
app/src/main/java/social/grain/data/push/PushManager.kt+1−1
@@ -22,7 +22,7 @@ import kotlin.coroutines.resumeWithException
private const val TAG = "Push"
/**
* FCM registration and the hatk server's view of it.
* FCM registration and the server's view of it.
*
* Everything here is a no-op when Firebase didn't initialize — `google-services.json`
* is kept out of the repo, so a fresh clone builds and runs without push rather
app/src/test/java/social/grain/EndpointsTest.kt+104−8
@@ -8,6 +8,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -22,6 +23,7 @@ import social.grain.data.api.GalleryService
import social.grain.data.api.PinnedFeed
import social.grain.data.api.ReportSubject
import social.grain.data.api.StoryService
import social.grain.data.api.XrpcException
import social.grain.data.api.applyWrites
import social.grain.data.api.createReport
import social.grain.data.api.createRecord
@@ -53,6 +55,7 @@ import social.grain.data.api.markNotificationsSeen
import social.grain.data.api.muteActor
import social.grain.data.api.putPreference
import social.grain.data.api.putRecord
import social.grain.data.api.isMethodMissing
import social.grain.data.api.registerPushToken
import social.grain.data.api.searchActorsTypeahead
import social.grain.data.api.searchGalleries
@@ -533,18 +536,111 @@ class EndpointsTest {
// MARK: - Push
private val registerPush = "social.grain.notification.registerPush"
private val unregisterPush = "social.grain.notification.unregisterPush"
private val registerToken = "dev.hatk.push.registerToken"
private val unregisterToken = "dev.hatk.push.unregisterToken"
/** The answers a server gives for a method it does not serve. */
private val methodMissing = listOf(
404 to "Not Found",
501 to """{"error":"MethodNotImplemented"}""",
400 to """{"error":"MethodNotImplemented","message":"Method Not Implemented"}""",
400 to """{"error":"MethodNotFound"}""",
)
/** Answers about the request rather than the method. */
private val otherFailures = listOf(
401 to """{"error":"AuthenticationRequired"}""",
400 to """{"error":"InvalidRequest","message":"Input/token must be a string"}""",
500 to "nope",
)
private fun assertPushRegistration(sent: JsonObject) {
assertEquals(setOf("serviceDid", "token", "platform", "appId"), sent.keys)
assertEquals("did:web:grain.social", sent["serviceDid"]!!.jsonPrimitive.content)
assertEquals("fcm-token", sent["token"]!!.jsonPrimitive.content)
assertEquals("android", sent["platform"]!!.jsonPrimitive.content)
assertEquals("social.grain", sent["appId"]!!.jsonPrimitive.content)
}
@Test
fun pushTokensRegisterAndUnregisterForThisPlatform() = runTest {
fun pushTokensRegisterAndUnregisterWithRegisterPush() = runTest {
client.registerPushToken("fcm-token", auth)
val registered = body("dev.hatk.push.registerToken")
assertEquals("fcm-token", registered["token"]!!.jsonPrimitive.content)
assertEquals("fcm", registered["platform"]!!.jsonPrimitive.content)
val registered = server.lastCall(registerPush)
assertEquals("POST", registered.method)
assertEquals("DPoP test-access-token", registered.authorization)
assertPushRegistration(registered.json)
client.unregisterPushToken("fcm-token", auth)
assertEquals(
"fcm-token",
body("dev.hatk.push.unregisterToken")["token"]!!.jsonPrimitive.content,
)
assertPushRegistration(body(unregisterPush))
assertEquals(0, server.countOf(registerToken))
assertEquals(0, server.countOf(unregisterToken))
}
@Test
fun pushRegistrationFallsBackWhenTheServerLacksRegisterPush() = runTest {
for ((code, reply) in methodMissing) {
val server = StubServer().on(registerPush, reply, code = code)
server.xrpc().registerPushToken("fcm-token", auth)
val label = "$code $reply"
assertEquals(label, listOf(registerPush, registerToken), server.calls.map { it.nsid })
val sent = server.lastCall(registerToken).json
assertEquals(label, setOf("token", "platform"), sent.keys)
assertEquals(label, "fcm-token", sent["token"]!!.jsonPrimitive.content)
assertEquals(label, "fcm", sent["platform"]!!.jsonPrimitive.content)
server.shutdown()
}
}
@Test
fun pushUnregistrationFallsBackWhenTheServerLacksUnregisterPush() = runTest {
for ((code, reply) in methodMissing) {
val server = StubServer().on(unregisterPush, reply, code = code)
server.xrpc().unregisterPushToken("fcm-token", auth)
val label = "$code $reply"
assertEquals(label, listOf(unregisterPush, unregisterToken), server.calls.map { it.nsid })
val sent = server.lastCall(unregisterToken).json
assertEquals(label, setOf("token"), sent.keys)
assertEquals(label, "fcm-token", sent["token"]!!.jsonPrimitive.content)
server.shutdown()
}
}
@Test
fun otherPushFailuresAreTheAnswerAndNotFallenBackFrom() = runTest {
for ((code, reply) in otherFailures) {
val server = StubServer()
.on(registerPush, reply, code = code)
.on(unregisterPush, reply, code = code)
val client = server.xrpc()
val label = "$code $reply"
val registering = runCatching { client.registerPushToken("fcm-token", auth) }
assertTrue(label, registering.exceptionOrNull() is XrpcException)
val unregistering = runCatching { client.unregisterPushToken("fcm-token", auth) }
assertTrue(label, unregistering.exceptionOrNull() is XrpcException)
assertEquals(label, listOf(registerPush, unregisterPush), server.calls.map { it.nsid })
server.shutdown()
}
}
@Test
fun onlyAMissingMethodCountsAsMissing() {
assertTrue(isMethodMissing(XrpcException.Http(404, null)))
assertTrue(isMethodMissing(XrpcException.Http(501, "")))
assertTrue(isMethodMissing(XrpcException.Http(400, """{"error":"MethodNotImplemented"}""")))
assertTrue(isMethodMissing(XrpcException.Http(400, """{"error":"MethodNotFound"}""")))
assertFalse(isMethodMissing(XrpcException.Http(400, """{"error":"InvalidRequest"}""")))
assertFalse(isMethodMissing(XrpcException.Http(400, "garbage")))
assertFalse(isMethodMissing(XrpcException.Http(400, null)))
assertFalse(isMethodMissing(XrpcException.Http(500, "{}")))
assertFalse(isMethodMissing(XrpcException.Unauthorized))
assertFalse(isMethodMissing(java.io.IOException("offline")))
}
// MARK: - Moderation lists