# 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 # 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