- Kotlin 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
56 branches deleted and 10 pull requests dispositioned on the Forgejo
server after PR #103 merged as
|
||
| .forgejo/workflows | ||
| app | ||
| docs | ||
| gradle | ||
| .gitignore | ||
| AGENTS.md | ||
| build.gradle.kts | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| gradle.properties | ||
| gradlew | ||
| README.md | ||
| settings.gradle.kts | ||
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:
- 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. - cursor pagination — 50 posts at a time, stable cursor chains from the xrpc spec. no offsets, no surprises.
- stable compose keys —
key = { it.uri }in everyLazyColumn. 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. @Immutable/@Stableeverywhere — 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.