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

grain.social/grain-android

1 collaborator
0
1
main
193 lines · 9.2 KB Raw History
# 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