1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# Grain for Android
A photography community built on the [AT Protocol](https://atproto.com). Android
port of [grain-ios](https://github.com/grain-social/grain-ios), talking to the
same `grain.social` appview.
## Status
Ported so far:
- **Auth** — atproto OAuth at the account's own PDS (PAR, PKCE, DPoP ES256) as a public native client, 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/…` and `grain://` open the profile,
gallery or story they name
- **Push** — FCM registration, notification channel and tap-to-deep-link
(see [Push notifications](#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
`app/google-services.json` is not in the repo, like `keystore.properties`.
Without it the Google Services plugin is skipped, the app builds and runs
normally, and `PushManager` does nothing because Firebase never initialized.
Drop the file from your Firebase project into `app/` and push registration
starts working on the next build: the app registers its FCM token with
grain-serve (`social.grain.notification.registerPush`, platform `android`),
which sends through FCM once it is set up with the project's key.
## 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
```bash
./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](https://protomaps.com)
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:
```bash
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`](justfile) wrapping the common loops, including finding
`adb` (which isn't on `PATH` on macOS) and picking a device:
```bash
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
([TID](app/src/main/java/social/grain/android/util/Tid.kt)s) 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`](app/src/main/java/social/grain/android/data/upload/GalleryUploadCenter.kt).
**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`](app/src/main/java/social/grain/android/ui/components/RichText.kt).
**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`](app/src/main/java/social/grain/android/data/local/ViewedStoryStore.kt).
**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`](app/src/main/java/social/grain/android/data/api/DPoP.kt) and
[`SecureStore`](app/src/main/java/social/grain/android/data/auth/SecureStore.kt).
## 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](LICENSE) — Copyright (c) 2026 Grain Social