Peregrine — a third-party native Android Bluesky client
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Pyrrha 69a839d413
All checks were successful
Peregrine CI / Build & Test (JDK 17) (push) Successful in 5m17s
Peregrine CI / Signed Minified Release Build (push) Successful in 4m4s
Peregrine CI / Instrumented Tests (API 29+) (push) Has been skipped
peregrine-ci / assembleDebug Build succeeded (3m55s)
docs: record Task 12 execution, close the cleanup pass
56 branches deleted and 10 pull requests dispositioned on the Forgejo
server after PR #103 merged as d9af65c. The reconciliation ledger gains an
Execution section recording the gate check, the live pre-deletion ancestry
re-derivation, and the resulting state; it and the pass's plan re-status
from Current guidance to Historical record, as both documents said they
would when Task 12 closed.

The gated deletion was verified before it ran: docs/pr-review-remediation-plan
was removed only after confirming its 412-line plan is reachable from main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQ56Mh1F6otBp3a4pRWm5Y
2026-07-25 23:25:59 -04:00
.forgejo/workflows ci: publish release verification artifacts to buildserv 2026-07-11 11:13:37 -04:00
app fix: verify DID document id matches requested DID (P1-3 follow-up) 2026-07-13 09:35:45 -04:00
docs docs: record Task 12 execution, close the cleanup pass 2026-07-25 23:25:59 -04:00
gradle feat: Path 3 design work — self-label prefix, bottom sheet provenance, Twitter-blue dot, labeler cap UX 2026-07-11 08:14:50 -04:00
.gitignore feat: Path 1 protocol integrity — OAuth redirect, routing, validation, signing 2026-07-10 07:36:34 -04:00
AGENTS.md docs: reduce AGENTS.md to a pointer at CLAUDE.md 2026-07-25 11:10:39 -04:00
build.gradle.kts Fix Hilt plugin: remove duplicate dagger.hilt.android.plugin, use single com.google.dagger.hilt.android 2026-06-08 19:40:59 -04:00
CHANGELOG.md fix: OAuth token-rotation bugs found in refresh-path audit 2026-07-01 20:04:27 -04:00
CLAUDE.md docs: fix wave from final whole-branch review 2026-07-25 16:29:04 -04:00
gradle.properties remove hardcoded Windows JDK path from gradle.properties 2026-06-08 22:13:11 -04:00
gradlew add gradle wrapper for CLI builds 2026-06-08 21:10:13 -04:00
README.md docs: fix wave from final whole-branch review 2026-07-25 16:29:04 -04:00
settings.gradle.kts Fix settings.gradle.kts: dependencyResolutionManagement + escaped regex 2026-06-08 19:38:40 -04:00

peregrine

a bluesky client for people who are never logging off

the official app is fine if you're normal. if you follow 40 people and open the app twice a day and have never encountered the "why is this profile taking 8 seconds to load" problem, genuinely, keep using it. this is not for you.

this is for the girlies with 85,000 posts and an opinion about custom feeds. for the people whose timeline is a second home. for anyone who remembers what fenix felt like on android before twitter killed everything good and called it progress.

naming moment: a peregrine falcon is the fastest animal alive. it dive-bombs pigeons at 240 mph in a hunting maneuver called a stoop. that is the energy we are bringing to atproto. also the word is fun to say.

ok but why did you build this

i have spent an unconscionable amount of my life in microblogging apps. before i transitioned, during, after. the timeline has been the one constant across three names and two genders. i have opinions. here's why peregrine exists:

  • custom feeds are the whole point of bluesky and the official app treats them like an afterthought. you know that tab you forget exists because it's buried behind two taps and a scroll? here they're pinned, swipeable, drag-to-reorder, synced to the server. first-class. the way they should've been from day one.
  • big profiles should not be a death sentence. i have 85,000+ posts. you try opening my profile in the official app and tell me it doesn't chug. (it does. it really does.) i fixed this.
  • power users deserve a client that actually respects them. thread composer that doesn't fight you. drafts that survive a process kill because android is ruthless about memory and we all know it. a repost button that actually calls the api instead of just... dismissing a sheet and doing nothing (this shipped. on main. i'm not proud of it but i fixed it).
  • third-party clients are how an ecosystem stays alive. twitter taught us this. then twitter murdered all of them. bluesky is doing better — they built the protocol for this — but the apps have to exist. someone has to write them. hi.

what's actually working

this is not a "coming soon" landing page. as of v0.2.0 on the current development baseline, 151 Kotlin source files and 710 JVM tests across 92 test classes (0 skipped), all green as of 9e2d705:

thing status
auth full OAuth 2.0 + DPoP. the real bluesky flow — PAR, PKCE, ES256 keys, DER→raw signature conversion (java why), PDS routing, token refresh, the whole nightmare. also app passwords if you're not feeling oauth today
home timeline + custom feeds paging 3 + room. room-first architecture — the ui reads from a local cache, the network fetches in the background. you scroll, room serves, the network catches up. your timeline never blocks
profile view banner, avatar, follow/unfollow (optimistic with rollback), posts/replies/media tabs that each preserve their own scroll position
post composition threads, images (up to 4 per post, compressed to <1MB), drafts with debounced auto-save, reply + quote flows, grapheme-correct 300-character counter (emojis count as one, as they should)
interactions like/repost on the real api with optimistic room writes + rollback on failure. thread view with recursive reply tree flattening. the repost button actually works now
notifications full notification system — list, unread badge, pull-to-refresh, reasonSubject-aware tap routing (taps take you to the right place for each notification type instead of crashing on newer notification types)
search posts + people search, tag-facet taps, recent searches stored locally
ui foundation material 3 design system, amoled black + dynamic color, ios-grade navigation motion, full-bleed timeline rows, embedded images and embeds persisted as json in room
moderation labelers, mute words, content filtering, mute/block actions, moderation settings, and profile label surfaces
play store prep 🟡 baseline profile, fail-closed release signing, CI artifact verification, and store listing. the boring last mile

the 85k problem

profiles like mine have 85,000+ posts. the official app has a bad time with this — scroll far enough and it turns into a slideshow or just gives up. peregrine does not, and here's exactly why:

  1. paging 3 + RemoteMediator — room-first. the ui reads from the local cache; the network fetches in the background. this is load-bearing architecture. do not make the ui read from the network directly.
  2. cursor pagination — 50 posts at a time, stable cursor chains from the xrpc spec. no offsets, no surprises.
  3. stable compose keyskey = { it.uri } in every LazyColumn. never use array index as a key. i learned this the hard way and now there are contract tests that will fail if you try it.
  4. @Immutable / @Stable everywhere — without these, compose literally cannot skip a recomposition and your scroll turns into a slideshow with 15fps. this happened during development. it was so bad. there are hot-path contract tests now specifically to prevent this regression.

how it's put together

single :app module. one activity, all-compose navhost. no fragments, no multi-module gradle hell. premature module splitting is a sin and we are virtuous here.

data/api/         retrofit interfaces + @Serializable atproto models
data/db/          room entities, DAOs, PeregrineDatabase (schema v12)
data/repository/  business logic — ViewModels never touch services directly
                  (this rule has been broken before and it was always wrong)
di/               hilt modules (NetworkModule, DatabaseModule)
auth/             SessionManager + AuthInterceptor + oauth/ DPoP stack
ui/<feature>/     Route + ViewModel + components/ per feature
ui/common/        shared composables (Avatar, RichText, PostCard, PreviewAsyncImage)
util/             pure helpers — no side effects, no dependencies

data flow: network → room → repository → viewmodel → compose. the RemoteMediator enforces this. the ui only ever reads room. this is not negotiable.

state pattern: feed ViewModels expose Flow<PagingData<TimelineItem>>.cachedIn(viewModelScope). detail views use StateFlow<sealed UiState> with Loading / Error(message) / Success(data) variants. composables collect with collectAsStateWithLifecycle() because lifecycle awareness is not optional.

tech stack (with commentary)

concern pick because
ui jetpack compose + material 3 it's 2026
di hilt 2.53.1 (ksp) standard, compose-native, does what it says on the tin
networking retrofit + okhttp we hand-rolled the xrpc layer because the atproto sdk is immature and i wanted control over every header. this was the right call
serialization kotlinx.serialization kotlin-first. gson would have been easier to set up but would have made me sad every day after
database room 2.6.1 (ksp) paging 3 integration is the reason. also sqlite is eternal and i respect that
images coil 3.2.0 compose-native, doesn't fight you, explicit memory budget controls
auth AppAuth-Android → custom OAuth 2.1 DPoP bluesky's oauth needs PAR + DPoP. app passwords are the fallback but oauth is the real flow now. i had to learn how ECDSA signatures work at the byte level for this. java's SHA256withECDSA produces DER-encoded output and JWTs need raw R‖S. it took me three days to figure out why every DPoP proof was being rejected. this is documented in the codebase
pagination paging 3 + RemoteMediator cursor-based atproto pagination, done correctly
prefs datastore + EncryptedSharedPreferences datastore for settings (async, typed), encrypted for anything that could hurt someone if it leaked

all dependency versions live in gradle/libs.versions.toml. update them there, never inline.

min SDK 29, target/compile 35, jvm 17.

build it

./gradlew assembleDebug       # debug APK
./gradlew bundleRelease       # release AAB
./gradlew test                # 710 JVM tests across 92 files — keep them green
./gradlew lint                # lint
./gradlew clean               # nuke build artifacts from orbit

ci runs on every push/pr via the checked-in .forgejo/workflows/ci.yml (unit tests → lint → debug build → instrumentation compilation on jdk 17). A separate release-check job verifies Android SDK setup, creates disposable CI signing material, builds minified release artifacts, generates checksums, and publishes verification artifacts. Builds execute on the peregrine-android runner; merges are blocked until required checks go green on durandal.exe.xyz.

how it's different from the official app

thing official app peregrine
thread composer basic single-post multi-post threads, images per segment, drafts, quote posts
large profiles suffers noticeably paging 3 + room, stays smooth
theme light / dark amoled black + dynamic color + system follow
target user "everyone" people who post for a living
repost button works works (shipped broken on main once, fixed, don't ask)
vibe corporate terminally online trans girl energy

the fenix test

for the uninitiated: fenix was the best android twitter client ever made. it was fast, clean, powerful, and respectful — no dark patterns, no algorithmic timeline manipulation, no engagement-hacking nonsense. the developer (matteo villa, one person) set a standard that nobody has matched since.

every ui decision in peregrine gets measured against fenix. would fenix do it this way? if not, we have a better reason than "it was easier." that's the bar.

this is not an aspirational goalpost. it's a design constraint.

what's not done yet (being honest)

  • release publishing is not complete yet. CI verifies signed minified artifacts, but the Play Store listing and public release process still need the final pass.
  • composer drafts restore the first segment's text + reply ref but not full thread segments or image uris after a process kill. the draft table exists, the column shape just needs extending.
  • image alt text isn't editable in the composer yet — it defaults to empty. this is accessibility debt and i know it. it'll ship before the play store listing.
  • notifications are pull-to-refresh only — no foreground polling. they refresh when you pull down, and there's a 60-second auto-refresh while you're on the tab, but no background push.
  • inline video playback is deferred. embeds show a thumbnail. playing inline is on the list.

see docs/plans/2026-07-11-consolidated-roadmap-and-review.md for the full roadmap and CHANGELOG.md for what landed when. (docs/MVP_IMPLEMENTATION_PLAN.md is the original greenfield plan — historical at this point, its phase table stopped getting updated after Phase 6.)

repo

primary: https://durandal.exe.xyz/starsetbyte/peregrine (forgejo)
mirror: git@github.com:acgh213/peregrine.git (github, kept as backup)

push to forgejo. the mirror just tracks. if the primary instance eats itself, github has you.

author

a 29-year-old trans woman who has spent an unreasonable fraction of her life posting. built on a debian vm named astraea1, tested on a galaxy z flip, compiled on a headless linux box. this app exists because the software i wanted didn't, and at some point you have to stop waiting and open android studio.

peregrine is a paid app (one-time purchase, not a subscription — i'm not a monster). if you want to follow development or yell at me about bugs, i'm on bluesky. you can probably find me.

"i will stay silly and not allow the world to make me bitter and cruel." — my personal mantra. this project is part of that.