Grain for Android #
A photography community built on the AT Protocol. Android
port of grain-ios, talking to the
same grain.social appview.
Status #
Ported so far:
- Auth — OAuth 2.0 with PAR, PKCE and DPoP (ES256), multi-account token storage
- Feed — pinned feed switching, cursor pagination, favorites, content labels, disk cache
- Profile — header with stats, galleries and favorites grids, follow/unfollow
- Create — photo picker, alt text, EXIF extraction, self-labels, resumable publish
- Gallery detail — full card, camera metadata, favorite, delete your own
- Comments — threaded, post, reply, delete your own, favorite, rich text
- Stories — strip with ring states, full-screen viewer, comments, a camera-first composer, 24h expiry
- Notifications — grouped feed with unseen badge on the navigation bar
- Search — galleries and profiles, with per-account recent searches
- Explore — the recent feed as a grid under chips for the top cameras and places, with camera and location feeds and an index of each (the locations index on a map)
- Settings — edit profile, upload defaults, blocked and muted lists, delete account
- Location — H3-indexed places on galleries and stories, searched via Nominatim
- Moderation — block and mute from a profile, report a gallery, story, comment or account
- Rich text — tappable mentions and hashtags in captions and bios, with a hashtag feed
- Deep links —
https://grain.social/profile/…andgrain://open the profile, gallery or story they name - Push — FCM registration, notification channel and tap-to-deep-link (see Push notifications)
Not ported yet: Bluesky cross-posting, camera capture for galleries, the full-screen zoomable photo viewer, and story comments.
Also missing: suggested follows, feed pinning, per-type notification preferences, the story archive tab and the account switcher.
Stories carry the functional behaviour but not iOS's viewer polish: no cube-face author transitions, drag-to-dismiss physics, heart-burst animation, strip re-order animation, unread-only mode or image prefetching.
Push notifications #
The client side is complete, but two things outside this repo are needed before a notification actually arrives:
app/google-services.json— not in the repo, likekeystore.properties. Without it the Google Services plugin is skipped, the app builds and runs normally, andPushManagerno-ops because Firebase never initialized. Drop the file from your Firebase project intoapp/and push registration starts working on the next build.- An FCM sender in hatk —
dev.hatk.push.registerTokenaccepts and storesplatform: "fcm", butpush.send()inpackages/hatk/src/push.tsskips every token that isn'tapns. Until that gains an FCM leg, Android tokens register and are never delivered to.
Requirements #
- JDK 21+ (AGP 9 requires it; the Studio-bundled JDK 17 is too old)
- Android SDK 37 (Gradle downloads it)
- minSdk 26, targetSdk 36
Build #
./gradlew assembleDebug # build the APK
./gradlew installDebug # build + install on a connected device
./gradlew test # JVM unit tests
./gradlew connectedAndroidTest # instrumented tests (needs a device and network)
local.properties needs sdk.dir pointing at your Android SDK; Android Studio
writes it on first open.
The location screens draw grain's own Protomaps
basemap through MapLibre — the same tiles as grain.social, served from
tiles.grain.social, with no API key. The MapLibre style ships as an asset,
generated from the same theme package the web uses:
just basemap-style # regenerates app/src/main/assets/basemap/{light,dark}.json
Needs node and npm; the output is committed, so a build needs neither. Label fonts and sprite icons come from Protomaps' public asset host until the tile Worker serves them.
Both build variants hit the production API at grain.social. The debug variant
installs under social.grain.debug so it can sit alongside a release build.
just #
There's a justfile wrapping the common loops, including finding
adb (which isn't on PATH on macOS) and picking a device:
just phone # build release + install over the Grain already on a device
just emulator # build debug + install as social.grain.debug
just uninstall-debug # remove a stray debug install
just link <url> # fire a deep link at the device
just logs # tail the running app
just check # unit tests + release build
just phone deliberately ships the release variant: it shares the app id and
the upload-key signature with a sideloaded install, so it upgrades in place and
leaves one Grain on the device with its session intact. just emulator is the
fast, debuggable loop, and the .debug suffix means it will sit alongside a
release build rather than replacing it — fine on an emulator, two identical
icons on a phone.
Architecture #
data/
api/ XRPC client, DPoP proof signing, endpoint extensions
auth/ OAuth flow, Keystore-sealed token and key storage
local/ feed disk cache, story viewed-state and live-story caches
model/ lexicon view and record types
upload/ gallery drafts, image processing, resumable publish
feature/ one package per screen: login, feed, profile, create
ui/ theme and shared composables
MVVM with Hilt for DI, Compose for UI, coroutines and StateFlow for state.
Each screen has a ViewModel exposing one immutable UI state.
The theme uses Material You dynamic colour on Android 12+, so the accent follows
the user's wallpaper rather than Grain's indigo. Pass dynamicColor = false to
GrainTheme to pin the brand palette instead — the trade is platform
convention against brand consistency, and iOS has no equivalent to match.
Four things worth knowing before changing them #
Publishing is a two-phase, resumable state machine. A gallery's record keys
(TIDs) are all assigned
before the first byte is uploaded, and the draft is checkpointed to disk after
every blob. Blobs go up one at a time; then every record lands in a single
atomic applyWrites commit. That is what makes tapping Post twice, or resuming
after the process is killed, safe — a retry overwrites its own records instead
of posting a second gallery. See
GalleryUploadCenter.
Facet offsets are UTF-8 bytes, not characters. Rich text in comments and
gallery descriptions is annotated with Facet ranges measured in bytes.
Slicing the Kotlin String by those numbers looks right in English and puts
every link in the wrong place as soon as a comment contains an emoji or an
accent. See
RichText.
Story viewed-state is two records, not one. A set of story URIs answers
"has this exact story been seen", which is how the viewer picks where to open.
A per-author high-water mark answers "is this author caught up", which decides
gradient ring versus grey — and it survives the URI set being trimmed. See
ViewedStoryStore.
DPoP keys are per-account and generated outside the Keystore. An access
token is bound to the thumbprint of the key that requested it, so each signed-in
account needs its own. Sign-in needs a key before the DID is known, and
AndroidKeyStore entries can't be re-aliased after the fact — so the key is
created ephemerally and persisted under its DID once the token response names
it, sealed at rest by a Keystore-backed AES-GCM master key. See
DPoP and
SecureStore.
App icon #
Generated from the iOS asset catalogue's 1024.png
(Grain/Assets.xcassets/AppIcon.appiconset/) into the Android density buckets,
plus store/play-store-icon-512.png for the Play listing.
Re-run python3 tools/generate_icons.py after changing the source or the
framing.
The icon is a photograph rather than a logo mark, which makes the adaptive icon
a compromise: Android masks the 108dp layer down to a 72dp safe zone, so the
launcher shows the centre ~67% of the image. The photo is the background layer
with a transparent foreground, and CROP_SIDE/CROP_TOP in the script pick
which square of the source lands on the canvas. It is pinned to the top of the
frame — a centred full-width crop loses the mountains and the dusk sky
entirely, which is most of what makes the photo readable at 48dp.
No monochrome layer is supplied. Android 13+ themed icons fall back to the full icon, which is the right outcome: a photograph has no meaningful single-colour form.
Tests #
JVM tests cover the pure logic where a silent bug is expensive: TID monotonicity, and the DER-to-JOSE signature conversion that ES256 requires.
Instrumented tests run against the live appview — they check that a DPoP proof
is actually accepted by grain.social, that live feed responses decode into the
ported models, and that real galleries render through the feed card. They need
network and a device.
License #
MIT — Copyright (c) 2026 Grain Social