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

grain.social/grain-android

1 collaborator
0
1

The app calls grain-serve by its own method names

Merged chadtmiller.com asked to merge into feature/rust-auth from grain.social/grain-android:feature/rust-calls
.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/navigation/DeepLink.kt+1−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.
app/src/test/java/social/grain/ExploreScreensTest.kt+10−14
app/src/test/java/social/grain/ExploreViewModelTest.kt+14−15
app/src/test/java/social/grain/FeedScreenTest.kt+10−11
app/src/test/java/social/grain/FeedViewModelTest.kt+37−38
app/src/test/java/social/grain/GalleryCommentsViewModelTest.kt+1−1
app/src/test/java/social/grain/GalleryDetailViewModelTest.kt+16−17
app/src/test/java/social/grain/GalleryFeedViewModelTest.kt+18−19
app/src/test/java/social/grain/GalleryUploadTest.kt+3−3
app/src/test/java/social/grain/LabelResolutionTest.kt+4−4
app/src/test/java/social/grain/LocalStoresTest.kt+60−29
app/src/test/java/social/grain/LoginScreenTest.kt+2−2
app/src/test/java/social/grain/LoginViewModelTest.kt+1−1
app/src/test/java/social/grain/MainViewModelTest.kt+31−9
app/src/test/java/social/grain/ModerationViewModelTest.kt+7−7
app/src/test/java/social/grain/NotificationsScreenTest.kt+4−3
app/src/test/java/social/grain/NotificationsViewModelTest.kt+16−12
app/src/test/java/social/grain/ProfileGalleryFeedScreenTest.kt+6−7
app/src/test/java/social/grain/ProfileScreenTest.kt+13−14
app/src/test/java/social/grain/ProfileSeedTest.ktnew+141−0
app/src/test/java/social/grain/ProfileViewModelTest.kt+51−52
app/src/test/java/social/grain/ReportViewModelTest.kt+57−51
app/src/test/java/social/grain/ScreenShellTest.kt+22−23
app/src/test/java/social/grain/SearchScreenTest.kt+13−14
app/src/test/java/social/grain/SearchViewModelTest.kt+11−12
app/src/test/java/social/grain/SettingsScreenTest.kt+4−3
app/src/test/java/social/grain/SettingsViewModelTest.kt+14−11
app/src/test/java/social/grain/StoryCommentsViewModelTest.kt+1−1
app/src/test/java/social/grain/StoryViewerScreenTest.kt+7−8
app/src/test/java/social/grain/StoryViewerViewModelTest.kt+13−14
app/src/test/java/social/grain/XrpcClientTest.kt+48−48
app/build.gradle.kts+18−2
justfile+13−0
README.md+7−13