The app calls grain-serve by its own method names
.knotless/workflows/test.yml+2−2
| @@ -1,5 +1,5 @@ | ||
# The JVM test suite and the coverage gate, on pushes to main and pulls into | ||
# it. | ||
# it or into a feature branch. | ||
# | ||
# No emulator. The suite runs the view models, the API layer and every Compose | ||
# screen under Robolectric, so all of it is a plain JVM run. The tests that | ||
| @@ -22,7 +22,7 @@ | ||
image: eclipse-temurin:17-jdk | ||
on: | ||
push: { refs: [refs/heads/main] } | ||
pull: { base: [refs/heads/main] } | ||
pull: { base: [refs/heads/main, refs/heads/feature/*] } | ||
cache: [/opt/android-sdk, /root/.gradle/caches, /root/.gradle/wrapper] | ||
env: | ||
# cmdline-tools 23.0, pinned: an SDK that moves under the pipeline turns an | ||
app/src/androidTest/java/social/grain/DpopParTest.kt+2−2
| @@ -89,7 +89,7 @@ class DpopParTest { | ||
@Test | ||
fun proofIsAWellFormedThreeSegmentJwt() { | ||
val proof = DPoP.createEphemeral() | ||
.createProof("GET", "https://grain.social/xrpc/dev.hatk.getFeed?feed=recent") | ||
.createProof("GET", "https://grain.social/xrpc/social.grain.feed.getFeed?limit=30") | ||
| ||
val segments = proof.split(".") | ||
assertEquals("A JWS has exactly three segments", 3, segments.size) | ||
| @@ -103,7 +103,7 @@ class DpopParTest { | ||
assertEquals("GET", payload.getString("htm")) | ||
// The query string must not survive into `htu` — servers normalise the | ||
// same way and compare literally. | ||
assertEquals("https://grain.social/xrpc/dev.hatk.getFeed", payload.getString("htu")) | ||
assertEquals("https://grain.social/xrpc/social.grain.feed.getFeed", payload.getString("htu")) | ||
| ||
val signature = android.util.Base64.decode(segments[2], BASE64_FLAGS) | ||
assertEquals("ES256 signatures are raw r||s, not DER", 64, signature.size) | ||
app/src/main/java/social/grain/data/api/Endpoints.kt+316−152
468 lines changed: not loaded until asked for.
app/src/main/java/social/grain/data/api/ProfileSeed.ktnew+95−0
| @@ -0,0 +1,95 @@ | ||
package social.grain.data.api | ||
| ||
import kotlinx.serialization.json.JsonObject | ||
import kotlinx.serialization.json.JsonPrimitive | ||
import kotlinx.serialization.json.buildJsonObject | ||
import kotlinx.serialization.json.contentOrNull | ||
import kotlinx.serialization.json.jsonPrimitive | ||
import social.grain.util.nowIso | ||
| ||
/** | ||
* A grain profile made for the account once it signs in, from its Bluesky | ||
* profile when it has one, as grain-serve's web login seeds one: in the | ||
* background, so a sign-in never waits on it and never fails by it. | ||
* | ||
* An account whose `social.grain.actor.profile` has a `displayName` is left as | ||
* it is. Otherwise its `app.bsky.actor.profile` gives the grain profile its | ||
* `displayName`, `description` and `avatar` (the blob as the Bluesky profile | ||
* names it), with the `createdAt` of the grain profile there is, or now. One | ||
* there is is put over, swapped on its CID; else one is made at `self`. An | ||
* account with no Bluesky profile and no grain profile gets a bare one, its | ||
* `createdAt` alone, so every account that signs in has the profile that | ||
* makes grain-serve track it; one with an unnamed grain profile and no | ||
* Bluesky profile is left as it is. Both are read from the account's PDS, | ||
* which has them before the index may; the write is then indexed, as every | ||
* write is. | ||
*/ | ||
object ProfileSeed { | ||
const val GRAIN = "social.grain.actor.profile" | ||
const val BSKY = "app.bsky.actor.profile" | ||
const val SELF = "self" | ||
| ||
/** What [seed] did. */ | ||
sealed interface Seeded { | ||
/** The grain profile has a name already. */ | ||
data object Named : Seeded | ||
| ||
/** | ||
* There is no Bluesky profile to name it from, and the grain profile | ||
* there is was left as it is. | ||
*/ | ||
data object NoBluesky : Seeded | ||
| ||
/** A grain profile was made, from the Bluesky one or bare, at this URI. */ | ||
data class Made(val uri: String?) : Seeded | ||
| ||
/** The grain profile there was was put over, at this URI. */ | ||
data class Put(val uri: String?) : Seeded | ||
} | ||
| ||
/** Seeds the signed-in account's grain profile, as the object says. */ | ||
suspend fun seed(client: XrpcClient, auth: AuthContext): Seeded { | ||
val did = auth.did ?: throw XrpcException.Unauthorized | ||
val grain = client.getRecordOrNull("at://$did/$GRAIN/$SELF", auth) | ||
val name = grain?.value?.get("displayName")?.jsonPrimitive?.contentOrNull | ||
if (!name.isNullOrEmpty()) return Seeded.Named | ||
val bsky = client.getRecordOrNull("at://$did/$BSKY/$SELF", auth)?.value | ||
if (bsky == null && grain != null) return Seeded.NoBluesky | ||
val record = fromBluesky(bsky ?: JsonObject(emptyMap()), grain?.value) | ||
val cid = grain?.cid | ||
return if (grain != null && cid != null) { | ||
Seeded.Put(client.putRecord(GRAIN, SELF, record, did, auth, swapRecord = cid).uri) | ||
} else { | ||
Seeded.Made(client.createRecord(GRAIN, did, record, auth, rkey = SELF).uri) | ||
} | ||
} | ||
| ||
/** | ||
* The grain profile [bsky], a Bluesky profile, makes: its name, | ||
* description and avatar where it has them, made when [grain], the grain | ||
* profile there is, says it was, or now. | ||
*/ | ||
fun fromBluesky(bsky: JsonObject, grain: JsonObject?): JsonObject { | ||
val created = grain?.get("createdAt")?.jsonPrimitive?.contentOrNull ?: nowIso() | ||
return buildJsonObject { | ||
put("\$type", JsonPrimitive(GRAIN)) | ||
put("createdAt", JsonPrimitive(created)) | ||
for (key in listOf("displayName", "description")) { | ||
val text = (bsky[key] as? JsonPrimitive)?.takeIf { it.isString }?.content | ||
if (!text.isNullOrEmpty()) put(key, JsonPrimitive(text)) | ||
} | ||
(bsky["avatar"] as? JsonObject)?.let { put("avatar", it) } | ||
} | ||
} | ||
} | ||
| ||
/** | ||
* The record at [uri], read from the account's PDS, or null where the PDS | ||
* holds none. | ||
*/ | ||
suspend fun XrpcClient.getRecordOrNull(uri: String, auth: AuthContext? = null): GetRecordResponse? = | ||
try { | ||
getRecord(uri, auth) | ||
} catch (e: XrpcException.Http) { | ||
if (e.body?.contains("RecordNotFound") == true) null else throw e | ||
} | ||
app/src/main/java/social/grain/data/api/XrpcClient.kt+12−6
| @@ -64,7 +64,9 @@ data class UploadBlobResponse(val blob: BlobRef) | ||
* proof per request; every method outside `com.atproto.*` also carries | ||
* `atproto-proxy: <appview did>#grain_appview`, so the PDS forwards it to | ||
* grain-serve as the account. `com.atproto.*` calls, the record writes and | ||
* blob uploads, are the PDS's own. | ||
* blob uploads, are the PDS's own, but for a report | ||
* (`com.atproto.moderation.createReport`), which goes to grain-serve's | ||
* moderation as the proxy names it rather than to the PDS's own. | ||
* | ||
* Two retry paths are folded in, both required by atproto OAuth. A 401 whose | ||
* `WWW-Authenticate` says `use_dpop_nonce`, or a 400 whose body does, means the | ||
| @@ -201,7 +203,7 @@ class XrpcClient( | ||
), | ||
) | ||
} | ||
if (auth?.pdsUrl != null && !nsid.startsWith("com.atproto.")) { | ||
if (auth?.pdsUrl != null && proxied(nsid)) { | ||
builder.header("atproto-proxy", appviewProxy) | ||
} | ||
| ||
| @@ -225,9 +227,13 @@ class XrpcClient( | ||
} | ||
} | ||
| ||
private companion object { | ||
val JSON_MEDIA_TYPE = "application/json".toMediaType() | ||
const val UPLOAD_BLOB = "com.atproto.repo.uploadBlob" | ||
companion object { | ||
/** Whether a signed-in call to [nsid] goes through the PDS to grain-serve. */ | ||
fun proxied(nsid: String): Boolean = | ||
!nsid.startsWith("com.atproto.") || nsid == "com.atproto.moderation.createReport" | ||
| ||
private val JSON_MEDIA_TYPE = "application/json".toMediaType() | ||
private const val UPLOAD_BLOB = "com.atproto.repo.uploadBlob" | ||
| ||
/** | ||
* Whether a response refuses the proof for want of a fresh nonce. A PDS | ||
| @@ -235,7 +241,7 @@ class XrpcClient( | ||
* included, so the header alone says nothing: the refusal is named in | ||
* `WWW-Authenticate` on a 401, or in the error body. | ||
*/ | ||
fun nonceRefused(code: Int, wwwAuthenticate: String?, body: String): Boolean = | ||
private fun nonceRefused(code: Int, wwwAuthenticate: String?, body: String): Boolean = | ||
(code == 401 || code == 400) && | ||
(wwwAuthenticate?.contains("use_dpop_nonce") == true || body.contains("use_dpop_nonce")) | ||
} | ||
app/src/main/java/social/grain/data/auth/AuthManager.kt+6−2
| @@ -24,6 +24,7 @@ import okhttp3.FormBody | ||
import okhttp3.HttpUrl.Companion.toHttpUrl | ||
import okhttp3.OkHttpClient | ||
import okhttp3.Request | ||
import social.grain.BuildConfig | ||
import social.grain.data.api.AuthContext | ||
import social.grain.data.api.DPoP | ||
import social.grain.data.api.DpopNonces | ||
| @@ -605,8 +606,11 @@ class AuthManager @Inject constructor( | ||
.let { Base64.encodeToString(it, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) } | ||
| ||
companion object { | ||
/** grain-serve: signed-out reads, and what the client ID and proxy name. */ | ||
const val SERVER_URL = "https://grain.social" | ||
/** | ||
* grain-serve: signed-out reads, and what the client ID and proxy | ||
* name. grain.social, or next.grain.social in the `next` build. | ||
*/ | ||
const val SERVER_URL = BuildConfig.SERVER_URL | ||
| ||
/** | ||
* The app's client metadata, which grain-serve hosts. A PDS fetches it | ||
app/src/main/java/social/grain/data/local/LabelDefinitionsCache.kt+21−26
| @@ -1,5 +1,6 @@ | ||
package social.grain.data.local | ||
| ||
import android.util.Log | ||
import androidx.lifecycle.ViewModel | ||
import androidx.lifecycle.viewModelScope | ||
import kotlinx.coroutines.CoroutineScope | ||
| @@ -13,21 +14,21 @@ import kotlinx.coroutines.sync.withLock | ||
import social.grain.data.api.XrpcClient | ||
import social.grain.data.api.describeLabels | ||
import social.grain.data.auth.AuthManager | ||
import social.grain.data.model.GrainLabels | ||
import social.grain.data.model.LabelDefinition | ||
import javax.inject.Inject | ||
import javax.inject.Singleton | ||
| ||
/** | ||
* What every label identifier the appview knows about means. | ||
* What every label identifier grain gives out means, as | ||
* `social.grain.moderation.describeLabels` says, read once and kept in | ||
* memory. Until it answers, or when it cannot, the bundled [GrainLabels] | ||
* stand in, so a gallery is never shown ungated for want of the network. | ||
* | ||
* Shared rather than fetched per screen because it is one answer for the whole | ||
* server and every surface that draws a gallery needs it: a card in search and | ||
* the same card in the feed have to gate on the same definitions, and a screen | ||
* that forgot to ask would quietly render labelled photos in the clear. | ||
* | ||
* A failed fetch leaves the list empty on purpose — `resolveLabels` still | ||
* covers the well-known ATProto set from its own fallbacks — but it doesn't | ||
* count as loaded, so the next screen to open tries again. | ||
* Shared so that every surface that draws a gallery gates on the same | ||
* definitions: a card in search and the same card in the feed have to agree. | ||
* A failed read doesn't count as loaded, so the next screen to open tries | ||
* again. | ||
*/ | ||
@Singleton | ||
class LabelDefinitionsCache @Inject constructor( | ||
| @@ -35,43 +36,37 @@ class LabelDefinitionsCache @Inject constructor( | ||
private val auth: AuthManager, | ||
private val appScope: CoroutineScope, | ||
) { | ||
| ||
private val _definitions = MutableStateFlow<List<LabelDefinition>>(emptyList()) | ||
private val _definitions = MutableStateFlow(GrainLabels.definitions) | ||
val definitions: StateFlow<List<LabelDefinition>> = _definitions.asStateFlow() | ||
| ||
private val loading = Mutex() | ||
private var loaded = false | ||
| ||
/** | ||
* Fetch the definitions unless they're already in hand. | ||
* | ||
* Runs on the application scope, not the caller's: several screens ask at | ||
* once on a cold start, and the one that happens to be cancelled first | ||
* shouldn't take the shared fetch down with it. | ||
* Read the definitions unless they're already in hand. On the application | ||
* scope, so a screen cancelled first doesn't take the shared read down. | ||
*/ | ||
fun loadIfNeeded() { | ||
if (loaded) return | ||
appScope.launch { | ||
loading.withLock { | ||
if (loaded) return@withLock | ||
runCatching { client.describeLabels(auth.authContext()).definitions.orEmpty() } | ||
runCatching { client.describeLabels(auth.authContext()).policies.labelValueDefinitions } | ||
.onSuccess { definitions -> | ||
loaded = true | ||
_definitions.value = definitions | ||
if (definitions.isNotEmpty()) { | ||
loaded = true | ||
_definitions.value = definitions | ||
} | ||
} | ||
.onFailure { Log.w("Labels", "Couldn't read the label definitions", it) } | ||
} | ||
} | ||
} | ||
} | ||
| ||
/** | ||
* Keep a screen's UI state carrying the current label definitions. | ||
* | ||
* Every screen that draws a gallery needs the same list, and the ones that | ||
* forgot to ask for it were the bug this replaced: a card rendered with an | ||
* empty definitions list still gates the well-known ATProto labels through | ||
* `resolveLabels`' fallbacks, so nothing looks broken — the server's own labels | ||
* just quietly stop covering anything. | ||
* Keep a screen's UI state carrying the label definitions, so every screen | ||
* that draws a gallery gates on the same list. | ||
*/ | ||
fun <T> ViewModel.observeLabelDefinitions( | ||
cache: LabelDefinitionsCache, | ||
app/src/main/java/social/grain/data/model/GrainLabels.ktnew+78−0
| @@ -0,0 +1,78 @@ | ||
package social.grain.data.model | ||
| ||
/** | ||
* The labels grain gives out, as grain-serve defines them | ||
* (`grain::labels::DEFINITIONS`), in its order: the reasons a report names and | ||
* the warnings a gallery carries. What the app shows until | ||
* `social.grain.moderation.describeLabels` answers, and when it cannot. | ||
*/ | ||
object GrainLabels { | ||
private fun label( | ||
identifier: String, | ||
severity: String, | ||
blurs: String, | ||
defaultSetting: String, | ||
name: String, | ||
description: String, | ||
) = LabelDefinition( | ||
identifier = identifier, | ||
locales = listOf(LabelLocale(name = name, description = description, lang = "en")), | ||
blurs = blurs, | ||
defaultSetting = defaultSetting, | ||
severity = severity, | ||
) | ||
| ||
val definitions: List<LabelDefinition> = listOf( | ||
label( | ||
"copyright", "alert", "content", "hide", "Copyright Violation", | ||
"Content that infringes on copyright or intellectual property.", | ||
), | ||
label( | ||
"csae", "alert", "content", "hide", "Child Safety", | ||
"Sexual content involving a minor, or the exploitation, grooming or endangerment of a child.", | ||
), | ||
label("gore", "alert", "media", "hide", "Graphic Media", "Graphic or violent imagery."), | ||
label( | ||
"harassment", "alert", "none", "warn", "Harassment", | ||
"Targeted harassment, bullying, or hate speech.", | ||
), | ||
label( | ||
"misleading", "inform", "none", "warn", "Misleading", | ||
"Impersonation, misinformation, or deceptive content.", | ||
), | ||
label("nudity", "inform", "media", "warn", "Nudity", "Content containing nudity."), | ||
label( | ||
"other", "none", "none", "ignore", "Other", | ||
"Content that does not fit other report categories.", | ||
), | ||
label( | ||
"sexual", "inform", "media", "warn", "Sexual Content", | ||
"Unwanted or mislabeled sexual content.", | ||
), | ||
label( | ||
"spam", "inform", "content", "hide", "Spam", | ||
"Unwanted or repetitive promotional content.", | ||
), | ||
) | ||
| ||
/** | ||
* The `reasonType` a report for each label names, the one grain-serve | ||
* files under that label (`grain::labels::label_for`): atproto's where it | ||
* has one, Ozone's for graphic media and child safety, grain's own for the | ||
* rest. | ||
*/ | ||
val reportReasons: Map<String, String> = mapOf( | ||
"copyright" to "social.grain.moderation.defs#reasonCopyright", | ||
"csae" to "tools.ozone.report.defs#reasonChildSafetyOther", | ||
"gore" to "tools.ozone.report.defs#reasonViolenceGraphicContent", | ||
"harassment" to "com.atproto.moderation.defs#reasonRude", | ||
"misleading" to "com.atproto.moderation.defs#reasonMisleading", | ||
"nudity" to "social.grain.moderation.defs#reasonNudity", | ||
"other" to "com.atproto.moderation.defs#reasonOther", | ||
"sexual" to "com.atproto.moderation.defs#reasonSexual", | ||
"spam" to "com.atproto.moderation.defs#reasonSpam", | ||
) | ||
| ||
/** The `reasonType` a report under [label] names; `other`'s for a label without one. */ | ||
fun reasonType(label: String): String = reportReasons[label] ?: reportReasons.getValue("other") | ||
} | ||
app/src/main/java/social/grain/data/model/Models.kt+7−2
| @@ -278,12 +278,17 @@ data class LabelDefinition( | ||
val locales: List<LabelLocale>? = null, | ||
val blurs: String? = null, | ||
val defaultSetting: String? = null, | ||
val severity: String? = null, | ||
) { | ||
val displayName: String get() = locales?.firstOrNull()?.name ?: identifier | ||
} | ||
| ||
@Serializable | ||
data class LabelLocale(val name: String) | ||
data class LabelLocale( | ||
val name: String, | ||
val description: String? = null, | ||
val lang: String? = null, | ||
) | ||
| ||
// MARK: - Comment | ||
| ||
| @@ -342,7 +347,7 @@ data class StoryViewerState( | ||
val viewed: Boolean? = null, | ||
) | ||
| ||
/** social.grain.unspecced.getStoryAuthors#storyAuthor */ | ||
/** social.grain.story.defs#storyAuthor */ | ||
@Serializable | ||
data class GrainStoryAuthor( | ||
val profile: GrainProfile, | ||
app/src/main/java/social/grain/data/model/Notification.kt+5−1
| @@ -4,12 +4,16 @@ import kotlinx.serialization.Serializable | ||
import social.grain.util.parseIso | ||
import java.time.Duration | ||
| ||
/** social.grain.unspecced.getNotifications#notificationItem */ | ||
/** social.grain.notification.defs#notificationItem */ | ||
@Serializable | ||
data class GrainNotification( | ||
val uri: String, | ||
val reason: String = "", | ||
val createdAt: String = "", | ||
/** When grain-serve first held it: what notifications are ordered by. */ | ||
val indexedAt: String = "", | ||
/** Whether the viewer had marked their notifications seen as of [indexedAt]. */ | ||
val isRead: Boolean = false, | ||
val author: GrainProfile, | ||
val galleryUri: String? = null, | ||
val galleryTitle: String? = null, | ||
app/src/main/java/social/grain/data/push/PushManager.kt+9−5
| @@ -22,7 +22,7 @@ import kotlin.coroutines.resumeWithException | ||
private const val TAG = "Push" | ||
| ||
/** | ||
* FCM registration and the server's view of it. | ||
* FCM registration and grain-serve'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 | ||
| @@ -37,6 +37,10 @@ class PushManager @Inject constructor( | ||
private val scope: CoroutineScope, | ||
) { | ||
| ||
/** The package name a registration names the app by. */ | ||
private val appId: String | ||
get() = context.packageName | ||
| ||
/** Whether a Firebase config was shipped with this build. */ | ||
val isAvailable: Boolean | ||
get() = FirebaseApp.getApps(context).isNotEmpty() | ||
| @@ -52,8 +56,8 @@ class PushManager @Inject constructor( | ||
/** | ||
* Register this device for the signed-in account. | ||
* | ||
* Safe to call on every launch: the server upserts on (did, token), and a | ||
* token that hasn't changed simply rewrites the same row. | ||
* Safe to call on every launch: the server keeps one registration per | ||
* (account, token), and a token that hasn't changed rewrites the same one. | ||
*/ | ||
fun register() { | ||
if (!isAvailable) { | ||
| @@ -85,7 +89,7 @@ class PushManager @Inject constructor( | ||
currentToken = null | ||
if (context == null) return | ||
scope.launch { | ||
runCatching { client.unregisterPushToken(token, context) } | ||
runCatching { client.unregisterPushToken(token, appId, context) } | ||
.onSuccess { Log.i(TAG, "Push token unregistered") } | ||
.onFailure { Log.w(TAG, "Couldn't unregister push token", it) } | ||
} | ||
| @@ -97,7 +101,7 @@ class PushManager @Inject constructor( | ||
Log.i(TAG, "No session; holding the token until sign-in") | ||
return | ||
} | ||
runCatching { client.registerPushToken(token, context) } | ||
runCatching { client.registerPushToken(token, appId, context) } | ||
.onSuccess { | ||
currentToken = token | ||
Log.i(TAG, "Push token registered") | ||
app/src/main/java/social/grain/feature/actors/ActorListViewModel.kt+4−5
app/src/main/java/social/grain/feature/moderation/ReportSheet.kt+24−31
app/src/main/java/social/grain/feature/moderation/ReportViewModel.kt+22−38
app/src/main/java/social/grain/feature/notifications/NotificationsViewModel.kt+1−1
app/src/main/java/social/grain/feature/notifications/UnseenNotificationCount.kt+3−3
app/src/main/java/social/grain/feature/profile/ProfileViewModel.kt+1−1
app/src/main/java/social/grain/feature/settings/EditProfileViewModel.kt+5−9
app/src/main/java/social/grain/feature/settings/SettingsViewModel.kt+4−1
app/src/main/java/social/grain/MainViewModel.kt+23−1
app/src/main/AndroidManifest.xml+3−2
app/src/test/java/social/grain/support/StubServer.kt+13−3
app/src/test/java/social/grain/ActorListViewModelTest.kt+14−14
app/src/test/java/social/grain/AuthManagerTest.kt+6−6
app/src/test/java/social/grain/BrowseIndexViewModelsTest.kt+22−22
app/src/test/java/social/grain/ComponentsTest.kt+2−2
app/src/test/java/social/grain/ComposerScreenTest.kt+16−34
app/src/test/java/social/grain/EndpointsTest.kt+256−215
471 lines changed: not loaded until asked for.